feat: add default workspace name to Template Embed form (#19688)

Fixes: https://github.com/coder/coder/issues/15798
This commit is contained in:
Marcin Tojek
2025-09-08 13:58:24 +02:00
committed by GitHub
parent 0b460b8d95
commit 5c1a708c73
3 changed files with 76 additions and 3 deletions
@@ -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&param.first_parameter=firstParameterValue&param.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&param.first_parameter=firstParameterValue&param.second_parameter=123456)`,
);
});
@@ -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<string, string>;
@@ -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<TemplateEmbedPageViewProps> = ({
template,
templateParameters,
@@ -79,6 +90,7 @@ export const TemplateEmbedPageView: FC<TemplateEmbedPageViewProps> = ({
if (templateParameters && !buttonValues) {
const buttonValues: ButtonValues = {
mode: "manual",
name: "",
};
for (const parameter of getInitialRichParameterValues(
templateParameters,
@@ -89,6 +101,27 @@ export const TemplateEmbedPageView: FC<TemplateEmbedPageViewProps> = ({
}
}, [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 (
<>
<Helmet>
@@ -126,6 +159,29 @@ export const TemplateEmbedPageView: FC<TemplateEmbedPageViewProps> = ({
</RadioGroup>
</FormSection>
<div className="flex flex-col gap-1">
<Label className="text-md" htmlFor={defaultWorkspaceNameID}>
Workspace name
</Label>
<div className="text-sm text-content-secondary pb-3">
Default name for the new workspace
</div>
<Input
id={defaultWorkspaceNameID}
value={buttonValues.name}
onChange={(event) => {
debouncedValidateWorkspaceName(event.target.value);
setButtonValues((buttonValues) => ({
...buttonValues,
name: event.target.value,
}));
}}
/>
<div className="text-sm text-highlight-red mt-1" role="alert">
{workspaceNameError}
</div>
</div>
{templateParameters.length > 0 && (
<div
css={{ display: "flex", flexDirection: "column", gap: 36 }}
@@ -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<typeof TemplateEmbedPageView> = {
@@ -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");
},
};