Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/app/components/full-page-error-view/full-page-error-view.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<!--
SPDX-FileCopyrightText: © 2026 Logan Magee
SPDX-License-Identifier: AGPL-3.0-only
-->

@let currentSummary = summary();
<span
class="flex size-16 items-center justify-center rounded-full bg-(--mat-sys-error-container) text-(--mat-sys-on-error-container)">
<mat-icon class="size-8! text-[32px]!">{{ currentSummary.icon }}</mat-icon>
</span>

@let currentError = error();
@let kind = currentError.kind;
<div class="flex flex-col gap-2" role="alert">
<h1 class="[font:var(--mat-sys-headline-small)]">{{ currentSummary.title }}</h1>
<p class="text-(--mat-sys-on-surface-variant)">
@switch (kind) {
@case ("unavailable") {
@let resource = currentError.resource;
@let resourceKind = resource.kind;
@switch (resourceKind) {
@case ("console") {
We couldn't load the console.
}
@case ("organization") {
We couldn't load organization <code>{{ resource.id }}</code
>.
}
@default never;
}
This isn't caused by anything you did. Please try again in a few minutes, or check
out our documentation in the meantime.
}
@case ("permissionDenied") {
You don't have permission to view organization
<code>{{ currentError.resource.id }}</code> (or it might not exist).
}
@default never;
}
</p>
</div>
@switch (kind) {
@case ("unavailable") {
<a matButton="filled" href="https://accrescent.app/docs/" target="_blank">
Read the docs
</a>
}
@case ("permissionDenied") {
<a matButton="filled" routerLink="/">Go to my organization</a>
}
@default never;
}
Original file line number Diff line number Diff line change
@@ -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<ComponentFixture<FullPageErrorView>> {
const fixture = TestBed.createComponent(FullPageErrorView, {
bindings: [inputBinding("error", signal(error))],
});
await fixture.whenStable();
return fixture;
}

function text(fixture: ComponentFixture<FullPageErrorView>): string {
return fixture.nativeElement.textContent.replace(/\s+/g, " ");
}

function link(fixture: ComponentFixture<FullPageErrorView>): string | null {
return fixture.nativeElement.querySelector("a").getAttribute("href");
}

describe("when unavailable", () => {
let fixture: ComponentFixture<FullPageErrorView>;

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<FullPageErrorView>;

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("/");
});
});
});
Original file line number Diff line number Diff line change
@@ -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<FullPageErrorView> = {
component: FullPageErrorView,
decorators: [applicationConfig({ providers: [provideRouter([])] })],
};

export default meta;

type Story = StoryObj<FullPageErrorView>;

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 },
},
},
};
90 changes: 90 additions & 0 deletions src/app/components/full-page-error-view/full-page-error-view.ts
Original file line number Diff line number Diff line change
@@ -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<FullPageError>();

/**
* 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<FullPageError["kind"], { icon: string; title: string }>;

/**
* 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<R extends FullPageErrorResource = FullPageErrorResource> {
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;
}
13 changes: 9 additions & 4 deletions src/app/components/home-container/home-container.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 () => {
Expand All @@ -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);
});
});

Expand Down
9 changes: 6 additions & 3 deletions src/app/components/home-container/home-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<HomePageState>({ kind: "loading" });

constructor() {
// Run as a pending task so that the app isn't considered stable until we've redirected or
Expand All @@ -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" } },
});
}
}
}
20 changes: 5 additions & 15 deletions src/app/components/home-page/home-page.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,13 @@

<main class="flex min-h-screen items-center justify-center p-4">
@let currentState = state();
@switch (currentState) {
@case (HomePageState.Loading) {
@let kind = currentState.kind;
@switch (kind) {
@case ("loading") {
<mat-progress-spinner mode="indeterminate" aria-label="Loading" />
}
@case (HomePageState.LoadingFailed) {
<div class="flex max-w-sm flex-col items-center gap-6 text-center">
<div class="flex flex-col gap-2" role="alert">
<h1 class="[font:var(--mat-sys-headline-small)]">Something went wrong</h1>
<p class="text-(--mat-sys-on-surface-variant)">
We couldn't load the console. This isn't caused by anything you did. Please
try again in a few minutes, or check out our documentation in the meantime.
</p>
</div>
<a matButton="filled" href="https://accrescent.app/docs/" target="_blank">
Read the docs
</a>
</div>
@case ("error") {
<acc-full-page-error-view [error]="currentState.error" />
}
@default never;
}
Expand Down
Loading
Loading