diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index b3b8f59981ae9..f0145fdde8b03 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -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 = () => ( + + + + + } + /> + + => { + const response = await axios.get(`/api/v2/organizations/${organizationId}/templates`) + return response.data +} + export const getWorkspace = async (workspaceId: string): Promise => { const response = await axios.get(`/api/v2/workspaces/${workspaceId}`) return response.data diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index 2938240744b0f..1a471dc8bcb28 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -30,6 +30,11 @@ export const NavbarView: React.FC = ({ user, onSignOut, display Workspaces + + + Templates + +
{displayAdminDropdown && } diff --git a/site/src/pages/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx new file mode 100644 index 0000000000000..03e18499f12c9 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplatePageView.tsx @@ -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 = (props) => { + const styles = useStyles() + return ( + + +
+ {props.canCreateTemplate && } +
+ + + + Name + Used By + Last Updated + + + + {!props.loading && !props.templates?.length && ( + + +
+ {props.canCreateTemplate ? ( + + + Create a template + +  {Language.emptyViewCreate} + + ) : ( + {Language.emptyViewNoPerms} + )} +
+
+
+ )} + {props.templates?.map((template) => { + return ( + + +
+ + {firstLetter(template.name)} + + + {template.name} + {template.description} + +
+
+ + {template.workspace_owner_count} developer{template.workspace_owner_count !== 1 && "s"} + + {dayjs().to(dayjs(template.updated_at))} +
+ ) + })} +
+
+
+
+ ) +} + +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, + }, + }, +})) diff --git a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx new file mode 100644 index 0000000000000..c74c07b0d6fbf --- /dev/null +++ b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx @@ -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() + + // Then + await screen.findByText(Language.emptyViewCreate) + }) + + it("renders a filled templates page", async () => { + // When + render() + + // 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() + + // Then + await screen.findByText(Language.emptyViewNoPerms) + }) +}) diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx new file mode 100644 index 0000000000000..545634172ca22 --- /dev/null +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -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 ( + + ) +} + +export default TemplatesPage diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx new file mode 100644 index 0000000000000..91fdba645d725 --- /dev/null +++ b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx @@ -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 + +const Template: Story = (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 = {} diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx new file mode 100644 index 0000000000000..1d41b257b6064 --- /dev/null +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -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 = (props) => { + const styles = useStyles() + return ( + + +
+ {props.canCreateTemplate && } +
+ + + + {Language.nameLabel} + {Language.usedByLabel} + {Language.lastUpdatedLabel} + + + + {props.loading && } + {!props.loading && !props.templates?.length && ( + + +
+ {props.canCreateTemplate ? ( + + + {Language.emptyViewCreateCTA} + +  {Language.emptyViewCreate} + + ) : ( + {Language.emptyViewNoPerms} + )} +
+
+
+ )} + {props.templates?.map((template) => ( + + + + + {firstLetter(template.name)} + + + {template.name} + {template.description} + + + + + {Language.developerCount(template.workspace_owner_count)} + + {dayjs().to(dayjs(template.updated_at))} + + ))} +
+
+
+
+ ) +} + +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, + }, + }, +})) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 355d709c7a7b0..21dbce27f13bb 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -40,7 +40,9 @@ export const WorkspacesPageView: React.FC = (props) =>
- + + +
@@ -58,7 +60,7 @@ export const WorkspacesPageView: React.FC = (props) =>
- + Create a workspace  {Language.emptyView} @@ -183,7 +185,7 @@ const useStyles = makeStyles((theme) => ({ display: "flex", height: theme.spacing(6), - "& button": { + "& > *": { marginLeft: "auto", }, }, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 76de23d2dacf4..b56c4c4806531 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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, diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 1f65874616dc1..846d7d68bec69 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -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) => { diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index d9e88a3c72f37..6ca72d406ba58 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -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 diff --git a/site/src/xServices/templates/templatesXService.ts b/site/src/xServices/templates/templatesXService.ts new file mode 100644 index 0000000000000..68e7a847e9fb7 --- /dev/null +++ b/site/src/xServices/templates/templatesXService.ts @@ -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) + }, + }, + }, +)