mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
refactor: Update create workspace flow to allow creation from the workspaces page (#1684)
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -15,6 +15,7 @@
|
|||||||
"drpcserver",
|
"drpcserver",
|
||||||
"Dsts",
|
"Dsts",
|
||||||
"fatih",
|
"fatih",
|
||||||
|
"Formik",
|
||||||
"goarch",
|
"goarch",
|
||||||
"gographviz",
|
"gographviz",
|
||||||
"goleak",
|
"goleak",
|
||||||
@ -22,6 +23,7 @@
|
|||||||
"gsyslog",
|
"gsyslog",
|
||||||
"hashicorp",
|
"hashicorp",
|
||||||
"hclsyntax",
|
"hclsyntax",
|
||||||
|
"httpapi",
|
||||||
"httpmw",
|
"httpmw",
|
||||||
"idtoken",
|
"idtoken",
|
||||||
"Iflag",
|
"Iflag",
|
||||||
@ -63,6 +65,7 @@
|
|||||||
"tfjson",
|
"tfjson",
|
||||||
"tfstate",
|
"tfstate",
|
||||||
"trimprefix",
|
"trimprefix",
|
||||||
|
"typegen",
|
||||||
"unconvert",
|
"unconvert",
|
||||||
"Untar",
|
"Untar",
|
||||||
"VMID",
|
"VMID",
|
||||||
|
@ -56,6 +56,16 @@ export const AppRouter: React.FC = () => (
|
|||||||
</AuthAndFrame>
|
</AuthAndFrame>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="new"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<CreateWorkspacePage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path=":workspace">
|
<Route path=":workspace">
|
||||||
<Route
|
<Route
|
||||||
index
|
index
|
||||||
@ -85,17 +95,6 @@ export const AppRouter: React.FC = () => (
|
|||||||
</AuthAndFrame>
|
</AuthAndFrame>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path=":template">
|
|
||||||
<Route
|
|
||||||
path="new"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<CreateWorkspacePage />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="users">
|
<Route path="users">
|
||||||
|
@ -35,7 +35,7 @@ export const FormFooter: React.FC<FormFooterProps> = ({
|
|||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
return (
|
return (
|
||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
<Button className={styles.button} onClick={onCancel} variant="outlined">
|
<Button type="button" className={styles.button} onClick={onCancel} variant="outlined">
|
||||||
{Language.cancelLabel}
|
{Language.cancelLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<LoadingButton loading={isLoading} className={styles.button} variant="contained" color="primary" type="submit">
|
<LoadingButton loading={isLoading} className={styles.button} variant="contained" color="primary" type="submit">
|
||||||
|
@ -5,10 +5,17 @@ import { reach, StringSchema } from "yup"
|
|||||||
import * as API from "../../api/api"
|
import * as API from "../../api/api"
|
||||||
import { Language as FooterLanguage } from "../../components/FormFooter/FormFooter"
|
import { Language as FooterLanguage } from "../../components/FormFooter/FormFooter"
|
||||||
import { MockTemplate, MockWorkspace } from "../../testHelpers/entities"
|
import { MockTemplate, MockWorkspace } from "../../testHelpers/entities"
|
||||||
import { history, render } from "../../testHelpers/renderHelpers"
|
import { renderWithAuth } from "../../testHelpers/renderHelpers"
|
||||||
import CreateWorkspacePage from "./CreateWorkspacePage"
|
import CreateWorkspacePage from "./CreateWorkspacePage"
|
||||||
import { Language, validationSchema } from "./CreateWorkspacePageView"
|
import { Language, validationSchema } from "./CreateWorkspacePageView"
|
||||||
|
|
||||||
|
const renderCreateWorkspacePage = () => {
|
||||||
|
return renderWithAuth(<CreateWorkspacePage />, {
|
||||||
|
route: "/workspaces/new?template=" + MockTemplate.name,
|
||||||
|
path: "/workspaces/new",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const fillForm = async ({ name = "example" }: { name?: string }) => {
|
const fillForm = async ({ name = "example" }: { name?: string }) => {
|
||||||
const nameField = await screen.findByLabelText(Language.nameLabel)
|
const nameField = await screen.findByLabelText(Language.nameLabel)
|
||||||
await userEvent.type(nameField, name)
|
await userEvent.type(nameField, name)
|
||||||
@ -19,25 +26,21 @@ const fillForm = async ({ name = "example" }: { name?: string }) => {
|
|||||||
const nameSchema = reach(validationSchema, "name") as StringSchema
|
const nameSchema = reach(validationSchema, "name") as StringSchema
|
||||||
|
|
||||||
describe("CreateWorkspacePage", () => {
|
describe("CreateWorkspacePage", () => {
|
||||||
beforeEach(() => {
|
|
||||||
history.replace("/templates/" + MockTemplate.name + "/new")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renders", async () => {
|
it("renders", async () => {
|
||||||
render(<CreateWorkspacePage />)
|
renderCreateWorkspacePage()
|
||||||
const element = await screen.findByText("Create workspace")
|
const element = await screen.findByText("Create workspace")
|
||||||
expect(element).toBeDefined()
|
expect(element).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("shows validation error message", async () => {
|
it("shows validation error message", async () => {
|
||||||
render(<CreateWorkspacePage />)
|
renderCreateWorkspacePage()
|
||||||
await fillForm({ name: "$$$" })
|
await fillForm({ name: "$$$" })
|
||||||
const errorMessage = await screen.findByText(Language.nameMatches)
|
const errorMessage = await screen.findByText(Language.nameMatches)
|
||||||
expect(errorMessage).toBeDefined()
|
expect(errorMessage).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("succeeds", async () => {
|
it("succeeds", async () => {
|
||||||
render(<CreateWorkspacePage />)
|
renderCreateWorkspacePage()
|
||||||
// You have to spy the method before it is used.
|
// You have to spy the method before it is used.
|
||||||
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace)
|
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace)
|
||||||
await fillForm({ name: "test" })
|
await fillForm({ name: "test" })
|
||||||
|
@ -1,36 +1,59 @@
|
|||||||
import { useMachine } from "@xstate/react"
|
import { useActor, useMachine } from "@xstate/react"
|
||||||
import React from "react"
|
import React, { useContext } from "react"
|
||||||
import { useNavigate } from "react-router"
|
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||||
import { useParams } from "react-router-dom"
|
import { Template } from "../../api/typesGenerated"
|
||||||
import { createWorkspace } from "../../api/api"
|
import { createWorkspaceMachine } from "../../xServices/createWorkspace/createWorkspaceXService"
|
||||||
import { templateMachine } from "../../xServices/template/templateXService"
|
import { XServiceContext } from "../../xServices/StateContext"
|
||||||
import { CreateWorkspacePageView } from "./CreateWorkspacePageView"
|
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 CreateWorkspacePage: React.FC = () => {
|
||||||
const { template } = useParams()
|
const organizationId = useOrganizationId()
|
||||||
const [templateState] = useMachine(templateMachine, {
|
const [searchParams] = useSearchParams()
|
||||||
context: {
|
const preSelectedTemplateName = searchParams.get("template")
|
||||||
name: 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 (
|
return (
|
||||||
<CreateWorkspacePageView
|
<CreateWorkspacePageView
|
||||||
template={templateState.context.template}
|
loadingTemplates={createWorkspaceState.matches("gettingTemplates")}
|
||||||
templateSchema={templateState.context.templateSchema}
|
loadingTemplateSchema={createWorkspaceState.matches("gettingTemplateSchema")}
|
||||||
loading={loading}
|
creatingWorkspace={createWorkspaceState.matches("creatingWorkspace")}
|
||||||
onCancel={() => navigate("/templates")}
|
templates={createWorkspaceState.context.templates}
|
||||||
onSubmit={async (req) => {
|
selectedTemplate={createWorkspaceState.context.selectedTemplate}
|
||||||
if (!templateState.context.template) {
|
templateSchema={createWorkspaceState.context.templateSchema}
|
||||||
throw new Error("template isn't valid")
|
onCancel={() => {
|
||||||
}
|
navigate(preSelectedTemplateName ? "/templates" : "/workspaces")
|
||||||
const workspace = await createWorkspace(templateState.context.template.organization_id, req)
|
}}
|
||||||
navigate("/workspaces/" + workspace.id)
|
onSubmit={(request) => {
|
||||||
|
send({
|
||||||
|
type: "CREATE_WORKSPACE",
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onSelectTemplate={(template: Template) => {
|
||||||
|
send({
|
||||||
|
type: "SELECT_TEMPLATE",
|
||||||
|
template,
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
@ -1,9 +1,32 @@
|
|||||||
import { ComponentMeta, Story } from "@storybook/react"
|
import { ComponentMeta, Story } from "@storybook/react"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { createParameterSchema } from "../../components/ParameterInput/ParameterInput.stories"
|
import { ParameterSchema } from "../../api/typesGenerated"
|
||||||
import { MockTemplate } from "../../testHelpers/entities"
|
import { MockTemplate } from "../../testHelpers/entities"
|
||||||
import { CreateWorkspacePageView, CreateWorkspacePageViewProps } from "./CreateWorkspacePageView"
|
import { CreateWorkspacePageView, CreateWorkspacePageViewProps } from "./CreateWorkspacePageView"
|
||||||
|
|
||||||
|
const createParameterSchema = (partial: Partial<ParameterSchema>): 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 {
|
export default {
|
||||||
title: "pages/CreateWorkspacePageView",
|
title: "pages/CreateWorkspacePageView",
|
||||||
component: CreateWorkspacePageView,
|
component: CreateWorkspacePageView,
|
||||||
@ -13,13 +36,15 @@ const Template: Story<CreateWorkspacePageViewProps> = (args) => <CreateWorkspace
|
|||||||
|
|
||||||
export const NoParameters = Template.bind({})
|
export const NoParameters = Template.bind({})
|
||||||
NoParameters.args = {
|
NoParameters.args = {
|
||||||
template: MockTemplate,
|
templates: [MockTemplate],
|
||||||
|
selectedTemplate: MockTemplate,
|
||||||
templateSchema: [],
|
templateSchema: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Parameters = Template.bind({})
|
export const Parameters = Template.bind({})
|
||||||
Parameters.args = {
|
Parameters.args = {
|
||||||
template: MockTemplate,
|
templates: [MockTemplate],
|
||||||
|
selectedTemplate: MockTemplate,
|
||||||
templateSchema: [
|
templateSchema: [
|
||||||
createParameterSchema({
|
createParameterSchema({
|
||||||
name: "region",
|
name: "region",
|
||||||
|
@ -1,16 +1,19 @@
|
|||||||
import { makeStyles } from "@material-ui/core/styles"
|
import MenuItem from "@material-ui/core/MenuItem"
|
||||||
import TextField from "@material-ui/core/TextField"
|
import TextField, { TextFieldProps } from "@material-ui/core/TextField"
|
||||||
import { FormikContextType, useFormik } from "formik"
|
import { FormikContextType, useFormik } from "formik"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import * as Yup from "yup"
|
import * as Yup from "yup"
|
||||||
import * as TypesGen from "../../api/typesGenerated"
|
import * as TypesGen from "../../api/typesGenerated"
|
||||||
import { FormFooter } from "../../components/FormFooter/FormFooter"
|
import { FormFooter } from "../../components/FormFooter/FormFooter"
|
||||||
import { FullPageForm } from "../../components/FullPageForm/FullPageForm"
|
import { FullPageForm } from "../../components/FullPageForm/FullPageForm"
|
||||||
|
import { Loader } from "../../components/Loader/Loader"
|
||||||
import { Margins } from "../../components/Margins/Margins"
|
import { Margins } from "../../components/Margins/Margins"
|
||||||
import { ParameterInput } from "../../components/ParameterInput/ParameterInput"
|
import { ParameterInput } from "../../components/ParameterInput/ParameterInput"
|
||||||
|
import { Stack } from "../../components/Stack/Stack"
|
||||||
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
|
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
|
||||||
|
|
||||||
export const Language = {
|
export const Language = {
|
||||||
|
templateLabel: "Template",
|
||||||
nameLabel: "Name",
|
nameLabel: "Name",
|
||||||
nameRequired: "Please enter a name.",
|
nameRequired: "Please enter a name.",
|
||||||
nameMatches: "Name must start with a-Z or 0-9 and can contain a-Z, 0-9 or -",
|
nameMatches: "Name must start with a-Z or 0-9 and can contain a-Z, 0-9 or -",
|
||||||
@ -24,12 +27,15 @@ const maxLenName = 32
|
|||||||
const usernameRE = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/
|
const usernameRE = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/
|
||||||
|
|
||||||
export interface CreateWorkspacePageViewProps {
|
export interface CreateWorkspacePageViewProps {
|
||||||
loading?: boolean
|
loadingTemplates: boolean
|
||||||
template: TypesGen.Template
|
loadingTemplateSchema: boolean
|
||||||
templateSchema: TypesGen.ParameterSchema[]
|
creatingWorkspace: boolean
|
||||||
|
templates?: TypesGen.Template[]
|
||||||
|
selectedTemplate?: TypesGen.Template
|
||||||
|
templateSchema?: TypesGen.ParameterSchema[]
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onSubmit: (req: TypesGen.CreateWorkspaceRequest) => Promise<void>
|
onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void
|
||||||
|
onSelectTemplate: (template: TypesGen.Template) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const validationSchema = Yup.object({
|
export const validationSchema = Yup.object({
|
||||||
@ -40,15 +46,19 @@ export const validationSchema = Yup.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const CreateWorkspacePageView: React.FC<CreateWorkspacePageViewProps> = (props) => {
|
export const CreateWorkspacePageView: React.FC<CreateWorkspacePageViewProps> = (props) => {
|
||||||
const styles = useStyles()
|
|
||||||
const [parameterValues, setParameterValues] = React.useState<Record<string, string>>({})
|
const [parameterValues, setParameterValues] = React.useState<Record<string, string>>({})
|
||||||
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> = useFormik<TypesGen.CreateWorkspaceRequest>({
|
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> = useFormik<TypesGen.CreateWorkspaceRequest>({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: "",
|
name: "",
|
||||||
template_id: props.template.id,
|
template_id: props.selectedTemplate ? props.selectedTemplate.id : "",
|
||||||
},
|
},
|
||||||
|
enableReinitialize: true,
|
||||||
validationSchema,
|
validationSchema,
|
||||||
onSubmit: (request) => {
|
onSubmit: (request) => {
|
||||||
|
if (!props.templateSchema) {
|
||||||
|
throw new Error("No template schema loaded")
|
||||||
|
}
|
||||||
|
|
||||||
const createRequests: TypesGen.CreateParameterRequest[] = []
|
const createRequests: TypesGen.CreateParameterRequest[] = []
|
||||||
props.templateSchema.forEach((schema) => {
|
props.templateSchema.forEach((schema) => {
|
||||||
let value = schema.default_source_value
|
let value = schema.default_source_value
|
||||||
@ -70,49 +80,84 @@ export const CreateWorkspacePageView: React.FC<CreateWorkspacePageViewProps> = (
|
|||||||
})
|
})
|
||||||
const getFieldHelpers = getFormHelpers<TypesGen.CreateWorkspaceRequest>(form)
|
const getFieldHelpers = getFormHelpers<TypesGen.CreateWorkspaceRequest>(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 (
|
return (
|
||||||
<Margins>
|
<Margins>
|
||||||
<FullPageForm title="Create workspace" onCancel={props.onCancel}>
|
<FullPageForm title="Create workspace" onCancel={props.onCancel}>
|
||||||
<form onSubmit={form.handleSubmit}>
|
<form onSubmit={form.handleSubmit}>
|
||||||
<TextField
|
{props.loadingTemplates && <Loader />}
|
||||||
{...getFieldHelpers("name")}
|
|
||||||
disabled={form.isSubmitting}
|
|
||||||
onChange={onChangeTrimmed(form)}
|
|
||||||
autoFocus
|
|
||||||
fullWidth
|
|
||||||
label={Language.nameLabel}
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
{props.templateSchema.length > 0 && (
|
|
||||||
<div className={styles.parameters}>
|
|
||||||
{props.templateSchema.map((schema) => (
|
|
||||||
<ParameterInput
|
|
||||||
disabled={form.isSubmitting}
|
|
||||||
key={schema.id}
|
|
||||||
onChange={(value) => {
|
|
||||||
setParameterValues({
|
|
||||||
...parameterValues,
|
|
||||||
[schema.name]: value,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
schema={schema}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormFooter onCancel={props.onCancel} isLoading={props.loading || form.isSubmitting} />
|
<Stack>
|
||||||
|
{props.templates && (
|
||||||
|
<TextField
|
||||||
|
{...getFieldHelpers("template_id")}
|
||||||
|
disabled={form.isSubmitting}
|
||||||
|
onChange={handleTemplateChange}
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
label={Language.templateLabel}
|
||||||
|
variant="outlined"
|
||||||
|
select
|
||||||
|
>
|
||||||
|
{props.templates.map((template) => (
|
||||||
|
<MenuItem key={template.id} value={template.id}>
|
||||||
|
{template.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{props.selectedTemplate && props.templateSchema && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
{...getFieldHelpers("name")}
|
||||||
|
disabled={form.isSubmitting}
|
||||||
|
onChange={onChangeTrimmed(form)}
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
label={Language.nameLabel}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{props.templateSchema.length > 0 && (
|
||||||
|
<Stack>
|
||||||
|
{props.templateSchema.map((schema) => (
|
||||||
|
<ParameterInput
|
||||||
|
disabled={form.isSubmitting}
|
||||||
|
key={schema.id}
|
||||||
|
onChange={(value) => {
|
||||||
|
setParameterValues({
|
||||||
|
...parameterValues,
|
||||||
|
[schema.name]: value,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
schema={schema}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormFooter onCancel={props.onCancel} isLoading={props.creatingWorkspace} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</FullPageForm>
|
</FullPageForm>
|
||||||
</Margins>
|
</Margins>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
parameters: {
|
|
||||||
paddingTop: theme.spacing(4),
|
|
||||||
"& > *": {
|
|
||||||
marginBottom: theme.spacing(4),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
@ -83,7 +83,11 @@ export const TemplatesPageView: React.FC<TemplatesPageViewProps> = (props) => {
|
|||||||
<Avatar variant="square" className={styles.templateAvatar}>
|
<Avatar variant="square" className={styles.templateAvatar}>
|
||||||
{firstLetter(template.name)}
|
{firstLetter(template.name)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Link component={RouterLink} to={`/templates/${template.name}/new`} className={styles.templateLink}>
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/workspaces/new?template=${template.name}`}
|
||||||
|
className={styles.templateLink}
|
||||||
|
>
|
||||||
<b>{template.name}</b>
|
<b>{template.name}</b>
|
||||||
<span>{template.description}</span>
|
<span>{template.description}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -39,7 +39,7 @@ export const WorkspacesPageView: React.FC<WorkspacesPageViewProps> = (props) =>
|
|||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
<Margins>
|
<Margins>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Link component={RouterLink} to="/templates">
|
<Link underline="none" component={RouterLink} to="/workspaces/new">
|
||||||
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
|
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
171
site/src/xServices/createWorkspace/createWorkspaceXService.ts
Normal file
171
site/src/xServices/createWorkspace/createWorkspaceXService.ts
Normal file
@ -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
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
@ -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)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
Reference in New Issue
Block a user