mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
refactor: Improve the load state for the list pages (#1428)
This commit is contained in:
@ -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")
|
||||
|
@ -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" },
|
||||
|
@ -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,
|
||||
},
|
||||
}))
|
||||
|
||||
|
@ -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()
|
||||
},
|
||||
|
27
site/src/components/TableLoader/TableLoader.tsx
Normal file
27
site/src/components/TableLoader/TableLoader.tsx
Normal 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),
|
||||
},
|
||||
}))
|
@ -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: [],
|
||||
}
|
81
site/src/components/TemplatesTable/TemplatesTable.tsx
Normal file
81
site/src/components/TemplatesTable/TemplatesTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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}>
|
||||
|
@ -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")
|
||||
},
|
||||
}
|
72
site/src/components/WorkspacesTable/WorkspacesTable.tsx
Normal file
72
site/src/components/WorkspacesTable/WorkspacesTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
|
@ -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),
|
||||
},
|
||||
}))
|
||||
|
@ -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 = () => {
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
@ -73,9 +73,6 @@ export const usersMachine = createMachine(
|
||||
},
|
||||
id: "usersState",
|
||||
initial: "idle",
|
||||
context: {
|
||||
users: [],
|
||||
},
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
|
Reference in New Issue
Block a user