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

View File

@ -32,6 +32,12 @@ export const PageHeaderSubtitle: React.FC = ({ children }) => {
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) => ({
root: {
display: "flex",
@ -58,6 +64,15 @@ const useStyles = makeStyles((theme) => ({
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: {
marginLeft: "auto",
},

View File

@ -4,14 +4,13 @@ import * as API from "../../api/api"
import { Language as FooterLanguage } from "../../components/FormFooter/FormFooter"
import { MockTemplate, MockWorkspace } from "../../testHelpers/entities"
import { renderWithAuth } from "../../testHelpers/renderHelpers"
import { Language as FormLanguage } from "../../util/formUtils"
import CreateWorkspacePage from "./CreateWorkspacePage"
import { Language } from "./CreateWorkspacePageView"
const renderCreateWorkspacePage = () => {
return renderWithAuth(<CreateWorkspacePage />, {
route: "/workspaces/new?template=" + MockTemplate.name,
path: "/workspaces/new",
route: "/templates/" + MockTemplate.name + "/workspace",
path: "/templates/:template/workspace",
})
}
@ -29,13 +28,6 @@ describe("CreateWorkspacePage", () => {
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 () => {
renderCreateWorkspacePage()
// You have to spy the method before it is used.

View File

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

View File

@ -33,11 +33,6 @@ export default {
const Template: Story<CreateWorkspacePageViewProps> = (args) => <CreateWorkspacePageView {...args} />
export const NoTemplates = Template.bind({})
NoTemplates.args = {
templates: [],
}
export const NoParameters = Template.bind({})
NoParameters.args = {
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 TextField, { TextFieldProps } from "@material-ui/core/TextField"
import OpenInNewIcon from "@material-ui/icons/OpenInNew"
import TextField from "@material-ui/core/TextField"
import { FormikContextType, useFormik } from "formik"
import { FC, useState } from "react"
import { Link as RouterLink } from "react-router-dom"
import * as Yup from "yup"
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 { FullPageForm } from "../../components/FullPageForm/FullPageForm"
import { Loader } from "../../components/Loader/Loader"
@ -20,29 +14,18 @@ import { getFormHelpers, nameValidator, onChangeTrimmed } from "../../util/formU
export const Language = {
templateLabel: "Template",
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 {
loadingTemplates: boolean
loadingTemplateSchema: boolean
creatingWorkspace: boolean
templateName: string
templates?: TypesGen.Template[]
selectedTemplate?: TypesGen.Template
templateSchema?: TypesGen.ParameterSchema[]
onCancel: () => void
onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void
onSelectTemplate: (template: TypesGen.Template) => void
}
export const validationSchema = Yup.object({
@ -51,7 +34,8 @@ export const validationSchema = Yup.object({
export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = (props) => {
const [parameterValues, setParameterValues] = useState<Record<string, string>>({})
const styles = useStyles()
useStyles()
const form: FormikContextType<TypesGen.CreateWorkspaceRequest> = useFormik<TypesGen.CreateWorkspaceRequest>({
initialValues: {
name: "",
@ -84,75 +68,20 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = (props)
},
})
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 (
<FullPageForm title="Create workspace" onCancel={props.onCancel}>
<form onSubmit={form.handleSubmit}>
{props.loadingTemplates && <Loader />}
<Stack>
{props.templates && props.templates.length === 0 && (
<EmptyState
className={styles.emptyState}
message={Language.emptyMessage}
description={Language.emptyDescription}
descriptionClassName={styles.emptyStateDescription}
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>
)}
<TextField
disabled
fullWidth
label={Language.templateLabel}
value={props.selectedTemplate?.name || props.templateName}
variant="outlined"
/>
{props.loadingTemplateSchema && <Loader />}
{props.selectedTemplate && props.templateSchema && (
<>
<TextField

View File

@ -39,7 +39,7 @@ export const TemplatePageView: FC<TemplatePageViewProps> = ({ template, activeTe
<Margins>
<PageHeader
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>
</Link>
}

View File

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

View File

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

View File

@ -4,26 +4,18 @@ import { CreateWorkspaceRequest, ParameterSchema, Template, Workspace } from "..
type CreateWorkspaceContext = {
organizationId: string
templateName: string
templates?: Template[]
selectedTemplate?: Template
templateSchema?: ParameterSchema[]
createWorkspaceRequest?: CreateWorkspaceRequest
createdWorkspace?: Workspace
// This is useful when the user wants to create a workspace from the template
// page having it pre selected. It is string or null because of the
// useSearchQuery
preSelectedTemplateName: string | null
}
type CreateWorkspaceEvent =
| {
type: "SELECT_TEMPLATE"
template: Template
}
| {
type: "CREATE_WORKSPACE"
request: CreateWorkspaceRequest
}
type CreateWorkspaceEvent = {
type: "CREATE_WORKSPACE"
request: CreateWorkspaceRequest
}
export const createWorkspaceMachine = createMachine(
{
@ -45,12 +37,6 @@ export const createWorkspaceMachine = createMachine(
},
},
tsTypes: {} as import("./createWorkspaceXService.typegen").Typegen0,
on: {
SELECT_TEMPLATE: {
actions: ["assignSelectedTemplate"],
target: "gettingTemplateSchema",
},
},
states: {
gettingTemplates: {
invoke: {
@ -58,17 +44,11 @@ export const createWorkspaceMachine = createMachine(
onDone: [
{
actions: ["assignTemplates"],
target: "waitingForTemplateGetCreated",
cond: "areTemplatesEmpty",
},
{
actions: ["assignTemplates", "assignPreSelectedTemplate"],
actions: ["assignTemplates", "assignSelectedTemplate"],
target: "gettingTemplateSchema",
cond: "hasValidPreSelectedTemplate",
},
{
actions: ["assignTemplates"],
target: "selectingTemplate",
},
],
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: {
invoke: {
src: "getTemplateSchema",
@ -164,13 +117,6 @@ export const createWorkspaceMachine = createMachine(
},
},
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,
},
actions: {
@ -178,7 +124,10 @@ export const createWorkspaceMachine = createMachine(
templates: (_, event) => event.data,
}),
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({
// Only show parameters that are allowed to be overridden.
@ -188,17 +137,6 @@ export const createWorkspaceMachine = createMachine(
assignCreateWorkspaceRequest: assign({
createWorkspaceRequest: (_, event) => event.request,
}),
assignPreSelectedTemplate: assign({
selectedTemplate: (ctx, event) => {
const selectedTemplate = event.data.find((template) => template.name === ctx.preSelectedTemplateName)
// The proper validation happens on hasValidPreSelectedTemplate
if (!selectedTemplate) {
throw new Error("Invalid template selected")
}
return selectedTemplate
},
}),
},
},
)