From 176f74742d8e2e6227a09cddc1f74d32999b7eca Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 3 Sep 2025 13:21:34 +0200 Subject: [PATCH 1/7] feat: add default workspace name to Template Embed form --- .../TemplateEmbedPage.test.tsx | 9 +++-- .../TemplateEmbedPage/TemplateEmbedPage.tsx | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx index a98e669807f89..613139d66d599 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx @@ -1,4 +1,4 @@ -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { API } from "api/api"; import { TemplateLayout } from "pages/TemplatePage/TemplateLayout"; @@ -30,6 +30,11 @@ test("Users can fill the parameters and copy the open in coder url", async () => await waitForLoaderToBeRemoved(); const user = userEvent.setup(); + const workspaceName = within( + screen.getByTestId("default-workspace-name"), + ).getByRole("textbox"); + await user.clear(workspaceName); + await user.type(workspaceName, "my-first-workspace"); const firstParameterField = screen.getByLabelText( parameter1.display_name ?? parameter1.name, { exact: false }, @@ -47,6 +52,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¶m.first_parameter=firstParameterValue¶m.second_parameter=123456&name=my-first-workspace)`, ); }); diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx index a0f80f046c6ad..a792efee3bafa 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx @@ -2,6 +2,7 @@ import Button from "@mui/material/Button"; import FormControlLabel from "@mui/material/FormControlLabel"; import Radio from "@mui/material/Radio"; import RadioGroup from "@mui/material/RadioGroup"; +import TextField from "@mui/material/TextField"; import { API } from "api/api"; import type { Template, TemplateVersionParameter } from "api/typesGenerated"; import { FormSection, VerticalForm } from "components/Form/Form"; @@ -13,9 +14,11 @@ import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; import { type FC, useEffect, 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; @@ -89,6 +92,17 @@ export const TemplateEmbedPageView: FC = ({ } }, [buttonValues, templateParameters]); + const [workspaceNameError, setWorkspaceNameError] = useState(""); + const workspaceNameValidator = nameValidator("Workspace name"); + const validateWorkspaceName = (workspaceName: string) => { + try { + workspaceName && workspaceNameValidator.validateSync(workspaceName); + setWorkspaceNameError(""); + } catch (e) { + setWorkspaceNameError(e instanceof ValidationError ? e.message : ""); + } + }; + return ( <> @@ -126,6 +140,26 @@ export const TemplateEmbedPageView: FC = ({ + + { + validateWorkspaceName(event.target.value); + setButtonValues((buttonValues) => ({ + ...buttonValues, + name: event.target.value, + })); + }} + error={workspaceNameError !== ""} + helperText={workspaceNameError} + /> + + {templateParameters.length > 0 && (
Date: Thu, 4 Sep 2025 11:24:12 +0200 Subject: [PATCH 2/7] Fixes --- .../TemplateEmbedPage/TemplateEmbedPage.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx index a792efee3bafa..02880c8ef1aea 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx @@ -63,6 +63,8 @@ function getClipboardCopyContent( return `[![Open in Coder](${deploymentUrl}/open-in-coder.svg)](${buttonUrl})`; } +const workspaceNameValidator = nameValidator("Workspace name"); + export const TemplateEmbedPageView: FC = ({ template, templateParameters, @@ -93,13 +95,16 @@ export const TemplateEmbedPageView: FC = ({ }, [buttonValues, templateParameters]); const [workspaceNameError, setWorkspaceNameError] = useState(""); - const workspaceNameValidator = nameValidator("Workspace name"); const validateWorkspaceName = (workspaceName: string) => { try { - workspaceName && workspaceNameValidator.validateSync(workspaceName); + if (workspaceName) { + workspaceNameValidator.validateSync(workspaceName); + } setWorkspaceNameError(""); } catch (e) { - setWorkspaceNameError(e instanceof ValidationError ? e.message : ""); + if (e instanceof ValidationError) { + setWorkspaceNameError(e.message); + } } }; From 6b576aa0c4438935229ec68a0cc9d4e967a92644 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Thu, 4 Sep 2025 12:36:08 +0200 Subject: [PATCH 3/7] switch mui to input --- .../TemplateEmbedPage.test.tsx | 10 ++-- .../TemplateEmbedPage/TemplateEmbedPage.tsx | 48 ++++++++++++++----- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx index 7c9c60c4c1b1e..187bce78fb1a9 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx @@ -7,7 +7,7 @@ import { renderWithAuth, waitForLoaderToBeRemoved, } from "testHelpers/renderHelpers"; -import { screen, within } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { API } from "api/api"; import { TemplateLayout } from "pages/TemplatePage/TemplateLayout"; @@ -30,9 +30,9 @@ test("Users can fill the parameters and copy the open in coder url", async () => await waitForLoaderToBeRemoved(); const user = userEvent.setup(); - const workspaceName = within( - screen.getByTestId("default-workspace-name"), - ).getByRole("textbox"); + const workspaceName = screen.getByRole("textbox", { + name: "Workspace name", + }); await user.clear(workspaceName); await user.type(workspaceName, "my-first-workspace"); const firstParameterField = screen.getByLabelText( @@ -52,6 +52,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&name=my-first-workspace)`, + `[![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 02880c8ef1aea..c26e10288604c 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx @@ -2,16 +2,17 @@ import Button from "@mui/material/Button"; import FormControlLabel from "@mui/material/FormControlLabel"; import Radio from "@mui/material/Radio"; import RadioGroup from "@mui/material/RadioGroup"; -import TextField from "@mui/material/TextField"; 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 { 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"; @@ -58,6 +59,9 @@ function getClipboardCopyContent( 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"); // skip default workspace name if undefined + } const buttonUrl = `${createWorkspaceUrl}?${createWorkspaceParams.toString()}`; return `[![Open in Coder](${deploymentUrl}/open-in-coder.svg)](${buttonUrl})`; @@ -84,6 +88,7 @@ export const TemplateEmbedPageView: FC = ({ if (templateParameters && !buttonValues) { const buttonValues: ButtonValues = { mode: "manual", + name: "", }; for (const parameter of getInitialRichParameterValues( templateParameters, @@ -108,6 +113,9 @@ export const TemplateEmbedPageView: FC = ({ } }; + const hookId = useId(); + const defaultWorkspaceNameID = `${hookId}-default-workspace-name`; + return ( <> @@ -145,14 +153,21 @@ export const TemplateEmbedPageView: FC = ({ - - + +
({ + color: theme.palette.text.secondary, + })} + > + Default name for the new workspace +
+ { validateWorkspaceName(event.target.value); setButtonValues((buttonValues) => ({ @@ -160,10 +175,17 @@ export const TemplateEmbedPageView: FC = ({ name: event.target.value, })); }} - error={workspaceNameError !== ""} - helperText={workspaceNameError} /> -
+
({ + color: theme.palette.error.main, + })} + > + {workspaceNameError || "\u00A0"} +
+
{templateParameters.length > 0 && (
Date: Fri, 5 Sep 2025 11:02:51 +0200 Subject: [PATCH 4/7] Review fixes --- .../TemplateEmbedPage.test.tsx | 1 - .../TemplateEmbedPage/TemplateEmbedPage.tsx | 19 ++++--------------- .../TemplateEmbedPageView.stories.tsx | 13 +++++++++++++ 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx index 187bce78fb1a9..e647ed7ebc708 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx @@ -33,7 +33,6 @@ test("Users can fill the parameters and copy the open in coder url", async () => const workspaceName = screen.getByRole("textbox", { name: "Workspace name", }); - await user.clear(workspaceName); await user.type(workspaceName, "my-first-workspace"); const firstParameterField = screen.getByLabelText( parameter1.display_name ?? parameter1.name, diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx index c26e10288604c..4734eead5b894 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx @@ -60,7 +60,7 @@ function getClipboardCopyContent( const createWorkspaceUrl = `${deploymentUrl}/templates/${organization}/${templateName}/workspace`; const createWorkspaceParams = new URLSearchParams(buttonValues); if (createWorkspaceParams.get("name") === "") { - createWorkspaceParams.delete("name"); // skip default workspace name if undefined + createWorkspaceParams.delete("name"); // no default workspace name if empty } const buttonUrl = `${createWorkspaceUrl}?${createWorkspaceParams.toString()}`; @@ -157,12 +157,7 @@ export const TemplateEmbedPageView: FC = ({ -
({ - color: theme.palette.text.secondary, - })} - > +
Default name for the new workspace
= ({ })); }} /> -
({ - color: theme.palette.error.main, - })} - > - {workspaceNameError || "\u00A0"} +
+ {workspaceNameError}
diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx index 5eac986498491..6f40729bd3e17 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx @@ -6,6 +6,7 @@ import { MockTemplateVersionParameter4, } from "testHelpers/entities"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import { screen, userEvent } from "storybook/test"; import { TemplateEmbedPageView } from "./TemplateEmbedPage"; const meta: Meta = { @@ -35,3 +36,15 @@ export const WithParameters: Story = { ], }, }; + +export const WrongWorkspaceName: Story = { + args: { + templateParameters: [MockTemplateVersionParameter1], + }, + play: async () => { + const workspaceName = screen.getByRole("textbox", { + name: "Workspace name", + }); + await userEvent.type(workspaceName, "b@d"); + }, +}; From 8fa32b2d905c36606d96165c8a887556edaf2f54 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 5 Sep 2025 11:12:07 +0200 Subject: [PATCH 5/7] Fix storybook --- .../TemplateEmbedPage/TemplateEmbedPageView.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx index 6f40729bd3e17..8aa1ee12334b2 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx @@ -42,7 +42,7 @@ export const WrongWorkspaceName: Story = { templateParameters: [MockTemplateVersionParameter1], }, play: async () => { - const workspaceName = screen.getByRole("textbox", { + const workspaceName = await screen.findByRole("textbox", { name: "Workspace name", }); await userEvent.type(workspaceName, "b@d"); From 98ea683159f91f2d738e12ca16beef9652806bcd Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Fri, 5 Sep 2025 11:22:31 +0200 Subject: [PATCH 6/7] enable debounced validation --- .../TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx index 4734eead5b894..d475215e1e740 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx @@ -9,6 +9,7 @@ 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"; @@ -112,6 +113,10 @@ export const TemplateEmbedPageView: FC = ({ } } }; + const { debounced: debouncedValidateWorkspaceName } = useDebouncedFunction( + validateWorkspaceName, + 500, + ); const hookId = useId(); const defaultWorkspaceNameID = `${hookId}-default-workspace-name`; @@ -164,7 +169,7 @@ export const TemplateEmbedPageView: FC = ({ id={defaultWorkspaceNameID} value={buttonValues.name} onChange={(event) => { - validateWorkspaceName(event.target.value); + debouncedValidateWorkspaceName(event.target.value); setButtonValues((buttonValues) => ({ ...buttonValues, name: event.target.value, From 4122e4c51a8badd03137b75eec1023b3edce20d1 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Mon, 8 Sep 2025 13:49:30 +0200 Subject: [PATCH 7/7] fix: window and padding --- .../TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx index d475215e1e740..de6a8ec91d735 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx @@ -52,12 +52,13 @@ 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") === "") { @@ -162,7 +163,7 @@ export const TemplateEmbedPageView: FC = ({ -
+
Default name for the new workspace