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(, { + 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 ( <> - {pageTitle("Create Workspace")} + {pageTitle(title)} - { - 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"), + ) && } + {createWorkspaceState.matches("loadError") && ( + + )} + {createWorkspaceState.matches("idle") && ( + { + 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 => { - const paramValues: Record = {} +): 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 = { + title: "components/Alert", component: CreateWorkspacePageView, -} as ComponentMeta - -const Template: Story = (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 -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> - 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 - defaultParameterValues?: Record + onSubmit: (req: TypesGen.CreateWorkspaceRequest, owner: TypesGen.User) => void } -export const CreateWorkspacePageView: FC< - React.PropsWithChildren -> = (props) => { - const templateParameters = props.templateParameters?.filter( - paramUsedToCreateWorkspace, - ) +export const CreateWorkspacePageView: FC = ({ + error, + defaultName, + defaultOwner, + template, + gitAuth, + parameters, + defaultBuildParameters, + permissions, + creatingWorkspace, + onSubmit, + onCancel, +}) => { const initialRichParameterValues = selectInitialRichParametersValues( - templateParameters, - props.defaultParameterValues, + parameters, + defaultBuildParameters, ) - const [gitAuthErrors, setGitAuthErrors] = useState>({}) - 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 = useFormik({ 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( form, - props.createWorkspaceErrors[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR], + error, ) - if (isLoading) { - return - } - return ( - + - {Boolean(props.hasTemplateErrors) && ( - - {Boolean( - props.createWorkspaceErrors[ - CreateWorkspaceErrors.GET_TEMPLATES_ERROR - ], - ) && ( - - )} - {Boolean( - props.createWorkspaceErrors[ - CreateWorkspaceErrors.GET_TEMPLATE_GITAUTH_ERROR - ], - ) && ( - - )} - - )} - - {Boolean( - props.createWorkspaceErrors[ - CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR - ], - ) && ( - - )} - + {Boolean(error) && } {/* General info */} - {props.selectedTemplate && ( - - )} - + - {/* Workspace owner */} - {props.canCreateForUser && ( + {permissions.createWorkspaceForUser && ( { + setOwner(user ?? defaultOwner) + }} label={t("ownerLabel").toString()} size="medium" /> @@ -220,14 +139,13 @@ export const CreateWorkspacePageView: FC< )} - {/* Template git auth */} - {props.templateGitAuth && props.templateGitAuth.length > 0 && ( + {gitAuth && gitAuth.length > 0 && ( - {props.templateGitAuth.map((auth, index) => ( + {gitAuth.map((auth, index) => ( )} - {templateParameters && ( + {parameters && ( <> { return { ...getFieldHelpers( @@ -264,7 +182,7 @@ export const CreateWorkspacePageView: FC< }} /> { return { @@ -289,8 +207,8 @@ export const CreateWorkspacePageView: FC< )} @@ -298,6 +216,44 @@ export const CreateWorkspacePageView: FC< ) } +type GitAuthErrors = Record + +const useGitAuthVerification = (gitAuth: TypesGen.TemplateVersionGitAuth[]) => { + const [gitAuthErrors, setGitAuthErrors] = useState({}) + + 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( - `[![Open in Coder](http://localhost/open-in-coder.svg)](http://localhost/templates/test-template/workspace?param.first_parameter=firstParameterValue¶m.second_parameter=123456)`, + `[![Open in Coder](http://localhost/open-in-coder.svg)](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 @@ -40,7 +43,7 @@ const TemplateEmbedPage = () => { @@ -51,7 +54,9 @@ export const TemplateEmbedPageView: FC<{ template: Template templateParameters?: TemplateVersionParameter[] }> = ({ template, templateParameters }) => { - const [buttonValues, setButtonValues] = useState({}) + const [buttonValues, setButtonValues] = useState({ + mode: "manual", + }) const initialRichParametersValues = templateParameters ? selectInitialRichParametersValues(templateParameters) : undefined @@ -92,20 +97,48 @@ export const TemplateEmbedPageView: FC<{ ) : ( - {templateParameters.length > 0 && ( - - - - - - - )} + + + + { + setButtonValues((buttonValues) => ({ + ...buttonValues, + mode: v, + })) + }} + > + } + label="Manual" + /> + } + label="Automatic" + /> + + + + {templateParameters.length > 0 && ( + <> + + + + )} + + = { export default meta type Story = StoryObj -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, + 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 - 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, - }), - 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> +} + +export const watchGitAuthRefresh = (callback: () => void) => { + const bc = new BroadcastChannel(REFRESH_GITAUTH_BROADCAST_CHANNEL) + bc.addEventListener("message", callback) + return bc +} + +export type CreateWSPermissions = Awaited< + ReturnType +> 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" pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy