feat: display specific errors if templates page fails (#4023)

* Surface templates page errors

* Format

* Separate error messages

* Fix story

* Format

* Format

* Fix imports

* Remove unnecessary check

* Format
This commit is contained in:
Presley Pizzo
2022-09-13 10:26:58 -04:00
committed by GitHub
parent 21e8fb243b
commit 83c35bb916
8 changed files with 219 additions and 177 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ import { NotFoundPage } from "./pages/404Page/404Page"
import { CliAuthenticationPage } from "./pages/CliAuthPage/CliAuthPage" import { CliAuthenticationPage } from "./pages/CliAuthPage/CliAuthPage"
import { HealthzPage } from "./pages/HealthzPage/HealthzPage" import { HealthzPage } from "./pages/HealthzPage/HealthzPage"
import { LoginPage } from "./pages/LoginPage/LoginPage" import { LoginPage } from "./pages/LoginPage/LoginPage"
import TemplatesPage from "./pages/TemplatesPage/TemplatesPage" import { TemplatesPage } from "./pages/TemplatesPage/TemplatesPage"
import { AccountPage } from "./pages/UserSettingsPage/AccountPage/AccountPage" import { AccountPage } from "./pages/UserSettingsPage/AccountPage/AccountPage"
import { SecurityPage } from "./pages/UserSettingsPage/SecurityPage/SecurityPage" import { SecurityPage } from "./pages/UserSettingsPage/SecurityPage/SecurityPage"
import { SSHKeysPage } from "./pages/UserSettingsPage/SSHKeysPage/SSHKeysPage" import { SSHKeysPage } from "./pages/UserSettingsPage/SSHKeysPage/SSHKeysPage"
+2
View File
@@ -1,6 +1,7 @@
import auditLog from "./auditLog.json" import auditLog from "./auditLog.json"
import common from "./common.json" import common from "./common.json"
import templatePage from "./templatePage.json" import templatePage from "./templatePage.json"
import templatesPage from "./templatesPage.json"
import workspacePage from "./workspacePage.json" import workspacePage from "./workspacePage.json"
export const en = { export const en = {
@@ -8,4 +9,5 @@ export const en = {
workspacePage, workspacePage,
auditLog, auditLog,
templatePage, templatePage,
templatesPage,
} }
+6
View File
@@ -0,0 +1,6 @@
{
"errors": {
"getOrganizationError": "Something went wrong fetching organizations.",
"getTemplatesError": "Something went wrong fetching templates."
}
}
@@ -4,7 +4,7 @@ import * as CreateDayString from "util/createDayString"
import { MockTemplate } from "../../testHelpers/entities" import { MockTemplate } from "../../testHelpers/entities"
import { history, render } from "../../testHelpers/renderHelpers" import { history, render } from "../../testHelpers/renderHelpers"
import { server } from "../../testHelpers/server" import { server } from "../../testHelpers/server"
import TemplatesPage from "./TemplatesPage" import { TemplatesPage } from "./TemplatesPage"
import { Language } from "./TemplatesPageView" import { Language } from "./TemplatesPageView"
describe("TemplatesPage", () => { describe("TemplatesPage", () => {
@@ -6,10 +6,11 @@ import { XServiceContext } from "../../xServices/StateContext"
import { templatesMachine } from "../../xServices/templates/templatesXService" import { templatesMachine } from "../../xServices/templates/templatesXService"
import { TemplatesPageView } from "./TemplatesPageView" import { TemplatesPageView } from "./TemplatesPageView"
const TemplatesPage: React.FC = () => { export const TemplatesPage: React.FC = () => {
const xServices = useContext(XServiceContext) const xServices = useContext(XServiceContext)
const [authState] = useActor(xServices.authXService) const [authState] = useActor(xServices.authXService)
const [templatesState] = useMachine(templatesMachine) const [templatesState] = useMachine(templatesMachine)
const { templates, getOrganizationsError, getTemplatesError } = templatesState.context
return ( return (
<> <>
@@ -17,12 +18,12 @@ const TemplatesPage: React.FC = () => {
<title>{pageTitle("Templates")}</title> <title>{pageTitle("Templates")}</title>
</Helmet> </Helmet>
<TemplatesPageView <TemplatesPageView
templates={templatesState.context.templates} templates={templates}
canCreateTemplate={authState.context.permissions?.createTemplates} canCreateTemplate={authState.context.permissions?.createTemplates}
loading={templatesState.hasTag("loading")} loading={templatesState.hasTag("loading")}
getOrganizationsError={getOrganizationsError}
getTemplatesError={getTemplatesError}
/> />
</> </>
) )
} }
export default TemplatesPage
@@ -1,5 +1,5 @@
import { ComponentMeta, Story } from "@storybook/react" import { ComponentMeta, Story } from "@storybook/react"
import { MockTemplate } from "../../testHelpers/entities" import { makeMockApiError, MockTemplate } from "../../testHelpers/entities"
import { TemplatesPageView, TemplatesPageViewProps } from "./TemplatesPageView" import { TemplatesPageView, TemplatesPageViewProps } from "./TemplatesPageView"
export default { export default {
@@ -49,3 +49,8 @@ EmptyCanCreate.args = {
export const EmptyCannotCreate = Template.bind({}) export const EmptyCannotCreate = Template.bind({})
EmptyCannotCreate.args = {} EmptyCannotCreate.args = {}
export const Error = Template.bind({})
Error.args = {
getTemplatesError: makeMockApiError({ message: "Something went wrong fetching templates." }),
}
@@ -8,7 +8,9 @@ import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow" import TableRow from "@material-ui/core/TableRow"
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
import useTheme from "@material-ui/styles/useTheme" import useTheme from "@material-ui/styles/useTheme"
import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
import { FC } from "react" import { FC } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom" import { useNavigate } from "react-router-dom"
import { createDayString } from "util/createDayString" import { createDayString } from "util/createDayString"
import { formatTemplateActiveDevelopers } from "util/templates" import { formatTemplateActiveDevelopers } from "util/templates"
@@ -77,12 +79,20 @@ export interface TemplatesPageViewProps {
loading?: boolean loading?: boolean
canCreateTemplate?: boolean canCreateTemplate?: boolean
templates?: TypesGen.Template[] templates?: TypesGen.Template[]
getOrganizationsError?: Error | unknown
getTemplatesError?: Error | unknown
} }
export const TemplatesPageView: FC<React.PropsWithChildren<TemplatesPageViewProps>> = (props) => { export const TemplatesPageView: FC<React.PropsWithChildren<TemplatesPageViewProps>> = (props) => {
const styles = useStyles() const styles = useStyles()
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation("templatesPage")
const theme: Theme = useTheme() const theme: Theme = useTheme()
const empty =
!props.loading &&
!props.getOrganizationsError &&
!props.getTemplatesError &&
!props.templates?.length
return ( return (
<Margins> <Margins>
@@ -114,94 +124,110 @@ export const TemplatesPageView: FC<React.PropsWithChildren<TemplatesPageViewProp
)} )}
</PageHeader> </PageHeader>
<TableContainer> {props.getOrganizationsError ? (
<Table> <ErrorSummary
<TableHead> error={props.getOrganizationsError}
<TableRow> defaultMessage={t("errors.getOrganizationsError")}
<TableCell width="50%">{Language.nameLabel}</TableCell> />
<TableCell width="16%">{Language.usedByLabel}</TableCell> ) : props.getTemplatesError ? (
<TableCell width="16%">{Language.lastUpdatedLabel}</TableCell> <ErrorSummary
<TableCell width="16%">{Language.createdByLabel}</TableCell> error={props.getTemplatesError}
<TableCell width="1%"></TableCell> defaultMessage={t("errors.getTemplatesError")}
</TableRow> />
</TableHead> ) : (
<TableBody> <TableContainer>
{props.loading && <TableLoader />} <Table>
{!props.loading && !props.templates?.length && ( <TableHead>
<TableRow> <TableRow>
<TableCell colSpan={999}> <TableCell width="50%">{Language.nameLabel}</TableCell>
<EmptyState <TableCell width="16%">{Language.usedByLabel}</TableCell>
message={Language.emptyMessage} <TableCell width="16%">{Language.lastUpdatedLabel}</TableCell>
description={ <TableCell width="16%">{Language.createdByLabel}</TableCell>
props.canCreateTemplate <TableCell width="1%"></TableCell>
? Language.emptyDescription
: Language.emptyViewNoPerms
}
descriptionClassName={styles.emptyDescription}
cta={<CodeExample code="coder templates init" />}
/>
</TableCell>
</TableRow> </TableRow>
)} </TableHead>
{props.templates?.map((template) => { <TableBody>
const templatePageLink = `/templates/${template.name}` {props.loading && <TableLoader />}
const hasIcon = template.icon && template.icon !== ""
return ( {empty ? (
<TableRow <TableRow>
key={template.id} <TableCell colSpan={999}>
hover <EmptyState
data-testid={`template-${template.id}`} message={Language.emptyMessage}
tabIndex={0} description={
onKeyDown={(event) => { props.canCreateTemplate
if (event.key === "Enter") { ? Language.emptyDescription
navigate(templatePageLink) : Language.emptyViewNoPerms
}
}}
className={styles.clickableTableRow}
>
<TableCellLink to={templatePageLink}>
<AvatarData
title={template.name}
subtitle={template.description}
highlightTitle
avatar={
hasIcon ? (
<div className={styles.templateIconWrapper}>
<img alt="" src={template.icon} />
</div>
) : undefined
} }
descriptionClassName={styles.emptyDescription}
cta={<CodeExample code="coder templates init" />}
/> />
</TableCellLink> </TableCell>
<TableCellLink to={templatePageLink}>
<span style={{ color: theme.palette.text.secondary }}>
{Language.developerCount(template.active_user_count)}
</span>
</TableCellLink>
<TableCellLink data-chromatic="ignore" to={templatePageLink}>
<span style={{ color: theme.palette.text.secondary }}>
{createDayString(template.updated_at)}
</span>
</TableCellLink>
<TableCellLink to={templatePageLink}>
<span style={{ color: theme.palette.text.secondary }}>
{template.created_by_name}
</span>
</TableCellLink>
<TableCellLink to={templatePageLink}>
<div className={styles.arrowCell}>
<KeyboardArrowRight className={styles.arrowRight} />
</div>
</TableCellLink>
</TableRow> </TableRow>
) ) : (
})} props.templates?.map((template) => {
</TableBody> const templatePageLink = `/templates/${template.name}`
</Table> const hasIcon = template.icon && template.icon !== ""
</TableContainer>
return (
<TableRow
key={template.id}
hover
data-testid={`template-${template.id}`}
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter") {
navigate(templatePageLink)
}
}}
className={styles.clickableTableRow}
>
<TableCellLink to={templatePageLink}>
<AvatarData
title={template.name}
subtitle={template.description}
highlightTitle
avatar={
hasIcon && (
<div className={styles.templateIconWrapper}>
<img alt="" src={template.icon} />
</div>
)
}
/>
</TableCellLink>
<TableCellLink to={templatePageLink}>
<span style={{ color: theme.palette.text.secondary }}>
{Language.developerCount(template.active_user_count)}
</span>
</TableCellLink>
<TableCellLink data-chromatic="ignore" to={templatePageLink}>
<span style={{ color: theme.palette.text.secondary }}>
{createDayString(template.updated_at)}
</span>
</TableCellLink>
<TableCellLink to={templatePageLink}>
<span style={{ color: theme.palette.text.secondary }}>
{template.created_by_name}
</span>
</TableCellLink>
<TableCellLink to={templatePageLink}>
<div className={styles.arrowCell}>
<KeyboardArrowRight className={styles.arrowRight} />
</div>
</TableCellLink>
</TableRow>
)
})
)}
</TableBody>
</Table>
</TableContainer>
)}
</Margins> </Margins>
) )
} }
@@ -6,99 +6,101 @@ interface TemplatesContext {
organizations?: TypesGen.Organization[] organizations?: TypesGen.Organization[]
templates?: TypesGen.Template[] templates?: TypesGen.Template[]
canCreateTemplate?: boolean canCreateTemplate?: boolean
permissionsError?: Error | unknown getOrganizationsError?: Error | unknown
organizationsError?: Error | unknown getTemplatesError?: Error | unknown
templatesError?: Error | unknown
} }
export const templatesMachine = createMachine( export const templatesMachine =
{ /** @xstate-layout N4IgpgJg5mDOIC5QBcwFsAOAbAhq2AysnmAHQzLICWAdlAPIBOUONVAXnlQPY2wDEEXmVoA3bgGsyFJizadqveEhAZusKopqJQAD0QAWAMwHSATgDsANgCsAJgt2rRuwAYAHBYsAaEAE9EAEZAu1IDMwiIm3dXG0DjdwBfRN9UTFx8IhJyMEpaBmZWDi4lfjBGRm5GUmw8ADMqtBzkWSKFHj4dVXVNDq79BGNTS1sHJxcPL18AhBMjUhtXJaWrOzM7O3cDG2TU9FrM4lRm6joAFX2MuEFhUjFJaVyL9JJlUDUNLX6gpeH1wJcIQs7giVmmiBB5kilgM7iMHmcOxSIDSBzgWWOFFOUGeaIE5Uq1QODUYTQouKub26nz6KgGgV+ULsAOZDhBZjB-kQbhspGWSxC0W2VncSORNG4EDgXVRlIxjzydFa8hKnRUH16vG+gzs4IQwTMYWhjgsRjm9l2KMur3lJ3yFNeXQ1XzpiCsVlcpFW4QMFnisJZeo5UMicQsNjMgRsSL2L0O2SENDATp6Lr0QTcFjCa2cfqsgRNeuC7j5-Ncq3WmwMgUtsptRzIBKqKZpWtd+sz2Y5RjzBYceo2WbNw9hrijZtr1vjqBbmu07cC7iLSWSiSAA */
tsTypes: {} as import("./templatesXService.typegen").Typegen0, createMachine(
schema: { {
context: {} as TemplatesContext, tsTypes: {} as import("./templatesXService.typegen").Typegen0,
services: {} as { schema: {
getOrganizations: { context: {} as TemplatesContext,
data: TypesGen.Organization[] services: {} as {
} getOrganizations: {
getPermissions: { data: TypesGen.Organization[]
data: boolean }
} getTemplates: {
getTemplates: { data: TypesGen.Template[]
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: { id: "templatesState",
entry: "clearTemplatesError", initial: "gettingOrganizations",
invoke: { states: {
src: "getTemplates", gettingOrganizations: {
id: "getTemplates", entry: "clearGetOrganizationsError",
onDone: { invoke: {
target: "done", src: "getOrganizations",
actions: ["assignTemplates", "clearTemplatesError"], id: "getOrganizations",
}, onDone: [
onError: { {
target: "error", actions: ["assignOrganizations"],
actions: "assignTemplatesError", target: "gettingTemplates",
},
],
onError: [
{
actions: "assignGetOrganizationsError",
target: "error",
},
],
}, },
tags: "loading",
}, },
tags: "loading", gettingTemplates: {
}, entry: "clearGetTemplatesError",
done: {}, invoke: {
error: {}, src: "getTemplates",
}, id: "getTemplates",
}, onDone: [
{ {
actions: { actions: ["assignTemplates"],
assignOrganizations: assign({ target: "done",
organizations: (_, event) => event.data, },
}), ],
assignOrganizationsError: assign({ onError: [
organizationsError: (_, event) => event.data, {
}), actions: "assignGetTemplatesError",
clearOrganizationsError: assign((context) => ({ target: "error",
...context, },
organizationsError: undefined, ],
})), },
assignTemplates: assign({ tags: "loading",
templates: (_, event) => event.data, },
}), done: {},
assignTemplatesError: assign({ error: {},
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)
}, },
}, },
}, {
) actions: {
assignOrganizations: assign({
organizations: (_, event) => event.data,
}),
assignGetOrganizationsError: assign({
getOrganizationsError: (_, event) => event.data,
}),
clearGetOrganizationsError: assign((context) => ({
...context,
getOrganizationsError: undefined,
})),
assignTemplates: assign({
templates: (_, event) => event.data,
}),
assignGetTemplatesError: assign({
getTemplatesError: (_, event) => event.data,
}),
clearGetTemplatesError: (context) => assign({ ...context, getTemplatesError: 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)
},
},
},
)