diff --git a/site/package.json b/site/package.json index 6a8e6ae153658..383d3e0f6457d 100644 --- a/site/package.json +++ b/site/package.json @@ -95,6 +95,7 @@ "ts-prune": "0.10.3", "tzdata": "1.0.30", "ua-parser-js": "1.0.33", + "unique-names-generator": "4.7.1", "uuid": "9.0.0", "vite": "4.4.2", "xstate": "4.38.1", diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index 6aa5fe32b83e8..b34ff6fd97939 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -12,6 +12,7 @@ import { MockTemplateVersionParameter2, MockTemplateVersionParameter3, MockTemplateVersionGitAuth, + MockOrganization, } from "testHelpers/entities" import { renderWithAuth, @@ -217,4 +218,29 @@ describe("CreateWorkspacePage", () => { await screen.findByText("You must authenticate to create a workspace!") }) + + it("auto create a workspace if uses mode=auto", async () => { + const param = "first_parameter" + const paramValue = "It works!" + const createWorkspaceSpy = jest.spyOn(API, "createWorkspace") + + renderWithAuth(<CreateWorkspacePage />, { + route: + "/templates/" + + MockTemplate.name + + `/workspace?param.${param}=${paramValue}&mode=auto`, + path: "/templates/:template/workspace", + }) + + await waitFor(() => { + expect(createWorkspaceSpy).toBeCalledWith( + MockOrganization.id, + "me", + expect.objectContaining({ + template_id: MockTemplate.id, + rich_parameter_values: [{ name: param, value: paramValue }], + }), + ) + }) + }) }) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 613655961582e..d49ea38894ed9 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -1,27 +1,47 @@ import { useMachine } from "@xstate/react" -import { TemplateVersionParameter } from "api/typesGenerated" +import { + Template, + TemplateVersionGitAuth, + TemplateVersionParameter, + WorkspaceBuildParameter, +} from "api/typesGenerated" import { useMe } from "hooks/useMe" import { useOrganizationId } from "hooks/useOrganizationId" import { FC } from "react" import { Helmet } from "react-helmet-async" import { useNavigate, useParams, useSearchParams } from "react-router-dom" import { pageTitle } from "utils/page" -import { createWorkspaceMachine } from "xServices/createWorkspace/createWorkspaceXService" import { - CreateWorkspaceErrors, - CreateWorkspacePageView, -} from "./CreateWorkspacePageView" + CreateWSPermissions, + CreateWorkspaceMode, + createWorkspaceMachine, +} from "xServices/createWorkspace/createWorkspaceXService" +import { CreateWorkspacePageView } from "./CreateWorkspacePageView" +import { Loader } from "components/Loader/Loader" +import { ErrorAlert } from "components/Alert/ErrorAlert" +import { + uniqueNamesGenerator, + animals, + colors, + NumberDictionary, +} from "unique-names-generator" const CreateWorkspacePage: FC = () => { const organizationId = useOrganizationId() const { template: templateName } = useParams() as { template: string } - const navigate = useNavigate() const me = useMe() + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const defaultBuildParameters = getDefaultBuildParameters(searchParams) + const mode = (searchParams.get("mode") ?? "form") as CreateWorkspaceMode const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, { context: { organizationId, templateName, - owner: me, + mode, + defaultBuildParameters, + defaultName: + mode === "auto" ? generateUniqueName() : searchParams.get("name") ?? "", }, actions: { onCreateWorkspace: (_, event) => { @@ -29,83 +49,65 @@ const CreateWorkspacePage: FC = () => { }, }, }) - const { - templates, - templateParameters, - templateGitAuth, - selectedTemplate, - getTemplateGitAuthError, - getTemplatesError, - createWorkspaceError, - permissions, - owner, - } = createWorkspaceState.context - const [searchParams] = useSearchParams() - const defaultParameterValues = getDefaultParameterValues(searchParams) - const name = getName(searchParams) + const { template, error, parameters, permissions, gitAuth, defaultName } = + createWorkspaceState.context + const title = createWorkspaceState.matches("autoCreating") + ? "Creating workspace..." + : "Create Workspace" return ( <> <Helmet> - <title>{pageTitle("Create Workspace")}</title> + <title>{pageTitle(title)}</title> </Helmet> - <CreateWorkspacePageView - name={name} - defaultParameterValues={defaultParameterValues} - loadingTemplates={createWorkspaceState.matches("gettingTemplates")} - creatingWorkspace={createWorkspaceState.matches("creatingWorkspace")} - hasTemplateErrors={createWorkspaceState.matches("error")} - templateName={templateName} - templates={templates} - selectedTemplate={selectedTemplate} - templateParameters={orderedTemplateParameters(templateParameters)} - templateGitAuth={templateGitAuth} - createWorkspaceErrors={{ - [CreateWorkspaceErrors.GET_TEMPLATES_ERROR]: getTemplatesError, - [CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]: createWorkspaceError, - [CreateWorkspaceErrors.GET_TEMPLATE_GITAUTH_ERROR]: - getTemplateGitAuthError, - }} - canCreateForUser={permissions?.createWorkspaceForUser} - owner={owner} - setOwner={(user) => { - send({ - type: "SELECT_OWNER", - owner: user, - }) - }} - onCancel={() => { - // Go back - navigate(-1) - }} - onSubmit={(request) => { - send({ - type: "CREATE_WORKSPACE", - request, - owner, - }) - }} - /> + {Boolean( + createWorkspaceState.matches("loadingFormData") || + createWorkspaceState.matches("autoCreating"), + ) && <Loader />} + {createWorkspaceState.matches("loadError") && ( + <ErrorAlert error={error} /> + )} + {createWorkspaceState.matches("idle") && ( + <CreateWorkspacePageView + defaultName={defaultName} + defaultOwner={me} + defaultBuildParameters={defaultBuildParameters} + error={error} + template={template as Template} + gitAuth={gitAuth as TemplateVersionGitAuth[]} + permissions={permissions as CreateWSPermissions} + parameters={parameters as TemplateVersionParameter[]} + creatingWorkspace={createWorkspaceState.matches("creatingWorkspace")} + onCancel={() => { + navigate(-1) + }} + onSubmit={(request, owner) => { + send({ + type: "CREATE_WORKSPACE", + request, + owner, + }) + }} + /> + )} </> ) } -const getName = (urlSearchParams: URLSearchParams): string => { - return urlSearchParams.get("name") ?? "" -} +export default CreateWorkspacePage -const getDefaultParameterValues = ( +const getDefaultBuildParameters = ( urlSearchParams: URLSearchParams, -): Record<string, string> => { - const paramValues: Record<string, string> = {} +): WorkspaceBuildParameter[] => { + const buildValues: WorkspaceBuildParameter[] = [] Array.from(urlSearchParams.keys()) .filter((key) => key.startsWith("param.")) .forEach((key) => { - const paramName = key.replace("param.", "") - const paramValue = urlSearchParams.get(key) - paramValues[paramName] = paramValue ?? "" + const name = key.replace("param.", "") + const value = urlSearchParams.get(key) ?? "" + buildValues.push({ name, value }) }) - return paramValues + return buildValues } export const orderedTemplateParameters = ( @@ -122,4 +124,12 @@ export const orderedTemplateParameters = ( return [...immutables, ...mutables] } -export default CreateWorkspacePage +const generateUniqueName = () => { + const numberDictionary = NumberDictionary.generate({ min: 0, max: 99 }) + return uniqueNamesGenerator({ + dictionaries: [colors, animals, numberDictionary], + separator: "-", + length: 3, + style: "lowerCase", + }) +} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index 94ac17a40d9c7..51cfc8c69b66f 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -1,64 +1,38 @@ -import { ComponentMeta, Story } from "@storybook/react" +import { Meta, StoryObj } from "@storybook/react" import { mockApiError, MockTemplate, MockTemplateVersionParameter1, MockTemplateVersionParameter2, MockTemplateVersionParameter3, + MockUser, } from "../../testHelpers/entities" -import { - CreateWorkspaceErrors, - CreateWorkspacePageView, - CreateWorkspacePageViewProps, -} from "./CreateWorkspacePageView" +import { CreateWorkspacePageView } from "./CreateWorkspacePageView" -export default { - title: "pages/CreateWorkspacePageView", +const meta: Meta<typeof CreateWorkspacePageView> = { + title: "components/Alert", component: CreateWorkspacePageView, -} as ComponentMeta<typeof CreateWorkspacePageView> - -const Template: Story<CreateWorkspacePageViewProps> = (args) => ( - <CreateWorkspacePageView {...args} /> -) - -export const NoParameters = Template.bind({}) -NoParameters.args = { - templates: [MockTemplate], - selectedTemplate: MockTemplate, - createWorkspaceErrors: {}, -} - -export const Parameters = Template.bind({}) -Parameters.args = { - templates: [MockTemplate], - selectedTemplate: MockTemplate, - createWorkspaceErrors: {}, + args: { + defaultName: "", + defaultOwner: MockUser, + defaultBuildParameters: [], + template: MockTemplate, + parameters: [], + gitAuth: [], + permissions: { + createWorkspaceForUser: true, + }, + }, } -export const RedisplayParameters = Template.bind({}) -RedisplayParameters.args = { - templates: [MockTemplate], - selectedTemplate: MockTemplate, - createWorkspaceErrors: {}, -} +export default meta +type Story = StoryObj<typeof CreateWorkspacePageView> -export const GetTemplatesError = Template.bind({}) -GetTemplatesError.args = { - ...Parameters.args, - createWorkspaceErrors: { - [CreateWorkspaceErrors.GET_TEMPLATES_ERROR]: mockApiError({ - message: "Failed to fetch templates.", - detail: "You do not have permission to access this resource.", - }), - }, - hasTemplateErrors: true, -} +export const NoParameters: Story = {} -export const CreateWorkspaceError = Template.bind({}) -CreateWorkspaceError.args = { - ...Parameters.args, - createWorkspaceErrors: { - [CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]: mockApiError({ +export const CreateWorkspaceError: Story = { + args: { + error: mockApiError({ message: 'Workspace "test" already exists in the "docker-amd64" template.', validations: [ @@ -69,72 +43,64 @@ CreateWorkspaceError.args = { ], }), }, - initialTouched: { - name: true, - }, } -export const RichParameters = Template.bind({}) -RichParameters.args = { - templates: [MockTemplate], - selectedTemplate: MockTemplate, - templateParameters: [ - MockTemplateVersionParameter1, - MockTemplateVersionParameter2, - MockTemplateVersionParameter3, - { - name: "Region", - required: false, - description: "", - description_plaintext: "", - type: "string", - mutable: false, - default_value: "", - icon: "/emojis/1f30e.png", - options: [ - { - name: "Pittsburgh", - description: "", - value: "us-pittsburgh", - icon: "/emojis/1f1fa-1f1f8.png", - }, - { - name: "Helsinki", - description: "", - value: "eu-helsinki", - icon: "/emojis/1f1eb-1f1ee.png", - }, - { - name: "Sydney", - description: "", - value: "ap-sydney", - icon: "/emojis/1f1e6-1f1fa.png", - }, - ], - ephemeral: false, - }, - ], - createWorkspaceErrors: {}, +export const Parameters: Story = { + args: { + parameters: [ + MockTemplateVersionParameter1, + MockTemplateVersionParameter2, + MockTemplateVersionParameter3, + { + name: "Region", + required: false, + description: "", + description_plaintext: "", + type: "string", + mutable: false, + default_value: "", + icon: "/emojis/1f30e.png", + options: [ + { + name: "Pittsburgh", + description: "", + value: "us-pittsburgh", + icon: "/emojis/1f1fa-1f1f8.png", + }, + { + name: "Helsinki", + description: "", + value: "eu-helsinki", + icon: "/emojis/1f1eb-1f1ee.png", + }, + { + name: "Sydney", + description: "", + value: "ap-sydney", + icon: "/emojis/1f1e6-1f1fa.png", + }, + ], + ephemeral: false, + }, + ], + }, } -export const GitAuth = Template.bind({}) -GitAuth.args = { - templates: [MockTemplate], - selectedTemplate: MockTemplate, - createWorkspaceErrors: {}, - templateParameters: [], - templateGitAuth: [ - { - id: "github", - type: "github", - authenticated: false, - authenticate_url: "", - }, - { - id: "gitlab", - type: "gitlab", - authenticated: true, - authenticate_url: "", - }, - ], +export const GitAuth: Story = { + args: { + gitAuth: [ + { + id: "github", + type: "github", + authenticated: false, + authenticate_url: "", + }, + { + id: "gitlab", + type: "gitlab", + authenticated: true, + authenticate_url: "", + }, + ], + }, } diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index eee8f4875990d..ecc16b9b5ce97 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -1,16 +1,13 @@ import TextField from "@mui/material/TextField" import * as TypesGen from "api/typesGenerated" -import { Stack } from "components/Stack/Stack" import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete" -import { FormikContextType, FormikTouched, useFormik } from "formik" +import { FormikContextType, useFormik } from "formik" import { FC, useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { getFormHelpers, nameValidator, onChangeTrimmed } from "utils/formUtils" import * as Yup from "yup" import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm" import { SelectedTemplate } from "./SelectedTemplate" -import { Loader } from "components/Loader/Loader" -import { GitAuth } from "components/GitAuth/GitAuth" import { FormFields, FormSection, @@ -27,171 +24,92 @@ import { ImmutableTemplateParametersSection, MutableTemplateParametersSection, } from "components/TemplateParameters/TemplateParameters" +import { CreateWSPermissions } from "xServices/createWorkspace/createWorkspaceXService" +import { GitAuth } from "components/GitAuth/GitAuth" import { ErrorAlert } from "components/Alert/ErrorAlert" -import { paramUsedToCreateWorkspace } from "utils/workspace" - -export enum CreateWorkspaceErrors { - GET_TEMPLATES_ERROR = "getTemplatesError", - GET_TEMPLATE_GITAUTH_ERROR = "getTemplateGitAuthError", - CREATE_WORKSPACE_ERROR = "createWorkspaceError", -} export interface CreateWorkspacePageViewProps { - name: string - loadingTemplates: boolean + error: unknown + defaultName: string + defaultOwner: TypesGen.User + template: TypesGen.Template + gitAuth: TypesGen.TemplateVersionGitAuth[] + parameters: TypesGen.TemplateVersionParameter[] + defaultBuildParameters: TypesGen.WorkspaceBuildParameter[] + permissions: CreateWSPermissions creatingWorkspace: boolean - hasTemplateErrors: boolean - templateName: string - templates?: TypesGen.Template[] - selectedTemplate?: TypesGen.Template - templateParameters?: TypesGen.TemplateVersionParameter[] - templateGitAuth?: TypesGen.TemplateVersionGitAuth[] - createWorkspaceErrors: Partial<Record<CreateWorkspaceErrors, Error | unknown>> - canCreateForUser?: boolean - owner: TypesGen.User | null - setOwner: (arg0: TypesGen.User | null) => void onCancel: () => void - onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void - // initialTouched is only used for testing the error state of the form. - initialTouched?: FormikTouched<TypesGen.CreateWorkspaceRequest> - defaultParameterValues?: Record<string, string> + onSubmit: (req: TypesGen.CreateWorkspaceRequest, owner: TypesGen.User) => void } -export const CreateWorkspacePageView: FC< - React.PropsWithChildren<CreateWorkspacePageViewProps> -> = (props) => { - const templateParameters = props.templateParameters?.filter( - paramUsedToCreateWorkspace, - ) +export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({ + error, + defaultName, + defaultOwner, + template, + gitAuth, + parameters, + defaultBuildParameters, + permissions, + creatingWorkspace, + onSubmit, + onCancel, +}) => { const initialRichParameterValues = selectInitialRichParametersValues( - templateParameters, - props.defaultParameterValues, + parameters, + defaultBuildParameters, ) - const [gitAuthErrors, setGitAuthErrors] = useState<Record<string, string>>({}) - useEffect(() => { - // templateGitAuth is refreshed automatically using a BroadcastChannel - // which may change the `authenticated` property. - // - // If the provider becomes authenticated, we want the error message - // to disappear. - setGitAuthErrors({}) - }, [props.templateGitAuth]) - const workspaceErrors = - props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR] - // Scroll to top of page if errors are present - useEffect(() => { - if (props.hasTemplateErrors || Boolean(workspaceErrors)) { - window.scrollTo(0, 0) - } - }, [props.hasTemplateErrors, workspaceErrors]) const { t } = useTranslation("createWorkspacePage") const styles = useStyles() + const [owner, setOwner] = useState(defaultOwner) + const { verifyGitAuth, gitAuthErrors } = useGitAuthVerification(gitAuth) const form: FormikContextType<TypesGen.CreateWorkspaceRequest> = useFormik<TypesGen.CreateWorkspaceRequest>({ initialValues: { - name: props.name, - template_id: props.selectedTemplate ? props.selectedTemplate.id : "", + name: defaultName, + template_id: template.id, rich_parameter_values: initialRichParameterValues, }, validationSchema: Yup.object({ name: nameValidator(t("nameLabel", { ns: "createWorkspacePage" })), rich_parameter_values: useValidationSchemaForRichParameters( "createWorkspacePage", - templateParameters, + parameters, ), }), enableReinitialize: true, - initialTouched: props.initialTouched, onSubmit: (request) => { - for (let i = 0; i < (props.templateGitAuth?.length || 0); i++) { - const auth = props.templateGitAuth?.[i] - if (!auth) { - continue - } - if (!auth.authenticated) { - setGitAuthErrors({ - [auth.id]: "You must authenticate to create a workspace!", - }) - form.setSubmitting(false) - return - } + if (!verifyGitAuth()) { + form.setSubmitting(false) + return } - props.onSubmit({ - ...request, - }) - form.setSubmitting(false) + + onSubmit(request, owner) }, }) - const isLoading = props.loadingTemplates + useEffect(() => { + if (error) { + window.scrollTo(0, 0) + } + }, [error]) const getFieldHelpers = getFormHelpers<TypesGen.CreateWorkspaceRequest>( form, - props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR], + error, ) - if (isLoading) { - return <Loader /> - } - return ( - <FullPageHorizontalForm title="New workspace" onCancel={props.onCancel}> + <FullPageHorizontalForm title="New workspace" onCancel={onCancel}> <HorizontalForm onSubmit={form.handleSubmit}> - {Boolean(props.hasTemplateErrors) && ( - <Stack> - {Boolean( - props.createWorkspaceErrors[ - CreateWorkspaceErrors.GET_TEMPLATES_ERROR - ], - ) && ( - <ErrorAlert - error={ - props.createWorkspaceErrors[ - CreateWorkspaceErrors.GET_TEMPLATES_ERROR - ] - } - /> - )} - {Boolean( - props.createWorkspaceErrors[ - CreateWorkspaceErrors.GET_TEMPLATE_GITAUTH_ERROR - ], - ) && ( - <ErrorAlert - error={ - props.createWorkspaceErrors[ - CreateWorkspaceErrors.GET_TEMPLATE_GITAUTH_ERROR - ] - } - /> - )} - </Stack> - )} - - {Boolean( - props.createWorkspaceErrors[ - CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR - ], - ) && ( - <ErrorAlert - error={ - props.createWorkspaceErrors[ - CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR - ] - } - /> - )} - + {Boolean(error) && <ErrorAlert error={error} />} {/* General info */} <FormSection title="General" description="The template and name of your new workspace." > <FormFields> - {props.selectedTemplate && ( - <SelectedTemplate template={props.selectedTemplate} /> - )} - + <SelectedTemplate template={template} /> <TextField {...getFieldHelpers("name")} disabled={form.isSubmitting} @@ -203,16 +121,17 @@ export const CreateWorkspacePageView: FC< </FormFields> </FormSection> - {/* Workspace owner */} - {props.canCreateForUser && ( + {permissions.createWorkspaceForUser && ( <FormSection title="Workspace Owner" description="Only admins can create workspace for other users." > <FormFields> <UserAutocomplete - value={props.owner} - onChange={props.setOwner} + value={owner} + onChange={(user) => { + setOwner(user ?? defaultOwner) + }} label={t("ownerLabel").toString()} size="medium" /> @@ -220,14 +139,13 @@ export const CreateWorkspacePageView: FC< </FormSection> )} - {/* Template git auth */} - {props.templateGitAuth && props.templateGitAuth.length > 0 && ( + {gitAuth && gitAuth.length > 0 && ( <FormSection title="Git Authentication" description="This template requires authentication to automatically perform Git operations on create." > <FormFields> - {props.templateGitAuth.map((auth, index) => ( + {gitAuth.map((auth, index) => ( <GitAuth key={index} authenticateURL={auth.authenticate_url} @@ -240,10 +158,10 @@ export const CreateWorkspacePageView: FC< </FormSection> )} - {templateParameters && ( + {parameters && ( <> <MutableTemplateParametersSection - templateParameters={templateParameters} + templateParameters={parameters} getInputProps={(parameter, index) => { return { ...getFieldHelpers( @@ -264,7 +182,7 @@ export const CreateWorkspacePageView: FC< }} /> <ImmutableTemplateParametersSection - templateParameters={templateParameters} + templateParameters={parameters} classes={{ root: styles.warningSection }} getInputProps={(parameter, index) => { return { @@ -289,8 +207,8 @@ export const CreateWorkspacePageView: FC< )} <FormFooter - onCancel={props.onCancel} - isLoading={props.creatingWorkspace} + onCancel={onCancel} + isLoading={creatingWorkspace} submitLabel={t("createWorkspace").toString()} /> </HorizontalForm> @@ -298,6 +216,44 @@ export const CreateWorkspacePageView: FC< ) } +type GitAuthErrors = Record<string, string> + +const useGitAuthVerification = (gitAuth: TypesGen.TemplateVersionGitAuth[]) => { + const [gitAuthErrors, setGitAuthErrors] = useState<GitAuthErrors>({}) + + useEffect(() => { + // templateGitAuth is refreshed automatically using a BroadcastChannel + // which may change the `authenticated` property. + // + // If the provider becomes authenticated, we want the error message + // to disappear. + setGitAuthErrors({}) + }, [gitAuth]) + + const verifyGitAuth = () => { + const errors: GitAuthErrors = {} + + for (let i = 0; i < gitAuth.length; i++) { + const auth = gitAuth.at(i) + if (!auth) { + continue + } + if (!auth.authenticated) { + errors[auth.id] = "You must authenticate to create a workspace!" + } + } + + setGitAuthErrors(errors) + const isValid = Object.keys(errors).length === 0 + return isValid + } + + return { + gitAuthErrors, + verifyGitAuth, + } +} + const useStyles = makeStyles((theme) => ({ warningText: { color: theme.palette.warning.light, diff --git a/site/src/pages/GitAuthPage/GitAuthPage.tsx b/site/src/pages/GitAuthPage/GitAuthPage.tsx index 97013b7b13fb4..efe62ad6a9f25 100644 --- a/site/src/pages/GitAuthPage/GitAuthPage.tsx +++ b/site/src/pages/GitAuthPage/GitAuthPage.tsx @@ -7,10 +7,10 @@ import { import { usePermissions } from "hooks" import { FC, useEffect } from "react" import { useParams } from "react-router-dom" -import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "xServices/createWorkspace/createWorkspaceXService" import GitAuthPageView from "./GitAuthPageView" import { ApiErrorResponse } from "api/errors" import { isAxiosError } from "axios" +import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "utils/gitAuth" const GitAuthPage: FC = () => { const { provider } = useParams() @@ -58,7 +58,6 @@ const GitAuthPage: FC = () => { } // This is used to notify the parent window that the Git auth token has been refreshed. // It's critical in the create workspace flow! - // eslint-disable-next-line compat/compat -- It actually is supported... not sure why it's complaining. const bc = new BroadcastChannel(REFRESH_GITAUTH_BROADCAST_CHANNEL) // The message doesn't matter, any message refreshes the page! bc.postMessage("noop") diff --git a/site/src/pages/GitAuthPage/GitAuthPageView.tsx b/site/src/pages/GitAuthPage/GitAuthPageView.tsx index 4bf1acded55ba..ce46539ac366a 100644 --- a/site/src/pages/GitAuthPage/GitAuthPageView.tsx +++ b/site/src/pages/GitAuthPage/GitAuthPageView.tsx @@ -12,7 +12,7 @@ import { CopyButton } from "components/CopyButton/CopyButton" import { SignInLayout } from "components/SignInLayout/SignInLayout" import { Welcome } from "components/Welcome/Welcome" import { FC, useEffect } from "react" -import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "xServices/createWorkspace/createWorkspaceXService" +import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "utils/gitAuth" export interface GitAuthPageViewProps { gitAuth: GitAuth diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx index 7da5ae0555940..898e89efeb24f 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx @@ -47,6 +47,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( - `[](http://localhost/templates/test-template/workspace?param.first_parameter=firstParameterValue¶m.second_parameter=123456)`, + `[](http://localhost/templates/test-template/workspace?mode=manual¶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 8874bc8d2bed0..32b9ffcd39afc 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx @@ -2,10 +2,13 @@ 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 FormControlLabel from "@mui/material/FormControlLabel" +import Radio from "@mui/material/Radio" +import RadioGroup from "@mui/material/RadioGroup" import { useQuery } from "@tanstack/react-query" import { getTemplateVersionRichParameters } from "api/api" import { Template, TemplateVersionParameter } from "api/typesGenerated" -import { VerticalForm } from "components/Form/Form" +import { FormSection, VerticalForm } from "components/Form/Form" import { Loader } from "components/Loader/Loader" import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayout" import { @@ -21,7 +24,7 @@ import { selectInitialRichParametersValues, workspaceBuildParameterValue, } from "utils/richParameters" -import { paramUsedToCreateWorkspace } from "utils/workspace" +import { paramsUsedToCreateWorkspace } from "utils/workspace" type ButtonValues = Record<string, string> @@ -40,7 +43,7 @@ const TemplateEmbedPage = () => { <TemplateEmbedPageView template={template} templateParameters={templateParameters?.filter( - paramUsedToCreateWorkspace, + paramsUsedToCreateWorkspace, )} /> </> @@ -51,7 +54,9 @@ export const TemplateEmbedPageView: FC<{ template: Template templateParameters?: TemplateVersionParameter[] }> = ({ template, templateParameters }) => { - const [buttonValues, setButtonValues] = useState<ButtonValues>({}) + const [buttonValues, setButtonValues] = useState<ButtonValues>({ + mode: "manual", + }) const initialRichParametersValues = templateParameters ? selectInitialRichParametersValues(templateParameters) : undefined @@ -92,20 +97,48 @@ export const TemplateEmbedPageView: FC<{ <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 flex={1} maxWidth={400}> + <VerticalForm> + <FormSection + title="Creation mode" + description="By changing the mode to automatic, when the user clicks the button, the workspace will be created automatically instead of showing a form to the user." + > + <RadioGroup + defaultValue={buttonValues.mode} + onChange={(_, v) => { + setButtonValues((buttonValues) => ({ + ...buttonValues, + mode: v, + })) + }} + > + <FormControlLabel + value="manual" + control={<Radio size="small" />} + label="Manual" + /> + <FormControlLabel + value="auto" + control={<Radio size="small" />} + label="Automatic" + /> + </RadioGroup> + </FormSection> + + {templateParameters.length > 0 && ( + <> + <MutableTemplateParametersSection + templateParameters={templateParameters} + getInputProps={getInputProps} + /> + <ImmutableTemplateParametersSection + templateParameters={templateParameters} + getInputProps={getInputProps} + /> + </> + )} + </VerticalForm> + </Box> <Box display="flex" height={{ diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx index f1330ab0b0727..39860cccb78d9 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPageView.stories.tsx @@ -20,7 +20,7 @@ const meta: Meta<typeof TemplateEmbedPageView> = { export default meta type Story = StoryObj<typeof TemplateEmbedPageView> -export const Empty: Story = { +export const NoParameters: Story = { args: { templateParameters: [], }, diff --git a/site/src/utils/gitAuth.ts b/site/src/utils/gitAuth.ts new file mode 100644 index 0000000000000..11a15668f1bc0 --- /dev/null +++ b/site/src/utils/gitAuth.ts @@ -0,0 +1 @@ +export const REFRESH_GITAUTH_BROADCAST_CHANNEL = "gitauth_refresh" diff --git a/site/src/utils/richParameters.test.ts b/site/src/utils/richParameters.test.ts new file mode 100644 index 0000000000000..66ccb3ac6b068 --- /dev/null +++ b/site/src/utils/richParameters.test.ts @@ -0,0 +1,53 @@ +import { TemplateVersionParameter } from "api/typesGenerated" +import { selectInitialRichParametersValues } from "./richParameters" + +test("selectInitialRichParametersValues return default value when default build parameter is not valid", () => { + const templateParameters: TemplateVersionParameter[] = [ + { + name: "cpu", + display_name: "CPU", + description: "The number of CPU cores", + description_plaintext: "The number of CPU cores", + type: "string", + mutable: true, + default_value: "2", + icon: "/icon/memory.svg", + options: [ + { + name: "2 Cores", + description: "", + value: "2", + icon: "", + }, + { + name: "4 Cores", + description: "", + value: "4", + icon: "", + }, + { + name: "6 Cores", + description: "", + value: "6", + icon: "", + }, + { + name: "8 Cores", + description: "", + value: "8", + icon: "", + }, + ], + required: false, + ephemeral: false, + }, + ] + + const cpuParameter = templateParameters[0] + const [cpuParameterInitialValue] = selectInitialRichParametersValues( + templateParameters, + [{ name: cpuParameter.name, value: "100" }], + ) + + expect(cpuParameterInitialValue.value).toBe(cpuParameter.default_value) +}) diff --git a/site/src/utils/richParameters.ts b/site/src/utils/richParameters.ts index f4c87eff48bd9..6fac6c33f25f4 100644 --- a/site/src/utils/richParameters.ts +++ b/site/src/utils/richParameters.ts @@ -7,7 +7,7 @@ import * as Yup from "yup" export const selectInitialRichParametersValues = ( templateParameters?: TemplateVersionParameter[], - defaultValuesFromQuery?: Record<string, string>, + defaultBuildParameters?: WorkspaceBuildParameter[], ): WorkspaceBuildParameter[] => { const defaults: WorkspaceBuildParameter[] = [] if (!templateParameters) { @@ -19,9 +19,20 @@ export const selectInitialRichParametersValues = ( if (parameter.options.length > 0) { parameterValue = parameterValue ?? parameter.options[0].value + const validValues = parameter.options.map((option) => option.value) - if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) { - parameterValue = defaultValuesFromQuery[parameter.name] + if (defaultBuildParameters) { + const defaultBuildParameter = defaultBuildParameters.find( + (p) => p.name === parameter.name, + ) + + // We don't want invalid values from default parameters to be set + if ( + defaultBuildParameter && + validValues.includes(defaultBuildParameter.value) + ) { + parameterValue = defaultBuildParameter?.value + } } const buildParameter: WorkspaceBuildParameter = { @@ -36,8 +47,14 @@ export const selectInitialRichParametersValues = ( parameterValue = parameter.default_value } - if (defaultValuesFromQuery && defaultValuesFromQuery[parameter.name]) { - parameterValue = defaultValuesFromQuery[parameter.name] + if (defaultBuildParameters) { + const buildParameter = defaultBuildParameters.find( + (p) => p.name === parameter.name, + ) + + if (buildParameter) { + parameterValue = buildParameter?.value + } } const buildParameter: WorkspaceBuildParameter = { diff --git a/site/src/utils/workspace.tsx b/site/src/utils/workspace.tsx index 61213bf21b81f..26b436aafe79e 100644 --- a/site/src/utils/workspace.tsx +++ b/site/src/utils/workspace.tsx @@ -286,6 +286,6 @@ export const hasJobError = (workspace: TypesGen.Workspace) => { return workspace.latest_build.job.error !== undefined } -export const paramUsedToCreateWorkspace = ( +export const paramsUsedToCreateWorkspace = ( param: TypesGen.TemplateVersionParameter, ) => !param.ephemeral diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts index a052e92d48f72..be7b0b0779db6 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -1,7 +1,7 @@ import { checkAuthorization, createWorkspace, - getTemplates, + getTemplateByName, getTemplateVersionGitAuth, getTemplateVersionRichParameters, } from "api/api" @@ -12,38 +12,33 @@ import { TemplateVersionParameter, User, Workspace, + WorkspaceBuildParameter, } from "api/typesGenerated" import { assign, createMachine } from "xstate" +import { paramsUsedToCreateWorkspace } from "utils/workspace" +import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "utils/gitAuth" -export const REFRESH_GITAUTH_BROADCAST_CHANNEL = "gitauth_refresh" +export type CreateWorkspaceMode = "form" | "auto" type CreateWorkspaceContext = { organizationId: string - owner: User | null templateName: string - templates?: Template[] - selectedTemplate?: Template - templateParameters?: TemplateVersionParameter[] - templateGitAuth?: TemplateVersionGitAuth[] - createWorkspaceRequest?: CreateWorkspaceRequest - createdWorkspace?: Workspace - createWorkspaceError?: Error | unknown - getTemplatesError?: Error | unknown - getTemplateParametersError?: Error | unknown - getTemplateGitAuthError?: Error | unknown + mode: CreateWorkspaceMode + defaultName: string + error?: Error | unknown + // Form + template?: Template + parameters?: TemplateVersionParameter[] permissions?: Record<string, boolean> - checkPermissionsError?: Error | unknown + gitAuth?: TemplateVersionGitAuth[] + // Used on auto-create + defaultBuildParameters?: WorkspaceBuildParameter[] } type CreateWorkspaceEvent = { type: "CREATE_WORKSPACE" request: CreateWorkspaceRequest - owner: User | null -} - -type SelectOwnerEvent = { - type: "SELECT_OWNER" - owner: User | null + owner: User } type RefreshGitAuthEvent = { @@ -59,117 +54,79 @@ export const createWorkspaceMachine = tsTypes: {} as import("./createWorkspaceXService.typegen").Typegen0, schema: { context: {} as CreateWorkspaceContext, - events: {} as - | CreateWorkspaceEvent - | SelectOwnerEvent - | RefreshGitAuthEvent, + events: {} as CreateWorkspaceEvent | RefreshGitAuthEvent, services: {} as { - getTemplates: { - data: Template[] - } - getTemplateGitAuth: { - data: TemplateVersionGitAuth[] - } - getTemplateParameters: { - data: TemplateVersionParameter[] + loadFormData: { + data: { + template: Template + permissions: CreateWSPermissions + parameters: TemplateVersionParameter[] + gitAuth: TemplateVersionGitAuth[] + } } createWorkspace: { data: Workspace } + autoCreateWorkspace: { + data: Workspace + } }, }, - initial: "gettingTemplates", + initial: "checkingMode", states: { - gettingTemplates: { - entry: "clearGetTemplatesError", - invoke: { - src: "getTemplates", - onDone: [ - { - actions: ["assignTemplates"], - cond: "areTemplatesEmpty", - }, - { - actions: ["assignTemplates", "assignSelectedTemplate"], - target: "gettingTemplateParameters", - }, - ], - onError: { - actions: ["assignGetTemplatesError"], - target: "error", - }, - }, - }, - gettingTemplateParameters: { - entry: "clearGetTemplateParametersError", - invoke: { - src: "getTemplateParameters", - onDone: { - actions: ["assignTemplateParameters"], - target: "checkingPermissions", - }, - onError: { - actions: ["assignGetTemplateParametersError"], - target: "error", + checkingMode: { + always: [ + { + target: "autoCreating", + cond: ({ mode }) => mode === "auto", }, - }, + { target: "loadingFormData" }, + ], }, - checkingPermissions: { - entry: "clearCheckPermissionsError", + autoCreating: { invoke: { - src: "checkPermissions", - id: "checkPermissions", + src: "autoCreateWorkspace", onDone: { - actions: "assignPermissions", - target: "gettingTemplateGitAuth", + actions: ["onCreateWorkspace"], }, onError: { - actions: ["assignCheckPermissionsError"], + actions: ["assignError"], + target: "loadingFormData", }, }, }, - gettingTemplateGitAuth: { - entry: "clearTemplateGitAuthError", + loadingFormData: { invoke: { - src: "getTemplateGitAuth", + src: "loadFormData", onDone: { - actions: ["assignTemplateGitAuth"], - target: "fillingParams", + target: "idle", + actions: ["assignFormData"], }, onError: { - actions: ["assignTemplateGitAuthError"], - target: "error", + target: "loadError", + actions: ["assignError"], }, }, }, - fillingParams: { - invoke: { - id: "listenForRefreshGitAuth", - src: () => (callback) => { - // eslint-disable-next-line compat/compat -- It actually is supported... not sure why eslint is complaining. - const bc = new BroadcastChannel(REFRESH_GITAUTH_BROADCAST_CHANNEL) - bc.addEventListener("message", () => { - callback("REFRESH_GITAUTH") - }) - return () => bc.close() + idle: { + invoke: [ + { + src: () => (callback) => { + const channel = watchGitAuthRefresh(() => { + callback("REFRESH_GITAUTH") + }) + return () => channel.close() + }, }, - }, + ], on: { CREATE_WORKSPACE: { - actions: ["assignCreateWorkspaceRequest", "assignOwner"], target: "creatingWorkspace", }, - SELECT_OWNER: { - actions: ["assignOwner"], - target: ["fillingParams"], - }, - REFRESH_GITAUTH: { - target: "gettingTemplateGitAuth", - }, }, }, creatingWorkspace: { - entry: "clearCreateWorkspaceError", + entry: "clearError", invoke: { src: "createWorkspace", onDone: { @@ -177,137 +134,100 @@ export const createWorkspaceMachine = target: "created", }, onError: { - actions: ["assignCreateWorkspaceError"], - target: "fillingParams", + actions: ["assignError"], + target: "idle", }, }, }, created: { type: "final", }, - error: {}, + loadError: { + type: "final", + }, }, }, { services: { - getTemplates: (context) => getTemplates(context.organizationId), - getTemplateGitAuth: (context) => { - const { selectedTemplate } = context - - if (!selectedTemplate) { - throw new Error("No selected template") - } - - return getTemplateVersionGitAuth(selectedTemplate.active_version_id) - }, - getTemplateParameters: (context) => { - const { selectedTemplate } = context - - if (!selectedTemplate) { - throw new Error("No selected template") - } - - return getTemplateVersionRichParameters( - selectedTemplate.active_version_id, - ) + createWorkspace: ({ organizationId }, { request, owner }) => { + return createWorkspace(organizationId, owner.id, request) }, - checkPermissions: async (context) => { - if (!context.organizationId) { - throw new Error("No organization ID") - } - - // HACK: below, we pass in * for the owner_id, which is a hacky way of checking if the - // current user can create a workspace on behalf of anyone within the org (only org owners should be able to do this). - // This pattern should not be replicated outside of this narrow use case. - const permissionsToCheck = { - createWorkspaceForUser: { - object: { - resource_type: "workspace", - organization_id: `${context.organizationId}`, - owner_id: "*", - }, - action: "create", - }, - } as const - - return checkAuthorization({ - checks: permissionsToCheck, + autoCreateWorkspace: async ({ + templateName, + organizationId, + defaultBuildParameters, + defaultName, + }) => { + const template = await getTemplateByName(organizationId, templateName) + return createWorkspace(organizationId, "me", { + template_id: template.id, + name: defaultName, + rich_parameter_values: defaultBuildParameters, }) }, - createWorkspace: (context) => { - const { createWorkspaceRequest, organizationId, owner } = context + loadFormData: async ({ templateName, organizationId }) => { + const [template, permissions] = await Promise.all([ + getTemplateByName(organizationId, templateName), + checkCreateWSPermissions(organizationId), + ]) + const [parameters, gitAuth] = await Promise.all([ + getTemplateVersionRichParameters(template.active_version_id).then( + (p) => p.filter(paramsUsedToCreateWorkspace), + ), + getTemplateVersionGitAuth(template.active_version_id), + ]) - if (!createWorkspaceRequest) { - throw new Error("No create workspace request") + return { + template, + permissions, + parameters, + gitAuth, } - - return createWorkspace( - organizationId, - owner?.id ?? "me", - createWorkspaceRequest, - ) }, }, - guards: { - areTemplatesEmpty: (_, event) => event.data.length === 0, - }, actions: { - assignTemplates: assign({ - templates: (_, event) => event.data, - }), - assignSelectedTemplate: assign({ - selectedTemplate: (ctx, event) => { - const templates = event.data.filter( - (template) => template.name === ctx.templateName, - ) - return templates.length > 0 ? templates[0] : undefined - }, - }), - assignTemplateParameters: assign({ - templateParameters: (_, event) => event.data, - }), - assignPermissions: assign({ - permissions: (_, event) => event.data as Record<string, boolean>, - }), - assignCheckPermissionsError: assign({ - checkPermissionsError: (_, event) => event.data, - }), - clearCheckPermissionsError: assign({ - checkPermissionsError: (_) => undefined, - }), - assignCreateWorkspaceRequest: assign({ - createWorkspaceRequest: (_, event) => event.request, - }), - assignOwner: assign({ - owner: (_, event) => event.owner, - }), - assignCreateWorkspaceError: assign({ - createWorkspaceError: (_, event) => event.data, - }), - clearCreateWorkspaceError: assign({ - createWorkspaceError: (_) => undefined, - }), - assignGetTemplatesError: assign({ - getTemplatesError: (_, event) => event.data, - }), - clearGetTemplatesError: assign({ - getTemplatesError: (_) => undefined, - }), - assignGetTemplateParametersError: assign({ - getTemplateParametersError: (_, event) => event.data, - }), - clearGetTemplateParametersError: assign({ - getTemplateParametersError: (_) => undefined, - }), - clearTemplateGitAuthError: assign({ - getTemplateGitAuthError: (_) => undefined, + assignFormData: assign((ctx, event) => { + return { + ...ctx, + ...event.data, + } }), - assignTemplateGitAuthError: assign({ - getTemplateGitAuthError: (_, event) => event.data, + assignError: assign({ + error: (_, event) => event.data, }), - assignTemplateGitAuth: assign({ - templateGitAuth: (_, event) => event.data, + clearError: assign({ + error: (_) => undefined, }), }, }, ) + +const checkCreateWSPermissions = async (organizationId: string) => { + // HACK: below, we pass in * for the owner_id, which is a hacky way of checking if the + // current user can create a workspace on behalf of anyone within the org (only org owners should be able to do this). + // This pattern should not be replicated outside of this narrow use case. + const permissionsToCheck = { + createWorkspaceForUser: { + object: { + resource_type: "workspace", + organization_id: organizationId, + owner_id: "*", + }, + action: "create", + }, + } as const + + return checkAuthorization({ + checks: permissionsToCheck, + }) as Promise<Record<keyof typeof permissionsToCheck, boolean>> +} + +export const watchGitAuthRefresh = (callback: () => void) => { + const bc = new BroadcastChannel(REFRESH_GITAUTH_BROADCAST_CHANNEL) + bc.addEventListener("message", callback) + return bc +} + +export type CreateWSPermissions = Awaited< + ReturnType<typeof checkCreateWSPermissions> +> diff --git a/site/yarn.lock b/site/yarn.lock index bbbe6d3984c13..125af4fabac20 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -11497,6 +11497,11 @@ unified@^10.0.0: trough "^2.0.0" vfile "^5.0.0" +unique-names-generator@4.7.1: + version "4.7.1" + resolved "https://registry.yarnpkg.com/unique-names-generator/-/unique-names-generator-4.7.1.tgz#966407b12ba97f618928f77322cfac8c80df5597" + integrity sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow== + unique-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d"