refactor: Improve the load state for the list pages (#1428)

This commit is contained in:
Bruno Quaresma
2022-05-13 14:12:35 -05:00
committed by GitHub
parent f970829b9e
commit 50ad2f8e31
15 changed files with 413 additions and 270 deletions

View File

@ -21,12 +21,12 @@ describe("EmptyState", () => {
await screen.findByText("Friendly greeting")
})
it("renders description component", async () => {
it("renders cta component", async () => {
// Given
const description = <button title="Click me" />
const cta = <button title="Click me" />
// When
render(<EmptyState message="Hello, world" description={description} />)
render(<EmptyState message="Hello, world" cta={cta} />)
// Then
await screen.findByText("Hello, world")

View File

@ -1,5 +1,4 @@
import Box from "@material-ui/core/Box"
import Button, { ButtonProps } from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import React from "react"
@ -8,8 +7,8 @@ export interface EmptyStateProps {
/** Text Message to display, placed inside Typography component */
message: string
/** Longer optional description to display below the message */
description?: React.ReactNode
button?: ButtonProps
description?: string
cta?: React.ReactNode
}
/**
@ -21,17 +20,22 @@ export interface EmptyStateProps {
* that you can directly pass props through to to customize the shape and layout of it.
*/
export const EmptyState: React.FC<EmptyStateProps> = (props) => {
const { message, description, button, ...boxProps } = props
const { message, description, cta, ...boxProps } = props
const styles = useStyles()
const buttonClassName = `${styles.button} ${button && button.className ? button.className : ""}`
return (
<Box className={styles.root} {...boxProps}>
<Typography variant="h5" color="textSecondary" className={styles.header}>
<div className={styles.header}>
<Typography variant="h5" className={styles.title}>
{message}
</Typography>
{description && <div className={styles.description}>{description}</div>}
{button && <Button variant="contained" color="primary" {...button} className={buttonClassName} />}
{description && (
<Typography variant="body2" color="textSecondary" className={styles.description}>
{description}
</Typography>
)}
</div>
{cta}
</Box>
)
}
@ -48,22 +52,13 @@ const useStyles = makeStyles(
padding: theme.spacing(3),
},
header: {
marginBottom: theme.spacing(3),
},
title: {
fontWeight: 400,
},
description: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(1),
color: theme.palette.text.secondary,
fontSize: theme.typography.body2.fontSize,
},
button: {
marginTop: theme.spacing(2),
},
icon: {
fontSize: theme.typography.h2.fontSize,
color: theme.palette.text.secondary,
marginBottom: theme.spacing(1),
opacity: 0.5,
marginTop: theme.spacing(1),
},
}),
{ name: "EmptyState" },

View File

@ -2,7 +2,7 @@ import CircularProgress from "@material-ui/core/CircularProgress"
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
export const useStyles = makeStyles(() => ({
export const useStyles = makeStyles((theme) => ({
root: {
position: "absolute",
top: "0",
@ -12,6 +12,7 @@ export const useStyles = makeStyles(() => ({
display: "flex",
justifyContent: "center",
alignItems: "center",
background: theme.palette.background.default,
},
}))

View File

@ -1,5 +1,6 @@
import { Story } from "@storybook/react"
import React from "react"
import { MockUser, MockUser2 } from "../../testHelpers/entities"
import { NavbarView, NavbarViewProps } from "./NavbarView"
export default {
@ -14,16 +15,7 @@ const Template: Story<NavbarViewProps> = (args: NavbarViewProps) => <NavbarView
export const ForAdmin = Template.bind({})
ForAdmin.args = {
user: {
id: "1",
username: "Administrator",
email: "admin@coder.com",
created_at: "dawn",
status: "active",
organization_ids: [],
roles: [],
},
displayAdminDropdown: true,
user: MockUser,
onSignOut: () => {
return Promise.resolve()
},
@ -31,16 +23,7 @@ ForAdmin.args = {
export const ForMember = Template.bind({})
ForMember.args = {
user: {
id: "1",
username: "CathyCoder",
email: "cathy@coder.com",
created_at: "dawn",
status: "active",
organization_ids: [],
roles: [],
},
displayAdminDropdown: false,
user: MockUser2,
onSignOut: () => {
return Promise.resolve()
},

View File

@ -0,0 +1,27 @@
import Box from "@material-ui/core/Box"
import CircularProgress from "@material-ui/core/CircularProgress"
import { makeStyles } from "@material-ui/core/styles"
import TableCell from "@material-ui/core/TableCell"
import TableRow from "@material-ui/core/TableRow"
import React from "react"
export const TableLoader: React.FC = () => {
const styles = useStyles()
return (
<TableRow>
<TableCell colSpan={999} className={styles.cell}>
<Box p={4}>
<CircularProgress size={26} />
</Box>
</TableCell>
</TableRow>
)
}
const useStyles = makeStyles((theme) => ({
cell: {
textAlign: "center",
height: theme.spacing(20),
},
}))

View File

@ -0,0 +1,29 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { MockOrganization, MockTemplate } from "../../testHelpers/entities"
import { TemplatesTable, TemplatesTableProps } from "./TemplatesTable"
export default {
title: "components/TemplatesTable",
component: TemplatesTable,
} as ComponentMeta<typeof TemplatesTable>
const Template: Story<TemplatesTableProps> = (args) => <TemplatesTable {...args} />
export const Example = Template.bind({})
Example.args = {
templates: [MockTemplate],
organizations: [MockOrganization],
}
export const Empty = Template.bind({})
Empty.args = {
templates: [],
organizations: [],
}
export const Loading = Template.bind({})
Loading.args = {
templates: undefined,
organizations: [],
}

View File

@ -0,0 +1,81 @@
import Box from "@material-ui/core/Box"
import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import React from "react"
import { Link } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated"
import { CodeExample } from "../../components/CodeExample/CodeExample"
import { EmptyState } from "../../components/EmptyState/EmptyState"
import { TableHeaderRow } from "../../components/TableHeaders/TableHeaders"
import { TableLoader } from "../../components/TableLoader/TableLoader"
import { TableTitle } from "../../components/TableTitle/TableTitle"
export const Language = {
title: "Templates",
tableTitle: "All templates",
nameLabel: "Name",
emptyMessage: "No templates have been created yet",
emptyDescription: "Run the following command to get started:",
totalLabel: "total",
}
export interface TemplatesTableProps {
templates?: TypesGen.Template[]
organizations?: TypesGen.Organization[]
}
export const TemplatesTable: React.FC<TemplatesTableProps> = ({ templates, organizations }) => {
const isLoading = !templates || !organizations
// Create a dictionary of organization ID -> organization Name
// Needed to properly construct links to dive into a template
const orgDictionary =
organizations &&
organizations.reduce((acc: Record<string, string>, curr: TypesGen.Organization) => {
return {
...acc,
[curr.id]: curr.name,
}
}, {})
return (
<Table>
<TableHead>
<TableTitle title={Language.tableTitle} />
<TableHeaderRow>
<TableCell size="small">{Language.nameLabel}</TableCell>
</TableHeaderRow>
</TableHead>
<TableBody>
{isLoading && <TableLoader />}
{templates &&
organizations &&
orgDictionary &&
templates.map((t) => (
<TableRow key={t.id}>
<TableCell>
<Link to={`/templates/${orgDictionary[t.organization_id]}/${t.name}`}>{t.name}</Link>
</TableCell>
</TableRow>
))}
{templates && templates.length === 0 && (
<TableRow>
<TableCell colSpan={999}>
<Box p={4}>
<EmptyState
message={Language.emptyMessage}
description={Language.emptyDescription}
cta={<CodeExample code="coder templates create" />}
/>
</Box>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
}

View File

@ -9,6 +9,7 @@ import * as TypesGen from "../../api/typesGenerated"
import { EmptyState } from "../EmptyState/EmptyState"
import { RoleSelect } from "../RoleSelect/RoleSelect"
import { TableHeaderRow } from "../TableHeaders/TableHeaders"
import { TableLoader } from "../TableLoader/TableLoader"
import { TableRowMenu } from "../TableRowMenu/TableRowMenu"
import { TableTitle } from "../TableTitle/TableTitle"
import { UserCell } from "../UserCell/UserCell"
@ -24,12 +25,12 @@ export const Language = {
}
export interface UsersTableProps {
users: TypesGen.User[]
users?: TypesGen.User[]
roles?: TypesGen.Role[]
isUpdatingUserRoles?: boolean
onSuspendUser: (user: TypesGen.User) => void
onResetUserPassword: (user: TypesGen.User) => void
onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void
roles: TypesGen.Role[]
isUpdatingUserRoles?: boolean
}
export const UsersTable: React.FC<UsersTableProps> = ({
@ -40,6 +41,8 @@ export const UsersTable: React.FC<UsersTableProps> = ({
onUpdateUserRoles,
isUpdatingUserRoles,
}) => {
const isLoading = !users || !roles
return (
<Table>
<TableHead>
@ -52,7 +55,10 @@ export const UsersTable: React.FC<UsersTableProps> = ({
</TableHeaderRow>
</TableHead>
<TableBody>
{users.map((u) => (
{isLoading && <TableLoader />}
{users &&
roles &&
users.map((u) => (
<TableRow key={u.id}>
<TableCell>
<UserCell Avatar={{ username: u.username }} primaryText={u.username} caption={u.email} />{" "}
@ -83,7 +89,7 @@ export const UsersTable: React.FC<UsersTableProps> = ({
</TableRow>
))}
{users.length === 0 && (
{users && users.length === 0 && (
<TableRow>
<TableCell colSpan={999}>
<Box p={4}>

View File

@ -0,0 +1,38 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { MockTemplate, MockWorkspace } from "../../testHelpers/entities"
import { WorkspacesTable, WorkspacesTableProps } from "./WorkspacesTable"
export default {
title: "components/WorkspacesTable",
component: WorkspacesTable,
} as ComponentMeta<typeof WorkspacesTable>
const Template: Story<WorkspacesTableProps> = (args) => <WorkspacesTable {...args} />
export const Example = Template.bind({})
Example.args = {
templateInfo: MockTemplate,
workspaces: [MockWorkspace],
onCreateWorkspace: () => {
console.info("Create workspace")
},
}
export const Empty = Template.bind({})
Empty.args = {
templateInfo: MockTemplate,
workspaces: [],
onCreateWorkspace: () => {
console.info("Create workspace")
},
}
export const Loading = Template.bind({})
Loading.args = {
templateInfo: MockTemplate,
workspaces: undefined,
onCreateWorkspace: () => {
console.info("Create workspace")
},
}

View File

@ -0,0 +1,72 @@
import Box from "@material-ui/core/Box"
import Button from "@material-ui/core/Button"
import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import React from "react"
import { Link } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated"
import { EmptyState } from "../EmptyState/EmptyState"
import { TableHeaderRow } from "../TableHeaders/TableHeaders"
import { TableLoader } from "../TableLoader/TableLoader"
import { TableTitle } from "../TableTitle/TableTitle"
export const Language = {
title: "Workspaces",
nameLabel: "Name",
emptyMessage: "No workspaces have been created yet",
emptyDescription: "Create a workspace to get started",
ctaAction: "Create workspace",
}
export interface WorkspacesTableProps {
templateInfo?: TypesGen.Template
workspaces?: TypesGen.Workspace[]
onCreateWorkspace: () => void
}
export const WorkspacesTable: React.FC<WorkspacesTableProps> = ({ templateInfo, workspaces, onCreateWorkspace }) => {
const isLoading = !templateInfo || !workspaces
return (
<Table>
<TableHead>
<TableTitle title={Language.title} />
<TableHeaderRow>
<TableCell size="small">{Language.nameLabel}</TableCell>
</TableHeaderRow>
</TableHead>
<TableBody>
{isLoading && <TableLoader />}
{workspaces &&
workspaces.map((w) => (
<TableRow key={w.id}>
<TableCell>
<Link to={`/workspaces/${w.id}`}>{w.name}</Link>
</TableCell>
</TableRow>
))}
{workspaces && workspaces.length === 0 && (
<TableRow>
<TableCell colSpan={999}>
<Box p={4}>
<EmptyState
message={Language.emptyMessage}
description={Language.emptyDescription}
cta={
<Button variant="contained" color="primary" onClick={onCreateWorkspace}>
{Language.ctaAction}
</Button>
}
/>
</Box>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
}

View File

@ -1,17 +1,19 @@
import React from "react"
import { Link, useNavigate, useParams } from "react-router-dom"
import { useNavigate, useParams } from "react-router-dom"
import useSWR from "swr"
import * as TypesGen from "../../../../api/typesGenerated"
import { EmptyState } from "../../../../components/EmptyState/EmptyState"
import { ErrorSummary } from "../../../../components/ErrorSummary/ErrorSummary"
import { Header } from "../../../../components/Header/Header"
import { FullScreenLoader } from "../../../../components/Loader/FullScreenLoader"
import { Margins } from "../../../../components/Margins/Margins"
import { Stack } from "../../../../components/Stack/Stack"
import { Column, Table } from "../../../../components/Table/Table"
import { WorkspacesTable } from "../../../../components/WorkspacesTable/WorkspacesTable"
import { unsafeSWRArgument } from "../../../../util"
import { firstOrItem } from "../../../../util/array"
export const Language = {
subtitle: "workspaces",
}
export const TemplatePage: React.FC = () => {
const navigate = useNavigate()
const { template: templateName, organization: organizationName } = useParams()
@ -26,69 +28,29 @@ export const TemplatePage: React.FC = () => {
// This just grabs all workspaces... and then later filters them to match the
// current template.
const { data: workspaces, error: workspacesError } = useSWR<TypesGen.Workspace[], Error>(
() => `/api/v2/organizations/${unsafeSWRArgument(organizationInfo).id}/workspaces`,
)
if (organizationError) {
return <ErrorSummary error={organizationError} />
}
if (templateError) {
return <ErrorSummary error={templateError} />
}
if (workspacesError) {
return <ErrorSummary error={workspacesError} />
}
if (!templateInfo || !workspaces) {
return <FullScreenLoader />
}
const hasError = organizationError || templateError || workspacesError
const createWorkspace = () => {
navigate(`/templates/${organizationName}/${templateName}/create`)
}
const emptyState = (
<EmptyState
button={{
children: "Create Workspace",
onClick: createWorkspace,
}}
message="No workspaces have been created yet"
description="Create a workspace to get started"
/>
)
const columns: Column<TypesGen.Workspace>[] = [
{
key: "name",
name: "Name",
renderer: (nameField: string | TypesGen.WorkspaceBuild, workspace: TypesGen.Workspace) => {
return <Link to={`/workspaces/${workspace.id}`}>{nameField}</Link>
},
},
]
const perTemplateWorkspaces = workspaces.filter((workspace) => {
const perTemplateWorkspaces =
workspaces && templateInfo
? workspaces.filter((workspace) => {
return workspace.template_id === templateInfo.id
})
const tableProps = {
title: "Workspaces",
columns,
data: perTemplateWorkspaces,
emptyState: emptyState,
}
: undefined
return (
<Stack spacing={4}>
<Header
title={firstOrItem(templateName, "")}
description={firstOrItem(organizationName, "")}
subTitle={`${perTemplateWorkspaces.length} workspaces`}
subTitle={perTemplateWorkspaces ? `${perTemplateWorkspaces.length} ${Language.subtitle}` : ""}
action={{
text: "Create Workspace",
onClick: createWorkspace,
@ -96,7 +58,12 @@ export const TemplatePage: React.FC = () => {
/>
<Margins>
<Table {...tableProps} />
{organizationError && <ErrorSummary error={organizationError} />}
{templateError && <ErrorSummary error={templateError} />}
{workspacesError && <ErrorSummary error={workspacesError} />}
{!hasError && (
<WorkspacesTable templateInfo={templateInfo} workspaces={workspaces} onCreateWorkspace={createWorkspace} />
)}
</Margins>
</Stack>
)

View File

@ -1,85 +1,37 @@
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import { Link } from "react-router-dom"
import useSWR from "swr"
import * as TypesGen from "../../api/typesGenerated"
import { CodeExample } from "../../components/CodeExample/CodeExample"
import { EmptyState } from "../../components/EmptyState/EmptyState"
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
import { Header } from "../../components/Header/Header"
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack"
import { Column, Table } from "../../components/Table/Table"
import { TemplatesTable } from "../../components/TemplatesTable/TemplatesTable"
export const Language = {
title: "Templates",
tableTitle: "All templates",
nameLabel: "Name",
emptyMessage: "No templates have been created yet",
emptyDescription: "Run the following command to get started:",
totalLabel: "total",
}
export const TemplatesPage: React.FC = () => {
const styles = useStyles()
const { data: orgs, error: orgsError } = useSWR<TypesGen.Organization[], Error>("/api/v2/users/me/organizations")
const { data: templates, error } = useSWR<TypesGen.Template[] | null, Error>(
orgs ? `/api/v2/organizations/${orgs[0].id}/templates` : null,
const { data: templates, error } = useSWR<TypesGen.Template[] | undefined, Error>(
orgs ? `/api/v2/organizations/${orgs[0].id}/templates` : undefined,
)
if (error) {
return <ErrorSummary error={error} />
}
if (orgsError) {
return <ErrorSummary error={error} />
}
if (!templates || !orgs) {
return <FullScreenLoader />
}
// Create a dictionary of organization ID -> organization Name
// Needed to properly construct links to dive into a template
const orgDictionary = orgs.reduce((acc: Record<string, string>, curr: TypesGen.Organization) => {
return {
...acc,
[curr.id]: curr.name,
}
}, {})
const columns: Column<TypesGen.Template>[] = [
{
key: "name",
name: "Name",
renderer: (nameField: string, data: TypesGen.Template) => {
return <Link to={`/templates/${orgDictionary[data.organization_id]}/${nameField}`}>{nameField}</Link>
},
},
]
const description = (
<div>
<div className={styles.descriptionLabel}>Run the following command to get started:</div>
<CodeExample code="coder templates create" />
</div>
)
const emptyState = <EmptyState message="No templates have been created yet" description={description} />
const tableProps = {
title: "All Templates",
columns: columns,
emptyState: emptyState,
data: templates,
}
const subTitle = `${templates.length} total`
const subTitle = templates ? `${templates.length} ${Language.totalLabel}` : undefined
const hasError = orgsError || error
return (
<Stack spacing={4}>
<Header title="Templates" subTitle={subTitle} />
<Header title={Language.title} subTitle={subTitle} />
<Margins>
<Table {...tableProps} />
{error && <ErrorSummary error={error} />}
{orgsError && <ErrorSummary error={orgsError} />}
{!hasError && <TemplatesTable organizations={orgs} templates={templates} />}
</Margins>
</Stack>
)
}
const useStyles = makeStyles((theme) => ({
descriptionLabel: {
marginBottom: theme.spacing(1),
},
}))

View File

@ -2,7 +2,6 @@ import { useActor } from "@xstate/react"
import React, { useContext, useEffect } from "react"
import { useNavigate } from "react-router"
import { ConfirmDialog } from "../../components/ConfirmDialog/ConfirmDialog"
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
import { ResetPasswordDialog } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
import { XServiceContext } from "../../xServices/StateContext"
import { UsersPageView } from "./UsersPageView"
@ -46,9 +45,6 @@ export const UsersPage: React.FC = () => {
usersSend("GET_USERS")
}, [usersSend])
if (!users || !roles) {
return <FullScreenLoader />
} else {
return (
<>
<UsersPageView
@ -109,4 +105,3 @@ export const UsersPage: React.FC = () => {
</>
)
}
}

View File

@ -12,14 +12,14 @@ export const Language = {
}
export interface UsersPageViewProps {
users: TypesGen.User[]
users?: TypesGen.User[]
roles?: TypesGen.Role[]
error?: unknown
isUpdatingUserRoles?: boolean
openUserCreationDialog: () => void
onSuspendUser: (user: TypesGen.User) => void
onResetUserPassword: (user: TypesGen.User) => void
onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void
roles: TypesGen.Role[]
error?: unknown
isUpdatingUserRoles?: boolean
}
export const UsersPageView: React.FC<UsersPageViewProps> = ({
@ -41,10 +41,10 @@ export const UsersPageView: React.FC<UsersPageViewProps> = ({
) : (
<UsersTable
users={users}
roles={roles}
onSuspendUser={onSuspendUser}
onResetUserPassword={onResetUserPassword}
onUpdateUserRoles={onUpdateUserRoles}
roles={roles}
isUpdatingUserRoles={isUpdatingUserRoles}
/>
)}

View File

@ -73,9 +73,6 @@ export const usersMachine = createMachine(
},
id: "usersState",
initial: "idle",
context: {
users: [],
},
states: {
idle: {
on: {