mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: Add create workspace page (#1589)
This commit is contained in:
@ -21,6 +21,7 @@ import { WorkspaceSettingsPage } from "./pages/WorkspaceSettingsPage/WorkspaceSe
|
|||||||
|
|
||||||
const TerminalPage = React.lazy(() => import("./pages/TerminalPage/TerminalPage"))
|
const TerminalPage = React.lazy(() => import("./pages/TerminalPage/TerminalPage"))
|
||||||
const WorkspacesPage = React.lazy(() => import("./pages/WorkspacesPage/WorkspacesPage"))
|
const WorkspacesPage = React.lazy(() => import("./pages/WorkspacesPage/WorkspacesPage"))
|
||||||
|
const CreateWorkspacePage = React.lazy(() => import("./pages/CreateWorkspacePage/CreateWorkspacePage"))
|
||||||
|
|
||||||
export const AppRouter: React.FC = () => (
|
export const AppRouter: React.FC = () => (
|
||||||
<React.Suspense fallback={<></>}>
|
<React.Suspense fallback={<></>}>
|
||||||
@ -84,6 +85,17 @@ 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">
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import axios, { AxiosRequestHeaders } from "axios"
|
import axios, { AxiosRequestHeaders } from "axios"
|
||||||
import { mutate } from "swr"
|
|
||||||
import { WorkspaceBuildTransition } from "./types"
|
import { WorkspaceBuildTransition } from "./types"
|
||||||
import * as TypesGen from "./typesGenerated"
|
import * as TypesGen from "./typesGenerated"
|
||||||
|
|
||||||
@ -22,34 +21,6 @@ export const provisioners: TypesGen.ProvisionerDaemon[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export namespace Workspace {
|
|
||||||
export const create = async (
|
|
||||||
organizationId: string,
|
|
||||||
request: TypesGen.CreateWorkspaceRequest,
|
|
||||||
): Promise<TypesGen.Workspace> => {
|
|
||||||
const response = await fetch(`/api/v2/organizations/${organizationId}/workspaces`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(request),
|
|
||||||
})
|
|
||||||
|
|
||||||
const body = await response.json()
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(body.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let SWR know that both the /api/v2/workspaces/* and /api/v2/templates/*
|
|
||||||
// endpoints will need to fetch new data.
|
|
||||||
const mutateWorkspacesPromise = mutate("/api/v2/workspaces")
|
|
||||||
const mutateTemplatesPromise = mutate("/api/v2/templates")
|
|
||||||
await Promise.all([mutateWorkspacesPromise, mutateTemplatesPromise])
|
|
||||||
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const login = async (email: string, password: string): Promise<TypesGen.LoginWithPasswordResponse> => {
|
export const login = async (email: string, password: string): Promise<TypesGen.LoginWithPasswordResponse> => {
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
email,
|
email,
|
||||||
@ -115,6 +86,21 @@ export const getTemplates = async (organizationId: string): Promise<TypesGen.Tem
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getTemplateByName = async (organizationId: string, name: string): Promise<TypesGen.Template> => {
|
||||||
|
const response = await axios.get<TypesGen.Template>(`/api/v2/organizations/${organizationId}/templates/${name}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTemplateVersion = async (versionId: string): Promise<TypesGen.TemplateVersion> => {
|
||||||
|
const response = await axios.get<TypesGen.TemplateVersion>(`/api/v2/templateversions/${versionId}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTemplateVersionSchema = async (versionId: string): Promise<TypesGen.ParameterSchema[]> => {
|
||||||
|
const response = await axios.get<TypesGen.ParameterSchema[]>(`/api/v2/templateversions/${versionId}/schema`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
export const getWorkspace = async (workspaceId: string): Promise<TypesGen.Workspace> => {
|
export const getWorkspace = async (workspaceId: string): Promise<TypesGen.Workspace> => {
|
||||||
const response = await axios.get<TypesGen.Workspace>(`/api/v2/workspaces/${workspaceId}`)
|
const response = await axios.get<TypesGen.Workspace>(`/api/v2/workspaces/${workspaceId}`)
|
||||||
return response.data
|
return response.data
|
||||||
@ -180,6 +166,14 @@ export const createUser = async (user: TypesGen.CreateUserRequest): Promise<Type
|
|||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createWorkspace = async (
|
||||||
|
organizationId: string,
|
||||||
|
workspace: TypesGen.CreateWorkspaceRequest,
|
||||||
|
): Promise<TypesGen.Workspace> => {
|
||||||
|
const response = await axios.post<TypesGen.Workspace>(`/api/v2/organizations/${organizationId}/workspaces`, workspace)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
export const getBuildInfo = async (): Promise<TypesGen.BuildInfoResponse> => {
|
export const getBuildInfo = async (): Promise<TypesGen.BuildInfoResponse> => {
|
||||||
const response = await axios.get("/api/v2/buildinfo")
|
const response = await axios.get("/api/v2/buildinfo")
|
||||||
return response.data
|
return response.data
|
||||||
|
@ -0,0 +1,52 @@
|
|||||||
|
import { Story } from "@storybook/react"
|
||||||
|
import React from "react"
|
||||||
|
import { ParameterSchema } from "../../api/typesGenerated"
|
||||||
|
import { ParameterInput, ParameterInputProps } from "./ParameterInput"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "components/ParameterInput",
|
||||||
|
component: ParameterInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Template: Story<ParameterInputProps> = (args: ParameterInputProps) => <ParameterInput {...args} />
|
||||||
|
|
||||||
|
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 const Basic = Template.bind({})
|
||||||
|
Basic.args = {
|
||||||
|
schema: createParameterSchema({
|
||||||
|
name: "project_name",
|
||||||
|
description: "Customize the name of a Google Cloud project that will be created!",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Contains = Template.bind({})
|
||||||
|
Contains.args = {
|
||||||
|
schema: createParameterSchema({
|
||||||
|
name: "region",
|
||||||
|
default_source_value: "🏈 US Central",
|
||||||
|
description: "Where would you like your workspace to live?",
|
||||||
|
validation_contains: ["🏈 US Central", "⚽ Brazil East", "💶 EU West", "🦘 Australia South"],
|
||||||
|
}),
|
||||||
|
}
|
94
site/src/components/ParameterInput/ParameterInput.tsx
Normal file
94
site/src/components/ParameterInput/ParameterInput.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import FormControlLabel from "@material-ui/core/FormControlLabel"
|
||||||
|
import Paper from "@material-ui/core/Paper"
|
||||||
|
import Radio from "@material-ui/core/Radio"
|
||||||
|
import RadioGroup from "@material-ui/core/RadioGroup"
|
||||||
|
import { lighten, makeStyles } from "@material-ui/core/styles"
|
||||||
|
import TextField from "@material-ui/core/TextField"
|
||||||
|
import React from "react"
|
||||||
|
import { ParameterSchema } from "../../api/typesGenerated"
|
||||||
|
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
||||||
|
|
||||||
|
export interface ParameterInputProps {
|
||||||
|
disabled?: boolean
|
||||||
|
schema: ParameterSchema
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ParameterInput: React.FC<ParameterInputProps> = ({ disabled, onChange, schema }) => {
|
||||||
|
const styles = useStyles()
|
||||||
|
return (
|
||||||
|
<Paper className={styles.paper}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
<h2>var.{schema.name}</h2>
|
||||||
|
{schema.description && <span>{schema.description}</span>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.input}>
|
||||||
|
<ParameterField disabled={disabled} onChange={onChange} schema={schema} />
|
||||||
|
</div>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ParameterField: React.FC<ParameterInputProps> = ({ disabled, onChange, schema }) => {
|
||||||
|
if (schema.validation_contains.length > 0) {
|
||||||
|
return (
|
||||||
|
<RadioGroup
|
||||||
|
defaultValue={schema.default_source_value}
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange(event.target.value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{schema.validation_contains.map((item) => (
|
||||||
|
<FormControlLabel
|
||||||
|
disabled={disabled}
|
||||||
|
key={item}
|
||||||
|
value={item}
|
||||||
|
control={<Radio color="primary" size="small" disableRipple />}
|
||||||
|
label={item}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A text field can technically handle all cases!
|
||||||
|
// As other cases become more prominent (like filtering for numbers),
|
||||||
|
// we should break this out into more finely scoped input fields.
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={schema.default_source_value}
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange(event.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
paper: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
background: lighten(theme.palette.background.default, 0.1),
|
||||||
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
|
padding: theme.spacing(3),
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
"& h2": {
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
"& span": {
|
||||||
|
paddingTop: theme.spacing(2),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
padding: theme.spacing(3),
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
maxWidth: 480,
|
||||||
|
},
|
||||||
|
}))
|
@ -1,64 +0,0 @@
|
|||||||
import { render, screen } from "@testing-library/react"
|
|
||||||
import React from "react"
|
|
||||||
import { reach, StringSchema } from "yup"
|
|
||||||
import { MockOrganization, MockTemplate, MockWorkspace } from "../testHelpers/renderHelpers"
|
|
||||||
import { CreateWorkspaceForm, validationSchema } from "./CreateWorkspaceForm"
|
|
||||||
|
|
||||||
const nameSchema = reach(validationSchema, "name") as StringSchema
|
|
||||||
|
|
||||||
describe("CreateWorkspaceForm", () => {
|
|
||||||
it("renders", async () => {
|
|
||||||
// Given
|
|
||||||
const onSubmit = () => Promise.resolve(MockWorkspace)
|
|
||||||
const onCancel = () => Promise.resolve()
|
|
||||||
|
|
||||||
// When
|
|
||||||
render(
|
|
||||||
<CreateWorkspaceForm
|
|
||||||
template={MockTemplate}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
onCancel={onCancel}
|
|
||||||
organizationId={MockOrganization.id}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
// Simple smoke test to verify form renders
|
|
||||||
const element = await screen.findByText("Create Workspace")
|
|
||||||
expect(element).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("validationSchema", () => {
|
|
||||||
it("allows a 1-letter name", () => {
|
|
||||||
const validate = () => nameSchema.validateSync("t")
|
|
||||||
expect(validate).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("allows a 32-letter name", () => {
|
|
||||||
const input = Array(32).fill("a").join("")
|
|
||||||
const validate = () => nameSchema.validateSync(input)
|
|
||||||
expect(validate).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("allows 'test-3' to be used as name", () => {
|
|
||||||
const validate = () => nameSchema.validateSync("test-3")
|
|
||||||
expect(validate).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("allows '3-test' to be used as a name", () => {
|
|
||||||
const validate = () => nameSchema.validateSync("3-test")
|
|
||||||
expect(validate).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("disallows a 33-letter name", () => {
|
|
||||||
const input = Array(33).fill("a").join("")
|
|
||||||
const validate = () => nameSchema.validateSync(input)
|
|
||||||
expect(validate).toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("disallows a space", () => {
|
|
||||||
const validate = () => nameSchema.validateSync("test 3")
|
|
||||||
expect(validate).toThrow()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
@ -1,129 +0,0 @@
|
|||||||
import Button from "@material-ui/core/Button"
|
|
||||||
import { makeStyles } from "@material-ui/core/styles"
|
|
||||||
import TextField from "@material-ui/core/TextField"
|
|
||||||
import { useFormik } from "formik"
|
|
||||||
import React from "react"
|
|
||||||
import * as Yup from "yup"
|
|
||||||
import * as TypesGen from "../api/typesGenerated"
|
|
||||||
import { FormCloseButton } from "../components/FormCloseButton/FormCloseButton"
|
|
||||||
import { FormSection } from "../components/FormSection/FormSection"
|
|
||||||
import { FormTitle } from "../components/FormTitle/FormTitle"
|
|
||||||
import { LoadingButton } from "../components/LoadingButton/LoadingButton"
|
|
||||||
import { maxWidth } from "../theme/constants"
|
|
||||||
import { getFormHelpers, onChangeTrimmed } from "../util/formUtils"
|
|
||||||
|
|
||||||
export const Language = {
|
|
||||||
nameHelperText: "A unique name describing your workspace",
|
|
||||||
nameLabel: "Workspace Name",
|
|
||||||
nameMatches: "Name must start with a-Z or 0-9 and can contain a-Z, 0-9 or -",
|
|
||||||
nameMax: "Name cannot be longer than 32 characters",
|
|
||||||
namePlaceholder: "my-workspace",
|
|
||||||
nameRequired: "Name is required",
|
|
||||||
}
|
|
||||||
export interface CreateWorkspaceForm {
|
|
||||||
template: TypesGen.Template
|
|
||||||
onSubmit: (organizationId: string, request: TypesGen.CreateWorkspaceRequest) => Promise<TypesGen.Workspace>
|
|
||||||
onCancel: () => void
|
|
||||||
organizationId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateWorkspaceFormValues {
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// REMARK: Keep in sync with coderd/httpapi/httpapi.go#L40
|
|
||||||
const maxLenName = 32
|
|
||||||
|
|
||||||
// REMARK: Keep in sync with coderd/httpapi/httpapi.go#L18
|
|
||||||
const usernameRE = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/
|
|
||||||
|
|
||||||
export const validationSchema = Yup.object({
|
|
||||||
name: Yup.string()
|
|
||||||
.matches(usernameRE, Language.nameMatches)
|
|
||||||
.max(maxLenName, Language.nameMax)
|
|
||||||
.required(Language.nameRequired),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const CreateWorkspaceForm: React.FC<CreateWorkspaceForm> = ({
|
|
||||||
template,
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
organizationId,
|
|
||||||
}) => {
|
|
||||||
const styles = useStyles()
|
|
||||||
|
|
||||||
const form = useFormik<CreateWorkspaceFormValues>({
|
|
||||||
initialValues: {
|
|
||||||
name: "",
|
|
||||||
},
|
|
||||||
onSubmit: ({ name }) => {
|
|
||||||
return onSubmit(organizationId, {
|
|
||||||
template_id: template.id,
|
|
||||||
name: name,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
validationSchema: validationSchema,
|
|
||||||
})
|
|
||||||
const getFieldHelpers = getFormHelpers<CreateWorkspaceFormValues>(form)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.root}>
|
|
||||||
<FormTitle
|
|
||||||
title="Create Workspace"
|
|
||||||
detail={
|
|
||||||
<span>
|
|
||||||
for template <strong>{template.name}</strong>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<FormCloseButton onClose={onCancel} />
|
|
||||||
|
|
||||||
<FormSection title="Name">
|
|
||||||
<TextField
|
|
||||||
{...getFieldHelpers("name", Language.nameHelperText)}
|
|
||||||
onChange={onChangeTrimmed(form)}
|
|
||||||
autoFocus
|
|
||||||
fullWidth
|
|
||||||
label={Language.nameLabel}
|
|
||||||
placeholder={Language.namePlaceholder}
|
|
||||||
/>
|
|
||||||
</FormSection>
|
|
||||||
|
|
||||||
<div className={styles.footer}>
|
|
||||||
<Button className={styles.button} onClick={onCancel} variant="outlined">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<LoadingButton
|
|
||||||
loading={form.isSubmitting}
|
|
||||||
className={styles.button}
|
|
||||||
onClick={form.submitForm}
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Submit
|
|
||||||
</LoadingButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const useStyles = makeStyles(() => ({
|
|
||||||
root: {
|
|
||||||
maxWidth,
|
|
||||||
width: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
display: "flex",
|
|
||||||
flex: "0",
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
margin: "1em",
|
|
||||||
},
|
|
||||||
}))
|
|
@ -0,0 +1,81 @@
|
|||||||
|
import { screen, waitFor } from "@testing-library/react"
|
||||||
|
import userEvent from "@testing-library/user-event"
|
||||||
|
import React from "react"
|
||||||
|
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 CreateWorkspacePage from "./CreateWorkspacePage"
|
||||||
|
import { Language, validationSchema } from "./CreateWorkspacePageView"
|
||||||
|
|
||||||
|
const fillForm = async ({ name = "example" }: { name?: string }) => {
|
||||||
|
const nameField = await screen.findByLabelText(Language.nameLabel)
|
||||||
|
await userEvent.type(nameField, name)
|
||||||
|
const submitButton = await screen.findByText(FooterLanguage.defaultSubmitLabel)
|
||||||
|
await userEvent.click(submitButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameSchema = reach(validationSchema, "name") as StringSchema
|
||||||
|
|
||||||
|
describe("CreateWorkspacePage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
history.replace("/templates/" + MockTemplate.name + "/new")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders", async () => {
|
||||||
|
render(<CreateWorkspacePage />)
|
||||||
|
const element = await screen.findByText("Create workspace")
|
||||||
|
expect(element).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows validation error message", async () => {
|
||||||
|
render(<CreateWorkspacePage />)
|
||||||
|
await fillForm({ name: "$$$" })
|
||||||
|
const errorMessage = await screen.findByText(Language.nameMatches)
|
||||||
|
expect(errorMessage).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("succeeds", async () => {
|
||||||
|
render(<CreateWorkspacePage />)
|
||||||
|
// You have to spy the method before it is used.
|
||||||
|
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace)
|
||||||
|
await fillForm({ name: "test" })
|
||||||
|
// Check if the request was made
|
||||||
|
await waitFor(() => expect(API.createWorkspace).toBeCalledTimes(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("validationSchema", () => {
|
||||||
|
it("allows a 1-letter name", () => {
|
||||||
|
const validate = () => nameSchema.validateSync("t")
|
||||||
|
expect(validate).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows a 32-letter name", () => {
|
||||||
|
const input = Array(32).fill("a").join("")
|
||||||
|
const validate = () => nameSchema.validateSync(input)
|
||||||
|
expect(validate).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows 'test-3' to be used as name", () => {
|
||||||
|
const validate = () => nameSchema.validateSync("test-3")
|
||||||
|
expect(validate).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows '3-test' to be used as a name", () => {
|
||||||
|
const validate = () => nameSchema.validateSync("3-test")
|
||||||
|
expect(validate).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("disallows a 33-letter name", () => {
|
||||||
|
const input = Array(33).fill("a").join("")
|
||||||
|
const validate = () => nameSchema.validateSync(input)
|
||||||
|
expect(validate).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("disallows a space", () => {
|
||||||
|
const validate = () => nameSchema.validateSync("test 3")
|
||||||
|
expect(validate).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
39
site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx
Normal file
39
site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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 { CreateWorkspacePageView } from "./CreateWorkspacePageView"
|
||||||
|
|
||||||
|
const CreateWorkspacePage: React.FC = () => {
|
||||||
|
const { template } = useParams()
|
||||||
|
const [templateState] = useMachine(templateMachine, {
|
||||||
|
context: {
|
||||||
|
name: template,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const loading = templateState.hasTag("loading")
|
||||||
|
if (!templateState.context.template || !templateState.context.templateSchema) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CreateWorkspacePageView
|
||||||
|
template={templateState.context.template}
|
||||||
|
templateSchema={templateState.context.templateSchema}
|
||||||
|
loading={loading}
|
||||||
|
onCancel={() => 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)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateWorkspacePage
|
@ -0,0 +1,37 @@
|
|||||||
|
import { ComponentMeta, Story } from "@storybook/react"
|
||||||
|
import React from "react"
|
||||||
|
import { createParameterSchema } from "../../components/ParameterInput/ParameterInput.stories"
|
||||||
|
import { MockTemplate } from "../../testHelpers/entities"
|
||||||
|
import { CreateWorkspacePageView, CreateWorkspacePageViewProps } from "./CreateWorkspacePageView"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "pages/CreateWorkspacePageView",
|
||||||
|
component: CreateWorkspacePageView,
|
||||||
|
} as ComponentMeta<typeof CreateWorkspacePageView>
|
||||||
|
|
||||||
|
const Template: Story<CreateWorkspacePageViewProps> = (args) => <CreateWorkspacePageView {...args} />
|
||||||
|
|
||||||
|
export const NoParameters = Template.bind({})
|
||||||
|
NoParameters.args = {
|
||||||
|
template: MockTemplate,
|
||||||
|
templateSchema: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Parameters = Template.bind({})
|
||||||
|
Parameters.args = {
|
||||||
|
template: MockTemplate,
|
||||||
|
templateSchema: [
|
||||||
|
createParameterSchema({
|
||||||
|
name: "region",
|
||||||
|
default_source_value: "🏈 US Central",
|
||||||
|
description: "Where would you like your workspace to live?",
|
||||||
|
validation_contains: ["🏈 US Central", "⚽ Brazil East", "💶 EU West", "🦘 Australia South"],
|
||||||
|
}),
|
||||||
|
createParameterSchema({
|
||||||
|
name: "instance_size",
|
||||||
|
default_source_value: "Big",
|
||||||
|
description: "How large should you instance be?",
|
||||||
|
validation_contains: ["Small", "Medium", "Big"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
118
site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx
Normal file
118
site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { makeStyles } from "@material-ui/core/styles"
|
||||||
|
import TextField from "@material-ui/core/TextField"
|
||||||
|
import { FormikContextType, useFormik } from "formik"
|
||||||
|
import React from "react"
|
||||||
|
import * as Yup from "yup"
|
||||||
|
import * as TypesGen from "../../api/typesGenerated"
|
||||||
|
import { FormFooter } from "../../components/FormFooter/FormFooter"
|
||||||
|
import { FullPageForm } from "../../components/FullPageForm/FullPageForm"
|
||||||
|
import { Margins } from "../../components/Margins/Margins"
|
||||||
|
import { ParameterInput } from "../../components/ParameterInput/ParameterInput"
|
||||||
|
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
|
||||||
|
|
||||||
|
export const Language = {
|
||||||
|
nameLabel: "Name",
|
||||||
|
nameRequired: "Please enter a name.",
|
||||||
|
nameMatches: "Name must start with a-Z or 0-9 and can contain a-Z, 0-9 or -",
|
||||||
|
nameMax: "Name cannot be longer than 32 characters",
|
||||||
|
}
|
||||||
|
|
||||||
|
// REMARK: Keep in sync with coderd/httpapi/httpapi.go#L40
|
||||||
|
const maxLenName = 32
|
||||||
|
|
||||||
|
// REMARK: Keep in sync with coderd/httpapi/httpapi.go#L18
|
||||||
|
const usernameRE = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/
|
||||||
|
|
||||||
|
export interface CreateWorkspacePageViewProps {
|
||||||
|
loading?: boolean
|
||||||
|
template: TypesGen.Template
|
||||||
|
templateSchema: TypesGen.ParameterSchema[]
|
||||||
|
|
||||||
|
onCancel: () => void
|
||||||
|
onSubmit: (req: TypesGen.CreateWorkspaceRequest) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validationSchema = Yup.object({
|
||||||
|
name: Yup.string()
|
||||||
|
.required(Language.nameRequired)
|
||||||
|
.matches(usernameRE, Language.nameMatches)
|
||||||
|
.max(maxLenName, Language.nameMax),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CreateWorkspacePageView: React.FC<CreateWorkspacePageViewProps> = (props) => {
|
||||||
|
const styles = useStyles()
|
||||||
|
const [parameterValues, setParameterValues] = React.useState<Record<string, string>>({})
|
||||||
|
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> = useFormik<TypesGen.CreateWorkspaceRequest>({
|
||||||
|
initialValues: {
|
||||||
|
name: "",
|
||||||
|
template_id: props.template.id,
|
||||||
|
},
|
||||||
|
validationSchema,
|
||||||
|
onSubmit: (request) => {
|
||||||
|
const createRequests: TypesGen.CreateParameterRequest[] = []
|
||||||
|
props.templateSchema.forEach((schema) => {
|
||||||
|
let value = schema.default_source_value
|
||||||
|
if (schema.name in parameterValues) {
|
||||||
|
value = parameterValues[schema.name]
|
||||||
|
}
|
||||||
|
createRequests.push({
|
||||||
|
name: schema.name,
|
||||||
|
destination_scheme: schema.default_destination_scheme,
|
||||||
|
source_scheme: schema.default_source_scheme,
|
||||||
|
source_value: value,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return props.onSubmit({
|
||||||
|
...request,
|
||||||
|
parameter_values: createRequests,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const getFieldHelpers = getFormHelpers<TypesGen.CreateWorkspaceRequest>(form)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Margins>
|
||||||
|
<FullPageForm title="Create workspace" onCancel={props.onCancel}>
|
||||||
|
<form onSubmit={form.handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
{...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} />
|
||||||
|
</form>
|
||||||
|
</FullPageForm>
|
||||||
|
</Margins>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
parameters: {
|
||||||
|
paddingTop: theme.spacing(4),
|
||||||
|
"& > *": {
|
||||||
|
marginBottom: theme.spacing(4),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
@ -1,153 +0,0 @@
|
|||||||
import Avatar from "@material-ui/core/Avatar"
|
|
||||||
import Button from "@material-ui/core/Button"
|
|
||||||
import Link from "@material-ui/core/Link"
|
|
||||||
import { makeStyles } from "@material-ui/core/styles"
|
|
||||||
import Table from "@material-ui/core/Table"
|
|
||||||
import TableBody from "@material-ui/core/TableBody"
|
|
||||||
import TableCell from "@material-ui/core/TableCell"
|
|
||||||
import TableHead from "@material-ui/core/TableHead"
|
|
||||||
import TableRow from "@material-ui/core/TableRow"
|
|
||||||
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
|
|
||||||
import dayjs from "dayjs"
|
|
||||||
import relativeTime from "dayjs/plugin/relativeTime"
|
|
||||||
import React from "react"
|
|
||||||
import { Link as RouterLink } from "react-router-dom"
|
|
||||||
import * as TypesGen from "../../api/typesGenerated"
|
|
||||||
import { Margins } from "../../components/Margins/Margins"
|
|
||||||
import { Stack } from "../../components/Stack/Stack"
|
|
||||||
import { firstLetter } from "../../util/firstLetter"
|
|
||||||
|
|
||||||
dayjs.extend(relativeTime)
|
|
||||||
|
|
||||||
export const Language = {
|
|
||||||
createButton: "Create Template",
|
|
||||||
emptyViewCreate: "to standardize development workspaces for your team.",
|
|
||||||
emptyViewNoPerms: "No templates have been created! Contact your Coder administrator.",
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TemplatesPageViewProps {
|
|
||||||
loading?: boolean
|
|
||||||
canCreateTemplate?: boolean
|
|
||||||
templates?: TypesGen.Template[]
|
|
||||||
error?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TemplatesPageView: React.FC<TemplatesPageViewProps> = (props) => {
|
|
||||||
const styles = useStyles()
|
|
||||||
return (
|
|
||||||
<Stack spacing={4}>
|
|
||||||
<Margins>
|
|
||||||
<div className={styles.actions}>
|
|
||||||
{props.canCreateTemplate && <Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>}
|
|
||||||
</div>
|
|
||||||
<Table>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>Name</TableCell>
|
|
||||||
<TableCell>Used By</TableCell>
|
|
||||||
<TableCell>Last Updated</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{!props.loading && !props.templates?.length && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={999}>
|
|
||||||
<div className={styles.welcome}>
|
|
||||||
{props.canCreateTemplate ? (
|
|
||||||
<span>
|
|
||||||
<Link component={RouterLink} to="/templates/new">
|
|
||||||
Create a template
|
|
||||||
</Link>
|
|
||||||
{Language.emptyViewCreate}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>{Language.emptyViewNoPerms}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{props.templates?.map((template) => {
|
|
||||||
return (
|
|
||||||
<TableRow key={template.id} className={styles.templateRow}>
|
|
||||||
<TableCell>
|
|
||||||
<div className={styles.templateName}>
|
|
||||||
<Avatar variant="square" className={styles.templateAvatar}>
|
|
||||||
{firstLetter(template.name)}
|
|
||||||
</Avatar>
|
|
||||||
<Link component={RouterLink} to={`/templates/${template.id}`} className={styles.templateLink}>
|
|
||||||
<b>{template.name}</b>
|
|
||||||
<span>{template.description}</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{template.workspace_owner_count} developer{template.workspace_owner_count !== 1 && "s"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell data-chromatic="ignore">{dayjs().to(dayjs(template.updated_at))}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</Margins>
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
|
||||||
actions: {
|
|
||||||
marginTop: theme.spacing(3),
|
|
||||||
marginBottom: theme.spacing(3),
|
|
||||||
display: "flex",
|
|
||||||
height: theme.spacing(6),
|
|
||||||
|
|
||||||
"& button": {
|
|
||||||
marginLeft: "auto",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
welcome: {
|
|
||||||
paddingTop: theme.spacing(12),
|
|
||||||
paddingBottom: theme.spacing(12),
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
"& span": {
|
|
||||||
maxWidth: 600,
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: theme.spacing(2),
|
|
||||||
lineHeight: `${theme.spacing(3)}px`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
templateRow: {
|
|
||||||
"& > td": {
|
|
||||||
paddingTop: theme.spacing(2),
|
|
||||||
paddingBottom: theme.spacing(2),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
templateAvatar: {
|
|
||||||
borderRadius: 2,
|
|
||||||
marginRight: theme.spacing(1),
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
templateName: {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
templateLink: {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
textDecoration: "none",
|
|
||||||
"&:hover": {
|
|
||||||
textDecoration: "underline",
|
|
||||||
},
|
|
||||||
"& span": {
|
|
||||||
fontSize: 12,
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
@ -83,7 +83,7 @@ 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.id}`} className={styles.templateLink}>
|
<Link component={RouterLink} to={`/templates/${template.name}/new`} className={styles.templateLink}>
|
||||||
<b>{template.name}</b>
|
<b>{template.name}</b>
|
||||||
<span>{template.description}</span>
|
<span>{template.description}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -78,6 +78,15 @@ export const MockCancelingProvisionerJob = {
|
|||||||
}
|
}
|
||||||
export const MockRunningProvisionerJob = { ...MockProvisionerJob, status: "running" as TypesGen.ProvisionerJobStatus }
|
export const MockRunningProvisionerJob = { ...MockProvisionerJob, status: "running" as TypesGen.ProvisionerJobStatus }
|
||||||
|
|
||||||
|
export const MockTemplateVersion: TypesGen.TemplateVersion = {
|
||||||
|
id: "test-template-version",
|
||||||
|
created_at: "",
|
||||||
|
updated_at: "",
|
||||||
|
job: MockProvisionerJob,
|
||||||
|
name: "test-version",
|
||||||
|
readme: "",
|
||||||
|
}
|
||||||
|
|
||||||
export const MockTemplate: TypesGen.Template = {
|
export const MockTemplate: TypesGen.Template = {
|
||||||
id: "test-template",
|
id: "test-template",
|
||||||
created_at: "2022-05-17T17:39:01.382927298Z",
|
created_at: "2022-05-17T17:39:01.382927298Z",
|
||||||
@ -85,7 +94,7 @@ export const MockTemplate: TypesGen.Template = {
|
|||||||
organization_id: MockOrganization.id,
|
organization_id: MockOrganization.id,
|
||||||
name: "Test Template",
|
name: "Test Template",
|
||||||
provisioner: MockProvisioner.provisioners[0],
|
provisioner: MockProvisioner.provisioners[0],
|
||||||
active_version_id: "",
|
active_version_id: MockTemplateVersion.id,
|
||||||
workspace_owner_count: 1,
|
workspace_owner_count: 1,
|
||||||
description: "This is a test description.",
|
description: "This is a test description.",
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,12 @@ export const handlers = [
|
|||||||
rest.get("/api/v2/templates/:templateId", async (req, res, ctx) => {
|
rest.get("/api/v2/templates/:templateId", async (req, res, ctx) => {
|
||||||
return res(ctx.status(200), ctx.json(M.MockTemplate))
|
return res(ctx.status(200), ctx.json(M.MockTemplate))
|
||||||
}),
|
}),
|
||||||
|
rest.get("/api/v2/templateversions/:templateVersionId", async (req, res, ctx) => {
|
||||||
|
return res(ctx.status(200), ctx.json(M.MockTemplateVersion))
|
||||||
|
}),
|
||||||
|
rest.get("/api/v2/templateversions/:templateVersionId/schema", async (req, res, ctx) => {
|
||||||
|
return res(ctx.status(200), ctx.json([]))
|
||||||
|
}),
|
||||||
|
|
||||||
// users
|
// users
|
||||||
rest.get("/api/v2/users", async (req, res, ctx) => {
|
rest.get("/api/v2/users", async (req, res, ctx) => {
|
||||||
@ -76,9 +82,6 @@ export const handlers = [
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// workspaces
|
// workspaces
|
||||||
|
|
||||||
// REMARK: This endpoint works with query parameters, but they won't be
|
|
||||||
// reflected in the return.
|
|
||||||
rest.get("/api/v2/workspaces", async (req, res, ctx) => {
|
rest.get("/api/v2/workspaces", async (req, res, ctx) => {
|
||||||
return res(ctx.status(200), ctx.json([M.MockWorkspace]))
|
return res(ctx.status(200), ctx.json([M.MockWorkspace]))
|
||||||
}),
|
}),
|
||||||
|
@ -74,6 +74,9 @@ export const getOverrides = (palette: PaletteOptions) => {
|
|||||||
"& input:-webkit-autofill": {
|
"& input:-webkit-autofill": {
|
||||||
WebkitBoxShadow: `0 0 0 1000px ${palette.background?.paper} inset`,
|
WebkitBoxShadow: `0 0 0 1000px ${palette.background?.paper} inset`,
|
||||||
},
|
},
|
||||||
|
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||||
|
borderColor: (palette.primary as SimplePaletteColorOptions).light,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
MuiLink: {
|
MuiLink: {
|
||||||
@ -81,5 +84,11 @@ export const getOverrides = (palette: PaletteOptions) => {
|
|||||||
color: (palette.primary as SimplePaletteColorOptions).light,
|
color: (palette.primary as SimplePaletteColorOptions).light,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
MuiPaper: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 2,
|
||||||
|
border: `1px solid ${palette.divider}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -38,4 +38,7 @@ export const props = {
|
|||||||
textColor: "primary",
|
textColor: "primary",
|
||||||
indicatorColor: "primary",
|
indicatorColor: "primary",
|
||||||
},
|
},
|
||||||
|
MuiPaper: {
|
||||||
|
elevation: 0,
|
||||||
|
},
|
||||||
} as ComponentsProps
|
} as ComponentsProps
|
||||||
|
167
site/src/xServices/template/templateXService.ts
Normal file
167
site/src/xServices/template/templateXService.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
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