diff --git a/.vscode/settings.json b/.vscode/settings.json index e962a55458f04..2ea7220cd50ab 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 b11a33d3e39cc..4ec0f463cae26 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 03233f42b720a..ec41d99a38ac9 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 57b0cebdd72f7..a0f11bd6e5f1a 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 177d3f1492cb6..ae71dd5cf4319 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 48f0131f1b9dc..0f27c89a201c7 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) => ( - } + + + {props.templates && ( + + {props.templates.map((template) => ( + + {template.name} + + ))} + + )} + + {props.selectedTemplate && props.templateSchema && ( + <> + { - setParameterValues({ - ...parameterValues, - [schema.name]: value, - }) - }} - schema={schema} + onChange={onChangeTrimmed(form)} + autoFocus + fullWidth + label={Language.nameLabel} + variant="outlined" /> - ))} -
- )} - + {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 122863beb3aa4..a48032e39bc29 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 e1f173675ccd5..48696fb46ae25 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 0000000000000..2988972fed76d --- /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 f2e610e4b5a24..0000000000000 --- 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) - }, - }, - }, -) 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