feat: Add templates page (#1510)

* feat: Add template page

* Create xService

* Update column names

* Show create template conditionally

* Add template description

* Route to templates

* Add empty states

* Add tests

* Add loading indicator

* Requested changes
This commit is contained in:
Kyle Carberry 2022-05-18 09:05:18 -05:00 committed by GitHub
parent b7481489b1
commit 76fc59aa79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 576 additions and 5 deletions

View File

@ -12,6 +12,7 @@ import { OrgsPage } from "./pages/OrgsPage/OrgsPage"
import { SettingsPage } from "./pages/SettingsPage/SettingsPage"
import { AccountPage } from "./pages/SettingsPages/AccountPage/AccountPage"
import { SSHKeysPage } from "./pages/SettingsPages/SSHKeysPage/SSHKeysPage"
import TemplatesPage from "./pages/TemplatesPage/TemplatesPage"
import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage"
import { UsersPage } from "./pages/UsersPage/UsersPage"
import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage"
@ -73,6 +74,17 @@ export const AppRouter: React.FC = () => (
</Route>
</Route>
<Route path="templates">
<Route
index
element={
<AuthAndFrame>
<TemplatesPage />
</AuthAndFrame>
}
/>
</Route>
<Route path="users">
<Route
index

View File

@ -110,6 +110,11 @@ export const getTemplate = async (templateId: string): Promise<TypesGen.Template
return response.data
}
export const getTemplates = async (organizationId: string): Promise<TypesGen.Template[]> => {
const response = await axios.get<TypesGen.Template[]>(`/api/v2/organizations/${organizationId}/templates`)
return response.data
}
export const getWorkspace = async (workspaceId: string): Promise<TypesGen.Workspace> => {
const response = await axios.get<TypesGen.Workspace>(`/api/v2/workspaces/${workspaceId}`)
return response.data

View File

@ -30,6 +30,11 @@ export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut, display
Workspaces
</NavLink>
</ListItem>
<ListItem button className={styles.item}>
<NavLink className={styles.link} to="/templates">
Templates
</NavLink>
</ListItem>
</List>
<div className={styles.fullWidth} />
{displayAdminDropdown && <AdminDropdown />}

View File

@ -0,0 +1,153 @@
import Avatar from "@material-ui/core/Avatar"
import Button from "@material-ui/core/Button"
import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles"
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 AddCircleOutline from "@material-ui/icons/AddCircleOutline"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import React from "react"
import { Link as RouterLink } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated"
import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack"
import { firstLetter } from "../../util/firstLetter"
dayjs.extend(relativeTime)
export const Language = {
createButton: "Create Template",
emptyViewCreate: "to standardize development workspaces for your team.",
emptyViewNoPerms: "No templates have been created! Contact your Coder administrator.",
}
export interface TemplatesPageViewProps {
loading?: boolean
canCreateTemplate?: boolean
templates?: TypesGen.Template[]
error?: unknown
}
export const TemplatesPageView: React.FC<TemplatesPageViewProps> = (props) => {
const styles = useStyles()
return (
<Stack spacing={4}>
<Margins>
<div className={styles.actions}>
{props.canCreateTemplate && <Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>}
</div>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Used By</TableCell>
<TableCell>Last Updated</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!props.loading && !props.templates?.length && (
<TableRow>
<TableCell colSpan={999}>
<div className={styles.welcome}>
{props.canCreateTemplate ? (
<span>
<Link component={RouterLink} to="/templates/new">
Create a template
</Link>
&nbsp;{Language.emptyViewCreate}
</span>
) : (
<span>{Language.emptyViewNoPerms}</span>
)}
</div>
</TableCell>
</TableRow>
)}
{props.templates?.map((template) => {
return (
<TableRow key={template.id} className={styles.templateRow}>
<TableCell>
<div className={styles.templateName}>
<Avatar variant="square" className={styles.templateAvatar}>
{firstLetter(template.name)}
</Avatar>
<Link component={RouterLink} to={`/templates/${template.id}`} className={styles.templateLink}>
<b>{template.name}</b>
<span>{template.description}</span>
</Link>
</div>
</TableCell>
<TableCell>
{template.workspace_owner_count} developer{template.workspace_owner_count !== 1 && "s"}
</TableCell>
<TableCell>{dayjs().to(dayjs(template.updated_at))}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Margins>
</Stack>
)
}
const useStyles = makeStyles((theme) => ({
actions: {
marginTop: theme.spacing(3),
marginBottom: theme.spacing(3),
display: "flex",
height: theme.spacing(6),
"& button": {
marginLeft: "auto",
},
},
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`,
},
},
templateRow: {
"& > td": {
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
},
},
templateAvatar: {
borderRadius: 2,
marginRight: theme.spacing(1),
width: 24,
height: 24,
fontSize: 16,
},
templateName: {
display: "flex",
alignItems: "center",
},
templateLink: {
display: "flex",
flexDirection: "column",
color: theme.palette.text.primary,
textDecoration: "none",
"&:hover": {
textDecoration: "underline",
},
"& span": {
fontSize: 12,
color: theme.palette.text.secondary,
},
},
}))

View File

@ -0,0 +1,67 @@
import { screen } from "@testing-library/react"
import { rest } from "msw"
import React from "react"
import { MockTemplate } from "../../testHelpers/entities"
import { history, render } from "../../testHelpers/renderHelpers"
import { server } from "../../testHelpers/server"
import TemplatesPage from "./TemplatesPage"
import { Language } from "./TemplatesPageView"
describe("TemplatesPage", () => {
beforeEach(() => {
history.replace("/workspaces")
})
it("renders an empty templates page", async () => {
// Given
server.use(
rest.get("/api/v2/organizations/:organizationId/templates", (req, res, ctx) => {
return res(ctx.status(200), ctx.json([]))
}),
rest.post("/api/v2/users/:userId/authorization", async (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
createTemplates: true,
}),
)
}),
)
// When
render(<TemplatesPage />)
// Then
await screen.findByText(Language.emptyViewCreate)
})
it("renders a filled templates page", async () => {
// When
render(<TemplatesPage />)
// Then
await screen.findByText(MockTemplate.name)
})
it("shows empty view without permissions to create", async () => {
server.use(
rest.get("/api/v2/organizations/:organizationId/templates", (req, res, ctx) => {
return res(ctx.status(200), ctx.json([]))
}),
rest.post("/api/v2/users/:userId/authorization", async (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
createTemplates: false,
}),
)
}),
)
// When
render(<TemplatesPage />)
// Then
await screen.findByText(Language.emptyViewNoPerms)
})
})

View File

@ -0,0 +1,21 @@
import { useActor, useMachine } from "@xstate/react"
import React, { useContext } from "react"
import { XServiceContext } from "../../xServices/StateContext"
import { templatesMachine } from "../../xServices/templates/templatesXService"
import { TemplatesPageView } from "./TemplatesPageView"
const TemplatesPage: React.FC = () => {
const xServices = useContext(XServiceContext)
const [authState] = useActor(xServices.authXService)
const [templatesState] = useMachine(templatesMachine)
return (
<TemplatesPageView
templates={templatesState.context.templates}
canCreateTemplate={authState.context.permissions?.createTemplates}
loading={templatesState.hasTag("loading")}
/>
)
}
export default TemplatesPage

View File

@ -0,0 +1,36 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { MockTemplate } from "../../testHelpers/entities"
import { TemplatesPageView, TemplatesPageViewProps } from "./TemplatesPageView"
export default {
title: "pages/TemplatesPageView",
component: TemplatesPageView,
} as ComponentMeta<typeof TemplatesPageView>
const Template: Story<TemplatesPageViewProps> = (args) => <TemplatesPageView {...args} />
export const AllStates = Template.bind({})
AllStates.args = {
canCreateTemplate: true,
templates: [
MockTemplate,
{
...MockTemplate,
description: "🚀 Some magical template that does some magical things!",
},
{
...MockTemplate,
workspace_owner_count: 150,
description: "😮 Wow, this one has a bunch of usage!",
},
],
}
export const EmptyCanCreate = Template.bind({})
EmptyCanCreate.args = {
canCreateTemplate: true,
}
export const EmptyCannotCreate = Template.bind({})
EmptyCannotCreate.args = {}

View File

@ -0,0 +1,156 @@
import Avatar from "@material-ui/core/Avatar"
import Box from "@material-ui/core/Box"
import Button from "@material-ui/core/Button"
import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles"
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 AddCircleOutline from "@material-ui/icons/AddCircleOutline"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import React from "react"
import { Link as RouterLink } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated"
import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack"
import { TableLoader } from "../../components/TableLoader/TableLoader"
import { firstLetter } from "../../util/firstLetter"
dayjs.extend(relativeTime)
export const Language = {
createButton: "Create Template",
developerCount: (ownerCount: number): string => {
return `${ownerCount} developer${ownerCount !== 1 ? "s" : ""}`
},
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.",
}
export interface TemplatesPageViewProps {
loading?: boolean
canCreateTemplate?: boolean
templates?: TypesGen.Template[]
}
export const TemplatesPageView: React.FC<TemplatesPageViewProps> = (props) => {
const styles = useStyles()
return (
<Stack spacing={4}>
<Margins>
<div className={styles.actions}>
{props.canCreateTemplate && <Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>}
</div>
<Table>
<TableHead>
<TableRow>
<TableCell>{Language.nameLabel}</TableCell>
<TableCell>{Language.usedByLabel}</TableCell>
<TableCell>{Language.lastUpdatedLabel}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{props.loading && <TableLoader />}
{!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>
</TableCell>
</TableRow>
)}
{props.templates?.map((template) => (
<TableRow key={template.id} className={styles.templateRow}>
<TableCell>
<Box alignItems="center" display="flex">
<Avatar variant="square" className={styles.templateAvatar}>
{firstLetter(template.name)}
</Avatar>
<Link component={RouterLink} to={`/templates/${template.id}`} className={styles.templateLink}>
<b>{template.name}</b>
<span>{template.description}</span>
</Link>
</Box>
</TableCell>
<TableCell>{Language.developerCount(template.workspace_owner_count)}</TableCell>
<TableCell>{dayjs().to(dayjs(template.updated_at))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Margins>
</Stack>
)
}
const useStyles = makeStyles((theme) => ({
actions: {
marginTop: theme.spacing(3),
marginBottom: theme.spacing(3),
display: "flex",
height: theme.spacing(6),
"& button": {
marginLeft: "auto",
},
},
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`,
},
},
templateRow: {
"& > td": {
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
},
},
templateAvatar: {
borderRadius: 2,
marginRight: theme.spacing(1),
width: 24,
height: 24,
fontSize: 16,
},
templateLink: {
display: "flex",
flexDirection: "column",
color: theme.palette.text.primary,
textDecoration: "none",
"&:hover": {
textDecoration: "underline",
},
"& span": {
fontSize: 12,
color: theme.palette.text.secondary,
},
},
}))

View File

@ -39,7 +39,9 @@ export const WorkspacesPageView: React.FC<WorkspacesPageViewProps> = (props) =>
<Stack spacing={4}>
<Margins>
<div className={styles.actions}>
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
<Link component={RouterLink} to="/templates">
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
</Link>
</div>
<Table>
<TableHead>
@ -57,7 +59,7 @@ export const WorkspacesPageView: React.FC<WorkspacesPageViewProps> = (props) =>
<TableCell colSpan={999}>
<div className={styles.welcome}>
<span>
<Link component={RouterLink} to="/workspaces/new">
<Link component={RouterLink} to="/templates">
Create a workspace
</Link>
&nbsp;{Language.emptyView}
@ -114,7 +116,7 @@ const useStyles = makeStyles((theme) => ({
display: "flex",
height: theme.spacing(6),
"& button": {
"& > *": {
marginLeft: "auto",
},
},

View File

@ -80,8 +80,8 @@ export const MockRunningProvisionerJob = { ...MockProvisionerJob, status: "runni
export const MockTemplate: TypesGen.Template = {
id: "test-template",
created_at: "",
updated_at: "",
created_at: new Date().toString(),
updated_at: new Date().toString(),
organization_id: MockOrganization.id,
name: "Test Template",
provisioner: MockProvisioner.id,

View File

@ -17,6 +17,9 @@ export const handlers = [
rest.get("/api/v2/organizations/:organizationId/templates/:templateId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockTemplate))
}),
rest.get("/api/v2/organizations/:organizationId/templates", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json([M.MockTemplate]))
}),
// templates
rest.get("/api/v2/templates/:templateId", async (req, res, ctx) => {

View File

@ -11,6 +11,7 @@ export const Language = {
export const checks = {
readAllUsers: "readAllUsers",
createTemplates: "createTemplates",
} as const
export const permissionsToCheck = {
@ -20,6 +21,12 @@ export const permissionsToCheck = {
},
action: "read",
},
[checks.createTemplates]: {
object: {
resource_type: "template",
},
action: "write",
},
} as const
type Permissions = Record<keyof typeof permissionsToCheck, boolean>

View File

@ -0,0 +1,104 @@
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import * as TypesGen from "../../api/typesGenerated"
interface TemplatesContext {
organizations?: TypesGen.Organization[]
templates?: TypesGen.Template[]
canCreateTemplate?: boolean
permissionsError?: Error | unknown
organizationsError?: Error | unknown
templatesError?: Error | unknown
}
export const templatesMachine = createMachine(
{
tsTypes: {} as import("./templatesXService.typegen").Typegen0,
schema: {
context: {} as TemplatesContext,
services: {} as {
getOrganizations: {
data: TypesGen.Organization[]
}
getPermissions: {
data: boolean
}
getTemplates: {
data: TypesGen.Template[]
}
},
},
id: "templatesState",
initial: "gettingOrganizations",
states: {
gettingOrganizations: {
entry: "clearOrganizationsError",
invoke: {
src: "getOrganizations",
id: "getOrganizations",
onDone: [
{
actions: ["assignOrganizations", "clearOrganizationsError"],
target: "gettingTemplates",
},
],
onError: [
{
actions: "assignOrganizationsError",
target: "error",
},
],
},
tags: "loading",
},
gettingTemplates: {
entry: "clearTemplatesError",
invoke: {
src: "getTemplates",
id: "getTemplates",
onDone: {
target: "done",
actions: ["assignTemplates", "clearTemplatesError"],
},
onError: {
target: "error",
actions: "assignTemplatesError",
},
},
tags: "loading",
},
done: {},
error: {},
},
},
{
actions: {
assignOrganizations: assign({
organizations: (_, event) => event.data,
}),
assignOrganizationsError: assign({
organizationsError: (_, event) => event.data,
}),
clearOrganizationsError: assign((context) => ({
...context,
organizationsError: undefined,
})),
assignTemplates: assign({
templates: (_, event) => event.data,
}),
assignTemplatesError: assign({
templatesError: (_, event) => event.data,
}),
clearTemplatesError: (context) => assign({ ...context, getWorkspacesError: undefined }),
},
services: {
getOrganizations: API.getOrganizations,
getTemplates: async (context) => {
if (!context.organizations || context.organizations.length === 0) {
throw new Error("no organizations")
}
return API.getTemplates(context.organizations[0].id)
},
},
},
)