diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx index abe227a17f..e647ed7ebc 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx @@ -30,6 +30,10 @@ test("Users can fill the parameters and copy the open in coder url", async () => await waitForLoaderToBeRemoved(); const user = userEvent.setup(); + const workspaceName = screen.getByRole("textbox", { + name: "Workspace name", + }); + await user.type(workspaceName, "my-first-workspace"); const firstParameterField = screen.getByLabelText( parameter1.display_name ?? parameter1.name, { exact: false }, @@ -47,6 +51,6 @@ test("Users can fill the parameters and copy the open in coder url", async () => 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/${MockTemplate.organization_name}/${MockTemplate.name}/workspace?mode=manual¶m.first_parameter=firstParameterValue¶m.second_parameter=123456)`, + `[![Open in Coder](http://localhost/open-in-coder.svg)](http://localhost/templates/${MockTemplate.organization_name}/${MockTemplate.name}/workspace?mode=manual&name=my-first-workspace¶m.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 index a0f80f046c..de6a8ec91d 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx @@ -5,17 +5,22 @@ import RadioGroup from "@mui/material/RadioGroup"; import { API } from "api/api"; import type { Template, TemplateVersionParameter } from "api/typesGenerated"; import { FormSection, VerticalForm } from "components/Form/Form"; +import { Input } from "components/Input/Input"; +import { Label } from "components/Label/Label"; import { Loader } from "components/Loader/Loader"; import { RichParameterInput } from "components/RichParameterInput/RichParameterInput"; +import { useDebouncedFunction } from "hooks/debounce"; import { useClipboard } from "hooks/useClipboard"; import { CheckIcon, CopyIcon } from "lucide-react"; import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; -import { type FC, useEffect, useState } from "react"; +import { type FC, useEffect, useId, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; +import { nameValidator } from "utils/formUtils"; import { pageTitle } from "utils/page"; import { getInitialRichParameterValues } from "utils/richParameters"; import { paramsUsedToCreateWorkspace } from "utils/workspace"; +import { ValidationError } from "yup"; type ButtonValues = Record; @@ -47,19 +52,25 @@ interface TemplateEmbedPageViewProps { templateParameters?: TemplateVersionParameter[]; } +const deploymentUrl = `${window.location.protocol}//${window.location.host}`; + function getClipboardCopyContent( templateName: string, organization: string, buttonValues: ButtonValues | undefined, ): string { - const deploymentUrl = `${window.location.protocol}//${window.location.host}`; const createWorkspaceUrl = `${deploymentUrl}/templates/${organization}/${templateName}/workspace`; const createWorkspaceParams = new URLSearchParams(buttonValues); + if (createWorkspaceParams.get("name") === "") { + createWorkspaceParams.delete("name"); // no default workspace name if empty + } const buttonUrl = `${createWorkspaceUrl}?${createWorkspaceParams.toString()}`; return `[![Open in Coder](${deploymentUrl}/open-in-coder.svg)](${buttonUrl})`; } +const workspaceNameValidator = nameValidator("Workspace name"); + export const TemplateEmbedPageView: FC = ({ template, templateParameters, @@ -79,6 +90,7 @@ export const TemplateEmbedPageView: FC = ({ if (templateParameters && !buttonValues) { const buttonValues: ButtonValues = { mode: "manual", + name: "", }; for (const parameter of getInitialRichParameterValues( templateParameters, @@ -89,6 +101,27 @@ export const TemplateEmbedPageView: FC = ({ } }, [buttonValues, templateParameters]); + const [workspaceNameError, setWorkspaceNameError] = useState(""); + const validateWorkspaceName = (workspaceName: string) => { + try { + if (workspaceName) { + workspaceNameValidator.validateSync(workspaceName); + } + setWorkspaceNameError(""); + } catch (e) { + if (e instanceof ValidationError) { + setWorkspaceNameError(e.message); + } + } + }; + const { debounced: debouncedValidateWorkspaceName } = useDebouncedFunction( + validateWorkspaceName, + 500, + ); + + const hookId = useId(); + const defaultWorkspaceNameID = `${hookId}-default-workspace-name`; + return ( <> @@ -126,6 +159,29 @@ export const TemplateEmbedPageView: FC = ({ +
+ +
+ Default name for the new workspace +
+ { + debouncedValidateWorkspaceName(event.target.value); + setButtonValues((buttonValues) => ({ + ...buttonValues, + name: event.target.value, + })); + }} + /> +
+ {workspaceNameError} +
+
+ {templateParameters.length > 0 && (
= { @@ -35,3 +36,15 @@ export const WithParameters: Story = { ], }, }; + +export const WrongWorkspaceName: Story = { + args: { + templateParameters: [MockTemplateVersionParameter1], + }, + play: async () => { + const workspaceName = await screen.findByRole("textbox", { + name: "Workspace name", + }); + await userEvent.type(workspaceName, "b@d"); + }, +};