From fcd610ee7b2e5151cd6dc2eddb62e4226910389c Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 24 May 2022 08:37:44 -0500 Subject: [PATCH] refactor: Update create workspace flow to allow creation from the workspaces page (#1684) --- .vscode/settings.json | 3 + site/src/AppRouter.tsx | 21 +-- site/src/components/FormFooter/FormFooter.tsx | 2 +- .../CreateWorkspacePage.test.tsx | 19 +- .../CreateWorkspacePage.tsx | 73 +++++--- .../CreateWorkspacePageView.stories.tsx | 31 +++- .../CreateWorkspacePageView.tsx | 135 +++++++++----- .../pages/TemplatesPage/TemplatesPageView.tsx | 6 +- .../WorkspacesPage/WorkspacesPageView.tsx | 2 +- .../createWorkspaceXService.ts | 171 ++++++++++++++++++ .../xServices/template/templateXService.ts | 167 ----------------- 11 files changed, 368 insertions(+), 262 deletions(-) create mode 100644 site/src/xServices/createWorkspace/createWorkspaceXService.ts delete mode 100644 site/src/xServices/template/templateXService.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index e962a55458..2ea7220cd5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,6 +15,7 @@ "drpcserver", "Dsts", "fatih", + "Formik", "goarch", "gographviz", "goleak", @@ -22,6 +23,7 @@ "gsyslog", "hashicorp", "hclsyntax", + "httpapi", "httpmw", "idtoken", "Iflag", @@ -63,6 +65,7 @@ "tfjson", "tfstate", "trimprefix", + "typegen", "unconvert", "Untar", "VMID", diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index b11a33d3e3..4ec0f463ca 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -56,6 +56,16 @@ export const AppRouter: React.FC = () => ( } /> + + + + + } + /> + ( } /> - - - - - - } - /> - diff --git a/site/src/components/FormFooter/FormFooter.tsx b/site/src/components/FormFooter/FormFooter.tsx index 03233f42b7..ec41d99a38 100644 --- a/site/src/components/FormFooter/FormFooter.tsx +++ b/site/src/components/FormFooter/FormFooter.tsx @@ -35,7 +35,7 @@ export const FormFooter: React.FC = ({ const styles = useStyles() return (
- diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index 57b0cebdd7..a0f11bd6e5 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -5,10 +5,17 @@ import { reach, StringSchema } from "yup" import * as API from "../../api/api" import { Language as FooterLanguage } from "../../components/FormFooter/FormFooter" import { MockTemplate, MockWorkspace } from "../../testHelpers/entities" -import { history, render } from "../../testHelpers/renderHelpers" +import { renderWithAuth } from "../../testHelpers/renderHelpers" import CreateWorkspacePage from "./CreateWorkspacePage" import { Language, validationSchema } from "./CreateWorkspacePageView" +const renderCreateWorkspacePage = () => { + return renderWithAuth(, { + route: "/workspaces/new?template=" + MockTemplate.name, + path: "/workspaces/new", + }) +} + const fillForm = async ({ name = "example" }: { name?: string }) => { const nameField = await screen.findByLabelText(Language.nameLabel) await userEvent.type(nameField, name) @@ -19,25 +26,21 @@ const fillForm = async ({ name = "example" }: { name?: string }) => { const nameSchema = reach(validationSchema, "name") as StringSchema describe("CreateWorkspacePage", () => { - beforeEach(() => { - history.replace("/templates/" + MockTemplate.name + "/new") - }) - it("renders", async () => { - render() + renderCreateWorkspacePage() const element = await screen.findByText("Create workspace") expect(element).toBeDefined() }) it("shows validation error message", async () => { - render() + renderCreateWorkspacePage() await fillForm({ name: "$$$" }) const errorMessage = await screen.findByText(Language.nameMatches) expect(errorMessage).toBeDefined() }) it("succeeds", async () => { - render() + renderCreateWorkspacePage() // You have to spy the method before it is used. jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace) await fillForm({ name: "test" }) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 177d3f1492..ae71dd5cf4 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -1,36 +1,59 @@ -import { useMachine } from "@xstate/react" -import React from "react" -import { useNavigate } from "react-router" -import { useParams } from "react-router-dom" -import { createWorkspace } from "../../api/api" -import { templateMachine } from "../../xServices/template/templateXService" +import { useActor, useMachine } from "@xstate/react" +import React, { useContext } from "react" +import { useNavigate, useSearchParams } from "react-router-dom" +import { Template } from "../../api/typesGenerated" +import { createWorkspaceMachine } from "../../xServices/createWorkspace/createWorkspaceXService" +import { XServiceContext } from "../../xServices/StateContext" import { CreateWorkspacePageView } from "./CreateWorkspacePageView" +const useOrganizationId = () => { + const xServices = useContext(XServiceContext) + const [authState] = useActor(xServices.authXService) + const organizationId = authState.context.me?.organization_ids[0] + + if (!organizationId) { + throw new Error("No organization ID found") + } + + return organizationId +} + const CreateWorkspacePage: React.FC = () => { - const { template } = useParams() - const [templateState] = useMachine(templateMachine, { - context: { - name: template, + const organizationId = useOrganizationId() + const [searchParams] = useSearchParams() + const preSelectedTemplateName = searchParams.get("template") + const navigate = useNavigate() + const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, { + context: { organizationId, preSelectedTemplateName }, + actions: { + onCreateWorkspace: (_, event) => { + navigate("/workspaces/" + event.data.id) + }, }, }) - const navigate = useNavigate() - const loading = templateState.hasTag("loading") - if (!templateState.context.template || !templateState.context.templateSchema) { - return null - } return ( navigate("/templates")} - onSubmit={async (req) => { - if (!templateState.context.template) { - throw new Error("template isn't valid") - } - const workspace = await createWorkspace(templateState.context.template.organization_id, req) - navigate("/workspaces/" + workspace.id) + loadingTemplates={createWorkspaceState.matches("gettingTemplates")} + loadingTemplateSchema={createWorkspaceState.matches("gettingTemplateSchema")} + creatingWorkspace={createWorkspaceState.matches("creatingWorkspace")} + templates={createWorkspaceState.context.templates} + selectedTemplate={createWorkspaceState.context.selectedTemplate} + templateSchema={createWorkspaceState.context.templateSchema} + onCancel={() => { + navigate(preSelectedTemplateName ? "/templates" : "/workspaces") + }} + onSubmit={(request) => { + send({ + type: "CREATE_WORKSPACE", + request, + }) + }} + onSelectTemplate={(template: Template) => { + send({ + type: "SELECT_TEMPLATE", + template, + }) }} /> ) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx index 48f0131f1b..0f27c89a20 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -1,9 +1,32 @@ import { ComponentMeta, Story } from "@storybook/react" import React from "react" -import { createParameterSchema } from "../../components/ParameterInput/ParameterInput.stories" +import { ParameterSchema } from "../../api/typesGenerated" import { MockTemplate } from "../../testHelpers/entities" import { CreateWorkspacePageView, CreateWorkspacePageViewProps } from "./CreateWorkspacePageView" +const createParameterSchema = (partial: Partial): ParameterSchema => { + return { + id: "000000", + job_id: "000000", + allow_override_destination: false, + allow_override_source: true, + created_at: "", + default_destination_scheme: "none", + default_refresh: "", + default_source_scheme: "data", + default_source_value: "default-value", + name: "parameter name", + description: "Some description!", + redisplay_value: false, + validation_condition: "", + validation_contains: [], + validation_error: "", + validation_type_system: "", + validation_value_type: "", + ...partial, + } +} + export default { title: "pages/CreateWorkspacePageView", component: CreateWorkspacePageView, @@ -13,13 +36,15 @@ const Template: Story = (args) => void - onSubmit: (req: TypesGen.CreateWorkspaceRequest) => Promise + onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void + onSelectTemplate: (template: TypesGen.Template) => void } export const validationSchema = Yup.object({ @@ -40,15 +46,19 @@ export const validationSchema = Yup.object({ }) export const CreateWorkspacePageView: React.FC = (props) => { - const styles = useStyles() const [parameterValues, setParameterValues] = React.useState>({}) const form: FormikContextType = useFormik({ initialValues: { name: "", - template_id: props.template.id, + template_id: props.selectedTemplate ? props.selectedTemplate.id : "", }, + enableReinitialize: true, validationSchema, onSubmit: (request) => { + if (!props.templateSchema) { + throw new Error("No template schema loaded") + } + const createRequests: TypesGen.CreateParameterRequest[] = [] props.templateSchema.forEach((schema) => { let value = schema.default_source_value @@ -70,49 +80,84 @@ export const CreateWorkspacePageView: React.FC = ( }) const getFieldHelpers = getFormHelpers(form) + const handleTemplateChange: TextFieldProps["onChange"] = (event) => { + if (!props.templates) { + throw new Error("Templates are not loaded") + } + + const templateId = event.target.value + const selectedTemplate = props.templates.find((template) => template.id === templateId) + + if (!selectedTemplate) { + throw new Error(`Template ${templateId} not found`) + } + + form.setFieldValue("template_id", selectedTemplate.id) + props.onSelectTemplate(selectedTemplate) + } + return (
- - {props.templateSchema.length > 0 && ( -
- {props.templateSchema.map((schema) => ( - { - setParameterValues({ - ...parameterValues, - [schema.name]: value, - }) - }} - schema={schema} - /> - ))} -
- )} + {props.loadingTemplates && } - + + {props.templates && ( + + {props.templates.map((template) => ( + + {template.name} + + ))} + + )} + + {props.selectedTemplate && props.templateSchema && ( + <> + + + {props.templateSchema.length > 0 && ( + + {props.templateSchema.map((schema) => ( + { + setParameterValues({ + ...parameterValues, + [schema.name]: value, + }) + }} + schema={schema} + /> + ))} + + )} + + + + )} +
) } - -const useStyles = makeStyles((theme) => ({ - parameters: { - paddingTop: theme.spacing(4), - "& > *": { - marginBottom: theme.spacing(4), - }, - }, -})) diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 44379fc3b4..66efe0945a 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -83,7 +83,11 @@ export const TemplatesPageView: React.FC = (props) => { {firstLetter(template.name)} - + {template.name} {template.description} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index f5a5f0bd48..f0a4b44ac9 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -39,7 +39,7 @@ export const WorkspacesPageView: React.FC = (props) =>
- +
diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts new file mode 100644 index 0000000000..2988972fed --- /dev/null +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -0,0 +1,171 @@ +import { assign, createMachine } from "xstate" +import { createWorkspace, getTemplates, getTemplateVersionSchema } from "../../api/api" +import { CreateWorkspaceRequest, ParameterSchema, Template, Workspace } from "../../api/typesGenerated" + +type CreateWorkspaceContext = { + organizationId: string + templates?: Template[] + selectedTemplate?: Template + templateSchema?: ParameterSchema[] + createWorkspaceRequest?: CreateWorkspaceRequest + createdWorkspace?: Workspace + // This is useful when the user wants to create a workspace from the template + // page having it pre selected. It is string or null because of the + // useSearchQuery + preSelectedTemplateName: string | null +} + +type CreateWorkspaceEvent = + | { + type: "SELECT_TEMPLATE" + template: Template + } + | { + type: "CREATE_WORKSPACE" + request: CreateWorkspaceRequest + } + +export const createWorkspaceMachine = createMachine( + { + id: "createWorkspaceState", + initial: "gettingTemplates", + schema: { + context: {} as CreateWorkspaceContext, + events: {} as CreateWorkspaceEvent, + services: {} as { + getTemplates: { + data: Template[] + } + getTemplateSchema: { + data: ParameterSchema[] + } + createWorkspace: { + data: Workspace + } + }, + }, + tsTypes: {} as import("./createWorkspaceXService.typegen").Typegen0, + states: { + gettingTemplates: { + invoke: { + src: "getTemplates", + onDone: [ + { + actions: ["assignTemplates", "assignPreSelectedTemplate"], + target: "gettingTemplateSchema", + cond: "hasValidPreSelectedTemplate", + }, + { + actions: ["assignTemplates"], + target: "selectingTemplate", + }, + ], + onError: { + target: "error", + }, + }, + }, + selectingTemplate: { + on: { + SELECT_TEMPLATE: { + actions: ["assignSelectedTemplate"], + target: "gettingTemplateSchema", + }, + }, + }, + gettingTemplateSchema: { + invoke: { + src: "getTemplateSchema", + onDone: { + actions: ["assignTemplateSchema"], + target: "fillingParams", + }, + onError: { + target: "error", + }, + }, + }, + fillingParams: { + on: { + CREATE_WORKSPACE: { + actions: ["assignCreateWorkspaceRequest"], + target: "creatingWorkspace", + }, + }, + }, + creatingWorkspace: { + invoke: { + src: "createWorkspace", + onDone: { + actions: ["onCreateWorkspace"], + target: "created", + }, + onError: { + target: "error", + }, + }, + }, + created: { + type: "final", + }, + error: {}, + }, + }, + { + services: { + getTemplates: (context) => getTemplates(context.organizationId), + getTemplateSchema: (context) => { + const { selectedTemplate } = context + + if (!selectedTemplate) { + throw new Error("No selected template") + } + + return getTemplateVersionSchema(selectedTemplate.active_version_id) + }, + createWorkspace: (context) => { + const { createWorkspaceRequest, organizationId } = context + + if (!createWorkspaceRequest) { + throw new Error("No create workspace request") + } + + return createWorkspace(organizationId, createWorkspaceRequest) + }, + }, + guards: { + hasValidPreSelectedTemplate: (ctx, event) => { + if (!ctx.preSelectedTemplateName) { + return false + } + const template = event.data.find((template) => template.name === ctx.preSelectedTemplateName) + return !!template + }, + }, + actions: { + assignTemplates: assign({ + templates: (_, event) => event.data, + }), + assignSelectedTemplate: assign({ + selectedTemplate: (_, event) => event.template, + }), + assignTemplateSchema: assign({ + templateSchema: (_, event) => event.data, + }), + assignCreateWorkspaceRequest: assign({ + createWorkspaceRequest: (_, event) => event.request, + }), + assignPreSelectedTemplate: assign({ + selectedTemplate: (ctx, event) => { + const selectedTemplate = event.data.find((template) => template.name === ctx.preSelectedTemplateName) + // The proper validation happens on hasValidPreSelectedTemplate + if (!selectedTemplate) { + throw new Error("Invalid template selected") + } + + return selectedTemplate + }, + }), + }, + }, +) diff --git a/site/src/xServices/template/templateXService.ts b/site/src/xServices/template/templateXService.ts deleted file mode 100644 index f2e610e4b5..0000000000 --- a/site/src/xServices/template/templateXService.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { assign, createMachine } from "xstate" -import * as API from "../../api/api" -import * as TypesGen from "../../api/typesGenerated" - -interface TemplateContext { - name: string - - organizations?: TypesGen.Organization[] - organizationsError?: Error | unknown - template?: TypesGen.Template - templateError?: Error | unknown - templateVersion?: TypesGen.TemplateVersion - templateVersionError?: Error | unknown - templateSchema?: TypesGen.ParameterSchema[] - templateSchemaError?: Error | unknown -} - -export const templateMachine = createMachine( - { - tsTypes: {} as import("./templateXService.typegen").Typegen0, - schema: { - context: {} as TemplateContext, - services: {} as { - getOrganizations: { - data: TypesGen.Organization[] - } - getTemplate: { - data: TypesGen.Template - } - getTemplateVersion: { - data: TypesGen.TemplateVersion - } - getTemplateSchema: { - data: TypesGen.ParameterSchema[] - } - }, - }, - id: "templateState", - initial: "gettingOrganizations", - states: { - gettingOrganizations: { - entry: "clearOrganizationsError", - invoke: { - src: "getOrganizations", - id: "getOrganizations", - onDone: [ - { - actions: ["assignOrganizations", "clearOrganizationsError"], - target: "gettingTemplate", - }, - ], - onError: [ - { - actions: "assignOrganizationsError", - target: "error", - }, - ], - }, - tags: "loading", - }, - gettingTemplate: { - entry: "clearTemplateError", - invoke: { - src: "getTemplate", - id: "getTemplate", - onDone: { - target: "gettingTemplateVersion", - actions: ["assignTemplate", "clearTemplateError"], - }, - onError: { - target: "error", - actions: "assignTemplateError", - }, - }, - tags: "loading", - }, - gettingTemplateVersion: { - entry: "clearTemplateVersionError", - invoke: { - src: "getTemplateVersion", - id: "getTemplateVersion", - onDone: { - target: "gettingTemplateSchema", - actions: ["assignTemplateVersion", "clearTemplateVersionError"], - }, - onError: { - target: "error", - actions: "assignTemplateVersionError", - }, - }, - }, - gettingTemplateSchema: { - entry: "clearTemplateSchemaError", - invoke: { - src: "getTemplateSchema", - id: "getTemplateSchema", - onDone: { - target: "done", - actions: ["assignTemplateSchema", "clearTemplateSchemaError"], - }, - onError: { - target: "error", - actions: "assignTemplateSchemaError", - }, - }, - }, - done: {}, - error: {}, - }, - }, - { - actions: { - assignOrganizations: assign({ - organizations: (_, event) => event.data, - }), - assignOrganizationsError: assign({ - organizationsError: (_, event) => event.data, - }), - clearOrganizationsError: assign((context) => ({ - ...context, - organizationsError: undefined, - })), - assignTemplate: assign({ - template: (_, event) => event.data, - }), - assignTemplateError: assign({ - templateError: (_, event) => event.data, - }), - clearTemplateError: (context) => assign({ ...context, templateError: undefined }), - assignTemplateVersion: assign({ - templateVersion: (_, event) => event.data, - }), - assignTemplateVersionError: assign({ - templateVersionError: (_, event) => event.data, - }), - clearTemplateVersionError: (context) => assign({ ...context, templateVersionError: undefined }), - assignTemplateSchema: assign({ - templateSchema: (_, event) => event.data, - }), - assignTemplateSchemaError: assign({ - templateSchemaError: (_, event) => event.data, - }), - clearTemplateSchemaError: (context) => assign({ ...context, templateSchemaError: undefined }), - }, - services: { - getOrganizations: API.getOrganizations, - getTemplate: async (context) => { - if (!context.organizations || context.organizations.length === 0) { - throw new Error("no organizations") - } - return API.getTemplateByName(context.organizations[0].id, context.name) - }, - getTemplateVersion: async (context) => { - if (!context.template) { - throw new Error("no template") - } - return API.getTemplateVersion(context.template.active_version_id) - }, - getTemplateSchema: async (context) => { - if (!context.templateVersion) { - throw new Error("no template version") - } - return API.getTemplateVersionSchema(context.templateVersion.id) - }, - }, - }, -)