mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: Improve empty states for workspaces and templates (#1950)
This commit is contained in:
@ -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,
|
||||
},
|
||||
}))
|
||||
|
@ -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" },
|
||||
|
@ -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,
|
||||
},
|
||||
}))
|
||||
|
@ -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],
|
||||
|
@ -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,
|
||||
},
|
||||
}))
|
||||
|
@ -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 () => {
|
||||
|
@ -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>
|
||||
{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),
|
||||
},
|
||||
}))
|
||||
|
@ -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 () => {
|
||||
|
@ -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: [],
|
||||
}
|
||||
|
@ -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>
|
||||
{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}>
|
||||
|
@ -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({
|
||||
|
Reference in New Issue
Block a user