feat: Improve empty states for workspaces and templates (#1950)

This commit is contained in:
Bruno Quaresma
2022-06-01 12:32:55 -05:00
committed by GitHub
parent 6be8a373e0
commit b85de3ee79
11 changed files with 196 additions and 89 deletions

View File

@ -1,22 +1,25 @@
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import { FC } from "react" import { FC } from "react"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { combineClasses } from "../../util/combineClasses"
import { CopyButton } from "../CopyButton/CopyButton" import { CopyButton } from "../CopyButton/CopyButton"
export interface CodeExampleProps { export interface CodeExampleProps {
code: string code: string
className?: string
buttonClassName?: string
} }
/** /**
* Component to show single-line code examples, with a copy button * Component to show single-line code examples, with a copy button
*/ */
export const CodeExample: FC<CodeExampleProps> = ({ code }) => { export const CodeExample: FC<CodeExampleProps> = ({ code, className, buttonClassName }) => {
const styles = useStyles() const styles = useStyles()
return ( return (
<div className={styles.root}> <div className={combineClasses([styles.root, className])}>
<code>{code}</code> <code className={styles.code}>{code}</code>
<CopyButton text={code} /> <CopyButton text={code} buttonClassName={combineClasses([styles.button, buttonClassName])} />
</div> </div>
) )
} }
@ -30,8 +33,17 @@ const useStyles = makeStyles((theme) => ({
background: theme.palette.background.default, background: theme.palette.background.default,
color: theme.palette.primary.contrastText, color: theme.palette.primary.contrastText,
fontFamily: MONOSPACE_FONT_FAMILY, fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 13, fontSize: 14,
padding: theme.spacing(2), borderRadius: theme.shape.borderRadius,
padding: theme.spacing(0.5),
},
code: {
padding: `${theme.spacing(0.5)}px ${theme.spacing(0.75)}px ${theme.spacing(0.5)}px ${theme.spacing(2)}px`,
},
button: {
border: 0,
minWidth: 42,
minHeight: 42,
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
}, },
})) }))

View File

@ -2,13 +2,17 @@ import Box from "@material-ui/core/Box"
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography" import Typography from "@material-ui/core/Typography"
import { FC, ReactNode } from "react" import { FC, ReactNode } from "react"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { combineClasses } from "../../util/combineClasses"
export interface EmptyStateProps { export interface EmptyStateProps {
/** Text Message to display, placed inside Typography component */ /** Text Message to display, placed inside Typography component */
message: string message: string
/** Longer optional description to display below the message */ /** Longer optional description to display below the message */
description?: string description?: string | React.ReactNode
descriptionClassName?: string
cta?: ReactNode cta?: ReactNode
className?: string
} }
/** /**
@ -20,17 +24,21 @@ export interface EmptyStateProps {
* that you can directly pass props through to to customize the shape and layout of it. * that you can directly pass props through to to customize the shape and layout of it.
*/ */
export const EmptyState: FC<EmptyStateProps> = (props) => { export const EmptyState: FC<EmptyStateProps> = (props) => {
const { message, description, cta, ...boxProps } = props const { message, description, cta, descriptionClassName, className, ...boxProps } = props
const styles = useStyles() const styles = useStyles()
return ( return (
<Box className={styles.root} {...boxProps}> <Box className={combineClasses([styles.root, className])} {...boxProps}>
<div className={styles.header}> <div className={styles.header}>
<Typography variant="h5" className={styles.title}> <Typography variant="h5" className={styles.title}>
{message} {message}
</Typography> </Typography>
{description && ( {description && (
<Typography variant="body2" color="textSecondary" className={styles.description}> <Typography
variant="body2"
color="textSecondary"
className={combineClasses([styles.description, descriptionClassName])}
>
{description} {description}
</Typography> </Typography>
)} )}
@ -48,17 +56,20 @@ const useStyles = makeStyles(
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
textAlign: "center", textAlign: "center",
minHeight: 120, minHeight: 300,
padding: theme.spacing(3), padding: theme.spacing(3),
fontFamily: MONOSPACE_FONT_FAMILY,
}, },
header: { header: {
marginBottom: theme.spacing(3), marginBottom: theme.spacing(3),
}, },
title: { title: {
fontWeight: 400, fontWeight: 600,
fontFamily: "inherit",
}, },
description: { description: {
marginTop: theme.spacing(1), marginTop: theme.spacing(1),
fontFamily: "inherit",
}, },
}), }),
{ name: "EmptyState" }, { name: "EmptyState" },

View File

@ -27,7 +27,12 @@ export const Footer: React.FC = ({ children }) => {
</div> </div>
{buildInfoState.context.buildInfo && ( {buildInfoState.context.buildInfo && (
<div className={styles.buildInfo}> <div className={styles.buildInfo}>
<Link variant="caption" target="_blank" href={buildInfoState.context.buildInfo.external_url}> <Link
className={styles.link}
variant="caption"
target="_blank"
href={buildInfoState.context.buildInfo.external_url}
>
{Language.buildInfoText(buildInfoState.context.buildInfo)} {Language.buildInfoText(buildInfoState.context.buildInfo)}
</Link> </Link>
</div> </div>
@ -38,6 +43,7 @@ export const Footer: React.FC = ({ children }) => {
const useFooterStyles = makeStyles((theme) => ({ const useFooterStyles = makeStyles((theme) => ({
root: { root: {
opacity: 0.6,
textAlign: "center", textAlign: "center",
flex: "0", flex: "0",
paddingTop: theme.spacing(2), paddingTop: theme.spacing(2),
@ -50,4 +56,8 @@ const useFooterStyles = makeStyles((theme) => ({
buildInfo: { buildInfo: {
margin: theme.spacing(0.25), margin: theme.spacing(0.25),
}, },
link: {
color: theme.palette.text.secondary,
fontWeight: 600,
},
})) }))

View File

@ -33,6 +33,11 @@ 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

@ -8,6 +8,8 @@ import { FC, useState } from "react"
import { Link as RouterLink } from "react-router-dom" 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"
@ -18,6 +20,17 @@ 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 {
@ -98,7 +111,18 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = (props)
{props.loadingTemplates && <Loader />} {props.loadingTemplates && <Loader />}
<Stack> <Stack>
{props.templates && ( {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 <TextField
{...getFieldHelpers("template_id")} {...getFieldHelpers("template_id")}
disabled={form.isSubmitting} disabled={form.isSubmitting}
@ -116,7 +140,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = (props)
to={`/templates/${selectedTemplate.name}`} to={`/templates/${selectedTemplate.name}`}
target="_blank" target="_blank"
> >
Read more about this template <OpenInNewIcon /> {Language.templateLink} <OpenInNewIcon />
</Link> </Link>
) )
} }
@ -179,4 +203,21 @@ const useStyles = makeStyles((theme) => ({
marginLeft: theme.spacing(0.5), marginLeft: theme.spacing(0.5),
}, },
}, },
emptyState: {
padding: 0,
fontFamily: "inherit",
textAlign: "left",
minHeight: "auto",
alignItems: "flex-start",
},
emptyStateDescription: {
lineHeight: "160%",
},
code: {
background: theme.palette.background.paper,
width: "100%",
},
codeButton: {
background: theme.palette.background.paper,
},
})) }))

View File

@ -31,7 +31,7 @@ describe("TemplatesPage", () => {
render(<TemplatesPage />) render(<TemplatesPage />)
// Then // Then
await screen.findByText(Language.emptyViewCreate) await screen.findByText(Language.emptyMessage)
}) })
it("renders a filled templates page", async () => { it("renders a filled templates page", async () => {

View File

@ -8,9 +8,10 @@ import TableRow from "@material-ui/core/TableRow"
import dayjs from "dayjs" import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime" import relativeTime from "dayjs/plugin/relativeTime"
import { FC } from "react" import { FC } from "react"
import { Link as RouterLink } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated" import * as TypesGen from "../../api/typesGenerated"
import { AvatarData } from "../../components/AvatarData/AvatarData" import { AvatarData } from "../../components/AvatarData/AvatarData"
import { CodeExample } from "../../components/CodeExample/CodeExample"
import { EmptyState } from "../../components/EmptyState/EmptyState"
import { Margins } from "../../components/Margins/Margins" import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack" import { Stack } from "../../components/Stack/Stack"
import { TableLoader } from "../../components/TableLoader/TableLoader" import { TableLoader } from "../../components/TableLoader/TableLoader"
@ -24,9 +25,17 @@ export const Language = {
nameLabel: "Name", nameLabel: "Name",
usedByLabel: "Used by", usedByLabel: "Used by",
lastUpdatedLabel: "Last updated", lastUpdatedLabel: "Last updated",
emptyViewCreateCTA: "Create a template", emptyViewNoPerms: "Contact your Coder administrator to create a template. You can share the code below.",
emptyViewCreate: "to standardize development workspaces for your team.", emptyMessage: "Create your first template",
emptyViewNoPerms: "No templates have been created! Contact your Coder administrator.", 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 using the following Coder CLI command:
</>
),
} }
export interface TemplatesPageViewProps { export interface TemplatesPageViewProps {
@ -53,18 +62,12 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = (props) => {
{!props.loading && !props.templates?.length && ( {!props.loading && !props.templates?.length && (
<TableRow> <TableRow>
<TableCell colSpan={999}> <TableCell colSpan={999}>
<div className={styles.welcome}> <EmptyState
{props.canCreateTemplate ? ( message={Language.emptyMessage}
<span> description={props.canCreateTemplate ? Language.emptyDescription : Language.emptyViewNoPerms}
<Link component={RouterLink} to="/templates/new"> descriptionClassName={styles.emptyDescription}
{Language.emptyViewCreateCTA} cta={<CodeExample code="coder template init" />}
</Link> />
&nbsp;{Language.emptyViewCreate}
</span>
) : (
<span>{Language.emptyViewNoPerms}</span>
)}
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@ -92,20 +95,9 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = (props) => {
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
marginTop: theme.spacing(3), marginTop: theme.spacing(10),
},
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`,
}, },
emptyDescription: {
maxWidth: theme.spacing(62),
}, },
})) }))

View File

@ -23,7 +23,7 @@ describe("WorkspacesPage", () => {
render(<WorkspacesPage />) render(<WorkspacesPage />)
// Then // Then
await screen.findByText(Language.emptyView) await screen.findByText(Language.emptyMessage)
}) })
it("renders a filled workspaces page", async () => { it("renders a filled workspaces page", async () => {

View File

@ -1,5 +1,5 @@
import { ComponentMeta, Story } from "@storybook/react" import { ComponentMeta, Story } from "@storybook/react"
import { ProvisionerJobStatus, Workspace } from "../../api/typesGenerated" import { ProvisionerJobStatus, Workspace, WorkspaceTransition } from "../../api/typesGenerated"
import { MockWorkspace } from "../../testHelpers/entities" import { MockWorkspace } from "../../testHelpers/entities"
import { WorkspacesPageView, WorkspacesPageViewProps } from "./WorkspacesPageView" import { WorkspacesPageView, WorkspacesPageViewProps } from "./WorkspacesPageView"
@ -10,7 +10,10 @@ export default {
const Template: Story<WorkspacesPageViewProps> = (args) => <WorkspacesPageView {...args} /> const Template: Story<WorkspacesPageViewProps> = (args) => <WorkspacesPageView {...args} />
const createWorkspaceWithStatus = (status: ProvisionerJobStatus, transition = "start"): Workspace => { const createWorkspaceWithStatus = (
status: ProvisionerJobStatus,
transition: WorkspaceTransition = "start",
): Workspace => {
return { return {
...MockWorkspace, ...MockWorkspace,
latest_build: { latest_build: {
@ -46,4 +49,6 @@ AllStates.args = {
} }
export const Empty = Template.bind({}) export const Empty = Template.bind({})
Empty.args = {} Empty.args = {
workspaces: [],
}

View File

@ -14,15 +14,18 @@ import { FC } from "react"
import { Link as RouterLink } from "react-router-dom" import { Link as RouterLink } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated" import * as TypesGen from "../../api/typesGenerated"
import { AvatarData } from "../../components/AvatarData/AvatarData" import { AvatarData } from "../../components/AvatarData/AvatarData"
import { EmptyState } from "../../components/EmptyState/EmptyState"
import { Margins } from "../../components/Margins/Margins" import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack" import { Stack } from "../../components/Stack/Stack"
import { TableLoader } from "../../components/TableLoader/TableLoader"
import { getDisplayStatus } from "../../util/workspace" import { getDisplayStatus } from "../../util/workspace"
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
export const Language = { export const Language = {
createButton: "Create workspace", createButton: "Create workspace",
emptyView: "so you can check out your repositories, edit your source code, and build and test your software.", emptyMessage: "Create your first workspace",
emptyDescription: "Start editing your source code and building your software",
} }
export interface WorkspacesPageViewProps { export interface WorkspacesPageViewProps {
@ -53,21 +56,24 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = (props) => {
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{!props.loading && !props.workspaces?.length && ( {props.loading && <TableLoader />}
{props.workspaces && props.workspaces.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={999}> <TableCell colSpan={999}>
<div className={styles.welcome}> <EmptyState
<span> message={Language.emptyMessage}
<Link component={RouterLink} to="/templates"> description={Language.emptyDescription}
Create a workspace cta={
<Link underline="none" component={RouterLink} to="/workspaces/new">
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
</Link> </Link>
&nbsp;{Language.emptyView} }
</span> />
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{props.workspaces?.map((workspace) => { {props.workspaces &&
props.workspaces.map((workspace) => {
const status = getDisplayStatus(theme, workspace.latest_build) const status = getDisplayStatus(theme, workspace.latest_build)
return ( return (
<TableRow key={workspace.id}> <TableRow key={workspace.id}>

View File

@ -56,6 +56,11 @@ export const createWorkspaceMachine = createMachine(
invoke: { invoke: {
src: "getTemplates", src: "getTemplates",
onDone: [ onDone: [
{
actions: ["assignTemplates"],
target: "waitingForTemplateGetCreated",
cond: "areTemplatesEmpty",
},
{ {
actions: ["assignTemplates", "assignPreSelectedTemplate"], actions: ["assignTemplates", "assignPreSelectedTemplate"],
target: "gettingTemplateSchema", target: "gettingTemplateSchema",
@ -71,6 +76,25 @@ 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: { selectingTemplate: {
on: { on: {
SELECT_TEMPLATE: { SELECT_TEMPLATE: {
@ -147,6 +171,7 @@ export const createWorkspaceMachine = createMachine(
const template = event.data.find((template) => template.name === ctx.preSelectedTemplateName) const template = event.data.find((template) => template.name === ctx.preSelectedTemplateName)
return !!template return !!template
}, },
areTemplatesEmpty: (_, event) => event.data.length === 0,
}, },
actions: { actions: {
assignTemplates: assign({ assignTemplates: assign({