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", 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