mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
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:
@@ -16,7 +16,7 @@ import { NotFoundPage } from "./pages/404Page/404Page"
|
||||
import { CliAuthenticationPage } from "./pages/CliAuthPage/CliAuthPage"
|
||||
import { HealthzPage } from "./pages/HealthzPage/HealthzPage"
|
||||
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 { SecurityPage } from "./pages/UserSettingsPage/SecurityPage/SecurityPage"
|
||||
import { SSHKeysPage } from "./pages/UserSettingsPage/SSHKeysPage/SSHKeysPage"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import auditLog from "./auditLog.json"
|
||||
import common from "./common.json"
|
||||
import templatePage from "./templatePage.json"
|
||||
import templatesPage from "./templatesPage.json"
|
||||
import workspacePage from "./workspacePage.json"
|
||||
|
||||
export const en = {
|
||||
@@ -8,4 +9,5 @@ export const en = {
|
||||
workspacePage,
|
||||
auditLog,
|
||||
templatePage,
|
||||
templatesPage,
|
||||
}
|
||||
|
||||
@@ -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 { history, render } from "../../testHelpers/renderHelpers"
|
||||
import { server } from "../../testHelpers/server"
|
||||
import TemplatesPage from "./TemplatesPage"
|
||||
import { TemplatesPage } from "./TemplatesPage"
|
||||
import { Language } from "./TemplatesPageView"
|
||||
|
||||
describe("TemplatesPage", () => {
|
||||
|
||||
@@ -6,10 +6,11 @@ import { XServiceContext } from "../../xServices/StateContext"
|
||||
import { templatesMachine } from "../../xServices/templates/templatesXService"
|
||||
import { TemplatesPageView } from "./TemplatesPageView"
|
||||
|
||||
const TemplatesPage: React.FC = () => {
|
||||
export const TemplatesPage: React.FC = () => {
|
||||
const xServices = useContext(XServiceContext)
|
||||
const [authState] = useActor(xServices.authXService)
|
||||
const [templatesState] = useMachine(templatesMachine)
|
||||
const { templates, getOrganizationsError, getTemplatesError } = templatesState.context
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -17,12 +18,12 @@ const TemplatesPage: React.FC = () => {
|
||||
<title>{pageTitle("Templates")}</title>
|
||||
</Helmet>
|
||||
<TemplatesPageView
|
||||
templates={templatesState.context.templates}
|
||||
templates={templates}
|
||||
canCreateTemplate={authState.context.permissions?.createTemplates}
|
||||
loading={templatesState.hasTag("loading")}
|
||||
getOrganizationsError={getOrganizationsError}
|
||||
getTemplatesError={getTemplatesError}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplatesPage
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ComponentMeta, Story } from "@storybook/react"
|
||||
import { MockTemplate } from "../../testHelpers/entities"
|
||||
import { makeMockApiError, MockTemplate } from "../../testHelpers/entities"
|
||||
import { TemplatesPageView, TemplatesPageViewProps } from "./TemplatesPageView"
|
||||
|
||||
export default {
|
||||
@@ -49,3 +49,8 @@ EmptyCanCreate.args = {
|
||||
|
||||
export const EmptyCannotCreate = Template.bind({})
|
||||
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 KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
|
||||
import useTheme from "@material-ui/styles/useTheme"
|
||||
import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
|
||||
import { FC } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { createDayString } from "util/createDayString"
|
||||
import { formatTemplateActiveDevelopers } from "util/templates"
|
||||
@@ -77,12 +79,20 @@ export interface TemplatesPageViewProps {
|
||||
loading?: boolean
|
||||
canCreateTemplate?: boolean
|
||||
templates?: TypesGen.Template[]
|
||||
getOrganizationsError?: Error | unknown
|
||||
getTemplatesError?: Error | unknown
|
||||
}
|
||||
|
||||
export const TemplatesPageView: FC<React.PropsWithChildren<TemplatesPageViewProps>> = (props) => {
|
||||
const styles = useStyles()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation("templatesPage")
|
||||
const theme: Theme = useTheme()
|
||||
const empty =
|
||||
!props.loading &&
|
||||
!props.getOrganizationsError &&
|
||||
!props.getTemplatesError &&
|
||||
!props.templates?.length
|
||||
|
||||
return (
|
||||
<Margins>
|
||||
@@ -114,94 +124,110 @@ export const TemplatesPageView: FC<React.PropsWithChildren<TemplatesPageViewProp
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="50%">{Language.nameLabel}</TableCell>
|
||||
<TableCell width="16%">{Language.usedByLabel}</TableCell>
|
||||
<TableCell width="16%">{Language.lastUpdatedLabel}</TableCell>
|
||||
<TableCell width="16%">{Language.createdByLabel}</TableCell>
|
||||
<TableCell width="1%"></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{props.loading && <TableLoader />}
|
||||
{!props.loading && !props.templates?.length && (
|
||||
{props.getOrganizationsError ? (
|
||||
<ErrorSummary
|
||||
error={props.getOrganizationsError}
|
||||
defaultMessage={t("errors.getOrganizationsError")}
|
||||
/>
|
||||
) : props.getTemplatesError ? (
|
||||
<ErrorSummary
|
||||
error={props.getTemplatesError}
|
||||
defaultMessage={t("errors.getTemplatesError")}
|
||||
/>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message={Language.emptyMessage}
|
||||
description={
|
||||
props.canCreateTemplate
|
||||
? Language.emptyDescription
|
||||
: Language.emptyViewNoPerms
|
||||
}
|
||||
descriptionClassName={styles.emptyDescription}
|
||||
cta={<CodeExample code="coder templates init" />}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell width="50%">{Language.nameLabel}</TableCell>
|
||||
<TableCell width="16%">{Language.usedByLabel}</TableCell>
|
||||
<TableCell width="16%">{Language.lastUpdatedLabel}</TableCell>
|
||||
<TableCell width="16%">{Language.createdByLabel}</TableCell>
|
||||
<TableCell width="1%"></TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{props.templates?.map((template) => {
|
||||
const templatePageLink = `/templates/${template.name}`
|
||||
const hasIcon = template.icon && template.icon !== ""
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{props.loading && <TableLoader />}
|
||||
|
||||
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>
|
||||
) : undefined
|
||||
{empty ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={999}>
|
||||
<EmptyState
|
||||
message={Language.emptyMessage}
|
||||
description={
|
||||
props.canCreateTemplate
|
||||
? Language.emptyDescription
|
||||
: Language.emptyViewNoPerms
|
||||
}
|
||||
descriptionClassName={styles.emptyDescription}
|
||||
cta={<CodeExample code="coder templates init" />}
|
||||
/>
|
||||
</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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
props.templates?.map((template) => {
|
||||
const templatePageLink = `/templates/${template.name}`
|
||||
const hasIcon = template.icon && template.icon !== ""
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,99 +6,101 @@ interface TemplatesContext {
|
||||
organizations?: TypesGen.Organization[]
|
||||
templates?: TypesGen.Template[]
|
||||
canCreateTemplate?: boolean
|
||||
permissionsError?: Error | unknown
|
||||
organizationsError?: Error | unknown
|
||||
templatesError?: Error | unknown
|
||||
getOrganizationsError?: Error | unknown
|
||||
getTemplatesError?: 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",
|
||||
},
|
||||
],
|
||||
export const templatesMachine =
|
||||
/** @xstate-layout N4IgpgJg5mDOIC5QBcwFsAOAbAhq2AysnmAHQzLICWAdlAPIBOUONVAXnlQPY2wDEEXmVoA3bgGsyFJizadqveEhAZusKopqJQAD0QAWAMwHSATgDsANgCsAJgt2rRuwAYAHBYsAaEAE9EAEZAu1IDMwiIm3dXG0DjdwBfRN9UTFx8IhJyMEpaBmZWDi4lfjBGRm5GUmw8ADMqtBzkWSKFHj4dVXVNDq79BGNTS1sHJxcPL18AhBMjUhtXJaWrOzM7O3cDG2TU9FrM4lRm6joAFX2MuEFhUjFJaVyL9JJlUDUNLX6gpeH1wJcIQs7giVmmiBB5kilgM7iMHmcOxSIDSBzgWWOFFOUGeaIE5Uq1QODUYTQouKub26nz6KgGgV+ULsAOZDhBZjB-kQbhspGWSxC0W2VncSORNG4EDgXVRlIxjzydFa8hKnRUH16vG+gzs4IQwTMYWhjgsRjm9l2KMur3lJ3yFNeXQ1XzpiCsVlcpFW4QMFnisJZeo5UMicQsNjMgRsSL2L0O2SENDATp6Lr0QTcFjCa2cfqsgRNeuC7j5-Ncq3WmwMgUtsptRzIBKqKZpWtd+sz2Y5RjzBYceo2WbNw9hrijZtr1vjqBbmu07cC7iLSWSiSAA */
|
||||
createMachine(
|
||||
{
|
||||
tsTypes: {} as import("./templatesXService.typegen").Typegen0,
|
||||
schema: {
|
||||
context: {} as TemplatesContext,
|
||||
services: {} as {
|
||||
getOrganizations: {
|
||||
data: TypesGen.Organization[]
|
||||
}
|
||||
getTemplates: {
|
||||
data: TypesGen.Template[]
|
||||
}
|
||||
},
|
||||
tags: "loading",
|
||||
},
|
||||
gettingTemplates: {
|
||||
entry: "clearTemplatesError",
|
||||
invoke: {
|
||||
src: "getTemplates",
|
||||
id: "getTemplates",
|
||||
onDone: {
|
||||
target: "done",
|
||||
actions: ["assignTemplates", "clearTemplatesError"],
|
||||
},
|
||||
onError: {
|
||||
target: "error",
|
||||
actions: "assignTemplatesError",
|
||||
id: "templatesState",
|
||||
initial: "gettingOrganizations",
|
||||
states: {
|
||||
gettingOrganizations: {
|
||||
entry: "clearGetOrganizationsError",
|
||||
invoke: {
|
||||
src: "getOrganizations",
|
||||
id: "getOrganizations",
|
||||
onDone: [
|
||||
{
|
||||
actions: ["assignOrganizations"],
|
||||
target: "gettingTemplates",
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
actions: "assignGetOrganizationsError",
|
||||
target: "error",
|
||||
},
|
||||
],
|
||||
},
|
||||
tags: "loading",
|
||||
},
|
||||
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)
|
||||
gettingTemplates: {
|
||||
entry: "clearGetTemplatesError",
|
||||
invoke: {
|
||||
src: "getTemplates",
|
||||
id: "getTemplates",
|
||||
onDone: [
|
||||
{
|
||||
actions: ["assignTemplates"],
|
||||
target: "done",
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
actions: "assignGetTemplatesError",
|
||||
target: "error",
|
||||
},
|
||||
],
|
||||
},
|
||||
tags: "loading",
|
||||
},
|
||||
done: {},
|
||||
error: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
{
|
||||
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)
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user