mirror of https://github.com/coder/coder.git
feat(site): Add template embed page (#7501)
This commit is contained in:
parent
049e557675
commit
6f62204d38
|
@ -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 = {
|
||||
|
|
|
@ -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 />} />
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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),
|
||||
},
|
||||
}))
|
||||
|
|
|
@ -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¶m.second_parameter=123456)`,
|
||||
)
|
||||
})
|
|
@ -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
|
|
@ -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,
|
||||
],
|
||||
},
|
||||
}
|
Loading…
Reference in New Issue