From a72ba4072796b637ac8869641d8bb4c60f45ca6a Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Mon, 16 May 2022 22:48:09 +0000 Subject: [PATCH 01/10] feat: Add template page --- site/src/pages/TemplatesPage/TemplatesPage.tsx | 10 ++++++++++ site/src/pages/TemplatesPage/TemplatesPageView.tsx | 5 +++++ 2 files changed, 15 insertions(+) create mode 100644 site/src/pages/TemplatesPage/TemplatesPage.tsx create mode 100644 site/src/pages/TemplatesPage/TemplatesPageView.tsx diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx new file mode 100644 index 0000000000000..e1ec51fe534d1 --- /dev/null +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -0,0 +1,10 @@ +import React from "react" +import { TemplatesPageView } from "./TemplatesPageView" + +const TemplatesPage: React.FC = () => { + return ( + + ) +} + +export default TemplatesPage diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx new file mode 100644 index 0000000000000..a0ce0b51b01f8 --- /dev/null +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -0,0 +1,5 @@ +import React from "react" + +export const TemplatesPageView: React.FC = () => { + return
testing
+} From c2699a8a0a3e282690bdc052743a07739bb88d87 Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Tue, 17 May 2022 03:56:23 +0000 Subject: [PATCH 02/10] Create xService --- site/src/AppRouter.tsx | 12 ++ site/src/api/api.ts | 5 + site/src/components/NavbarView/NavbarView.tsx | 5 + .../src/pages/TemplatesPage/TemplatesPage.tsx | 8 +- .../pages/TemplatesPage/TemplatesPageView.tsx | 151 +++++++++++++++++- .../xServices/templates/templatesXService.ts | 99 ++++++++++++ site/static/terraform-logo.svg | 1 + site/webpack.dev.ts | 2 +- 8 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 site/src/xServices/templates/templatesXService.ts create mode 100644 site/static/terraform-logo.svg 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/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx index e1ec51fe534d1..0e9c7df77f59c 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -1,10 +1,12 @@ +import { useMachine } from "@xstate/react" import React from "react" +import { templatesMachine } from "../../xServices/templates/templatesXService" import { TemplatesPageView } from "./TemplatesPageView" const TemplatesPage: React.FC = () => { - return ( - - ) + const [templatesState] = useMachine(templatesMachine) + + return } export default TemplatesPage diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index a0ce0b51b01f8..a6a97d7178a45 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -1,5 +1,152 @@ +import Avatar from "@material-ui/core/Avatar" +import Button from "@material-ui/core/Button" +import Link from "@material-ui/core/Link" +import { makeStyles, Theme } 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 useTheme from "@material-ui/styles/useTheme" +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" -export const TemplatesPageView: React.FC = () => { - return
testing
+dayjs.extend(relativeTime) + +export const Language = { + createButton: "Create Template", + emptyView: "so you can check out your repositories, edit your source code, and build and test your software.", +} + +export interface TemplatesPageViewProps { + loading?: boolean + templates?: TypesGen.Template[] + error?: unknown } + +export const TemplatesPageView: React.FC = (props) => { + const styles = useStyles() + const theme: Theme = useTheme() + return ( + + +
+ +
+ + + + Name + Description + Last Updated + Provisioner + Developers + + + + {!props.loading && !props.templates?.length && ( + + +
+ + + Create a template + +  {Language.emptyView} + +
+
+
+ )} + {props.templates?.map((template) => { + return ( + + +
+ + {firstLetter(template.name)} + + + {template.name} + +
+
+ {template.description} + {dayjs().to(dayjs(template.updated_at))} + + Terraform + + {template.workspace_owner_count} +
+ ) + })} +
+
+
+
+ ) +} + +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/xServices/templates/templatesXService.ts b/site/src/xServices/templates/templatesXService.ts new file mode 100644 index 0000000000000..42528828a76bb --- /dev/null +++ b/site/src/xServices/templates/templatesXService.ts @@ -0,0 +1,99 @@ +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[] + 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[] + } + 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) + }, + }, + }, +) diff --git a/site/static/terraform-logo.svg b/site/static/terraform-logo.svg new file mode 100644 index 0000000000000..fdf70cf341fe3 --- /dev/null +++ b/site/static/terraform-logo.svg @@ -0,0 +1 @@ + diff --git a/site/webpack.dev.ts b/site/webpack.dev.ts index 8a981e2330444..52a19dc61e1ac 100644 --- a/site/webpack.dev.ts +++ b/site/webpack.dev.ts @@ -61,7 +61,7 @@ const config: Configuration = { port: process.env.PORT || 8080, proxy: { "/api": { - target: "http://localhost:3000", + target: "https://dev.coder.com", ws: true, secure: false, }, From eb6644fc4913f563572cf4210b283d8b9784ebab Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Tue, 17 May 2022 04:03:34 +0000 Subject: [PATCH 03/10] Update column names --- site/src/pages/TemplatesPage/TemplatesPageView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index a6a97d7178a45..9abacaceb384e 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -44,10 +44,10 @@ export const TemplatesPageView: React.FC = (props) => { Name - Description + Resources Last Updated Provisioner - Developers + Used By @@ -83,7 +83,7 @@ export const TemplatesPageView: React.FC = (props) => { Terraform - {template.workspace_owner_count} + {template.workspace_owner_count} developer{template.workspace_owner_count !== 1 && "s"} ) })} From 3d70c676d69170be576b94317d205ac2f9a2a7f5 Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Tue, 17 May 2022 04:35:03 +0000 Subject: [PATCH 04/10] Show create template conditionally --- .../src/pages/TemplatesPage/TemplatesPage.tsx | 8 ++- .../pages/TemplatesPage/TemplatesPageView.tsx | 13 +++-- .../xServices/templates/templatesXService.ts | 51 ++++++++++++++++++- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx index 0e9c7df77f59c..a9013082864ce 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -6,7 +6,13 @@ import { TemplatesPageView } from "./TemplatesPageView" const TemplatesPage: React.FC = () => { const [templatesState] = useMachine(templatesMachine) - return + return ( + + ) } export default TemplatesPage diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 9abacaceb384e..8fba1c0186cab 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -16,6 +16,7 @@ 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 { combineClasses } from "../../util/combineClasses" import { firstLetter } from "../../util/firstLetter" dayjs.extend(relativeTime) @@ -27,6 +28,7 @@ export const Language = { export interface TemplatesPageViewProps { loading?: boolean + canCreateTemplate?: boolean templates?: TypesGen.Template[] error?: unknown } @@ -38,16 +40,16 @@ export const TemplatesPageView: React.FC = (props) => {
+ {props.canCreateTemplate && ( + )}
Name - Resources - Last Updated - Provisioner Used By + Last Updated @@ -76,13 +78,10 @@ export const TemplatesPageView: React.FC = (props) => { {template.name} + {template.description} - {template.description} {dayjs().to(dayjs(template.updated_at))} - - Terraform - {template.workspace_owner_count} developer{template.workspace_owner_count !== 1 && "s"} ) diff --git a/site/src/xServices/templates/templatesXService.ts b/site/src/xServices/templates/templatesXService.ts index 42528828a76bb..920f724f2aa1f 100644 --- a/site/src/xServices/templates/templatesXService.ts +++ b/site/src/xServices/templates/templatesXService.ts @@ -5,6 +5,8 @@ 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 } @@ -18,6 +20,9 @@ export const templatesMachine = createMachine( getOrganizations: { data: TypesGen.Organization[] } + getPermissions: { + data: boolean + } getTemplates: { data: TypesGen.Template[] } @@ -34,7 +39,7 @@ export const templatesMachine = createMachine( onDone: [ { actions: ["assignOrganizations", "clearOrganizationsError"], - target: "gettingTemplates", + target: "gettingPermissions", }, ], onError: [ @@ -46,6 +51,26 @@ export const templatesMachine = createMachine( }, tags: "loading", }, + gettingPermissions: { + entry: "clearPermissionsError", + invoke: { + src: "getPermissions", + id: "getPermissions", + onDone: [ + { + target: "gettingTemplates", + actions: ["assignPermissions", "clearPermissionsError"], + }, + ], + onError: [ + { + actions: "assignPermissionsError", + target: "error", + }, + ], + }, + tags: "loading", + }, gettingTemplates: { entry: "clearTemplatesError", invoke: { @@ -78,6 +103,16 @@ export const templatesMachine = createMachine( ...context, organizationsError: undefined, })), + assignPermissions: assign({ + canCreateTemplate: (_, event) => event.data, + }), + assignPermissionsError: assign({ + permissionsError: (_, event) => event.data, + }), + clearPermissionsError: assign((context) => ({ + ...context, + permissionsError: undefined, + })), assignTemplates: assign({ templates: (_, event) => event.data, }), @@ -88,6 +123,20 @@ export const templatesMachine = createMachine( }, services: { getOrganizations: API.getOrganizations, + getPermissions: async () => { + const permName = "createTemplates" + const resp = await API.checkUserPermissions("me", { + checks: { + [permName]: { + action: "write", + object: { + resource_type: "template", + }, + }, + }, + }) + return resp[permName] + }, getTemplates: async (context) => { if (!context.organizations || context.organizations.length === 0) { throw new Error("no organizations") From c63c5632ad190d9bdf90bf17eac564e3c7548214 Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Tue, 17 May 2022 04:46:40 +0000 Subject: [PATCH 05/10] Add template description --- site/src/pages/TemplatesPage/TemplatesPageView.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 8fba1c0186cab..7f7549a1104a1 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -77,12 +77,14 @@ export const TemplatesPageView: React.FC = (props) => { {template.name} + + {template.description} + - {template.description} - {dayjs().to(dayjs(template.updated_at))} {template.workspace_owner_count} developer{template.workspace_owner_count !== 1 && "s"} + {dayjs().to(dayjs(template.updated_at))} ) })} From 08069d39c6730c5a43df4f1269ce216ebfd58e90 Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Tue, 17 May 2022 04:57:53 +0000 Subject: [PATCH 06/10] Route to templates --- site/src/pages/WorkspacesPage/WorkspacesPageView.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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", }, }, From 584d3a34c848de6bd17ff717e75f6444788b168d Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Tue, 17 May 2022 05:06:40 +0000 Subject: [PATCH 07/10] Add empty states --- .../TemplatesPageView.stories.tsx | 36 +++++++++++++++++++ .../pages/TemplatesPage/TemplatesPageView.tsx | 35 +++++++++--------- site/src/testHelpers/entities.ts | 4 +-- 3 files changed, 55 insertions(+), 20 deletions(-) create mode 100644 site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx new file mode 100644 index 0000000000000..e0daa5103d5d3 --- /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 index 7f7549a1104a1..46e844333eea8 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -1,14 +1,13 @@ import Avatar from "@material-ui/core/Avatar" import Button from "@material-ui/core/Button" import Link from "@material-ui/core/Link" -import { makeStyles, Theme } from "@material-ui/core/styles" +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 useTheme from "@material-ui/styles/useTheme" import dayjs from "dayjs" import relativeTime from "dayjs/plugin/relativeTime" import React from "react" @@ -16,14 +15,13 @@ 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 { combineClasses } from "../../util/combineClasses" import { firstLetter } from "../../util/firstLetter" dayjs.extend(relativeTime) export const Language = { createButton: "Create Template", - emptyView: "so you can check out your repositories, edit your source code, and build and test your software.", + emptyView: "to standardize development workspaces for your team.", } export interface TemplatesPageViewProps { @@ -35,14 +33,11 @@ export interface TemplatesPageViewProps { export const TemplatesPageView: React.FC = (props) => { const styles = useStyles() - const theme: Theme = useTheme() return (
- {props.canCreateTemplate && ( - - )} + {props.canCreateTemplate && }
@@ -57,12 +52,16 @@ export const TemplatesPageView: React.FC = (props) => {
- - - Create a template - -  {Language.emptyView} - + {props.canCreateTemplate ? ( + + + Create a template + +  {Language.emptyView} + + ) : ( + No templates have been created! Contact your Coder administrator. + )}
@@ -77,13 +76,13 @@ export const TemplatesPageView: React.FC = (props) => { {template.name} - - {template.description} - + {template.description} - {template.workspace_owner_count} developer{template.workspace_owner_count !== 1 && "s"} + + {template.workspace_owner_count} developer{template.workspace_owner_count !== 1 && "s"} + {dayjs().to(dayjs(template.updated_at))} ) 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, From 09e8a27623d0c726d39ef1b9f0bdd1d816703bfe Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Tue, 17 May 2022 14:22:42 +0000 Subject: [PATCH 08/10] Add tests --- .../pages/TemplatePage/TemplatePageView.tsx | 153 ++++++++++++++++++ .../TemplatesPage/TemplatesPage.test.tsx | 67 ++++++++ .../TemplatesPageView.stories.tsx | 20 +-- .../pages/TemplatesPage/TemplatesPageView.tsx | 7 +- site/src/testHelpers/handlers.ts | 3 + site/static/terraform-logo.svg | 1 - site/webpack.dev.ts | 2 +- 7 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 site/src/pages/TemplatePage/TemplatePageView.tsx create mode 100644 site/src/pages/TemplatesPage/TemplatesPage.test.tsx delete mode 100644 site/static/terraform-logo.svg 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/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx index e0daa5103d5d3..91fdba645d725 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx @@ -14,16 +14,16 @@ 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!" - } + 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!", + }, ], } diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 46e844333eea8..03e18499f12c9 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -21,7 +21,8 @@ dayjs.extend(relativeTime) export const Language = { createButton: "Create Template", - emptyView: "to standardize development workspaces for your team.", + emptyViewCreate: "to standardize development workspaces for your team.", + emptyViewNoPerms: "No templates have been created! Contact your Coder administrator.", } export interface TemplatesPageViewProps { @@ -57,10 +58,10 @@ export const TemplatesPageView: React.FC = (props) => { Create a template -  {Language.emptyView} +  {Language.emptyViewCreate} ) : ( - No templates have been created! Contact your Coder administrator. + {Language.emptyViewNoPerms} )}
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/static/terraform-logo.svg b/site/static/terraform-logo.svg deleted file mode 100644 index fdf70cf341fe3..0000000000000 --- a/site/static/terraform-logo.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/site/webpack.dev.ts b/site/webpack.dev.ts index 52a19dc61e1ac..8a981e2330444 100644 --- a/site/webpack.dev.ts +++ b/site/webpack.dev.ts @@ -61,7 +61,7 @@ const config: Configuration = { port: process.env.PORT || 8080, proxy: { "/api": { - target: "https://dev.coder.com", + target: "http://localhost:3000", ws: true, secure: false, }, From a005711dd9b08c34c9a4db961a7a443196cbcb66 Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Tue, 17 May 2022 15:32:34 +0000 Subject: [PATCH 09/10] Add loading indicator --- .../pages/TemplatesPage/TemplatesPageView.tsx | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 03e18499f12c9..bdbf51e641802 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -1,4 +1,5 @@ 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" @@ -15,12 +16,17 @@ 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" : ""}` + }, + emptyViewCreateCTA: "Create a template", emptyViewCreate: "to standardize development workspaces for your team.", emptyViewNoPerms: "No templates have been created! Contact your Coder administrator.", } @@ -29,7 +35,6 @@ export interface TemplatesPageViewProps { loading?: boolean canCreateTemplate?: boolean templates?: TypesGen.Template[] - error?: unknown } export const TemplatesPageView: React.FC = (props) => { @@ -49,6 +54,7 @@ export const TemplatesPageView: React.FC = (props) => { + {props.loading && } {!props.loading && !props.templates?.length && ( @@ -56,7 +62,7 @@ export const TemplatesPageView: React.FC = (props) => { {props.canCreateTemplate ? ( - Create a template + {Language.emptyViewCreateCTA}  {Language.emptyViewCreate} @@ -67,27 +73,25 @@ export const TemplatesPageView: React.FC = (props) => { )} - {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))} -
- ) - })} + {props.templates?.map((template) => ( + + + + + {firstLetter(template.name)} + + + {template.name} + {template.description} + + + + + {Language.developerCount(template.workspace_owner_count)} + + {dayjs().to(dayjs(template.updated_at))} + + ))}
@@ -133,10 +137,6 @@ const useStyles = makeStyles((theme) => ({ height: 24, fontSize: 16, }, - templateName: { - display: "flex", - alignItems: "center", - }, templateLink: { display: "flex", flexDirection: "column", From 8eaf97d3c51451f61ffa01a2c6854b94cce5cf5c Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Wed, 18 May 2022 13:34:40 +0000 Subject: [PATCH 10/10] Requested changes --- .../src/pages/TemplatesPage/TemplatesPage.tsx | 9 ++-- .../pages/TemplatesPage/TemplatesPageView.tsx | 9 ++-- site/src/xServices/auth/authXService.ts | 7 +++ .../xServices/templates/templatesXService.ts | 46 +------------------ 4 files changed, 20 insertions(+), 51 deletions(-) diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx index a9013082864ce..545634172ca22 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -1,15 +1,18 @@ -import { useMachine } from "@xstate/react" -import React from "react" +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 ( ) diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index bdbf51e641802..1d41b257b6064 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -26,6 +26,9 @@ export const Language = { 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.", @@ -48,9 +51,9 @@ export const TemplatesPageView: React.FC = (props) => { - Name - Used By - Last Updated + {Language.nameLabel} + {Language.usedByLabel} + {Language.lastUpdatedLabel} 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 index 920f724f2aa1f..68e7a847e9fb7 100644 --- a/site/src/xServices/templates/templatesXService.ts +++ b/site/src/xServices/templates/templatesXService.ts @@ -39,32 +39,12 @@ export const templatesMachine = createMachine( onDone: [ { actions: ["assignOrganizations", "clearOrganizationsError"], - target: "gettingPermissions", - }, - ], - onError: [ - { - actions: "assignOrganizationsError", - target: "error", - }, - ], - }, - tags: "loading", - }, - gettingPermissions: { - entry: "clearPermissionsError", - invoke: { - src: "getPermissions", - id: "getPermissions", - onDone: [ - { target: "gettingTemplates", - actions: ["assignPermissions", "clearPermissionsError"], }, ], onError: [ { - actions: "assignPermissionsError", + actions: "assignOrganizationsError", target: "error", }, ], @@ -103,16 +83,6 @@ export const templatesMachine = createMachine( ...context, organizationsError: undefined, })), - assignPermissions: assign({ - canCreateTemplate: (_, event) => event.data, - }), - assignPermissionsError: assign({ - permissionsError: (_, event) => event.data, - }), - clearPermissionsError: assign((context) => ({ - ...context, - permissionsError: undefined, - })), assignTemplates: assign({ templates: (_, event) => event.data, }), @@ -123,20 +93,6 @@ export const templatesMachine = createMachine( }, services: { getOrganizations: API.getOrganizations, - getPermissions: async () => { - const permName = "createTemplates" - const resp = await API.checkUserPermissions("me", { - checks: { - [permName]: { - action: "write", - object: { - resource_type: "template", - }, - }, - }, - }) - return resp[permName] - }, getTemplates: async (context) => { if (!context.organizations || context.organizations.length === 0) { throw new Error("no organizations")