Skip to content

Commit 104d07f

Browse files
feat: Add the template page (coder#1754)
1 parent 7c59ec4 commit 104d07f

19 files changed

+1082
-23
lines changed

site/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"history": "5.3.0",
4242
"react": "17.0.2",
4343
"react-dom": "17.0.2",
44+
"react-markdown": "8.0.3",
4445
"react-router-dom": "6.3.0",
4546
"sourcemapped-stacktrace": "1.1.11",
4647
"swr": "1.2.2",

site/src/AppRouter.tsx

+10
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 { TemplatePage } from "./pages/TemplatePage/TemplatePage"
1516
import TemplatesPage from "./pages/TemplatesPage/TemplatesPage"
1617
import { CreateUserPage } from "./pages/UsersPage/CreateUserPage/CreateUserPage"
1718
import { UsersPage } from "./pages/UsersPage/UsersPage"
@@ -104,6 +105,15 @@ export const AppRouter: React.FC = () => (
104105
</AuthAndFrame>
105106
}
106107
/>
108+
109+
<Route
110+
path=":template"
111+
element={
112+
<AuthAndFrame>
113+
<TemplatePage />
114+
</AuthAndFrame>
115+
}
116+
/>
107117
</Route>
108118

109119
<Route path="users">

site/src/__mocks__/react-markdown.tsx

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import React from "react"
2+
3+
const ReactMarkdown: React.FC = ({ children }) => {
4+
return <div data-testid="markdown">{children}</div>
5+
}
6+
7+
export default ReactMarkdown

site/src/api/api.ts

+5
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ export const getTemplateVersionSchema = async (versionId: string): Promise<Types
102102
return response.data
103103
}
104104

105+
export const getTemplateVersionResources = async (versionId: string): Promise<TypesGen.WorkspaceResource[]> => {
106+
const response = await axios.get<TypesGen.WorkspaceResource[]>(`/api/v2/templateversions/${versionId}/resources`)
107+
return response.data
108+
}
109+
105110
export const getWorkspace = async (workspaceId: string): Promise<TypesGen.Workspace> => {
106111
const response = await axios.get<TypesGen.Workspace>(`/api/v2/workspaces/${workspaceId}`)
107112
return response.data

site/src/components/Resources/Resources.tsx

+10-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ export const Resources: React.FC<ResourcesProps> = ({ resources, getResourcesErr
7979
)}
8080

8181
<TableCell className={styles.agentColumn}>
82-
<span style={{ color: theme.palette.text.secondary }}>{agent.name}</span>
82+
{agent.name}
83+
<span className={styles.operatingSystem}>{agent.operating_system}</span>
8384
</TableCell>
8485
<TableCell>
8586
<span style={{ color: getDisplayAgentStatus(theme, agent).color }}>
@@ -143,4 +144,12 @@ const useStyles = makeStyles((theme) => ({
143144
marginRight: theme.spacing(1.5),
144145
},
145146
},
147+
148+
operatingSystem: {
149+
fontSize: 14,
150+
color: theme.palette.text.secondary,
151+
marginTop: theme.spacing(0.5),
152+
display: "block",
153+
textTransform: "capitalize",
154+
},
146155
}))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { makeStyles } from "@material-ui/core/styles"
2+
import Table from "@material-ui/core/Table"
3+
import TableBody from "@material-ui/core/TableBody"
4+
import TableCell from "@material-ui/core/TableCell"
5+
import TableHead from "@material-ui/core/TableHead"
6+
import TableRow from "@material-ui/core/TableRow"
7+
import React from "react"
8+
import { WorkspaceResource } from "../../api/typesGenerated"
9+
import { TableHeaderRow } from "../TableHeaders/TableHeaders"
10+
11+
const Language = {
12+
resourceLabel: "Resource",
13+
agentLabel: "Agent",
14+
}
15+
16+
interface TemplateResourcesProps {
17+
resources: WorkspaceResource[]
18+
}
19+
20+
export const TemplateResourcesTable: React.FC<TemplateResourcesProps> = ({ resources }) => {
21+
const styles = useStyles()
22+
23+
return (
24+
<Table className={styles.table}>
25+
<TableHead>
26+
<TableHeaderRow>
27+
<TableCell>{Language.resourceLabel}</TableCell>
28+
<TableCell className={styles.agentColumn}>{Language.agentLabel}</TableCell>
29+
</TableHeaderRow>
30+
</TableHead>
31+
<TableBody>
32+
{resources.map((resource) => {
33+
// We need to initialize the agents to display the resource
34+
const agents = resource.agents ?? [null]
35+
return agents.map((agent, agentIndex) => {
36+
// If there is no agent, just display the resource name
37+
if (!agent) {
38+
return (
39+
<TableRow>
40+
<TableCell className={styles.resourceNameCell}>
41+
{resource.name}
42+
<span className={styles.resourceType}>{resource.type}</span>
43+
</TableCell>
44+
<TableCell colSpan={3}></TableCell>
45+
</TableRow>
46+
)
47+
}
48+
49+
return (
50+
<TableRow key={`${resource.id}-${agent.id}`}>
51+
{/* We only want to display the name in the first row because we are using rowSpan */}
52+
{/* The rowspan should be the same than the number of agents */}
53+
{agentIndex === 0 && (
54+
<TableCell className={styles.resourceNameCell} rowSpan={agents.length}>
55+
{resource.name}
56+
<span className={styles.resourceType}>{resource.type}</span>
57+
</TableCell>
58+
)}
59+
60+
<TableCell className={styles.agentColumn}>
61+
{agent.name}
62+
<span className={styles.operatingSystem}>{agent.operating_system}</span>
63+
</TableCell>
64+
</TableRow>
65+
)
66+
})
67+
})}
68+
</TableBody>
69+
</Table>
70+
)
71+
}
72+
73+
const useStyles = makeStyles((theme) => ({
74+
sectionContents: {
75+
margin: 0,
76+
},
77+
78+
table: {
79+
border: 0,
80+
},
81+
82+
resourceNameCell: {
83+
borderRight: `1px solid ${theme.palette.divider}`,
84+
},
85+
86+
resourceType: {
87+
fontSize: 14,
88+
color: theme.palette.text.secondary,
89+
marginTop: theme.spacing(0.5),
90+
display: "block",
91+
},
92+
93+
// Adds some left spacing
94+
agentColumn: {
95+
paddingLeft: `${theme.spacing(2)}px !important`,
96+
},
97+
98+
operatingSystem: {
99+
fontSize: 14,
100+
color: theme.palette.text.secondary,
101+
marginTop: theme.spacing(0.5),
102+
display: "block",
103+
textTransform: "capitalize",
104+
},
105+
}))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Story } from "@storybook/react"
2+
import React from "react"
3+
import * as Mocks from "../../testHelpers/renderHelpers"
4+
import { TemplateStats, TemplateStatsProps } from "../TemplateStats/TemplateStats"
5+
6+
export default {
7+
title: "components/TemplateStats",
8+
component: TemplateStats,
9+
}
10+
11+
const Template: Story<TemplateStatsProps> = (args) => <TemplateStats {...args} />
12+
13+
export const Example = Template.bind({})
14+
Example.args = {
15+
template: Mocks.MockTemplate,
16+
activeVersion: Mocks.MockTemplateVersion,
17+
}
18+
19+
export const UsedByMany = Template.bind({})
20+
UsedByMany.args = {
21+
template: {
22+
...Mocks.MockTemplate,
23+
workspace_owner_count: 15,
24+
},
25+
activeVersion: Mocks.MockTemplateVersion,
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { makeStyles } from "@material-ui/core/styles"
2+
import dayjs from "dayjs"
3+
import relativeTime from "dayjs/plugin/relativeTime"
4+
import React from "react"
5+
import { Template, TemplateVersion } from "../../api/typesGenerated"
6+
import { CardRadius, MONOSPACE_FONT_FAMILY } from "../../theme/constants"
7+
8+
dayjs.extend(relativeTime)
9+
10+
const Language = {
11+
usedByLabel: "Used by",
12+
activeVersionLabel: "Active version",
13+
lastUpdateLabel: "Last updated",
14+
userPlural: "users",
15+
userSingular: "user",
16+
}
17+
18+
export interface TemplateStatsProps {
19+
template: Template
20+
activeVersion: TemplateVersion
21+
}
22+
23+
export const TemplateStats: React.FC<TemplateStatsProps> = ({ template, activeVersion }) => {
24+
const styles = useStyles()
25+
26+
return (
27+
<div className={styles.stats}>
28+
<div className={styles.statItem}>
29+
<span className={styles.statsLabel}>{Language.usedByLabel}</span>
30+
31+
<span className={styles.statsValue}>
32+
{template.workspace_owner_count}{" "}
33+
{template.workspace_owner_count === 1 ? Language.userSingular : Language.userPlural}
34+
</span>
35+
</div>
36+
<div className={styles.statsDivider} />
37+
<div className={styles.statItem}>
38+
<span className={styles.statsLabel}>{Language.activeVersionLabel}</span>
39+
<span className={styles.statsValue}>{activeVersion.name}</span>
40+
</div>
41+
<div className={styles.statsDivider} />
42+
<div className={styles.statItem}>
43+
<span className={styles.statsLabel}>{Language.lastUpdateLabel}</span>
44+
<span className={styles.statsValue} data-chromatic="ignore">
45+
{dayjs().to(dayjs(template.updated_at))}
46+
</span>
47+
</div>
48+
</div>
49+
)
50+
}
51+
52+
const useStyles = makeStyles((theme) => ({
53+
stats: {
54+
paddingLeft: theme.spacing(2),
55+
paddingRight: theme.spacing(2),
56+
backgroundColor: theme.palette.background.paper,
57+
borderRadius: CardRadius,
58+
display: "flex",
59+
alignItems: "center",
60+
color: theme.palette.text.secondary,
61+
fontFamily: MONOSPACE_FONT_FAMILY,
62+
border: `1px solid ${theme.palette.divider}`,
63+
},
64+
65+
statItem: {
66+
minWidth: theme.spacing(20),
67+
padding: theme.spacing(2),
68+
paddingTop: theme.spacing(1.75),
69+
},
70+
71+
statsLabel: {
72+
fontSize: 12,
73+
textTransform: "uppercase",
74+
display: "block",
75+
fontWeight: 600,
76+
},
77+
78+
statsValue: {
79+
fontSize: 16,
80+
marginTop: theme.spacing(0.25),
81+
display: "inline-block",
82+
},
83+
84+
statsDivider: {
85+
width: 1,
86+
height: theme.spacing(5),
87+
backgroundColor: theme.palette.divider,
88+
marginRight: theme.spacing(2),
89+
},
90+
}))

site/src/hooks/useOrganizationId.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useSelector } from "@xstate/react"
2+
import { useContext } from "react"
3+
import { selectOrgId } from "../xServices/auth/authSelectors"
4+
import { XServiceContext } from "../xServices/StateContext"
5+
6+
export const useOrganizationId = (): string => {
7+
const xServices = useContext(XServiceContext)
8+
const organizationId = useSelector(xServices.authXService, selectOrgId)
9+
10+
if (!organizationId) {
11+
throw new Error("No organization ID found")
12+
}
13+
14+
return organizationId
15+
}

site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx

+3-15
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,11 @@
1-
import { useActor, useMachine } from "@xstate/react"
2-
import React, { useContext } from "react"
1+
import { useMachine } from "@xstate/react"
2+
import React from "react"
33
import { useNavigate, useSearchParams } from "react-router-dom"
44
import { Template } from "../../api/typesGenerated"
5+
import { useOrganizationId } from "../../hooks/useOrganizationId"
56
import { createWorkspaceMachine } from "../../xServices/createWorkspace/createWorkspaceXService"
6-
import { XServiceContext } from "../../xServices/StateContext"
77
import { CreateWorkspacePageView } from "./CreateWorkspacePageView"
88

9-
const useOrganizationId = () => {
10-
const xServices = useContext(XServiceContext)
11-
const [authState] = useActor(xServices.authXService)
12-
const organizationId = authState.context.me?.organization_ids[0]
13-
14-
if (!organizationId) {
15-
throw new Error("No organization ID found")
16-
}
17-
18-
return organizationId
19-
}
20-
219
const CreateWorkspacePage: React.FC = () => {
2210
const organizationId = useOrganizationId()
2311
const [searchParams] = useSearchParams()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { screen } from "@testing-library/react"
2+
import React from "react"
3+
import { MockTemplate, MockWorkspaceResource, renderWithAuth } from "../../testHelpers/renderHelpers"
4+
import { TemplatePage } from "./TemplatePage"
5+
6+
describe("TemplatePage", () => {
7+
it("shows the template name, readme and resources", async () => {
8+
renderWithAuth(<TemplatePage />, { route: `/templates/${MockTemplate.id}`, path: "/templates/:template" })
9+
await screen.findByText(MockTemplate.name)
10+
screen.getByTestId("markdown")
11+
screen.getByText(MockWorkspaceResource.name)
12+
})
13+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { useMachine } from "@xstate/react"
2+
import React from "react"
3+
import { useParams } from "react-router-dom"
4+
import { Loader } from "../../components/Loader/Loader"
5+
import { useOrganizationId } from "../../hooks/useOrganizationId"
6+
import { templateMachine } from "../../xServices/template/templateXService"
7+
import { TemplatePageView } from "./TemplatePageView"
8+
9+
const useTemplateName = () => {
10+
const { template } = useParams()
11+
12+
if (!template) {
13+
throw new Error("No template found in the URL")
14+
}
15+
16+
return template
17+
}
18+
19+
export const TemplatePage: React.FC = () => {
20+
const organizationId = useOrganizationId()
21+
const templateName = useTemplateName()
22+
const [templateState] = useMachine(templateMachine, {
23+
context: {
24+
templateName,
25+
organizationId,
26+
},
27+
})
28+
const { template, activeTemplateVersion, templateResources } = templateState.context
29+
const isLoading = !template || !activeTemplateVersion || !templateResources
30+
31+
if (isLoading) {
32+
return <Loader />
33+
}
34+
35+
return (
36+
<TemplatePageView
37+
template={template}
38+
activeTemplateVersion={activeTemplateVersion}
39+
templateResources={templateResources}
40+
/>
41+
)
42+
}

0 commit comments

Comments
 (0)