From 3be356095ff668e1b07621be99b265be9af494da Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Thu, 19 May 2022 15:51:10 -0500 Subject: [PATCH] feat: Add create workspace page (#1589) --- site/src/AppRouter.tsx | 12 ++ site/src/api/api.ts | 52 +++--- .../ParameterInput/ParameterInput.stories.tsx | 52 ++++++ .../ParameterInput/ParameterInput.tsx | 94 ++++++++++ site/src/forms/CreateWorkspaceForm.test.tsx | 64 ------- site/src/forms/CreateWorkspaceForm.tsx | 129 -------------- .../CreateWorkspacePage.test.tsx | 81 +++++++++ .../CreateWorkspacePage.tsx | 39 ++++ .../CreateWorkspacePageView.stories.tsx | 37 ++++ .../CreateWorkspacePageView.tsx | 118 +++++++++++++ .../pages/TemplatePage/TemplatePageView.tsx | 153 ---------------- .../pages/TemplatesPage/TemplatesPageView.tsx | 2 +- site/src/testHelpers/entities.ts | 11 +- site/src/testHelpers/handlers.ts | 9 +- site/src/theme/overrides.ts | 9 + site/src/theme/props.ts | 3 + .../xServices/template/templateXService.ts | 167 ++++++++++++++++++ 17 files changed, 652 insertions(+), 380 deletions(-) create mode 100644 site/src/components/ParameterInput/ParameterInput.stories.tsx create mode 100644 site/src/components/ParameterInput/ParameterInput.tsx delete mode 100644 site/src/forms/CreateWorkspaceForm.test.tsx delete mode 100644 site/src/forms/CreateWorkspaceForm.tsx create mode 100644 site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx create mode 100644 site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx create mode 100644 site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx create mode 100644 site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx delete mode 100644 site/src/pages/TemplatePage/TemplatePageView.tsx create mode 100644 site/src/xServices/template/templateXService.ts diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 8e269f9251..b11a33d3e3 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -21,6 +21,7 @@ import { WorkspaceSettingsPage } from "./pages/WorkspaceSettingsPage/WorkspaceSe const TerminalPage = React.lazy(() => import("./pages/TerminalPage/TerminalPage")) const WorkspacesPage = React.lazy(() => import("./pages/WorkspacesPage/WorkspacesPage")) +const CreateWorkspacePage = React.lazy(() => import("./pages/CreateWorkspacePage/CreateWorkspacePage")) export const AppRouter: React.FC = () => ( }> @@ -84,6 +85,17 @@ export const AppRouter: React.FC = () => ( } /> + + + + + + } + /> + diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 1a234718b7..8b763bb644 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1,5 +1,4 @@ import axios, { AxiosRequestHeaders } from "axios" -import { mutate } from "swr" import { WorkspaceBuildTransition } from "./types" 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 => { - 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 => { const payload = JSON.stringify({ email, @@ -115,6 +86,21 @@ export const getTemplates = async (organizationId: string): Promise => { + const response = await axios.get(`/api/v2/organizations/${organizationId}/templates/${name}`) + return response.data +} + +export const getTemplateVersion = async (versionId: string): Promise => { + const response = await axios.get(`/api/v2/templateversions/${versionId}`) + return response.data +} + +export const getTemplateVersionSchema = async (versionId: string): Promise => { + const response = await axios.get(`/api/v2/templateversions/${versionId}/schema`) + return response.data +} + export const getWorkspace = async (workspaceId: string): Promise => { const response = await axios.get(`/api/v2/workspaces/${workspaceId}`) return response.data @@ -180,6 +166,14 @@ export const createUser = async (user: TypesGen.CreateUserRequest): Promise => { + const response = await axios.post(`/api/v2/organizations/${organizationId}/workspaces`, workspace) + return response.data +} + export const getBuildInfo = async (): Promise => { const response = await axios.get("/api/v2/buildinfo") return response.data diff --git a/site/src/components/ParameterInput/ParameterInput.stories.tsx b/site/src/components/ParameterInput/ParameterInput.stories.tsx new file mode 100644 index 0000000000..af5e05bdac --- /dev/null +++ b/site/src/components/ParameterInput/ParameterInput.stories.tsx @@ -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 = (args: ParameterInputProps) => + +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 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"], + }), +} diff --git a/site/src/components/ParameterInput/ParameterInput.tsx b/site/src/components/ParameterInput/ParameterInput.tsx new file mode 100644 index 0000000000..521ea20aa6 --- /dev/null +++ b/site/src/components/ParameterInput/ParameterInput.tsx @@ -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 = ({ disabled, onChange, schema }) => { + const styles = useStyles() + return ( + +
+

var.{schema.name}

+ {schema.description && {schema.description}} +
+
+ +
+
+ ) +} + +const ParameterField: React.FC = ({ disabled, onChange, schema }) => { + if (schema.validation_contains.length > 0) { + return ( + { + onChange(event.target.value) + }} + > + {schema.validation_contains.map((item) => ( + } + label={item} + /> + ))} + + ) + } + + // 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 ( + { + 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, + }, +})) diff --git a/site/src/forms/CreateWorkspaceForm.test.tsx b/site/src/forms/CreateWorkspaceForm.test.tsx deleted file mode 100644 index 8d523820e8..0000000000 --- a/site/src/forms/CreateWorkspaceForm.test.tsx +++ /dev/null @@ -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( - , - ) - - // 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() - }) - }) -}) diff --git a/site/src/forms/CreateWorkspaceForm.tsx b/site/src/forms/CreateWorkspaceForm.tsx deleted file mode 100644 index a9d963543d..0000000000 --- a/site/src/forms/CreateWorkspaceForm.tsx +++ /dev/null @@ -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 - 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 = ({ - template, - onSubmit, - onCancel, - organizationId, -}) => { - const styles = useStyles() - - const form = useFormik({ - initialValues: { - name: "", - }, - onSubmit: ({ name }) => { - return onSubmit(organizationId, { - template_id: template.id, - name: name, - }) - }, - validationSchema: validationSchema, - }) - const getFieldHelpers = getFormHelpers(form) - - return ( -
- - for template {template.name} - - } - /> - - - - - - -
- - - Submit - -
-
- ) -} - -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", - }, -})) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx new file mode 100644 index 0000000000..57b0cebdd7 --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -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() + const element = await screen.findByText("Create workspace") + expect(element).toBeDefined() + }) + + it("shows validation error message", async () => { + render() + await fillForm({ name: "$$$" }) + const errorMessage = await screen.findByText(Language.nameMatches) + expect(errorMessage).toBeDefined() + }) + + it("succeeds", async () => { + render() + // 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() + }) + }) +}) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx new file mode 100644 index 0000000000..177d3f1492 --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -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 ( + 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 diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx new file mode 100644 index 0000000000..48f0131f1b --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.stories.tsx @@ -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 + +const Template: Story = (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"], + }), + ], +} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx new file mode 100644 index 0000000000..cdc24710cc --- /dev/null +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -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 +} + +export const validationSchema = Yup.object({ + name: Yup.string() + .required(Language.nameRequired) + .matches(usernameRE, Language.nameMatches) + .max(maxLenName, Language.nameMax), +}) + +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, + }, + 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(form) + + return ( + + +
+ + {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/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx deleted file mode 100644 index d034d1e2ba..0000000000 --- a/site/src/pages/TemplatePage/TemplatePageView.tsx +++ /dev/null @@ -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 = (props) => { - const styles = useStyles() - return ( - - -
- {props.canCreateTemplate && } -
- - - - Name - Used By - Last Updated - - - - {!props.loading && !props.templates?.length && ( - - -
- {props.canCreateTemplate ? ( - - - Create a template - -  {Language.emptyViewCreate} - - ) : ( - {Language.emptyViewNoPerms} - )} -
-
-
- )} - {props.templates?.map((template) => { - return ( - - -
- - {firstLetter(template.name)} - - - {template.name} - {template.description} - -
-
- - {template.workspace_owner_count} developer{template.workspace_owner_count !== 1 && "s"} - - {dayjs().to(dayjs(template.updated_at))} -
- ) - })} -
-
-
-
- ) -} - -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, - }, - }, -})) diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index fe2e2846be..debe2fa600 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -83,7 +83,7 @@ export const TemplatesPageView: React.FC = (props) => { {firstLetter(template.name)} - + {template.name} {template.description} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index bd3c48c857..9c5cb45654 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -78,6 +78,15 @@ export const MockCancelingProvisionerJob = { } 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 = { id: "test-template", created_at: "2022-05-17T17:39:01.382927298Z", @@ -85,7 +94,7 @@ export const MockTemplate: TypesGen.Template = { organization_id: MockOrganization.id, name: "Test Template", provisioner: MockProvisioner.provisioners[0], - active_version_id: "", + active_version_id: MockTemplateVersion.id, workspace_owner_count: 1, description: "This is a test description.", } diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 48d35d8afa..7b42ff65b5 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -25,6 +25,12 @@ export const handlers = [ rest.get("/api/v2/templates/:templateId", async (req, res, ctx) => { 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 rest.get("/api/v2/users", async (req, res, ctx) => { @@ -76,9 +82,6 @@ export const handlers = [ }), // 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) => { return res(ctx.status(200), ctx.json([M.MockWorkspace])) }), diff --git a/site/src/theme/overrides.ts b/site/src/theme/overrides.ts index 593d4d2032..c6261c4cce 100644 --- a/site/src/theme/overrides.ts +++ b/site/src/theme/overrides.ts @@ -74,6 +74,9 @@ export const getOverrides = (palette: PaletteOptions) => { "& input:-webkit-autofill": { WebkitBoxShadow: `0 0 0 1000px ${palette.background?.paper} inset`, }, + "&:hover .MuiOutlinedInput-notchedOutline": { + borderColor: (palette.primary as SimplePaletteColorOptions).light, + }, }, }, MuiLink: { @@ -81,5 +84,11 @@ export const getOverrides = (palette: PaletteOptions) => { color: (palette.primary as SimplePaletteColorOptions).light, }, }, + MuiPaper: { + root: { + borderRadius: 2, + border: `1px solid ${palette.divider}`, + }, + }, } } diff --git a/site/src/theme/props.ts b/site/src/theme/props.ts index 2ac31c067f..eddeb36427 100644 --- a/site/src/theme/props.ts +++ b/site/src/theme/props.ts @@ -38,4 +38,7 @@ export const props = { textColor: "primary", indicatorColor: "primary", }, + MuiPaper: { + elevation: 0, + }, } as ComponentsProps diff --git a/site/src/xServices/template/templateXService.ts b/site/src/xServices/template/templateXService.ts new file mode 100644 index 0000000000..f2e610e4b5 --- /dev/null +++ b/site/src/xServices/template/templateXService.ts @@ -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) + }, + }, + }, +)