diff --git a/site/.storybook/preview.jsx b/site/.storybook/preview.jsx index 62c80dcb50..5297d730d9 100644 --- a/site/.storybook/preview.jsx +++ b/site/.storybook/preview.jsx @@ -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 = [ ) }, + (Story) => { + return ( + + + + ) + }, ] export const parameters = { diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 8bfd505949..b477e2ffc7 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -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 = () => { } /> } /> } /> + } /> } /> diff --git a/site/src/components/Dashboard/DashboardLayout.tsx b/site/src/components/Dashboard/DashboardLayout.tsx index 3835ef5935..a7ce6b500e 100644 --- a/site/src/components/Dashboard/DashboardLayout.tsx +++ b/site/src/components/Dashboard/DashboardLayout.tsx @@ -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, diff --git a/site/src/components/RichParameterInput/RichParameterInput.tsx b/site/src/components/RichParameterInput/RichParameterInput.tsx index b791dc4dd2..b1d0943bd5 100644 --- a/site/src/components/RichParameterInput/RichParameterInput.tsx +++ b/site/src/components/RichParameterInput/RichParameterInput.tsx @@ -54,7 +54,7 @@ const ParameterLabel: FC = ({ id, parameter }) => { ) } -export type RichParameterInputProps = TextFieldProps & { +export type RichParameterInputProps = Omit & { index: number parameter: TemplateVersionParameter onChange: (value: string) => void diff --git a/site/src/components/TabSidebar/TabSidebar.tsx b/site/src/components/TabSidebar/TabSidebar.tsx index 77fda5bddd..af92db8a5d 100644 --- a/site/src/components/TabSidebar/TabSidebar.tsx +++ b/site/src/components/TabSidebar/TabSidebar.tsx @@ -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, diff --git a/site/src/components/TemplateLayout/TemplateLayout.tsx b/site/src/components/TemplateLayout/TemplateLayout.tsx index e9cbea1dc8..b63035362e 100644 --- a/site/src/components/TemplateLayout/TemplateLayout.tsx +++ b/site/src/components/TemplateLayout/TemplateLayout.tsx @@ -146,6 +146,17 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({ > Versions + + combineClasses([ + styles.tabItem, + isActive ? styles.tabItemActive : undefined, + ]) + } + > + Embed + diff --git a/site/src/components/TemplateParameters/TemplateParameters.tsx b/site/src/components/TemplateParameters/TemplateParameters.tsx new file mode 100644 index 0000000000..fbd4a6c1d1 --- /dev/null +++ b/site/src/components/TemplateParameters/TemplateParameters.tsx @@ -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 +} & Pick, "classes"> + +export const MutableTemplateParametersSection: FC< + TemplateParametersSectionProps +> = ({ templateParameters, getInputProps, ...formSectionProps }) => { + const hasMutableParameters = + templateParameters.filter((p) => p.mutable).length > 0 + + return ( + <> + {hasMutableParameters && ( + + + {templateParameters.map( + (parameter, index) => + parameter.mutable && ( + + ), + )} + + + )} + + ) +} + +export const ImmutableTemplateParametersSection: FC< + TemplateParametersSectionProps +> = ({ templateParameters, getInputProps, ...formSectionProps }) => { + const hasImmutableParameters = + templateParameters.filter((p) => !p.mutable).length > 0 + + return ( + <> + {hasImmutableParameters && ( + + These parameters are also provided by your Terraform configuration + but they{" "} + cannot be changed after creating the workspace. + + } + > + + {templateParameters.map( + (parameter, index) => + !parameter.mutable && ( + + ), + )} + + + )} + + ) +} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 2be426b1ae..7534150a02 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -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< )} - {/* Mutable rich parameters */} - {props.templateParameters && - props.templateParameters.filter((p) => p.mutable).length > 0 && ( - - - {props.templateParameters.map( - (parameter, index) => - parameter.mutable && ( - { - form.setFieldValue("rich_parameter_values." + index, { - name: parameter.name, - value: value, - }) - }} - parameter={parameter} - initialValue={workspaceBuildParameterValue( - initialRichParameterValues, - parameter, - )} - /> - ), - )} - - - )} - - {/* Immutable rich parameters */} - {props.templateParameters && - props.templateParameters.filter((p) => !p.mutable).length > 0 && ( - + { + 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, + } + }} + /> + - These parameters are also provided by your Terraform - configuration but they{" "} - - cannot be changed after creating the workspace. - - - } - > - - {props.templateParameters.map( - (parameter, index) => - !parameter.mutable && ( - { - form.setFieldValue("rich_parameter_values." + index, { - name: parameter.name, - value: value, - }) - }} - parameter={parameter} - initialValue={workspaceBuildParameterValue( - initialRichParameterValues, - parameter, - )} - /> - ), - )} - - - )} + 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, + } + }} + /> + + )} ({ 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), }, })) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx new file mode 100644 index 0000000000..7da5ae0555 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx @@ -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( + + + , + { + 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)`, + ) +}) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx new file mode 100644 index 0000000000..cc48cdf6e1 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx @@ -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 + +const TemplateEmbedPage = () => { + const { template } = useTemplateLayoutContext() + const { data: templateParameters } = useQuery({ + queryKey: ["template", template.id, "embed"], + queryFn: () => getTemplateVersionRichParameters(template.active_version_id), + }) + + return ( + <> + + {pageTitle(`${template.name} · Embed`)} + + + + ) +} + +export const TemplateEmbedPageView: FC<{ + template: Template + templateParameters?: TemplateVersionParameter[] +}> = ({ template, templateParameters }) => { + const [buttonValues, setButtonValues] = useState({}) + 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 ( + <> + + {pageTitle(`${template.name} · Embed`)} + + {!templateParameters ? ( + + ) : ( + + {templateParameters.length > 0 && ( + + + + + + + )} + `1px solid ${theme.palette.divider}`} + > + Open in Coder button + + + + + + )} + + ) +} + +export default TemplateEmbedPage diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx new file mode 100644 index 0000000000..f1330ab0b0 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx @@ -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 = { + title: "pages/TemplateEmbedPageView", + component: TemplateEmbedPageView, + args: { + template: MockTemplate, + }, +} + +export default meta +type Story = StoryObj + +export const Empty: Story = { + args: { + templateParameters: [], + }, +} + +export const WithParameters: Story = { + args: { + templateParameters: [ + MockTemplateVersionParameter1, + MockTemplateVersionParameter2, + MockTemplateVersionParameter3, + MockTemplateVersionParameter4, + ], + }, +}