From a82a189744f23c57680afc84fa850242a8589ac5 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Thu, 20 Jul 2023 20:24:39 +0000 Subject: [PATCH 01/10] Add one click workspace --- package.json | 5 ++ .../CreateWorkspacePage.tsx | 50 ++++++++++----- .../CreateWorkspacePageView.tsx | 10 +-- site/src/utils/richParameters.ts | 22 +++++-- .../createWorkspaceXService.ts | 62 ++++++++++++++++++- yarn.lock | 8 +++ 6 files changed, 129 insertions(+), 28 deletions(-) create mode 100644 package.json create mode 100644 yarn.lock diff --git a/package.json b/package.json new file mode 100644 index 0000000000000..1b13451f673de --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "unique-names-generator": "^4.7.1" + } +} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 613655961582e..36e8bea2a671d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -1,27 +1,39 @@ import { useMachine } from "@xstate/react" -import { TemplateVersionParameter } from "api/typesGenerated" +import { + 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 { + CreateWorkspaceMode, + createWorkspaceMachine, +} from "xServices/createWorkspace/createWorkspaceXService" import { CreateWorkspaceErrors, CreateWorkspacePageView, } from "./CreateWorkspacePageView" +import Box from "@mui/material/Box" const CreateWorkspacePage: FC = () => { const organizationId = useOrganizationId() const { template: templateName } = useParams() as { template: string } const navigate = useNavigate() const me = useMe() + const [searchParams] = useSearchParams() + const defaultBuildParameters = getDefaultBuildParameters(searchParams) + const name = searchParams.get("name") ?? "" const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, { context: { organizationId, templateName, owner: me, + mode: (searchParams.get("mode") ?? "form") as CreateWorkspaceMode, + defaultBuildParameters, }, actions: { onCreateWorkspace: (_, event) => { @@ -40,9 +52,17 @@ const CreateWorkspacePage: FC = () => { permissions, owner, } = createWorkspaceState.context - const [searchParams] = useSearchParams() - const defaultParameterValues = getDefaultParameterValues(searchParams) - const name = getName(searchParams) + + if (createWorkspaceState.matches("autoCreating")) { + return ( + <> + + {pageTitle("Creating workspace...")} + + We‘re creating a new workspace for you + + ) + } return ( <> @@ -51,7 +71,7 @@ const CreateWorkspacePage: FC = () => { { ) } -const getName = (urlSearchParams: URLSearchParams): string => { - return urlSearchParams.get("name") ?? "" -} - -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 = ( diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index eee8f4875990d..aaeacb05e0c4d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -54,18 +54,18 @@ export interface CreateWorkspacePageViewProps { onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void // initialTouched is only used for testing the error state of the form. initialTouched?: FormikTouched - defaultParameterValues?: Record + defaultBuildParameters?: TypesGen.WorkspaceBuildParameter[] } -export const CreateWorkspacePageView: FC< - React.PropsWithChildren -> = (props) => { +export const CreateWorkspacePageView: FC = ( + props, +) => { const templateParameters = props.templateParameters?.filter( paramUsedToCreateWorkspace, ) const initialRichParameterValues = selectInitialRichParametersValues( templateParameters, - props.defaultParameterValues, + props.defaultBuildParameters, ) const [gitAuthErrors, setGitAuthErrors] = useState>({}) useEffect(() => { diff --git a/site/src/utils/richParameters.ts b/site/src/utils/richParameters.ts index f4c87eff48bd9..77b9e6250acde 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) { @@ -20,8 +20,14 @@ export const selectInitialRichParametersValues = ( if (parameter.options.length > 0) { parameterValue = parameterValue ?? parameter.options[0].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 = { @@ -36,8 +42,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/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts index a052e92d48f72..6259db9e4a8e4 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -1,6 +1,7 @@ import { checkAuthorization, createWorkspace, + getTemplateByName, getTemplates, getTemplateVersionGitAuth, getTemplateVersionRichParameters, @@ -12,8 +13,17 @@ import { TemplateVersionParameter, User, Workspace, + WorkspaceBuildParameter, } from "api/typesGenerated" import { assign, createMachine } from "xstate" +import { + uniqueNamesGenerator, + animals, + colors, + NumberDictionary, +} from "unique-names-generator" + +export type CreateWorkspaceMode = "form" | "auto" export const REFRESH_GITAUTH_BROADCAST_CHANNEL = "gitauth_refresh" @@ -21,6 +31,7 @@ type CreateWorkspaceContext = { organizationId: string owner: User | null templateName: string + mode: CreateWorkspaceMode templates?: Template[] selectedTemplate?: Template templateParameters?: TemplateVersionParameter[] @@ -33,6 +44,8 @@ type CreateWorkspaceContext = { getTemplateGitAuthError?: Error | unknown permissions?: Record checkPermissionsError?: Error | unknown + // Used on auto-create + defaultBuildParameters?: WorkspaceBuildParameter[] } type CreateWorkspaceEvent = { @@ -76,10 +89,35 @@ export const createWorkspaceMachine = createWorkspace: { data: Workspace } + autoCreateWorkspace: { + data: Workspace + } }, }, - initial: "gettingTemplates", + initial: "checkingMode", states: { + checkingMode: { + always: [ + { + target: "autoCreating", + cond: ({ mode }) => mode === "auto", + }, + { target: "gettingTemplates" }, + ], + }, + autoCreating: { + invoke: { + src: "autoCreateWorkspace", + onDone: { + actions: ["onCreateWorkspace"], + target: "created", + }, + onError: { + actions: ["assignCreateWorkspaceError"], + target: "fillingParams", + }, + }, + }, gettingTemplates: { entry: "clearGetTemplatesError", invoke: { @@ -247,6 +285,18 @@ export const createWorkspaceMachine = createWorkspaceRequest, ) }, + autoCreateWorkspace: async ({ + templateName, + organizationId, + defaultBuildParameters, + }) => { + const template = await getTemplateByName(organizationId, templateName) + return createWorkspace(organizationId, "me", { + template_id: template.id, + name: generateUniqueName(), + rich_parameter_values: defaultBuildParameters, + }) + }, }, guards: { areTemplatesEmpty: (_, event) => event.data.length === 0, @@ -311,3 +361,13 @@ export const createWorkspaceMachine = }, }, ) + +const generateUniqueName = () => { + const numberDictionary = NumberDictionary.generate({ min: 0, max: 99 }) + return uniqueNamesGenerator({ + dictionaries: [animals, colors, numberDictionary], + separator: "_", + length: 3, + style: "lowerCase", + }) +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000000000..8b3bdb901bbca --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +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== From 3a38c032561a75734c7728a517da0b39eb1699cb Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 21 Jul 2023 15:41:35 +0000 Subject: [PATCH 02/10] feat(site): add auto mode on the create workspace form --- .../CreateWorkspacePage.tsx | 112 +++--- .../CreateWorkspacePageView.stories.tsx | 190 +++++----- .../CreateWorkspacePageView.tsx | 235 +++++-------- site/src/pages/GitAuthPage/GitAuthPage.tsx | 3 +- .../src/pages/GitAuthPage/GitAuthPageView.tsx | 2 +- .../TemplateEmbedPage/TemplateEmbedPage.tsx | 4 +- site/src/utils/gitAuth.ts | 1 + site/src/utils/workspace.tsx | 2 +- .../createWorkspaceXService.ts | 330 ++++++------------ 9 files changed, 324 insertions(+), 555 deletions(-) create mode 100644 site/src/utils/gitAuth.ts diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 36e8bea2a671d..854ea655e2a88 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -1,5 +1,7 @@ import { useMachine } from "@xstate/react" import { + Template, + TemplateVersionGitAuth, TemplateVersionParameter, WorkspaceBuildParameter, } from "api/typesGenerated" @@ -10,28 +12,26 @@ import { Helmet } from "react-helmet-async" import { useNavigate, useParams, useSearchParams } from "react-router-dom" import { pageTitle } from "utils/page" import { + CreateWSPermissions, CreateWorkspaceMode, createWorkspaceMachine, } from "xServices/createWorkspace/createWorkspaceXService" -import { - CreateWorkspaceErrors, - CreateWorkspacePageView, -} from "./CreateWorkspacePageView" -import Box from "@mui/material/Box" +import { CreateWorkspacePageView } from "./CreateWorkspacePageView" +import { Loader } from "components/Loader/Loader" +import { ErrorAlert } from "components/Alert/ErrorAlert" 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 name = searchParams.get("name") ?? "" + const defaultName = searchParams.get("name") ?? "" const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, { context: { organizationId, templateName, - owner: me, mode: (searchParams.get("mode") ?? "form") as CreateWorkspaceMode, defaultBuildParameters, }, @@ -41,71 +41,47 @@ const CreateWorkspacePage: FC = () => { }, }, }) - const { - templates, - templateParameters, - templateGitAuth, - selectedTemplate, - getTemplateGitAuthError, - getTemplatesError, - createWorkspaceError, - permissions, - owner, - } = createWorkspaceState.context - - if (createWorkspaceState.matches("autoCreating")) { - return ( - <> - - {pageTitle("Creating workspace...")} - - We‘re creating a new workspace for you - - ) - } + const { template, error, parameters, permissions, gitAuth } = + 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, + }) + }} + /> + )} ) } 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 aaeacb05e0c4d..29a390d4d1e88 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,90 @@ import { ImmutableTemplateParametersSection, MutableTemplateParametersSection, } from "components/TemplateParameters/TemplateParameters" -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", -} +import { CreateWSPermissions } from "xServices/createWorkspace/createWorkspaceXService" +import { GitAuth } from "components/GitAuth/GitAuth" 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 - defaultBuildParameters?: TypesGen.WorkspaceBuildParameter[] + onSubmit: (req: TypesGen.CreateWorkspaceRequest, owner: TypesGen.User) => void } -export const CreateWorkspacePageView: FC = ( - 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.defaultBuildParameters, + 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 - ], - ) && ( - - )} - {/* General info */} - {props.selectedTemplate && ( - - )} - + = ( - {/* Workspace owner */} - {props.canCreateForUser && ( + {permissions.createWorkspaceForUser && ( { + setOwner(user ?? defaultOwner) + }} label={t("ownerLabel").toString()} size="medium" /> @@ -220,14 +137,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 +180,7 @@ export const CreateWorkspacePageView: FC = ( }} /> { return { @@ -289,8 +205,8 @@ export const CreateWorkspacePageView: FC = ( )} @@ -298,6 +214,43 @@ 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) + return Object.keys(errors).length > 0 + } + + 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.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx index 8874bc8d2bed0..1832cd78260ba 100644 --- a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx +++ b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx @@ -21,7 +21,7 @@ import { selectInitialRichParametersValues, workspaceBuildParameterValue, } from "utils/richParameters" -import { paramUsedToCreateWorkspace } from "utils/workspace" +import { paramsUsedToCreateWorkspace } from "utils/workspace" type ButtonValues = Record @@ -40,7 +40,7 @@ const TemplateEmbedPage = () => { 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/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 6259db9e4a8e4..2d3521ed4a645 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -2,7 +2,6 @@ import { checkAuthorization, createWorkspace, getTemplateByName, - getTemplates, getTemplateVersionGitAuth, getTemplateVersionRichParameters, } from "api/api" @@ -22,28 +21,21 @@ import { colors, NumberDictionary, } from "unique-names-generator" +import { paramsUsedToCreateWorkspace } from "utils/workspace" +import { REFRESH_GITAUTH_BROADCAST_CHANNEL } from "utils/gitAuth" export type CreateWorkspaceMode = "form" | "auto" -export const REFRESH_GITAUTH_BROADCAST_CHANNEL = "gitauth_refresh" - type CreateWorkspaceContext = { organizationId: string - owner: User | null templateName: string mode: CreateWorkspaceMode - templates?: Template[] - selectedTemplate?: Template - templateParameters?: TemplateVersionParameter[] - templateGitAuth?: TemplateVersionGitAuth[] - createWorkspaceRequest?: CreateWorkspaceRequest - createdWorkspace?: Workspace - createWorkspaceError?: Error | unknown - getTemplatesError?: Error | unknown - getTemplateParametersError?: Error | unknown - getTemplateGitAuthError?: Error | unknown + error?: Error | unknown + // Form + template?: Template + parameters?: TemplateVersionParameter[] permissions?: Record - checkPermissionsError?: Error | unknown + gitAuth?: TemplateVersionGitAuth[] // Used on auto-create defaultBuildParameters?: WorkspaceBuildParameter[] } @@ -51,12 +43,7 @@ type CreateWorkspaceContext = { type CreateWorkspaceEvent = { type: "CREATE_WORKSPACE" request: CreateWorkspaceRequest - owner: User | null -} - -type SelectOwnerEvent = { - type: "SELECT_OWNER" - owner: User | null + owner: User } type RefreshGitAuthEvent = { @@ -72,19 +59,15 @@ 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 @@ -102,7 +85,7 @@ export const createWorkspaceMachine = target: "autoCreating", cond: ({ mode }) => mode === "auto", }, - { target: "gettingTemplates" }, + { target: "loadingFormData" }, ], }, autoCreating: { @@ -110,104 +93,45 @@ export const createWorkspaceMachine = src: "autoCreateWorkspace", onDone: { actions: ["onCreateWorkspace"], - target: "created", }, onError: { - actions: ["assignCreateWorkspaceError"], - target: "fillingParams", + actions: ["assignError"], + target: "idle", }, }, }, - gettingTemplates: { - entry: "clearGetTemplatesError", + loadingFormData: { 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", - }, - }, - }, - checkingPermissions: { - entry: "clearCheckPermissionsError", - invoke: { - src: "checkPermissions", - id: "checkPermissions", + src: "loadFormData", onDone: { - actions: "assignPermissions", - target: "gettingTemplateGitAuth", + target: "idle", + actions: ["assignFormData"], }, onError: { - actions: ["assignCheckPermissionsError"], + target: "loadError", + actions: ["assignError"], }, }, }, - gettingTemplateGitAuth: { - entry: "clearTemplateGitAuthError", - invoke: { - src: "getTemplateGitAuth", - onDone: { - actions: ["assignTemplateGitAuth"], - target: "fillingParams", - }, - onError: { - actions: ["assignTemplateGitAuthError"], - target: "error", - }, - }, - }, - 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: { @@ -215,75 +139,23 @@ 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, - ) - }, - 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, - }) - }, - createWorkspace: (context) => { - const { createWorkspaceRequest, organizationId, owner } = context - - if (!createWorkspaceRequest) { - throw new Error("No create workspace request") - } - - return createWorkspace( - organizationId, - owner?.id ?? "me", - createWorkspaceRequest, - ) + createWorkspace: ({ organizationId }, { request, owner }) => { + return createWorkspace(organizationId, owner.id, request) }, autoCreateWorkspace: async ({ templateName, @@ -297,66 +169,38 @@ export const createWorkspaceMachine = rich_parameter_values: defaultBuildParameters, }) }, - }, - guards: { - areTemplatesEmpty: (_, event) => event.data.length === 0, + 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), + ]) + + return { + template, + permissions, + parameters, + gitAuth, + } + }, }, 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, }), }, }, @@ -366,8 +210,38 @@ const generateUniqueName = () => { const numberDictionary = NumberDictionary.generate({ min: 0, max: 99 }) return uniqueNamesGenerator({ dictionaries: [animals, colors, numberDictionary], - separator: "_", + separator: "-", length: 3, style: "lowerCase", }) } + +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 +> From f255f37b2147e3ae951ee64f7e2e1d6349fbcd94 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 21 Jul 2023 15:45:42 +0000 Subject: [PATCH 03/10] fix deps --- package.json | 5 ----- site/package.json | 1 + site/yarn.lock | 5 +++++ yarn.lock | 8 -------- 4 files changed, 6 insertions(+), 13 deletions(-) delete mode 100644 package.json delete mode 100644 yarn.lock diff --git a/package.json b/package.json deleted file mode 100644 index 1b13451f673de..0000000000000 --- a/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "dependencies": { - "unique-names-generator": "^4.7.1" - } -} diff --git a/site/package.json b/site/package.json index 40abc6afb125e..3a04429302e3f 100644 --- a/site/package.json +++ b/site/package.json @@ -94,6 +94,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/yarn.lock b/site/yarn.lock index b5495b67a90c4..8a9912f05974e 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -11291,6 +11291,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" diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index 8b3bdb901bbca..0000000000000 --- a/yarn.lock +++ /dev/null @@ -1,8 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -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== From 37e6dd3b4d18b782247e4bfc689c9853ab9217e9 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Fri, 21 Jul 2023 16:03:07 +0000 Subject: [PATCH 04/10] fix git auth validation result and callback --- site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx | 3 ++- site/src/xServices/createWorkspace/createWorkspaceXService.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 29a390d4d1e88..e4f2d5dee4084 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -242,7 +242,8 @@ const useGitAuthVerification = (gitAuth: TypesGen.TemplateVersionGitAuth[]) => { } setGitAuthErrors(errors) - return Object.keys(errors).length > 0 + const isValid = Object.keys(errors).length === 0 + return isValid } return { diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts index 2d3521ed4a645..4e805841fbed7 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -120,7 +120,7 @@ export const createWorkspaceMachine = const channel = watchGitAuthRefresh(() => { callback("REFRESH_GITAUTH") }) - return channel.close + return () => channel.close() }, }, ], From fefcf2f819b0033bd8ccf8b8cc1e23ed2f22f144 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 26 Jul 2023 13:49:32 +0000 Subject: [PATCH 05/10] Fix invalid option as default parameter --- site/src/utils/richParameters.test.ts | 53 +++++++++++++++++++++++++++ site/src/utils/richParameters.ts | 11 ++++-- 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 site/src/utils/richParameters.test.ts 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 77b9e6250acde..6fac6c33f25f4 100644 --- a/site/src/utils/richParameters.ts +++ b/site/src/utils/richParameters.ts @@ -19,14 +19,19 @@ export const selectInitialRichParametersValues = ( if (parameter.options.length > 0) { parameterValue = parameterValue ?? parameter.options[0].value + const validValues = parameter.options.map((option) => option.value) if (defaultBuildParameters) { - const buildParameter = defaultBuildParameters.find( + const defaultBuildParameter = defaultBuildParameters.find( (p) => p.name === parameter.name, ) - if (buildParameter) { - parameterValue = buildParameter?.value + // We don't want invalid values from default parameters to be set + if ( + defaultBuildParameter && + validValues.includes(defaultBuildParameter.value) + ) { + parameterValue = defaultBuildParameter?.value } } From c78528221446cfcb08b8d9c341f6864643c72b1d Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 26 Jul 2023 14:08:34 +0000 Subject: [PATCH 06/10] Display error when auto creation fails --- .../CreateWorkspacePage.tsx | 26 ++++++++++++++++--- .../CreateWorkspacePageView.tsx | 2 ++ .../createWorkspaceXService.ts | 22 +++------------- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 854ea655e2a88..b7407e9117284 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -19,6 +19,12 @@ import { 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() @@ -27,13 +33,15 @@ const CreateWorkspacePage: FC = () => { const navigate = useNavigate() const [searchParams] = useSearchParams() const defaultBuildParameters = getDefaultBuildParameters(searchParams) - const defaultName = searchParams.get("name") ?? "" + const mode = (searchParams.get("mode") ?? "form") as CreateWorkspaceMode const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, { context: { organizationId, templateName, - mode: (searchParams.get("mode") ?? "form") as CreateWorkspaceMode, + mode, defaultBuildParameters, + defaultName: + mode === "auto" ? generateUniqueName() : searchParams.get("name") ?? "", }, actions: { onCreateWorkspace: (_, event) => { @@ -41,7 +49,7 @@ const CreateWorkspacePage: FC = () => { }, }, }) - const { template, error, parameters, permissions, gitAuth } = + const { template, error, parameters, permissions, gitAuth, defaultName } = createWorkspaceState.context const title = createWorkspaceState.matches("autoCreating") ? "Creating workspace..." @@ -86,6 +94,8 @@ const CreateWorkspacePage: FC = () => { ) } +export default CreateWorkspacePage + const getDefaultBuildParameters = ( urlSearchParams: URLSearchParams, ): WorkspaceBuildParameter[] => { @@ -114,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: [animals, colors, numberDictionary], + separator: "-", + length: 3, + style: "lowerCase", + }) +} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index e4f2d5dee4084..ecc16b9b5ce97 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -26,6 +26,7 @@ import { } from "components/TemplateParameters/TemplateParameters" import { CreateWSPermissions } from "xServices/createWorkspace/createWorkspaceXService" import { GitAuth } from "components/GitAuth/GitAuth" +import { ErrorAlert } from "components/Alert/ErrorAlert" export interface CreateWorkspacePageViewProps { error: unknown @@ -101,6 +102,7 @@ export const CreateWorkspacePageView: FC = ({ return ( + {Boolean(error) && } {/* General info */} { const template = await getTemplateByName(organizationId, templateName) return createWorkspace(organizationId, "me", { template_id: template.id, - name: generateUniqueName(), + name: defaultName, rich_parameter_values: defaultBuildParameters, }) }, @@ -206,16 +202,6 @@ export const createWorkspaceMachine = }, ) -const generateUniqueName = () => { - const numberDictionary = NumberDictionary.generate({ min: 0, max: 99 }) - return uniqueNamesGenerator({ - dictionaries: [animals, colors, numberDictionary], - separator: "-", - length: 3, - style: "lowerCase", - }) -} - 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). From 4d7a7c3a2023c41e48f2e1ae124de8c9cb415ae4 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 26 Jul 2023 16:52:11 +0000 Subject: [PATCH 07/10] Add test to check auto create flow --- .../CreateWorkspacePage.test.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) 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 }], + }), + ) + }) + }) }) From 73dbc0fcdc2d25e7b13832896212fdc7e2201c87 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 26 Jul 2023 17:14:49 +0000 Subject: [PATCH 08/10] Add option to the user choose auto or manual --- .../TemplateEmbedPage/TemplateEmbedPage.tsx | 65 ++++++++++++++----- .../TemplateEmbedPageView.stories.tsx | 2 +- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx b/site/src/pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage.tsx index 1832cd78260ba..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 { @@ -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: [], }, From ce75ca01f344b87493534e73d286da2ce7ddc675 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 26 Jul 2023 17:23:41 +0000 Subject: [PATCH 09/10] Fix tests --- .../TemplatePage/TemplateEmbedPage/TemplateEmbedPage.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)`, ) }) From 850497134c86e80dd2dbaa06decc7f6ebe989eb1 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 26 Jul 2023 17:26:08 +0000 Subject: [PATCH 10/10] Better unique names --- site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index b7407e9117284..d49ea38894ed9 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -127,7 +127,7 @@ export const orderedTemplateParameters = ( const generateUniqueName = () => { const numberDictionary = NumberDictionary.generate({ min: 0, max: 99 }) return uniqueNamesGenerator({ - dictionaries: [animals, colors, numberDictionary], + dictionaries: [colors, animals, numberDictionary], separator: "-", length: 3, style: "lowerCase",