fix: push create workspace UX to templates page (#2142)

This commit is contained in:
Garrett Delfosse
2022-06-09 18:43:49 -05:00
committed by GitHub
parent 119db78bff
commit b7234a6ce1
10 changed files with 78 additions and 209 deletions

View File

@ -56,15 +56,6 @@ export const AppRouter: FC = () => (
</AuthAndFrame> </AuthAndFrame>
} }
/> />
<Route
path="new"
element={
<RequireAuth>
<CreateWorkspacePage />
</RequireAuth>
}
/>
</Route> </Route>
<Route path="templates"> <Route path="templates">
@ -77,14 +68,24 @@ export const AppRouter: FC = () => (
} }
/> />
<Route <Route path=":template">
path=":template" <Route
element={ index
<AuthAndFrame> element={
<TemplatePage /> <AuthAndFrame>
</AuthAndFrame> <TemplatePage />
} </AuthAndFrame>
/> }
/>
<Route
path="workspace"
element={
<RequireAuth>
<CreateWorkspacePage />
</RequireAuth>
}
/>
</Route>
</Route> </Route>
<Route path="users"> <Route path="users">

View File

@ -32,6 +32,12 @@ export const PageHeaderSubtitle: React.FC = ({ children }) => {
return <h2 className={styles.subtitle}>{children}</h2> return <h2 className={styles.subtitle}>{children}</h2>
} }
export const PageHeaderText: React.FC = ({ children }) => {
const styles = useStyles()
return <h3 className={styles.text}>{children}</h3>
}
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
display: "flex", display: "flex",
@ -58,6 +64,15 @@ const useStyles = makeStyles((theme) => ({
marginTop: theme.spacing(1), marginTop: theme.spacing(1),
}, },
text: {
fontSize: theme.spacing(2),
color: theme.palette.text.secondary,
fontWeight: 400,
display: "block",
margin: 0,
marginTop: theme.spacing(1),
},
actions: { actions: {
marginLeft: "auto", marginLeft: "auto",
}, },

View File

@ -4,14 +4,13 @@ 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 { renderWithAuth } from "../../testHelpers/renderHelpers" import { renderWithAuth } from "../../testHelpers/renderHelpers"
import { Language as FormLanguage } from "../../util/formUtils"
import CreateWorkspacePage from "./CreateWorkspacePage" import CreateWorkspacePage from "./CreateWorkspacePage"
import { Language } from "./CreateWorkspacePageView" import { Language } from "./CreateWorkspacePageView"
const renderCreateWorkspacePage = () => { const renderCreateWorkspacePage = () => {
return renderWithAuth(<CreateWorkspacePage />, { return renderWithAuth(<CreateWorkspacePage />, {
route: "/workspaces/new?template=" + MockTemplate.name, route: "/templates/" + MockTemplate.name + "/workspace",
path: "/workspaces/new", path: "/templates/:template/workspace",
}) })
} }
@ -29,13 +28,6 @@ describe("CreateWorkspacePage", () => {
expect(element).toBeDefined() expect(element).toBeDefined()
}) })
it("shows validation error message", async () => {
renderCreateWorkspacePage()
await fillForm({ name: "$$$" })
const errorMessage = await screen.findByText(FormLanguage.nameInvalidChars(Language.nameLabel))
expect(errorMessage).toBeDefined()
})
it("succeeds", async () => { it("succeeds", async () => {
renderCreateWorkspacePage() renderCreateWorkspacePage()
// You have to spy the method before it is used. // You have to spy the method before it is used.

View File

@ -1,8 +1,7 @@
import { useMachine } from "@xstate/react" import { useMachine } from "@xstate/react"
import { FC } from "react" import { FC } from "react"
import { Helmet } from "react-helmet" import { Helmet } from "react-helmet"
import { useNavigate, useSearchParams } from "react-router-dom" import { useNavigate, useParams } from "react-router-dom"
import { Template } from "../../api/typesGenerated"
import { useOrganizationId } from "../../hooks/useOrganizationId" import { useOrganizationId } from "../../hooks/useOrganizationId"
import { pageTitle } from "../../util/page" import { pageTitle } from "../../util/page"
import { createWorkspaceMachine } from "../../xServices/createWorkspace/createWorkspaceXService" import { createWorkspaceMachine } from "../../xServices/createWorkspace/createWorkspaceXService"
@ -10,11 +9,11 @@ import { CreateWorkspacePageView } from "./CreateWorkspacePageView"
const CreateWorkspacePage: FC = () => { const CreateWorkspacePage: FC = () => {
const organizationId = useOrganizationId() const organizationId = useOrganizationId()
const [searchParams] = useSearchParams() const { template } = useParams()
const preSelectedTemplateName = searchParams.get("template") const templateName = template ? template : ""
const navigate = useNavigate() const navigate = useNavigate()
const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, { const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, {
context: { organizationId, preSelectedTemplateName }, context: { organizationId, templateName },
actions: { actions: {
onCreateWorkspace: (_, event) => { onCreateWorkspace: (_, event) => {
navigate(`/@${event.data.owner_name}/${event.data.name}`) navigate(`/@${event.data.owner_name}/${event.data.name}`)
@ -31,11 +30,12 @@ const CreateWorkspacePage: FC = () => {
loadingTemplates={createWorkspaceState.matches("gettingTemplates")} loadingTemplates={createWorkspaceState.matches("gettingTemplates")}
loadingTemplateSchema={createWorkspaceState.matches("gettingTemplateSchema")} loadingTemplateSchema={createWorkspaceState.matches("gettingTemplateSchema")}
creatingWorkspace={createWorkspaceState.matches("creatingWorkspace")} creatingWorkspace={createWorkspaceState.matches("creatingWorkspace")}
templateName={createWorkspaceState.context.templateName}
templates={createWorkspaceState.context.templates} templates={createWorkspaceState.context.templates}
selectedTemplate={createWorkspaceState.context.selectedTemplate} selectedTemplate={createWorkspaceState.context.selectedTemplate}
templateSchema={createWorkspaceState.context.templateSchema} templateSchema={createWorkspaceState.context.templateSchema}
onCancel={() => { onCancel={() => {
navigate(preSelectedTemplateName ? "/templates" : "/workspaces") navigate("/templates")
}} }}
onSubmit={(request) => { onSubmit={(request) => {
send({ send({
@ -43,12 +43,6 @@ const CreateWorkspacePage: FC = () => {
request, request,
}) })
}} }}
onSelectTemplate={(template: Template) => {
send({
type: "SELECT_TEMPLATE",
template,
})
}}
/> />
</> </>
) )

View File

@ -33,11 +33,6 @@ export default {
const Template: Story<CreateWorkspacePageViewProps> = (args) => <CreateWorkspacePageView {...args} /> const Template: Story<CreateWorkspacePageViewProps> = (args) => <CreateWorkspacePageView {...args} />
export const NoTemplates = Template.bind({})
NoTemplates.args = {
templates: [],
}
export const NoParameters = Template.bind({}) export const NoParameters = Template.bind({})
NoParameters.args = { NoParameters.args = {
templates: [MockTemplate], templates: [MockTemplate],

View File

@ -1,15 +1,9 @@
import Link from "@material-ui/core/Link"
import MenuItem from "@material-ui/core/MenuItem"
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import TextField, { TextFieldProps } from "@material-ui/core/TextField" import TextField from "@material-ui/core/TextField"
import OpenInNewIcon from "@material-ui/icons/OpenInNew"
import { FormikContextType, useFormik } from "formik" import { FormikContextType, useFormik } from "formik"
import { FC, useState } from "react" import { FC, useState } from "react"
import { Link as RouterLink } from "react-router-dom"
import * as Yup from "yup" import * as Yup from "yup"
import * as TypesGen from "../../api/typesGenerated" import * as TypesGen from "../../api/typesGenerated"
import { CodeExample } from "../../components/CodeExample/CodeExample"
import { EmptyState } from "../../components/EmptyState/EmptyState"
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 { Loader } from "../../components/Loader/Loader"
@ -20,29 +14,18 @@ import { getFormHelpers, nameValidator, onChangeTrimmed } from "../../util/formU
export const Language = { export const Language = {
templateLabel: "Template", templateLabel: "Template",
nameLabel: "Name", nameLabel: "Name",
emptyMessage: "Let's create your first template",
emptyDescription: (
<>
To create a workspace you need to have a template. You can{" "}
<Link target="_blank" href="https://github.com/coder/coder/blob/main/docs/templates.md">
create one from scratch
</Link>{" "}
or use a built-in template by typing the following Coder CLI command:
</>
),
templateLink: "Read more about this template",
} }
export interface CreateWorkspacePageViewProps { export interface CreateWorkspacePageViewProps {
loadingTemplates: boolean loadingTemplates: boolean
loadingTemplateSchema: boolean loadingTemplateSchema: boolean
creatingWorkspace: boolean creatingWorkspace: boolean
templateName: string
templates?: TypesGen.Template[] templates?: TypesGen.Template[]
selectedTemplate?: TypesGen.Template selectedTemplate?: TypesGen.Template
templateSchema?: TypesGen.ParameterSchema[] templateSchema?: TypesGen.ParameterSchema[]
onCancel: () => void onCancel: () => void
onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void
onSelectTemplate: (template: TypesGen.Template) => void
} }
export const validationSchema = Yup.object({ export const validationSchema = Yup.object({
@ -51,7 +34,8 @@ export const validationSchema = Yup.object({
export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = (props) => { export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = (props) => {
const [parameterValues, setParameterValues] = useState<Record<string, string>>({}) const [parameterValues, setParameterValues] = useState<Record<string, string>>({})
const styles = useStyles() useStyles()
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> = useFormik<TypesGen.CreateWorkspaceRequest>({ const form: FormikContextType<TypesGen.CreateWorkspaceRequest> = useFormik<TypesGen.CreateWorkspaceRequest>({
initialValues: { initialValues: {
name: "", name: "",
@ -84,75 +68,20 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = (props)
}, },
}) })
const getFieldHelpers = getFormHelpers<TypesGen.CreateWorkspaceRequest>(form) const getFieldHelpers = getFormHelpers<TypesGen.CreateWorkspaceRequest>(form)
const selectedTemplate =
props.templates &&
form.values.template_id &&
props.templates.find((template) => template.id === form.values.template_id)
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 (
<FullPageForm title="Create workspace" onCancel={props.onCancel}> <FullPageForm title="Create workspace" onCancel={props.onCancel}>
<form onSubmit={form.handleSubmit}> <form onSubmit={form.handleSubmit}>
{props.loadingTemplates && <Loader />}
<Stack> <Stack>
{props.templates && props.templates.length === 0 && ( <TextField
<EmptyState disabled
className={styles.emptyState} fullWidth
message={Language.emptyMessage} label={Language.templateLabel}
description={Language.emptyDescription} value={props.selectedTemplate?.name || props.templateName}
descriptionClassName={styles.emptyStateDescription} variant="outlined"
cta={ />
<CodeExample className={styles.code} buttonClassName={styles.codeButton} code="coder template init" />
}
/>
)}
{props.templates && props.templates.length > 0 && (
<TextField
{...getFieldHelpers("template_id")}
disabled={form.isSubmitting}
onChange={handleTemplateChange}
autoFocus
fullWidth
label={Language.templateLabel}
variant="outlined"
select
helperText={
selectedTemplate && (
<Link
className={styles.readMoreLink}
component={RouterLink}
to={`/templates/${selectedTemplate.name}`}
target="_blank"
>
{Language.templateLink} <OpenInNewIcon />
</Link>
)
}
>
{props.templates.map((template) => (
<MenuItem key={template.id} value={template.id}>
{template.name}
</MenuItem>
))}
</TextField>
)}
{props.loadingTemplateSchema && <Loader />}
{props.selectedTemplate && props.templateSchema && ( {props.selectedTemplate && props.templateSchema && (
<> <>
<TextField <TextField

View File

@ -39,7 +39,7 @@ export const TemplatePageView: FC<TemplatePageViewProps> = ({ template, activeTe
<Margins> <Margins>
<PageHeader <PageHeader
actions={ actions={
<Link underline="none" component={RouterLink} to={`/workspaces/new?template=${template.name}`}> <Link underline="none" component={RouterLink} to={`/templates/${template.name}/workspace`}>
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button> <Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
</Link> </Link>
} }

View File

@ -22,7 +22,7 @@ import {
HelpTooltipTitle, HelpTooltipTitle,
} from "../../components/HelpTooltip/HelpTooltip" } from "../../components/HelpTooltip/HelpTooltip"
import { Margins } from "../../components/Margins/Margins" import { Margins } from "../../components/Margins/Margins"
import { PageHeader, PageHeaderTitle } from "../../components/PageHeader/PageHeader" import { PageHeader, PageHeaderText, PageHeaderTitle } from "../../components/PageHeader/PageHeader"
import { Stack } from "../../components/Stack/Stack" import { Stack } from "../../components/Stack/Stack"
import { TableLoader } from "../../components/TableLoader/TableLoader" import { TableLoader } from "../../components/TableLoader/TableLoader"
@ -84,6 +84,9 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = (props) => {
<TemplateHelpTooltip /> <TemplateHelpTooltip />
</Stack> </Stack>
</PageHeaderTitle> </PageHeaderTitle>
{props.templates && props.templates.length > 0 && (
<PageHeaderText>Choose a template to create a new workspace.</PageHeaderText>
)}
</PageHeader> </PageHeader>
<Table> <Table>

View File

@ -32,7 +32,7 @@ import {
HelpTooltipTitle, HelpTooltipTitle,
} from "../../components/HelpTooltip/HelpTooltip" } from "../../components/HelpTooltip/HelpTooltip"
import { Margins } from "../../components/Margins/Margins" import { Margins } from "../../components/Margins/Margins"
import { PageHeader, PageHeaderTitle } from "../../components/PageHeader/PageHeader" import { PageHeader, PageHeaderText, PageHeaderTitle } from "../../components/PageHeader/PageHeader"
import { Stack } from "../../components/Stack/Stack" import { Stack } from "../../components/Stack/Stack"
import { TableLoader } from "../../components/TableLoader/TableLoader" import { TableLoader } from "../../components/TableLoader/TableLoader"
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils" import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
@ -41,7 +41,7 @@ import { getDisplayStatus, workspaceFilterQuery } from "../../util/workspace"
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
export const Language = { export const Language = {
createWorkspaceButton: "Create workspace", createFromTemplateButton: "Create from template",
emptyCreateWorkspaceMessage: "Create your first workspace", emptyCreateWorkspaceMessage: "Create your first workspace",
emptyCreateWorkspaceDescription: "Start editing your source code and building your software", emptyCreateWorkspaceDescription: "Start editing your source code and building your software",
emptyResultsMessage: "No results matched your search", emptyResultsMessage: "No results matched your search",
@ -132,11 +132,13 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({ loading, works
<Margins> <Margins>
<PageHeader <PageHeader
actions={ actions={
<Link underline="none" component={RouterLink} to="/workspaces/new"> <PageHeaderText>
<Button startIcon={<AddCircleOutline />} style={{ height: "44px" }}> Create a new workspace from a{" "}
{Language.createWorkspaceButton} <Link component={RouterLink} to="/templates">
</Button> Template
</Link> </Link>
.
</PageHeaderText>
} }
> >
<PageHeaderTitle> <PageHeaderTitle>
@ -213,7 +215,7 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({ loading, works
description={Language.emptyCreateWorkspaceDescription} description={Language.emptyCreateWorkspaceDescription}
cta={ cta={
<Link underline="none" component={RouterLink} to="/workspaces/new"> <Link underline="none" component={RouterLink} to="/workspaces/new">
<Button startIcon={<AddCircleOutline />}>{Language.createWorkspaceButton}</Button> <Button startIcon={<AddCircleOutline />}>{Language.createFromTemplateButton}</Button>
</Link> </Link>
} }
/> />

View File

@ -4,26 +4,18 @@ import { CreateWorkspaceRequest, ParameterSchema, Template, Workspace } from "..
type CreateWorkspaceContext = { type CreateWorkspaceContext = {
organizationId: string organizationId: string
templateName: string
templates?: Template[] templates?: Template[]
selectedTemplate?: Template selectedTemplate?: Template
templateSchema?: ParameterSchema[] templateSchema?: ParameterSchema[]
createWorkspaceRequest?: CreateWorkspaceRequest createWorkspaceRequest?: CreateWorkspaceRequest
createdWorkspace?: Workspace 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 CreateWorkspaceEvent = {
| { type: "CREATE_WORKSPACE"
type: "SELECT_TEMPLATE" request: CreateWorkspaceRequest
template: Template }
}
| {
type: "CREATE_WORKSPACE"
request: CreateWorkspaceRequest
}
export const createWorkspaceMachine = createMachine( export const createWorkspaceMachine = createMachine(
{ {
@ -45,12 +37,6 @@ export const createWorkspaceMachine = createMachine(
}, },
}, },
tsTypes: {} as import("./createWorkspaceXService.typegen").Typegen0, tsTypes: {} as import("./createWorkspaceXService.typegen").Typegen0,
on: {
SELECT_TEMPLATE: {
actions: ["assignSelectedTemplate"],
target: "gettingTemplateSchema",
},
},
states: { states: {
gettingTemplates: { gettingTemplates: {
invoke: { invoke: {
@ -58,17 +44,11 @@ export const createWorkspaceMachine = createMachine(
onDone: [ onDone: [
{ {
actions: ["assignTemplates"], actions: ["assignTemplates"],
target: "waitingForTemplateGetCreated",
cond: "areTemplatesEmpty", cond: "areTemplatesEmpty",
}, },
{ {
actions: ["assignTemplates", "assignPreSelectedTemplate"], actions: ["assignTemplates", "assignSelectedTemplate"],
target: "gettingTemplateSchema", target: "gettingTemplateSchema",
cond: "hasValidPreSelectedTemplate",
},
{
actions: ["assignTemplates"],
target: "selectingTemplate",
}, },
], ],
onError: { onError: {
@ -76,33 +56,6 @@ export const createWorkspaceMachine = createMachine(
}, },
}, },
}, },
waitingForTemplateGetCreated: {
initial: "refreshingTemplates",
states: {
refreshingTemplates: {
invoke: {
src: "getTemplates",
onDone: [
{ target: "waiting", cond: "areTemplatesEmpty" },
{ target: "#createWorkspaceState.selectingTemplate", actions: ["assignTemplates"] },
],
},
},
waiting: {
after: {
2_000: "refreshingTemplates",
},
},
},
},
selectingTemplate: {
on: {
SELECT_TEMPLATE: {
actions: ["assignSelectedTemplate"],
target: "gettingTemplateSchema",
},
},
},
gettingTemplateSchema: { gettingTemplateSchema: {
invoke: { invoke: {
src: "getTemplateSchema", src: "getTemplateSchema",
@ -164,13 +117,6 @@ export const createWorkspaceMachine = createMachine(
}, },
}, },
guards: { guards: {
hasValidPreSelectedTemplate: (ctx, event) => {
if (!ctx.preSelectedTemplateName) {
return false
}
const template = event.data.find((template) => template.name === ctx.preSelectedTemplateName)
return !!template
},
areTemplatesEmpty: (_, event) => event.data.length === 0, areTemplatesEmpty: (_, event) => event.data.length === 0,
}, },
actions: { actions: {
@ -178,7 +124,10 @@ export const createWorkspaceMachine = createMachine(
templates: (_, event) => event.data, templates: (_, event) => event.data,
}), }),
assignSelectedTemplate: assign({ assignSelectedTemplate: assign({
selectedTemplate: (_, event) => event.template, selectedTemplate: (ctx, event) => {
const templates = event.data.filter((template) => template.name === ctx.templateName)
return templates.length ? templates[0] : undefined
},
}), }),
assignTemplateSchema: assign({ assignTemplateSchema: assign({
// Only show parameters that are allowed to be overridden. // Only show parameters that are allowed to be overridden.
@ -188,17 +137,6 @@ export const createWorkspaceMachine = createMachine(
assignCreateWorkspaceRequest: assign({ assignCreateWorkspaceRequest: assign({
createWorkspaceRequest: (_, event) => event.request, 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
},
}),
}, },
}, },
) )