Skip to content

Commit 8594936

Browse files
committed
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
1 parent e4c7eef commit 8594936

File tree

13 files changed

+576
-5
lines changed

13 files changed

+576
-5
lines changed

site/src/AppRouter.tsx

+12
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { OrgsPage } from "./pages/OrgsPage/OrgsPage"
1212
import { SettingsPage } from "./pages/SettingsPage/SettingsPage"
1313
import { AccountPage } from "./pages/SettingsPages/AccountPage/AccountPage"
1414
import { SSHKeysPage } from "./pages/SettingsPages/SSHKeysPage/SSHKeysPage"
15+
import TemplatesPage from "./pages/TemplatesPage/TemplatesPage"
1516
import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage"
1617
import { UsersPage } from "./pages/UsersPage/UsersPage"
1718
import { WorkspacePage } from "./pages/WorkspacePage/WorkspacePage"
@@ -73,6 +74,17 @@ export const AppRouter: React.FC = () => (
7374
</Route>
7475
</Route>
7576

77+
<Route path="templates">
78+
<Route
79+
index
80+
element={
81+
<AuthAndFrame>
82+
<TemplatesPage />
83+
</AuthAndFrame>
84+
}
85+
/>
86+
</Route>
87+
7688
<Route path="users">
7789
<Route
7890
index

site/src/api/api.ts

+5
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ export const getTemplate = async (templateId: string): Promise<TypesGen.Template
110110
return response.data
111111
}
112112

113+
export const getTemplates = async (organizationId: string): Promise<TypesGen.Template[]> => {
114+
const response = await axios.get<TypesGen.Template[]>(`/api/v2/organizations/${organizationId}/templates`)
115+
return response.data
116+
}
117+
113118
export const getWorkspace = async (workspaceId: string): Promise<TypesGen.Workspace> => {
114119
const response = await axios.get<TypesGen.Workspace>(`/api/v2/workspaces/${workspaceId}`)
115120
return response.data

site/src/components/NavbarView/NavbarView.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut, display
3030
Workspaces
3131
</NavLink>
3232
</ListItem>
33+
<ListItem button className={styles.item}>
34+
<NavLink className={styles.link} to="/templates">
35+
Templates
36+
</NavLink>
37+
</ListItem>
3338
</List>
3439
<div className={styles.fullWidth} />
3540
{displayAdminDropdown && <AdminDropdown />}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import Avatar from "@material-ui/core/Avatar"
2+
import Button from "@material-ui/core/Button"
3+
import Link from "@material-ui/core/Link"
4+
import { makeStyles } from "@material-ui/core/styles"
5+
import Table from "@material-ui/core/Table"
6+
import TableBody from "@material-ui/core/TableBody"
7+
import TableCell from "@material-ui/core/TableCell"
8+
import TableHead from "@material-ui/core/TableHead"
9+
import TableRow from "@material-ui/core/TableRow"
10+
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
11+
import dayjs from "dayjs"
12+
import relativeTime from "dayjs/plugin/relativeTime"
13+
import React from "react"
14+
import { Link as RouterLink } from "react-router-dom"
15+
import * as TypesGen from "../../api/typesGenerated"
16+
import { Margins } from "../../components/Margins/Margins"
17+
import { Stack } from "../../components/Stack/Stack"
18+
import { firstLetter } from "../../util/firstLetter"
19+
20+
dayjs.extend(relativeTime)
21+
22+
export const Language = {
23+
createButton: "Create Template",
24+
emptyViewCreate: "to standardize development workspaces for your team.",
25+
emptyViewNoPerms: "No templates have been created! Contact your Coder administrator.",
26+
}
27+
28+
export interface TemplatesPageViewProps {
29+
loading?: boolean
30+
canCreateTemplate?: boolean
31+
templates?: TypesGen.Template[]
32+
error?: unknown
33+
}
34+
35+
export const TemplatesPageView: React.FC<TemplatesPageViewProps> = (props) => {
36+
const styles = useStyles()
37+
return (
38+
<Stack spacing={4}>
39+
<Margins>
40+
<div className={styles.actions}>
41+
{props.canCreateTemplate && <Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>}
42+
</div>
43+
<Table>
44+
<TableHead>
45+
<TableRow>
46+
<TableCell>Name</TableCell>
47+
<TableCell>Used By</TableCell>
48+
<TableCell>Last Updated</TableCell>
49+
</TableRow>
50+
</TableHead>
51+
<TableBody>
52+
{!props.loading && !props.templates?.length && (
53+
<TableRow>
54+
<TableCell colSpan={999}>
55+
<div className={styles.welcome}>
56+
{props.canCreateTemplate ? (
57+
<span>
58+
<Link component={RouterLink} to="/templates/new">
59+
Create a template
60+
</Link>
61+
&nbsp;{Language.emptyViewCreate}
62+
</span>
63+
) : (
64+
<span>{Language.emptyViewNoPerms}</span>
65+
)}
66+
</div>
67+
</TableCell>
68+
</TableRow>
69+
)}
70+
{props.templates?.map((template) => {
71+
return (
72+
<TableRow key={template.id} className={styles.templateRow}>
73+
<TableCell>
74+
<div className={styles.templateName}>
75+
<Avatar variant="square" className={styles.templateAvatar}>
76+
{firstLetter(template.name)}
77+
</Avatar>
78+
<Link component={RouterLink} to={`/templates/${template.id}`} className={styles.templateLink}>
79+
<b>{template.name}</b>
80+
<span>{template.description}</span>
81+
</Link>
82+
</div>
83+
</TableCell>
84+
<TableCell>
85+
{template.workspace_owner_count} developer{template.workspace_owner_count !== 1 && "s"}
86+
</TableCell>
87+
<TableCell>{dayjs().to(dayjs(template.updated_at))}</TableCell>
88+
</TableRow>
89+
)
90+
})}
91+
</TableBody>
92+
</Table>
93+
</Margins>
94+
</Stack>
95+
)
96+
}
97+
98+
const useStyles = makeStyles((theme) => ({
99+
actions: {
100+
marginTop: theme.spacing(3),
101+
marginBottom: theme.spacing(3),
102+
display: "flex",
103+
height: theme.spacing(6),
104+
105+
"& button": {
106+
marginLeft: "auto",
107+
},
108+
},
109+
welcome: {
110+
paddingTop: theme.spacing(12),
111+
paddingBottom: theme.spacing(12),
112+
display: "flex",
113+
flexDirection: "column",
114+
alignItems: "center",
115+
justifyContent: "center",
116+
"& span": {
117+
maxWidth: 600,
118+
textAlign: "center",
119+
fontSize: theme.spacing(2),
120+
lineHeight: `${theme.spacing(3)}px`,
121+
},
122+
},
123+
templateRow: {
124+
"& > td": {
125+
paddingTop: theme.spacing(2),
126+
paddingBottom: theme.spacing(2),
127+
},
128+
},
129+
templateAvatar: {
130+
borderRadius: 2,
131+
marginRight: theme.spacing(1),
132+
width: 24,
133+
height: 24,
134+
fontSize: 16,
135+
},
136+
templateName: {
137+
display: "flex",
138+
alignItems: "center",
139+
},
140+
templateLink: {
141+
display: "flex",
142+
flexDirection: "column",
143+
color: theme.palette.text.primary,
144+
textDecoration: "none",
145+
"&:hover": {
146+
textDecoration: "underline",
147+
},
148+
"& span": {
149+
fontSize: 12,
150+
color: theme.palette.text.secondary,
151+
},
152+
},
153+
}))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { screen } from "@testing-library/react"
2+
import { rest } from "msw"
3+
import React from "react"
4+
import { MockTemplate } from "../../testHelpers/entities"
5+
import { history, render } from "../../testHelpers/renderHelpers"
6+
import { server } from "../../testHelpers/server"
7+
import TemplatesPage from "./TemplatesPage"
8+
import { Language } from "./TemplatesPageView"
9+
10+
describe("TemplatesPage", () => {
11+
beforeEach(() => {
12+
history.replace("/workspaces")
13+
})
14+
15+
it("renders an empty templates page", async () => {
16+
// Given
17+
server.use(
18+
rest.get("/api/v2/organizations/:organizationId/templates", (req, res, ctx) => {
19+
return res(ctx.status(200), ctx.json([]))
20+
}),
21+
rest.post("/api/v2/users/:userId/authorization", async (req, res, ctx) => {
22+
return res(
23+
ctx.status(200),
24+
ctx.json({
25+
createTemplates: true,
26+
}),
27+
)
28+
}),
29+
)
30+
31+
// When
32+
render(<TemplatesPage />)
33+
34+
// Then
35+
await screen.findByText(Language.emptyViewCreate)
36+
})
37+
38+
it("renders a filled templates page", async () => {
39+
// When
40+
render(<TemplatesPage />)
41+
42+
// Then
43+
await screen.findByText(MockTemplate.name)
44+
})
45+
46+
it("shows empty view without permissions to create", async () => {
47+
server.use(
48+
rest.get("/api/v2/organizations/:organizationId/templates", (req, res, ctx) => {
49+
return res(ctx.status(200), ctx.json([]))
50+
}),
51+
rest.post("/api/v2/users/:userId/authorization", async (req, res, ctx) => {
52+
return res(
53+
ctx.status(200),
54+
ctx.json({
55+
createTemplates: false,
56+
}),
57+
)
58+
}),
59+
)
60+
61+
// When
62+
render(<TemplatesPage />)
63+
64+
// Then
65+
await screen.findByText(Language.emptyViewNoPerms)
66+
})
67+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useActor, useMachine } from "@xstate/react"
2+
import React, { useContext } from "react"
3+
import { XServiceContext } from "../../xServices/StateContext"
4+
import { templatesMachine } from "../../xServices/templates/templatesXService"
5+
import { TemplatesPageView } from "./TemplatesPageView"
6+
7+
const TemplatesPage: React.FC = () => {
8+
const xServices = useContext(XServiceContext)
9+
const [authState] = useActor(xServices.authXService)
10+
const [templatesState] = useMachine(templatesMachine)
11+
12+
return (
13+
<TemplatesPageView
14+
templates={templatesState.context.templates}
15+
canCreateTemplate={authState.context.permissions?.createTemplates}
16+
loading={templatesState.hasTag("loading")}
17+
/>
18+
)
19+
}
20+
21+
export default TemplatesPage
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { ComponentMeta, Story } from "@storybook/react"
2+
import React from "react"
3+
import { MockTemplate } from "../../testHelpers/entities"
4+
import { TemplatesPageView, TemplatesPageViewProps } from "./TemplatesPageView"
5+
6+
export default {
7+
title: "pages/TemplatesPageView",
8+
component: TemplatesPageView,
9+
} as ComponentMeta<typeof TemplatesPageView>
10+
11+
const Template: Story<TemplatesPageViewProps> = (args) => <TemplatesPageView {...args} />
12+
13+
export const AllStates = Template.bind({})
14+
AllStates.args = {
15+
canCreateTemplate: true,
16+
templates: [
17+
MockTemplate,
18+
{
19+
...MockTemplate,
20+
description: "🚀 Some magical template that does some magical things!",
21+
},
22+
{
23+
...MockTemplate,
24+
workspace_owner_count: 150,
25+
description: "😮 Wow, this one has a bunch of usage!",
26+
},
27+
],
28+
}
29+
30+
export const EmptyCanCreate = Template.bind({})
31+
EmptyCanCreate.args = {
32+
canCreateTemplate: true,
33+
}
34+
35+
export const EmptyCannotCreate = Template.bind({})
36+
EmptyCannotCreate.args = {}

0 commit comments

Comments
 (0)