From a1ea8ecb0a487ac63b8f1af22691e5ba5091ae87 Mon Sep 17 00:00:00 2001 From: lberrymage Date: Sat, 24 Oct 2026 02:31:58 +0000 Subject: [PATCH] Deduplicate full-page errors --- .../full-page-error-view.html | 53 +++++++++++ .../full-page-error-view.spec.ts | 88 ++++++++++++++++++ .../full-page-error-view.stories.ts | 39 ++++++++ .../full-page-error-view.ts | 90 +++++++++++++++++++ .../home-container/home-container.spec.ts | 13 ++- .../home-container/home-container.ts | 9 +- src/app/components/home-page/home-page.html | 20 ++--- .../components/home-page/home-page.spec.ts | 9 +- .../components/home-page/home-page.stories.ts | 6 +- src/app/components/home-page/home-page.ts | 49 ++++++---- .../org-dashboard-container.spec.ts | 13 ++- .../org-dashboard-container.ts | 17 ++-- .../org-dashboard-page.html | 31 +------ .../org-dashboard-page.spec.ts | 15 ++-- .../org-dashboard-page.stories.ts | 13 ++- .../org-dashboard-page/org-dashboard-page.ts | 28 +++--- 16 files changed, 385 insertions(+), 108 deletions(-) create mode 100644 src/app/components/full-page-error-view/full-page-error-view.html create mode 100644 src/app/components/full-page-error-view/full-page-error-view.spec.ts create mode 100644 src/app/components/full-page-error-view/full-page-error-view.stories.ts create mode 100644 src/app/components/full-page-error-view/full-page-error-view.ts diff --git a/src/app/components/full-page-error-view/full-page-error-view.html b/src/app/components/full-page-error-view/full-page-error-view.html new file mode 100644 index 00000000..5109977f --- /dev/null +++ b/src/app/components/full-page-error-view/full-page-error-view.html @@ -0,0 +1,53 @@ + + +@let currentSummary = summary(); + + {{ currentSummary.icon }} + + +@let currentError = error(); +@let kind = currentError.kind; + +@switch (kind) { + @case ("unavailable") { + + Read the docs + + } + @case ("permissionDenied") { + Go to my organization + } + @default never; +} diff --git a/src/app/components/full-page-error-view/full-page-error-view.spec.ts b/src/app/components/full-page-error-view/full-page-error-view.spec.ts new file mode 100644 index 00000000..7664dbfb --- /dev/null +++ b/src/app/components/full-page-error-view/full-page-error-view.spec.ts @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: © 2026 Logan Magee +// +// SPDX-License-Identifier: AGPL-3.0-only + +import { inputBinding, signal } from "@angular/core"; +import { type ComponentFixture, TestBed } from "@angular/core/testing"; +import { provideRouter } from "@angular/router"; +import { type FullPageError, FullPageErrorView } from "./full-page-error-view"; + +describe("FullPageErrorView", () => { + beforeEach(() => { + TestBed.configureTestingModule({ providers: [provideRouter([])] }); + }); + + /** + * Creates a {@link FullPageErrorView} for the given error and waits for it to render. + */ + async function render(error: FullPageError): Promise> { + const fixture = TestBed.createComponent(FullPageErrorView, { + bindings: [inputBinding("error", signal(error))], + }); + await fixture.whenStable(); + return fixture; + } + + function text(fixture: ComponentFixture): string { + return fixture.nativeElement.textContent.replace(/\s+/g, " "); + } + + function link(fixture: ComponentFixture): string | null { + return fixture.nativeElement.querySelector("a").getAttribute("href"); + } + + describe("when unavailable", () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + fixture = await render({ kind: "unavailable", resource: { kind: "console" } }); + }); + + it("should show an error", () => { + const alert: HTMLElement = fixture.nativeElement.querySelector("[role=alert]"); + expect(alert.textContent).toContain("Something went wrong"); + }); + + it("should name the console", () => { + expect(text(fixture)).toContain("We couldn't load the console."); + }); + + it("should link to the docs", () => { + expect(link(fixture)).toBe("https://accrescent.app/docs/"); + }); + }); + + it("should identify the organization when it's unavailable", async () => { + const fixture = await render({ + kind: "unavailable", + resource: { kind: "organization", id: "org-123" }, + }); + + expect(text(fixture)).toContain("We couldn't load organization org-123."); + }); + + describe("when permission is denied", () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + fixture = await render({ + kind: "permissionDenied", + resource: { kind: "organization", id: "org-123" }, + }); + }); + + it("should show that the user has no access", () => { + expect(fixture.nativeElement.querySelector("h1").textContent).toContain("No access"); + }); + + it("should identify the organization", () => { + expect(text(fixture)).toContain( + "You don't have permission to view organization org-123", + ); + }); + + it("should link to the user's own organization", () => { + expect(link(fixture)).toBe("/"); + }); + }); +}); diff --git a/src/app/components/full-page-error-view/full-page-error-view.stories.ts b/src/app/components/full-page-error-view/full-page-error-view.stories.ts new file mode 100644 index 00000000..e8de0f27 --- /dev/null +++ b/src/app/components/full-page-error-view/full-page-error-view.stories.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: © 2026 Logan Magee +// +// SPDX-License-Identifier: AGPL-3.0-only + +import { provideRouter } from "@angular/router"; +import { applicationConfig, type Meta, type StoryObj } from "@storybook/angular-vite"; +import { FullPageErrorView } from "./full-page-error-view"; + +const ORGANIZATION_ID = "org_21nsTL6hybsteGOPytcUzx"; + +const meta: Meta = { + component: FullPageErrorView, + decorators: [applicationConfig({ providers: [provideRouter([])] })], +}; + +export default meta; + +type Story = StoryObj; + +export const ConsoleUnavailable: Story = { + args: { + error: { kind: "unavailable", resource: { kind: "console" } }, + }, +}; + +export const OrganizationUnavailable: Story = { + args: { + error: { kind: "unavailable", resource: { kind: "organization", id: ORGANIZATION_ID } }, + }, +}; + +export const OrganizationPermissionDenied: Story = { + args: { + error: { + kind: "permissionDenied", + resource: { kind: "organization", id: ORGANIZATION_ID }, + }, + }, +}; diff --git a/src/app/components/full-page-error-view/full-page-error-view.ts b/src/app/components/full-page-error-view/full-page-error-view.ts new file mode 100644 index 00000000..0339a668 --- /dev/null +++ b/src/app/components/full-page-error-view/full-page-error-view.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: © 2026 Logan Magee +// +// SPDX-License-Identifier: AGPL-3.0-only + +import { Component, computed, input } from "@angular/core"; +import { MatButton } from "@angular/material/button"; +import { MatIcon } from "@angular/material/icon"; +import { RouterLink } from "@angular/router"; + +/** + * A display for an error which applies to a whole page, usually because it prevents the page from + * loading any of its content. + */ +@Component({ + imports: [MatButton, MatIcon, RouterLink], + selector: "acc-full-page-error-view", + host: { class: "m-auto flex max-w-sm flex-col items-center gap-6 text-center" }, + templateUrl: "./full-page-error-view.html", +}) +export class FullPageErrorView { + /** + * The error to display. + */ + readonly error = input.required(); + + /** + * The icon and title shown above the error message. + */ + protected readonly summary = computed(() => SUMMARIES[this.error().kind]); +} + +const SUMMARIES = { + unavailable: { icon: "error", title: "Something went wrong" }, + permissionDenied: { icon: "lock", title: "No access" }, +} satisfies Record; + +/** + * An error which applies to a whole page. + */ +export type FullPageError = FullPageErrorUnavailable | FullPageErrorPermissionDenied; + +/** + * A resource couldn't be loaded for a reason outside the user's control. + * + * @typeParam R - the kind of resource that can be unavailable. + */ +export interface FullPageErrorUnavailable { + readonly kind: "unavailable"; + + /** + * The resource which couldn't be loaded. + */ + readonly resource: R; +} + +/** + * The user isn't allowed to view an organization. + */ +export interface FullPageErrorPermissionDenied { + readonly kind: "permissionDenied"; + + /** + * The organization the user isn't allowed to view. + */ + readonly resource: OrganizationResource; +} + +/** + * A resource that a {@link FullPageError} prevented from being shown. + */ +export type FullPageErrorResource = ConsoleResource | OrganizationResource; + +/** + * The console as a whole. + */ +export interface ConsoleResource { + readonly kind: "console"; +} + +/** + * An organization. + */ +export interface OrganizationResource { + readonly kind: "organization"; + + /** + * The ID of the organization. + */ + readonly id: string; +} diff --git a/src/app/components/home-container/home-container.spec.ts b/src/app/components/home-container/home-container.spec.ts index 4a0aa152..7f0d0770 100644 --- a/src/app/components/home-container/home-container.spec.ts +++ b/src/app/components/home-container/home-container.spec.ts @@ -17,11 +17,16 @@ import type { GetMyOrganizationResponse } from "../../../domain/ports/console/ge import type { GetOrganizationResponse } from "../../../domain/ports/console/get-organization-response"; import type { GetSelfResponse } from "../../../domain/ports/console/get-self-response"; import { CONSOLE_API } from "../../tokens"; -import { HomePage, HomePageState } from "../home-page/home-page"; +import { HomePage, type HomePageState } from "../home-page/home-page"; import { HomeContainer } from "./home-container"; const ORGANIZATION_ID = "org-123"; +const CONSOLE_UNAVAILABLE: HomePageState = { + kind: "error", + error: { kind: "unavailable", resource: { kind: "console" } }, +}; + describe("HomeContainer", () => { let consoleApi: FakeConsoleApi; @@ -48,11 +53,11 @@ describe("HomeContainer", () => { await vi.advanceTimersByTimeAsync(DELAY_MS - 1); fixture.detectChanges(); - expect(page.state()).toBe(HomePageState.Loading); + expect(page.state()).toEqual({ kind: "loading" }); await vi.advanceTimersByTimeAsync(1); fixture.detectChanges(); - expect(page.state()).toBe(HomePageState.LoadingFailed); + expect(page.state()).toEqual(CONSOLE_UNAVAILABLE); }); it("should redirect to the organization dashboard when authenticated", async () => { @@ -78,7 +83,7 @@ describe("HomeContainer", () => { fixture.detectChanges(); const page = fixture.debugElement.query(By.directive(HomePage)).injector.get(HomePage); - expect(page.state()).toBe(HomePageState.LoadingFailed); + expect(page.state()).toEqual(CONSOLE_UNAVAILABLE); }); }); diff --git a/src/app/components/home-container/home-container.ts b/src/app/components/home-container/home-container.ts index 79264e4c..6c6eedde 100644 --- a/src/app/components/home-container/home-container.ts +++ b/src/app/components/home-container/home-container.ts @@ -6,7 +6,7 @@ import { Component, inject, PendingTasks, signal } from "@angular/core"; import { Router } from "@angular/router"; import { withDelay } from "../../../domain/delay"; import { CONSOLE_API } from "../../tokens"; -import { HomePage, HomePageState } from "../home-page/home-page"; +import { HomePage, type HomePageState } from "../home-page/home-page"; /** * Effectful container for the home page which redirects the user to their organization's dashboard @@ -22,7 +22,7 @@ export class HomeContainer { private readonly router = inject(Router); private readonly pendingTasks = inject(PendingTasks); - protected readonly state = signal(HomePageState.Loading); + protected readonly state = signal({ kind: "loading" }); constructor() { // Run as a pending task so that the app isn't considered stable until we've redirected or @@ -43,7 +43,10 @@ export class HomeContainer { } else if (result.error.kind === "unauthenticated") { await this.router.navigate(["/login"], { replaceUrl: true }); } else { - this.state.set(HomePageState.LoadingFailed); + this.state.set({ + kind: "error", + error: { kind: "unavailable", resource: { kind: "console" } }, + }); } } } diff --git a/src/app/components/home-page/home-page.html b/src/app/components/home-page/home-page.html index 5317987a..aa0e8a7d 100644 --- a/src/app/components/home-page/home-page.html +++ b/src/app/components/home-page/home-page.html @@ -6,23 +6,13 @@
@let currentState = state(); - @switch (currentState) { - @case (HomePageState.Loading) { + @let kind = currentState.kind; + @switch (kind) { + @case ("loading") { } - @case (HomePageState.LoadingFailed) { -
- - - Read the docs - -
+ @case ("error") { + } @default never; } diff --git a/src/app/components/home-page/home-page.spec.ts b/src/app/components/home-page/home-page.spec.ts index 877acae6..71719210 100644 --- a/src/app/components/home-page/home-page.spec.ts +++ b/src/app/components/home-page/home-page.spec.ts @@ -4,7 +4,7 @@ import { inputBinding, signal } from "@angular/core"; import { type ComponentFixture, TestBed } from "@angular/core/testing"; -import { HomePage, HomePageState } from "./home-page"; +import { HomePage, type HomePageState } from "./home-page"; describe("HomePage", () => { beforeEach(async () => { @@ -28,7 +28,7 @@ describe("HomePage", () => { let fixture: ComponentFixture; beforeEach(async () => { - fixture = await render(HomePageState.Loading); + fixture = await render({ kind: "loading" }); }); it("should show a progress indicator", () => { @@ -40,7 +40,10 @@ describe("HomePage", () => { let fixture: ComponentFixture; beforeEach(async () => { - fixture = await render(HomePageState.LoadingFailed); + fixture = await render({ + kind: "error", + error: { kind: "unavailable", resource: { kind: "console" } }, + }); }); it("should show an error", () => { diff --git a/src/app/components/home-page/home-page.stories.ts b/src/app/components/home-page/home-page.stories.ts index e75ac9c1..5fcca187 100644 --- a/src/app/components/home-page/home-page.stories.ts +++ b/src/app/components/home-page/home-page.stories.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { Meta, StoryObj } from "@storybook/angular-vite"; -import { HomePage, HomePageState } from "./home-page"; +import { HomePage } from "./home-page"; const meta: Meta = { component: HomePage, @@ -18,12 +18,12 @@ type Story = StoryObj; export const Loading: Story = { args: { - state: HomePageState.Loading, + state: { kind: "loading" }, }, }; export const LoadingFailed: Story = { args: { - state: HomePageState.LoadingFailed, + state: { kind: "error", error: { kind: "unavailable", resource: { kind: "console" } } }, }, }; diff --git a/src/app/components/home-page/home-page.ts b/src/app/components/home-page/home-page.ts index 435260b7..0e9e55b0 100644 --- a/src/app/components/home-page/home-page.ts +++ b/src/app/components/home-page/home-page.ts @@ -3,37 +3,48 @@ // SPDX-License-Identifier: AGPL-3.0-only import { Component, input } from "@angular/core"; -import { MatButton } from "@angular/material/button"; import { MatProgressSpinner } from "@angular/material/progress-spinner"; - -/** - * The state of the {@link HomePage}. - */ -export enum HomePageState { - /** - * Determining where to send the user is in progress. - */ - Loading, - - /** - * Determining where to send the user failed. - */ - LoadingFailed, -} +import { + type ConsoleResource, + type FullPageErrorUnavailable, + FullPageErrorView, +} from "../full-page-error-view/full-page-error-view"; /** * The page shown while determining where to send the user on entering the console. */ @Component({ - imports: [MatButton, MatProgressSpinner], + imports: [FullPageErrorView, MatProgressSpinner], selector: "acc-home-page", templateUrl: "./home-page.html", }) export class HomePage { - protected readonly HomePageState = HomePageState; - /** * The state of determining where to send the user. */ readonly state = input.required(); } + +/** + * The state of the {@link HomePage}. + */ +export type HomePageState = HomePageLoading | HomePageError; + +/** + * Determining where to send the user is in progress. + */ +export interface HomePageLoading { + readonly kind: "loading"; +} + +/** + * Determining where to send the user failed. + */ +export interface HomePageError { + readonly kind: "error"; + + /** + * The error that occurred. + */ + readonly error: FullPageErrorUnavailable; +} diff --git a/src/app/components/org-dashboard-container/org-dashboard-container.spec.ts b/src/app/components/org-dashboard-container/org-dashboard-container.spec.ts index 57b6767c..85d8fa89 100644 --- a/src/app/components/org-dashboard-container/org-dashboard-container.spec.ts +++ b/src/app/components/org-dashboard-container/org-dashboard-container.spec.ts @@ -94,7 +94,13 @@ describe("OrgDashboardContainer", () => { const fixture = await render(); - expect(pageState(fixture)).toEqual({ kind: "permissionDenied" }); + expect(pageState(fixture)).toEqual({ + kind: "error", + error: { + kind: "permissionDenied", + resource: { kind: "organization", id: ORGANIZATION.id }, + }, + }); }); it("should redirect to the login page when unauthenticated", async () => { @@ -110,7 +116,10 @@ describe("OrgDashboardContainer", () => { const fixture = await render(); - expect(pageState(fixture)).toEqual({ kind: "loadingFailed" }); + expect(pageState(fixture)).toEqual({ + kind: "error", + error: { kind: "unavailable", resource: { kind: "organization", id: ORGANIZATION.id } }, + }); }); }); diff --git a/src/app/components/org-dashboard-container/org-dashboard-container.ts b/src/app/components/org-dashboard-container/org-dashboard-container.ts index 908373ee..7fdbd1ed 100644 --- a/src/app/components/org-dashboard-container/org-dashboard-container.ts +++ b/src/app/components/org-dashboard-container/org-dashboard-container.ts @@ -9,6 +9,7 @@ import { withDelay } from "../../../domain/delay"; import type { GetOrganizationError } from "../../../domain/ports/console/errors"; import type { GetOrganizationResponse } from "../../../domain/ports/console/get-organization-response"; import { CONSOLE_API } from "../../tokens"; +import type { OrganizationResource } from "../full-page-error-view/full-page-error-view"; import { OrgDashboardPage, type OrgDashboardPageState, @@ -38,11 +39,12 @@ export class OrgDashboardContainer { withDelay(this.consoleApi.getOrganization(params, abortSignal)), }); protected readonly state = computed((): OrgDashboardPageState => { + const resource: OrganizationResource = { kind: "organization", id: this.orgId() }; if (this.organization.status() === "error") { - return { kind: "loadingFailed" }; + return { kind: "error", error: { kind: "unavailable", resource } }; } return this.organization.hasValue() - ? toPageState(this.organization.value()) + ? toPageState(this.organization.value(), resource) : { kind: "loading" }; }); @@ -62,18 +64,21 @@ export class OrgDashboardContainer { function toPageState( result: Result, + resource: OrganizationResource, ): OrgDashboardPageState { if (result.ok) { return { kind: "loaded", organization: result.value.organization }; } switch (result.error.kind) { - case "permissionDenied": - return { kind: "permissionDenied" }; // Keep showing progress while redirecting to the login page case "unauthenticated": return { kind: "loading" }; - default: - return { kind: "loadingFailed" }; + case "permissionDenied": + return { kind: "error", error: { kind: "permissionDenied", resource } }; + case "internal": + case "serviceUnavailable": + case "unknown": + return { kind: "error", error: { kind: "unavailable", resource } }; } } diff --git a/src/app/components/org-dashboard-page/org-dashboard-page.html b/src/app/components/org-dashboard-page/org-dashboard-page.html index 6af67f7e..9ba5d556 100644 --- a/src/app/components/org-dashboard-page/org-dashboard-page.html +++ b/src/app/components/org-dashboard-page/org-dashboard-page.html @@ -13,35 +13,8 @@ } - @case ("loadingFailed") { -
- - - Read the docs - -
- } - @case ("permissionDenied") { -
- - lock - -
-

No access

-

- You don't have permission to view this organization (or it might not exist). -

-
- Go to my organization -
+ @case ("error") { + } @case ("loaded") { @let organization = currentState.organization; diff --git a/src/app/components/org-dashboard-page/org-dashboard-page.spec.ts b/src/app/components/org-dashboard-page/org-dashboard-page.spec.ts index 460cdefe..63adc110 100644 --- a/src/app/components/org-dashboard-page/org-dashboard-page.spec.ts +++ b/src/app/components/org-dashboard-page/org-dashboard-page.spec.ts @@ -44,7 +44,13 @@ describe("OrgDashboardPage", () => { let fixture: ComponentFixture; beforeEach(async () => { - fixture = await render({ kind: "loadingFailed" }); + fixture = await render({ + kind: "error", + error: { + kind: "unavailable", + resource: { kind: "organization", id: ORGANIZATION.id }, + }, + }); }); it("should show an error", () => { @@ -53,13 +59,6 @@ describe("OrgDashboardPage", () => { }); }); - it("should link to the user's own organization when permission is denied", async () => { - const fixture = await render({ kind: "permissionDenied" }); - - const link: HTMLAnchorElement = fixture.nativeElement.querySelector("a"); - expect(link.getAttribute("href")).toBe("/"); - }); - describe("when loaded", () => { let fixture: ComponentFixture; diff --git a/src/app/components/org-dashboard-page/org-dashboard-page.stories.ts b/src/app/components/org-dashboard-page/org-dashboard-page.stories.ts index 44194eb5..7b46e6a0 100644 --- a/src/app/components/org-dashboard-page/org-dashboard-page.stories.ts +++ b/src/app/components/org-dashboard-page/org-dashboard-page.stories.ts @@ -52,13 +52,22 @@ export const Loading: Story = { export const LoadingFailed: Story = { args: { - state: { kind: "loadingFailed" }, + state: { + kind: "error", + error: { kind: "unavailable", resource: { kind: "organization", id: ORGANIZATION.id } }, + }, }, }; export const PermissionDenied: Story = { args: { - state: { kind: "permissionDenied" }, + state: { + kind: "error", + error: { + kind: "permissionDenied", + resource: { kind: "organization", id: ORGANIZATION.id }, + }, + }, }, }; diff --git a/src/app/components/org-dashboard-page/org-dashboard-page.ts b/src/app/components/org-dashboard-page/org-dashboard-page.ts index 34407e24..c0b92ce4 100644 --- a/src/app/components/org-dashboard-page/org-dashboard-page.ts +++ b/src/app/components/org-dashboard-page/org-dashboard-page.ts @@ -3,18 +3,21 @@ // SPDX-License-Identifier: AGPL-3.0-only import { Component, input } from "@angular/core"; -import { MatButton } from "@angular/material/button"; -import { MatIcon } from "@angular/material/icon"; import { MatProgressSpinner } from "@angular/material/progress-spinner"; -import { RouterLink } from "@angular/router"; import type { Organization } from "../../../domain/ports/console/organization"; +import { + type FullPageErrorPermissionDenied, + type FullPageErrorUnavailable, + FullPageErrorView, + type OrganizationResource, +} from "../full-page-error-view/full-page-error-view"; import { QuotaCard } from "../quota-card/quota-card"; /** * The dashboard page for a given organization. */ @Component({ - imports: [MatButton, MatIcon, MatProgressSpinner, QuotaCard, RouterLink], + imports: [FullPageErrorView, MatProgressSpinner, QuotaCard], selector: "acc-org-dashboard-page", templateUrl: "./org-dashboard-page.html", }) @@ -30,8 +33,7 @@ export class OrgDashboardPage { */ export type OrgDashboardPageState = | OrgDashboardPageLoading - | OrgDashboardPageLoadingFailed - | OrgDashboardPagePermissionDenied + | OrgDashboardPageError | OrgDashboardPageLoaded; /** @@ -44,15 +46,13 @@ export interface OrgDashboardPageLoading { /** * Loading the organization failed. */ -export interface OrgDashboardPageLoadingFailed { - readonly kind: "loadingFailed"; -} +export interface OrgDashboardPageError { + readonly kind: "error"; -/** - * The user isn't allowed to view the organization. - */ -export interface OrgDashboardPagePermissionDenied { - readonly kind: "permissionDenied"; + /** + * The error that occurred. + */ + readonly error: FullPageErrorUnavailable | FullPageErrorPermissionDenied; } /**