feat(site): Add template embed page (#7501)

This commit is contained in:
Bruno Quaresma 2023-05-15 13:07:39 -03:00 committed by GitHub
parent 049e557675
commit 6f62204d38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 408 additions and 85 deletions

View File

@ -2,6 +2,7 @@ import CssBaseline from "@mui/material/CssBaseline"
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles"
import { createMemoryHistory } from "history"
import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom"
import { HelmetProvider } from "react-helmet-async"
import { dark } from "../src/theme"
import "../src/theme/globalFonts"
import "../src/i18n"
@ -24,6 +25,13 @@ export const decorators = [
</HistoryRouter>
)
},
(Story) => {
return (
<HelmetProvider>
<Story />
</HelmetProvider>
)
},
]
export const parameters = {

View File

@ -176,6 +176,9 @@ const AddNewLicensePage = lazy(
() =>
import("./pages/DeploySettingsPage/LicensesSettingsPage/AddNewLicensePage"),
)
const TemplateEmbedPage = lazy(
() => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"),
)
export const AppRouter: FC = () => {
return (
@ -208,6 +211,7 @@ export const AppRouter: FC = () => {
<Route path="docs" element={<TemplateDocsPage />} />
<Route path="files" element={<TemplateFilesPage />} />
<Route path="versions" element={<TemplateVersionsPage />} />
<Route path="embed" element={<TemplateEmbedPage />} />
</Route>
<Route path="workspace" element={<CreateWorkspacePage />} />

View File

@ -72,7 +72,7 @@ const useStyles = makeStyles((theme) => ({
// It also give a more pleasant distance to the site content when
// the banner is visible.
marginTop: theme.spacing(2),
marginBottom: -theme.spacing(2),
marginBottom: theme.spacing(-2),
},
siteContent: {
flex: 1,

View File

@ -54,7 +54,7 @@ const ParameterLabel: FC<ParameterLabelProps> = ({ id, parameter }) => {
)
}
export type RichParameterInputProps = TextFieldProps & {
export type RichParameterInputProps = Omit<TextFieldProps, "onChange"> & {
index: number
parameter: TemplateVersionParameter
onChange: (value: string) => void

View File

@ -73,7 +73,7 @@ const useStyles = makeStyles((theme) => ({
},
menuItem: {
letterSpacing: -theme.spacing(0.0375),
letterSpacing: theme.spacing(-0.0375),
padding: 0,
fontSize: 18,
color: theme.palette.text.secondary,

View File

@ -146,6 +146,17 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
>
Versions
</NavLink>
<NavLink
to={`/templates/${templateName}/embed`}
className={({ isActive }) =>
combineClasses([
styles.tabItem,
isActive ? styles.tabItemActive : undefined,
])
}
>
Embed
</NavLink>
</Stack>
</Margins>
</div>

View File

@ -0,0 +1,87 @@
import { TemplateVersionParameter } from "api/typesGenerated"
import { FormSection, FormFields } from "components/Form/Form"
import {
RichParameterInput,
RichParameterInputProps,
} from "components/RichParameterInput/RichParameterInput"
import { ComponentProps, FC } from "react"
export type TemplateParametersSectionProps = {
templateParameters: TemplateVersionParameter[]
getInputProps: (
parameter: TemplateVersionParameter,
index: number,
) => Omit<RichParameterInputProps, "parameter" | "index">
} & Pick<ComponentProps<typeof FormSection>, "classes">
export const MutableTemplateParametersSection: FC<
TemplateParametersSectionProps
> = ({ templateParameters, getInputProps, ...formSectionProps }) => {
const hasMutableParameters =
templateParameters.filter((p) => p.mutable).length > 0
return (
<>
{hasMutableParameters && (
<FormSection
{...formSectionProps}
title="Parameters"
description="These parameters are provided by your template's Terraform configuration and can be changed after creating the workspace."
>
<FormFields>
{templateParameters.map(
(parameter, index) =>
parameter.mutable && (
<RichParameterInput
{...getInputProps(parameter, index)}
index={index}
key={parameter.name}
parameter={parameter}
/>
),
)}
</FormFields>
</FormSection>
)}
</>
)
}
export const ImmutableTemplateParametersSection: FC<
TemplateParametersSectionProps
> = ({ templateParameters, getInputProps, ...formSectionProps }) => {
const hasImmutableParameters =
templateParameters.filter((p) => !p.mutable).length > 0
return (
<>
{hasImmutableParameters && (
<FormSection
{...formSectionProps}
title="Immutable parameters"
description={
<>
These parameters are also provided by your Terraform configuration
but they{" "}
<strong>cannot be changed after creating the workspace.</strong>
</>
}
>
<FormFields>
{templateParameters.map(
(parameter, index) =>
!parameter.mutable && (
<RichParameterInput
{...getInputProps(parameter, index)}
index={index}
key={parameter.name}
parameter={parameter}
/>
),
)}
</FormFields>
</FormSection>
)}
</>
)
}

View File

@ -1,7 +1,6 @@
import TextField from "@mui/material/TextField"
import * as TypesGen from "api/typesGenerated"
import { ParameterInput } from "components/ParameterInput/ParameterInput"
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"
import { Stack } from "components/Stack/Stack"
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"
import { FormikContextType, FormikTouched, useFormik } from "formik"
@ -26,6 +25,10 @@ import {
useValidationSchemaForRichParameters,
workspaceBuildParameterValue,
} from "utils/richParameters"
import {
ImmutableTemplateParametersSection,
MutableTemplateParametersSection,
} from "components/TemplateParameters/TemplateParameters"
export enum CreateWorkspaceErrors {
GET_TEMPLATES_ERROR = "getTemplatesError",
@ -308,86 +311,53 @@ export const CreateWorkspacePageView: FC<
</FormSection>
)}
{/* Mutable rich parameters */}
{props.templateParameters &&
props.templateParameters.filter((p) => p.mutable).length > 0 && (
<FormSection
title="Parameters"
description="These parameters are provided by your template's Terraform configuration and can be changed after creating the workspace."
>
<FormFields>
{props.templateParameters.map(
(parameter, index) =>
parameter.mutable && (
<RichParameterInput
{...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
)}
disabled={form.isSubmitting}
index={index}
key={parameter.name}
onChange={(value) => {
form.setFieldValue("rich_parameter_values." + index, {
name: parameter.name,
value: value,
})
}}
parameter={parameter}
initialValue={workspaceBuildParameterValue(
initialRichParameterValues,
parameter,
)}
/>
),
)}
</FormFields>
</FormSection>
)}
{/* Immutable rich parameters */}
{props.templateParameters &&
props.templateParameters.filter((p) => !p.mutable).length > 0 && (
<FormSection
title="Immutable parameters"
{props.templateParameters && (
<>
<MutableTemplateParametersSection
templateParameters={props.templateParameters}
getInputProps={(parameter, index) => {
return {
...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
),
onChange: (value) => {
form.setFieldValue("rich_parameter_values." + index, {
name: parameter.name,
value: value,
})
},
initialValue: workspaceBuildParameterValue(
initialRichParameterValues,
parameter,
),
disabled: form.isSubmitting,
}
}}
/>
<ImmutableTemplateParametersSection
templateParameters={props.templateParameters}
classes={{ root: styles.warningSection }}
description={
<>
These parameters are also provided by your Terraform
configuration but they{" "}
<strong className={styles.warningText}>
cannot be changed after creating the workspace.
</strong>
</>
}
>
<FormFields>
{props.templateParameters.map(
(parameter, index) =>
!parameter.mutable && (
<RichParameterInput
{...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
)}
disabled={form.isSubmitting}
index={index}
key={parameter.name}
onChange={(value) => {
form.setFieldValue("rich_parameter_values." + index, {
name: parameter.name,
value: value,
})
}}
parameter={parameter}
initialValue={workspaceBuildParameterValue(
initialRichParameterValues,
parameter,
)}
/>
),
)}
</FormFields>
</FormSection>
)}
getInputProps={(parameter, index) => {
return {
...getFieldHelpers(
"rich_parameter_values[" + index + "].value",
),
onChange: (value) => {
form.setFieldValue("rich_parameter_values." + index, {
name: parameter.name,
value: value,
})
},
initialValue: workspaceBuildParameterValue(
initialRichParameterValues,
parameter,
),
disabled: form.isSubmitting,
}
}}
/>
</>
)}
<FormFooter
onCancel={props.onCancel}
@ -408,7 +378,7 @@ const useStyles = makeStyles((theme) => ({
borderRadius: 8,
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(10),
marginLeft: -theme.spacing(10),
marginRight: -theme.spacing(10),
marginLeft: theme.spacing(-10),
marginRight: theme.spacing(-10),
},
}))

View File

@ -0,0 +1,52 @@
import {
renderWithAuth,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers"
import TemplateEmbedPage from "./TemplateEmbedPage"
import { TemplateLayout } from "components/TemplateLayout/TemplateLayout"
import {
MockTemplate,
MockTemplateVersionParameter1 as parameter1,
MockTemplateVersionParameter2 as parameter2,
} from "testHelpers/entities"
import * as API from "api/api"
import userEvent from "@testing-library/user-event"
import { screen } from "@testing-library/react"
test("Users can fill the parameters and copy the open in coder url", async () => {
jest
.spyOn(API, "getTemplateVersionRichParameters")
.mockResolvedValue([parameter1, parameter2])
renderWithAuth(
<TemplateLayout>
<TemplateEmbedPage />
</TemplateLayout>,
{
route: `/templates/${MockTemplate.name}/embed`,
path: "/templates/:template/embed",
},
)
await waitForLoaderToBeRemoved()
const user = userEvent.setup()
const firstParameterField = screen.getByLabelText(
parameter1.display_name ?? parameter1.name,
{ exact: false },
)
await user.clear(firstParameterField)
await user.type(firstParameterField, "firstParameterValue")
const secondParameterField = screen.getByLabelText(
parameter2.display_name ?? parameter2.name,
{ exact: false },
)
await user.clear(secondParameterField)
await user.type(secondParameterField, "123456")
jest.spyOn(window.navigator.clipboard, "writeText")
const copyButton = screen.getByRole("button", { name: /copy/i })
await userEvent.click(copyButton)
expect(window.navigator.clipboard.writeText).toBeCalledWith(
`[![Open in Coder](http://localhost/open-in-coder.svg)](http://localhost/templates/test-template/workspace?param.first_parameter=firstParameterValue&param.second_parameter=123456)`,
)
})

View File

@ -0,0 +1,153 @@
import CheckOutlined from "@mui/icons-material/CheckOutlined"
import FileCopyOutlined from "@mui/icons-material/FileCopyOutlined"
import Box from "@mui/material/Box"
import Button from "@mui/material/Button"
import { useQuery } from "@tanstack/react-query"
import { getTemplateVersionRichParameters } from "api/api"
import { Template, TemplateVersionParameter } from "api/typesGenerated"
import { VerticalForm } from "components/Form/Form"
import { Loader } from "components/Loader/Loader"
import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayout"
import {
ImmutableTemplateParametersSection,
MutableTemplateParametersSection,
TemplateParametersSectionProps,
} from "components/TemplateParameters/TemplateParameters"
import { useClipboard } from "hooks/useClipboard"
import { FC, useState } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "utils/page"
import {
selectInitialRichParametersValues,
workspaceBuildParameterValue,
} from "utils/richParameters"
type ButtonValues = Record<string, string>
const TemplateEmbedPage = () => {
const { template } = useTemplateLayoutContext()
const { data: templateParameters } = useQuery({
queryKey: ["template", template.id, "embed"],
queryFn: () => getTemplateVersionRichParameters(template.active_version_id),
})
return (
<>
<Helmet>
<title>{pageTitle(`${template.name} · Embed`)}</title>
</Helmet>
<TemplateEmbedPageView
template={template}
templateParameters={templateParameters}
/>
</>
)
}
export const TemplateEmbedPageView: FC<{
template: Template
templateParameters?: TemplateVersionParameter[]
}> = ({ template, templateParameters }) => {
const [buttonValues, setButtonValues] = useState<ButtonValues>({})
const initialRichParametersValues = templateParameters
? selectInitialRichParametersValues(templateParameters)
: undefined
const deploymentUrl = `${window.location.protocol}//${window.location.host}`
const createWorkspaceUrl = `${deploymentUrl}/templates/${template.name}/workspace`
const createWorkspaceParams = new URLSearchParams(buttonValues)
const buttonUrl = `${createWorkspaceUrl}?${createWorkspaceParams.toString()}`
const buttonMkdCode = `[![Open in Coder](${deploymentUrl}/open-in-coder.svg)](${buttonUrl})`
const clipboard = useClipboard(buttonMkdCode)
const getInputProps: TemplateParametersSectionProps["getInputProps"] = (
parameter,
) => {
if (!initialRichParametersValues) {
throw new Error("initialRichParametersValues is undefined")
}
return {
id: parameter.name,
initialValue: workspaceBuildParameterValue(
initialRichParametersValues,
parameter,
),
onChange: (value) => {
setButtonValues((buttonValues) => ({
...buttonValues,
[`param.${parameter.name}`]: value,
}))
},
}
}
return (
<>
<Helmet>
<title>{pageTitle(`${template.name} · Embed`)}</title>
</Helmet>
{!templateParameters ? (
<Loader />
) : (
<Box display="flex" alignItems="flex-start" gap={6}>
{templateParameters.length > 0 && (
<Box flex={1} maxWidth={400}>
<VerticalForm>
<MutableTemplateParametersSection
templateParameters={templateParameters}
getInputProps={getInputProps}
/>
<ImmutableTemplateParametersSection
templateParameters={templateParameters}
getInputProps={getInputProps}
/>
</VerticalForm>
</Box>
)}
<Box
display="flex"
height={{
// 80px is the vertical padding of the content area
// 36px is from the status bar from the bottom
md: "calc(100vh - (80px + 36px))",
top: 40,
position: "sticky",
}}
p={8}
flex={1}
alignItems="center"
justifyContent="center"
borderRadius={1}
bgcolor="background.paper"
border={(theme) => `1px solid ${theme.palette.divider}`}
>
<img src="/open-in-coder.svg" alt="Open in Coder button" />
<Box
p={2}
py={6}
display="flex"
justifyContent="center"
position="absolute"
bottom={0}
left={0}
width="100%"
>
<Button
sx={{ borderRadius: 999 }}
startIcon={
clipboard.isCopied ? <CheckOutlined /> : <FileCopyOutlined />
}
variant="contained"
onClick={clipboard.copy}
disabled={clipboard.isCopied}
>
Copy button code
</Button>
</Box>
</Box>
</Box>
)}
</>
)
}
export default TemplateEmbedPage

View File

@ -0,0 +1,38 @@
import type { Meta, StoryObj } from "@storybook/react"
import { TemplateEmbedPageView } from "./TemplateEmbedPage"
import {
MockTemplate,
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
MockTemplateVersionParameter4,
} from "testHelpers/entities"
const meta: Meta<typeof TemplateEmbedPageView> = {
title: "pages/TemplateEmbedPageView",
component: TemplateEmbedPageView,
args: {
template: MockTemplate,
},
}
export default meta
type Story = StoryObj<typeof TemplateEmbedPageView>
export const Empty: Story = {
args: {
templateParameters: [],
},
}
export const WithParameters: Story = {
args: {
templateParameters: [
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockTemplateVersionParameter3,
MockTemplateVersionParameter4,
],
},
}