diff --git a/site/src/components/EmptyState/EmptyState.test.tsx b/site/src/components/EmptyState/EmptyState.test.tsx index 02164d90b77ee..0a33e516b7a65 100644 --- a/site/src/components/EmptyState/EmptyState.test.tsx +++ b/site/src/components/EmptyState/EmptyState.test.tsx @@ -21,12 +21,12 @@ describe("EmptyState", () => { await screen.findByText("Friendly greeting") }) - it("renders description component", async () => { + it("renders cta component", async () => { // Given - const description = + const cta = // When - render() + render() // Then await screen.findByText("Hello, world") diff --git a/site/src/components/EmptyState/EmptyState.tsx b/site/src/components/EmptyState/EmptyState.tsx index e3539dee3aa74..0662beb0d6a65 100644 --- a/site/src/components/EmptyState/EmptyState.tsx +++ b/site/src/components/EmptyState/EmptyState.tsx @@ -1,5 +1,4 @@ import Box from "@material-ui/core/Box" -import Button, { ButtonProps } from "@material-ui/core/Button" import { makeStyles } from "@material-ui/core/styles" import Typography from "@material-ui/core/Typography" import React from "react" @@ -8,8 +7,8 @@ export interface EmptyStateProps { /** Text Message to display, placed inside Typography component */ message: string /** Longer optional description to display below the message */ - description?: React.ReactNode - button?: ButtonProps + description?: string + cta?: React.ReactNode } /** @@ -21,17 +20,22 @@ export interface EmptyStateProps { * that you can directly pass props through to to customize the shape and layout of it. */ export const EmptyState: React.FC = (props) => { - const { message, description, button, ...boxProps } = props + const { message, description, cta, ...boxProps } = props const styles = useStyles() - const buttonClassName = `${styles.button} ${button && button.className ? button.className : ""}` return ( - - {message} - - {description && {description}} - {button && } + + + {message} + + {description && ( + + {description} + + )} + + {cta} ) } @@ -48,22 +52,13 @@ const useStyles = makeStyles( padding: theme.spacing(3), }, header: { + marginBottom: theme.spacing(3), + }, + title: { fontWeight: 400, }, description: { - marginTop: theme.spacing(2), - marginBottom: theme.spacing(1), - color: theme.palette.text.secondary, - fontSize: theme.typography.body2.fontSize, - }, - button: { - marginTop: theme.spacing(2), - }, - icon: { - fontSize: theme.typography.h2.fontSize, - color: theme.palette.text.secondary, - marginBottom: theme.spacing(1), - opacity: 0.5, + marginTop: theme.spacing(1), }, }), { name: "EmptyState" }, diff --git a/site/src/components/Loader/FullScreenLoader.tsx b/site/src/components/Loader/FullScreenLoader.tsx index 7fbd290adc145..fa6aabb98235b 100644 --- a/site/src/components/Loader/FullScreenLoader.tsx +++ b/site/src/components/Loader/FullScreenLoader.tsx @@ -2,7 +2,7 @@ import CircularProgress from "@material-ui/core/CircularProgress" import { makeStyles } from "@material-ui/core/styles" import React from "react" -export const useStyles = makeStyles(() => ({ +export const useStyles = makeStyles((theme) => ({ root: { position: "absolute", top: "0", @@ -12,6 +12,7 @@ export const useStyles = makeStyles(() => ({ display: "flex", justifyContent: "center", alignItems: "center", + background: theme.palette.background.default, }, })) diff --git a/site/src/components/NavbarView/NavbarView.stories.tsx b/site/src/components/NavbarView/NavbarView.stories.tsx index 564fe4cf6781c..ec00f8b4843f8 100644 --- a/site/src/components/NavbarView/NavbarView.stories.tsx +++ b/site/src/components/NavbarView/NavbarView.stories.tsx @@ -1,5 +1,6 @@ import { Story } from "@storybook/react" import React from "react" +import { MockUser, MockUser2 } from "../../testHelpers/entities" import { NavbarView, NavbarViewProps } from "./NavbarView" export default { @@ -14,16 +15,7 @@ const Template: Story = (args: NavbarViewProps) => { return Promise.resolve() }, @@ -31,16 +23,7 @@ ForAdmin.args = { export const ForMember = Template.bind({}) ForMember.args = { - user: { - id: "1", - username: "CathyCoder", - email: "cathy@coder.com", - created_at: "dawn", - status: "active", - organization_ids: [], - roles: [], - }, - displayAdminDropdown: false, + user: MockUser2, onSignOut: () => { return Promise.resolve() }, diff --git a/site/src/components/TableLoader/TableLoader.tsx b/site/src/components/TableLoader/TableLoader.tsx new file mode 100644 index 0000000000000..938cf6c376bf3 --- /dev/null +++ b/site/src/components/TableLoader/TableLoader.tsx @@ -0,0 +1,27 @@ +import Box from "@material-ui/core/Box" +import CircularProgress from "@material-ui/core/CircularProgress" +import { makeStyles } from "@material-ui/core/styles" +import TableCell from "@material-ui/core/TableCell" +import TableRow from "@material-ui/core/TableRow" +import React from "react" + +export const TableLoader: React.FC = () => { + const styles = useStyles() + + return ( + + + + + + + + ) +} + +const useStyles = makeStyles((theme) => ({ + cell: { + textAlign: "center", + height: theme.spacing(20), + }, +})) diff --git a/site/src/components/TemplatesTable/TemplatesTable.stories.tsx b/site/src/components/TemplatesTable/TemplatesTable.stories.tsx new file mode 100644 index 0000000000000..870ea07f3a078 --- /dev/null +++ b/site/src/components/TemplatesTable/TemplatesTable.stories.tsx @@ -0,0 +1,29 @@ +import { ComponentMeta, Story } from "@storybook/react" +import React from "react" +import { MockOrganization, MockTemplate } from "../../testHelpers/entities" +import { TemplatesTable, TemplatesTableProps } from "./TemplatesTable" + +export default { + title: "components/TemplatesTable", + component: TemplatesTable, +} as ComponentMeta + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + templates: [MockTemplate], + organizations: [MockOrganization], +} + +export const Empty = Template.bind({}) +Empty.args = { + templates: [], + organizations: [], +} + +export const Loading = Template.bind({}) +Loading.args = { + templates: undefined, + organizations: [], +} diff --git a/site/src/components/TemplatesTable/TemplatesTable.tsx b/site/src/components/TemplatesTable/TemplatesTable.tsx new file mode 100644 index 0000000000000..4999df461c2c3 --- /dev/null +++ b/site/src/components/TemplatesTable/TemplatesTable.tsx @@ -0,0 +1,81 @@ +import Box from "@material-ui/core/Box" +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 React from "react" +import { Link } from "react-router-dom" +import * as TypesGen from "../../api/typesGenerated" +import { CodeExample } from "../../components/CodeExample/CodeExample" +import { EmptyState } from "../../components/EmptyState/EmptyState" +import { TableHeaderRow } from "../../components/TableHeaders/TableHeaders" +import { TableLoader } from "../../components/TableLoader/TableLoader" +import { TableTitle } from "../../components/TableTitle/TableTitle" + +export const Language = { + title: "Templates", + tableTitle: "All templates", + nameLabel: "Name", + emptyMessage: "No templates have been created yet", + emptyDescription: "Run the following command to get started:", + totalLabel: "total", +} + +export interface TemplatesTableProps { + templates?: TypesGen.Template[] + organizations?: TypesGen.Organization[] +} + +export const TemplatesTable: React.FC = ({ templates, organizations }) => { + const isLoading = !templates || !organizations + + // Create a dictionary of organization ID -> organization Name + // Needed to properly construct links to dive into a template + const orgDictionary = + organizations && + organizations.reduce((acc: Record, curr: TypesGen.Organization) => { + return { + ...acc, + [curr.id]: curr.name, + } + }, {}) + + return ( + + + + + {Language.nameLabel} + + + + {isLoading && } + {templates && + organizations && + orgDictionary && + templates.map((t) => ( + + + {t.name} + + + ))} + + {templates && templates.length === 0 && ( + + + + } + /> + + + + )} + + + ) +} diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index ec548cc47d6e8..c961c4b2f9aa8 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -9,6 +9,7 @@ import * as TypesGen from "../../api/typesGenerated" import { EmptyState } from "../EmptyState/EmptyState" import { RoleSelect } from "../RoleSelect/RoleSelect" import { TableHeaderRow } from "../TableHeaders/TableHeaders" +import { TableLoader } from "../TableLoader/TableLoader" import { TableRowMenu } from "../TableRowMenu/TableRowMenu" import { TableTitle } from "../TableTitle/TableTitle" import { UserCell } from "../UserCell/UserCell" @@ -24,12 +25,12 @@ export const Language = { } export interface UsersTableProps { - users: TypesGen.User[] + users?: TypesGen.User[] + roles?: TypesGen.Role[] + isUpdatingUserRoles?: boolean onSuspendUser: (user: TypesGen.User) => void onResetUserPassword: (user: TypesGen.User) => void onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void - roles: TypesGen.Role[] - isUpdatingUserRoles?: boolean } export const UsersTable: React.FC = ({ @@ -40,6 +41,8 @@ export const UsersTable: React.FC = ({ onUpdateUserRoles, isUpdatingUserRoles, }) => { + const isLoading = !users || !roles + return ( @@ -52,38 +55,41 @@ export const UsersTable: React.FC = ({ - {users.map((u) => ( - - - {" "} - - - onUpdateUserRoles(u, roles)} - /> - - - - - - ))} + {isLoading && } + {users && + roles && + users.map((u) => ( + + + {" "} + + + onUpdateUserRoles(u, roles)} + /> + + + + + + ))} - {users.length === 0 && ( + {users && users.length === 0 && ( diff --git a/site/src/components/WorkspacesTable/WorkspacesTable.stories.tsx b/site/src/components/WorkspacesTable/WorkspacesTable.stories.tsx new file mode 100644 index 0000000000000..800b7272d1147 --- /dev/null +++ b/site/src/components/WorkspacesTable/WorkspacesTable.stories.tsx @@ -0,0 +1,38 @@ +import { ComponentMeta, Story } from "@storybook/react" +import React from "react" +import { MockTemplate, MockWorkspace } from "../../testHelpers/entities" +import { WorkspacesTable, WorkspacesTableProps } from "./WorkspacesTable" + +export default { + title: "components/WorkspacesTable", + component: WorkspacesTable, +} as ComponentMeta + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + templateInfo: MockTemplate, + workspaces: [MockWorkspace], + onCreateWorkspace: () => { + console.info("Create workspace") + }, +} + +export const Empty = Template.bind({}) +Empty.args = { + templateInfo: MockTemplate, + workspaces: [], + onCreateWorkspace: () => { + console.info("Create workspace") + }, +} + +export const Loading = Template.bind({}) +Loading.args = { + templateInfo: MockTemplate, + workspaces: undefined, + onCreateWorkspace: () => { + console.info("Create workspace") + }, +} diff --git a/site/src/components/WorkspacesTable/WorkspacesTable.tsx b/site/src/components/WorkspacesTable/WorkspacesTable.tsx new file mode 100644 index 0000000000000..efea5b2e2ebb5 --- /dev/null +++ b/site/src/components/WorkspacesTable/WorkspacesTable.tsx @@ -0,0 +1,72 @@ +import Box from "@material-ui/core/Box" +import Button from "@material-ui/core/Button" +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 React from "react" +import { Link } from "react-router-dom" +import * as TypesGen from "../../api/typesGenerated" +import { EmptyState } from "../EmptyState/EmptyState" +import { TableHeaderRow } from "../TableHeaders/TableHeaders" +import { TableLoader } from "../TableLoader/TableLoader" +import { TableTitle } from "../TableTitle/TableTitle" + +export const Language = { + title: "Workspaces", + nameLabel: "Name", + emptyMessage: "No workspaces have been created yet", + emptyDescription: "Create a workspace to get started", + ctaAction: "Create workspace", +} + +export interface WorkspacesTableProps { + templateInfo?: TypesGen.Template + workspaces?: TypesGen.Workspace[] + onCreateWorkspace: () => void +} + +export const WorkspacesTable: React.FC = ({ templateInfo, workspaces, onCreateWorkspace }) => { + const isLoading = !templateInfo || !workspaces + + return ( + + + + + {Language.nameLabel} + + + + {isLoading && } + {workspaces && + workspaces.map((w) => ( + + + {w.name} + + + ))} + + {workspaces && workspaces.length === 0 && ( + + + + + {Language.ctaAction} + + } + /> + + + + )} + + + ) +} diff --git a/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx b/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx index 0bc9718b4f827..9e1dfc4807515 100644 --- a/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx +++ b/site/src/pages/TemplatesPages/OrganizationPage/TemplatePage/TemplatePage.tsx @@ -1,17 +1,19 @@ import React from "react" -import { Link, useNavigate, useParams } from "react-router-dom" +import { useNavigate, useParams } from "react-router-dom" import useSWR from "swr" import * as TypesGen from "../../../../api/typesGenerated" -import { EmptyState } from "../../../../components/EmptyState/EmptyState" import { ErrorSummary } from "../../../../components/ErrorSummary/ErrorSummary" import { Header } from "../../../../components/Header/Header" -import { FullScreenLoader } from "../../../../components/Loader/FullScreenLoader" import { Margins } from "../../../../components/Margins/Margins" import { Stack } from "../../../../components/Stack/Stack" -import { Column, Table } from "../../../../components/Table/Table" +import { WorkspacesTable } from "../../../../components/WorkspacesTable/WorkspacesTable" import { unsafeSWRArgument } from "../../../../util" import { firstOrItem } from "../../../../util/array" +export const Language = { + subtitle: "workspaces", +} + export const TemplatePage: React.FC = () => { const navigate = useNavigate() const { template: templateName, organization: organizationName } = useParams() @@ -26,69 +28,29 @@ export const TemplatePage: React.FC = () => { // This just grabs all workspaces... and then later filters them to match the // current template. - const { data: workspaces, error: workspacesError } = useSWR( () => `/api/v2/organizations/${unsafeSWRArgument(organizationInfo).id}/workspaces`, ) - if (organizationError) { - return - } - - if (templateError) { - return - } - - if (workspacesError) { - return - } - - if (!templateInfo || !workspaces) { - return - } + const hasError = organizationError || templateError || workspacesError const createWorkspace = () => { navigate(`/templates/${organizationName}/${templateName}/create`) } - const emptyState = ( - - ) - - const columns: Column[] = [ - { - key: "name", - name: "Name", - renderer: (nameField: string | TypesGen.WorkspaceBuild, workspace: TypesGen.Workspace) => { - return {nameField} - }, - }, - ] - - const perTemplateWorkspaces = workspaces.filter((workspace) => { - return workspace.template_id === templateInfo.id - }) - - const tableProps = { - title: "Workspaces", - columns, - data: perTemplateWorkspaces, - emptyState: emptyState, - } + const perTemplateWorkspaces = + workspaces && templateInfo + ? workspaces.filter((workspace) => { + return workspace.template_id === templateInfo.id + }) + : undefined return ( { /> - + {organizationError && } + {templateError && } + {workspacesError && } + {!hasError && ( + + )} ) diff --git a/site/src/pages/TemplatesPages/TemplatesPage.tsx b/site/src/pages/TemplatesPages/TemplatesPage.tsx index 1e167567b5cf1..30abe71c23199 100644 --- a/site/src/pages/TemplatesPages/TemplatesPage.tsx +++ b/site/src/pages/TemplatesPages/TemplatesPage.tsx @@ -1,85 +1,37 @@ -import { makeStyles } from "@material-ui/core/styles" import React from "react" -import { Link } from "react-router-dom" import useSWR from "swr" import * as TypesGen from "../../api/typesGenerated" -import { CodeExample } from "../../components/CodeExample/CodeExample" -import { EmptyState } from "../../components/EmptyState/EmptyState" import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary" import { Header } from "../../components/Header/Header" -import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" import { Margins } from "../../components/Margins/Margins" import { Stack } from "../../components/Stack/Stack" -import { Column, Table } from "../../components/Table/Table" +import { TemplatesTable } from "../../components/TemplatesTable/TemplatesTable" + +export const Language = { + title: "Templates", + tableTitle: "All templates", + nameLabel: "Name", + emptyMessage: "No templates have been created yet", + emptyDescription: "Run the following command to get started:", + totalLabel: "total", +} export const TemplatesPage: React.FC = () => { - const styles = useStyles() const { data: orgs, error: orgsError } = useSWR("/api/v2/users/me/organizations") - const { data: templates, error } = useSWR( - orgs ? `/api/v2/organizations/${orgs[0].id}/templates` : null, - ) - - if (error) { - return - } - - if (orgsError) { - return - } - - if (!templates || !orgs) { - return - } - - // Create a dictionary of organization ID -> organization Name - // Needed to properly construct links to dive into a template - const orgDictionary = orgs.reduce((acc: Record, curr: TypesGen.Organization) => { - return { - ...acc, - [curr.id]: curr.name, - } - }, {}) - - const columns: Column[] = [ - { - key: "name", - name: "Name", - renderer: (nameField: string, data: TypesGen.Template) => { - return {nameField} - }, - }, - ] - - const description = ( - - Run the following command to get started: - - + const { data: templates, error } = useSWR( + orgs ? `/api/v2/organizations/${orgs[0].id}/templates` : undefined, ) - - const emptyState = - - const tableProps = { - title: "All Templates", - columns: columns, - emptyState: emptyState, - data: templates, - } - - const subTitle = `${templates.length} total` + const subTitle = templates ? `${templates.length} ${Language.totalLabel}` : undefined + const hasError = orgsError || error return ( - + - + {error && } + {orgsError && } + {!hasError && } ) } - -const useStyles = makeStyles((theme) => ({ - descriptionLabel: { - marginBottom: theme.spacing(1), - }, -})) diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 5c7d4c0e968e8..579924832f9f6 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -2,7 +2,6 @@ import { useActor } from "@xstate/react" import React, { useContext, useEffect } from "react" import { useNavigate } from "react-router" import { ConfirmDialog } from "../../components/ConfirmDialog/ConfirmDialog" -import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" import { ResetPasswordDialog } from "../../components/ResetPasswordDialog/ResetPasswordDialog" import { XServiceContext } from "../../xServices/StateContext" import { UsersPageView } from "./UsersPageView" @@ -46,67 +45,63 @@ export const UsersPage: React.FC = () => { usersSend("GET_USERS") }, [usersSend]) - if (!users || !roles) { - return - } else { - return ( - <> - { - navigate("/users/create") - }} - onSuspendUser={(user) => { - usersSend({ type: "SUSPEND_USER", userId: user.id }) - }} - onResetUserPassword={(user) => { - usersSend({ type: "RESET_USER_PASSWORD", userId: user.id }) - }} - onUpdateUserRoles={(user, roles) => { - usersSend({ - type: "UPDATE_USER_ROLES", - userId: user.id, - roles, - }) - }} - error={getUsersError} - isUpdatingUserRoles={usersState.matches("updatingUserRoles")} - /> + return ( + <> + { + navigate("/users/create") + }} + onSuspendUser={(user) => { + usersSend({ type: "SUSPEND_USER", userId: user.id }) + }} + onResetUserPassword={(user) => { + usersSend({ type: "RESET_USER_PASSWORD", userId: user.id }) + }} + onUpdateUserRoles={(user, roles) => { + usersSend({ + type: "UPDATE_USER_ROLES", + userId: user.id, + roles, + }) + }} + error={getUsersError} + isUpdatingUserRoles={usersState.matches("updatingUserRoles")} + /> - { - usersSend("CONFIRM_USER_SUSPENSION") - }} - onClose={() => { - usersSend("CANCEL_USER_SUSPENSION") - }} - description={ - <> - {Language.suspendDialogMessagePrefix} {userToBeSuspended?.username}? - > - } - /> + { + usersSend("CONFIRM_USER_SUSPENSION") + }} + onClose={() => { + usersSend("CANCEL_USER_SUSPENSION") + }} + description={ + <> + {Language.suspendDialogMessagePrefix} {userToBeSuspended?.username}? + > + } + /> - { - usersSend("CANCEL_USER_PASSWORD_RESET") - }} - onConfirm={() => { - usersSend("CONFIRM_USER_PASSWORD_RESET") - }} - /> - > - ) - } + { + usersSend("CANCEL_USER_PASSWORD_RESET") + }} + onConfirm={() => { + usersSend("CONFIRM_USER_PASSWORD_RESET") + }} + /> + > + ) } diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index c4a9da548d9be..e795bf38dbf02 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -12,14 +12,14 @@ export const Language = { } export interface UsersPageViewProps { - users: TypesGen.User[] + users?: TypesGen.User[] + roles?: TypesGen.Role[] + error?: unknown + isUpdatingUserRoles?: boolean openUserCreationDialog: () => void onSuspendUser: (user: TypesGen.User) => void onResetUserPassword: (user: TypesGen.User) => void onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void - roles: TypesGen.Role[] - error?: unknown - isUpdatingUserRoles?: boolean } export const UsersPageView: React.FC = ({ @@ -41,10 +41,10 @@ export const UsersPageView: React.FC = ({ ) : ( )} diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index 0d764ba1325b9..ba467c03df237 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -73,9 +73,6 @@ export const usersMachine = createMachine( }, id: "usersState", initial: "idle", - context: { - users: [], - }, states: { idle: { on: {