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 { FC } from "react"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { combineClasses } from "../../util/combineClasses"
import { CopyButton } from "../CopyButton/CopyButton"
export interface CodeExampleProps {
code: string
className?: string
buttonClassName?: string
}
/**
* 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()
return (
<div className={styles.root}>
<code>{code}</code>
<CopyButton text={code} />
<div className={combineClasses([styles.root, className])}>
<code className={styles.code}>{code}</code>
<CopyButton text={code} buttonClassName={combineClasses([styles.button, buttonClassName])} />
</div>
)
}
@ -30,8 +33,17 @@ const useStyles = makeStyles((theme) => ({
background: theme.palette.background.default,
color: theme.palette.primary.contrastText,
fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 13,
padding: theme.spacing(2),
fontSize: 14,
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,
},
}))

View File

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

View File

@ -27,7 +27,12 @@ export const Footer: React.FC = ({ children }) => {
</div>
{buildInfoState.context.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)}
</Link>
</div>
@ -38,6 +43,7 @@ export const Footer: React.FC = ({ children }) => {
const useFooterStyles = makeStyles((theme) => ({
root: {
opacity: 0.6,
textAlign: "center",
flex: "0",
paddingTop: theme.spacing(2),
@ -50,4 +56,8 @@ const useFooterStyles = makeStyles((theme) => ({
buildInfo: {
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} />
export const NoTemplates = Template.bind({})
NoTemplates.args = {
templates: [],
}
export const NoParameters = Template.bind({})
NoParameters.args = {
templates: [MockTemplate],

View File

@ -8,6 +8,8 @@ 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"
@ -18,6 +20,17 @@ 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 {
@ -98,7 +111,18 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = (props)
{props.loadingTemplates && <Loader />}
<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
{...getFieldHelpers("template_id")}
disabled={form.isSubmitting}
@ -116,7 +140,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = (props)
to={`/templates/${selectedTemplate.name}`}
target="_blank"
>
Read more about this template <OpenInNewIcon />
{Language.templateLink} <OpenInNewIcon />
</Link>
)
}
@ -179,4 +203,21 @@ const useStyles = makeStyles((theme) => ({
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 />)
// Then
await screen.findByText(Language.emptyViewCreate)
await screen.findByText(Language.emptyMessage)
})
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 relativeTime from "dayjs/plugin/relativeTime"
import { FC } from "react"
import { Link as RouterLink } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated"
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 { Stack } from "../../components/Stack/Stack"
import { TableLoader } from "../../components/TableLoader/TableLoader"
@ -24,9 +25,17 @@ export const Language = {
nameLabel: "Name",
usedByLabel: "Used by",
lastUpdatedLabel: "Last updated",
emptyViewCreateCTA: "Create a template",
emptyViewCreate: "to standardize development workspaces for your team.",
emptyViewNoPerms: "No templates have been created! Contact your Coder administrator.",
emptyViewNoPerms: "Contact your Coder administrator to create a template. You can share the code below.",
emptyMessage: "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 using the following Coder CLI command:
</>
),
}
export interface TemplatesPageViewProps {
@ -53,18 +62,12 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = (props) => {
{!props.loading && !props.templates?.length && (
<TableRow>
<TableCell colSpan={999}>
<div className={styles.welcome}>
{props.canCreateTemplate ? (
<span>
<Link component={RouterLink} to="/templates/new">
{Language.emptyViewCreateCTA}
</Link>
&nbsp;{Language.emptyViewCreate}
</span>
) : (
<span>{Language.emptyViewNoPerms}</span>
)}
</div>
<EmptyState
message={Language.emptyMessage}
description={props.canCreateTemplate ? Language.emptyDescription : Language.emptyViewNoPerms}
descriptionClassName={styles.emptyDescription}
cta={<CodeExample code="coder template init" />}
/>
</TableCell>
</TableRow>
)}
@ -92,20 +95,9 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = (props) => {
const useStyles = makeStyles((theme) => ({
root: {
marginTop: theme.spacing(3),
},
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`,
marginTop: theme.spacing(10),
},
emptyDescription: {
maxWidth: theme.spacing(62),
},
}))

View File

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

View File

@ -1,5 +1,5 @@
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 { WorkspacesPageView, WorkspacesPageViewProps } from "./WorkspacesPageView"
@ -10,7 +10,10 @@ export default {
const Template: Story<WorkspacesPageViewProps> = (args) => <WorkspacesPageView {...args} />
const createWorkspaceWithStatus = (status: ProvisionerJobStatus, transition = "start"): Workspace => {
const createWorkspaceWithStatus = (
status: ProvisionerJobStatus,
transition: WorkspaceTransition = "start",
): Workspace => {
return {
...MockWorkspace,
latest_build: {
@ -46,4 +49,6 @@ AllStates.args = {
}
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 * as TypesGen from "../../api/typesGenerated"
import { AvatarData } from "../../components/AvatarData/AvatarData"
import { EmptyState } from "../../components/EmptyState/EmptyState"
import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack"
import { TableLoader } from "../../components/TableLoader/TableLoader"
import { getDisplayStatus } from "../../util/workspace"
dayjs.extend(relativeTime)
export const Language = {
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 {
@ -53,21 +56,24 @@ export const WorkspacesPageView: FC<WorkspacesPageViewProps> = (props) => {
</TableRow>
</TableHead>
<TableBody>
{!props.loading && !props.workspaces?.length && (
{props.loading && <TableLoader />}
{props.workspaces && props.workspaces.length === 0 && (
<TableRow>
<TableCell colSpan={999}>
<div className={styles.welcome}>
<span>
<Link component={RouterLink} to="/templates">
Create a workspace
<EmptyState
message={Language.emptyMessage}
description={Language.emptyDescription}
cta={
<Link underline="none" component={RouterLink} to="/workspaces/new">
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
</Link>
&nbsp;{Language.emptyView}
</span>
</div>
}
/>
</TableCell>
</TableRow>
)}
{props.workspaces?.map((workspace) => {
{props.workspaces &&
props.workspaces.map((workspace) => {
const status = getDisplayStatus(theme, workspace.latest_build)
return (
<TableRow key={workspace.id}>

View File

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