10 · Project — An Issue Tracker¶
This project brings Level 2 together in a small but realistic CRUD app: list issues with search and status filters kept in the URL, open an issue, change its status or delete it, and create new issues through a validated form that also shows errors coming back from the server.
To keep it runnable with no backend, the "server" is an interceptor that implements a
small REST API in memory, with 300 ms of latency so loading states are visible. Swap it
for a real API by removing one line from app.config.ts.
| Lesson | Where it shows up |
|---|---|
| 02 Signal forms | NewIssuePage: schema validation, submit(), server error on title |
| 03 DI | Busy service shared by an interceptor and the header |
| 04 RxJS | the fake backend (of, delay, timer, switchMap), finalize |
| 06 Resources | httpResource for the list and the detail page |
| 07 Router | lazy routes, query params as inputs, canDeactivate |
| 08 Interceptors | busy counter, error mapping, the fake backend |
| 09 Pipes & directives | DatePipe with explicit time zone, host bindings in StatusBadge |
Every file below was built with ng build, tested with ng test, and driven end-to-end
in headless Chromium on Angular 22.2. The results are in
Checking it works.
Setup¶
Files live under src/app/issues/ (feature code) and src/app/http/ (HTTP plumbing).
The model¶
export type IssueStatus = 'open' | 'in-progress' | 'closed';
export type Priority = 'low' | 'medium' | 'high';
export interface Issue {
id: number;
title: string;
description: string;
status: IssueStatus;
priority: Priority;
createdAt: string; // ISO timestamp
}
export type NewIssue = Pick<Issue, 'title' | 'description' | 'priority'>;
The in-memory API¶
import { HttpErrorResponse, HttpInterceptorFn, HttpRequest, HttpResponse } from '@angular/common/http';
import { Observable, delay, of, switchMap, throwError, timer } from 'rxjs';
import { Issue, NewIssue } from '../issues/issue';
// An in-memory REST API so the project runs with no server.
// Only requests under /api/ are handled; everything else passes through.
let issues: Issue[] = [
{ id: 1, title: 'Login button misaligned on mobile', description: 'Overlaps the logo below 360px.', status: 'open', priority: 'medium', createdAt: '2026-09-20T09:00:00Z' },
{ id: 2, title: 'Export to CSV drops the last row', description: 'Reproducible with any report over 100 rows.', status: 'in-progress', priority: 'high', createdAt: '2026-09-22T14:30:00Z' },
{ id: 3, title: 'Typo in welcome email', description: '"Welcom" in the subject line.', status: 'closed', priority: 'low', createdAt: '2026-09-25T08:15:00Z' },
];
let nextId = 4;
const LATENCY_MS = 300;
export function resetFakeDb(data: Issue[]) {
issues = structuredClone(data);
nextId = Math.max(0, ...issues.map((i) => i.id)) + 1;
}
export const fakeBackendInterceptor: HttpInterceptorFn = (req, next) => {
const url = new URL(req.urlWithParams, 'http://local');
if (!url.pathname.startsWith('/api/issues')) return next(req);
const id = Number(url.pathname.split('/')[3]) || null;
switch (true) {
case req.method === 'GET' && id === null: {
const q = (url.searchParams.get('q') ?? '').toLowerCase();
const status = url.searchParams.get('status');
const result = issues
.filter((i) => !q || i.title.toLowerCase().includes(q))
.filter((i) => !status || i.status === status)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return ok(result);
}
case req.method === 'GET': {
const issue = issues.find((i) => i.id === id);
return issue ? ok(issue) : fail(404, `Issue ${id} not found`);
}
case req.method === 'POST': {
const body = (req as HttpRequest<NewIssue>).body!;
if (issues.some((i) => i.title.toLowerCase() === body.title.trim().toLowerCase())) {
return fail(409, 'An issue with this title already exists.');
}
const created: Issue = { ...body, title: body.title.trim(), id: nextId++, status: 'open', createdAt: new Date().toISOString() };
issues = [...issues, created];
return ok(created, 201);
}
case req.method === 'PATCH': {
const existing = issues.find((i) => i.id === id);
if (!existing) return fail(404, `Issue ${id} not found`);
const updated = { ...existing, ...(req.body as Partial<Issue>), id: existing.id };
issues = issues.map((i) => (i.id === id ? updated : i));
return ok(updated);
}
case req.method === 'DELETE':
issues = issues.filter((i) => i.id !== id);
return ok(null, 204);
default:
return fail(405, 'Method not allowed');
}
function ok<T>(body: T, status = 200): Observable<HttpResponse<T>> {
return of(new HttpResponse({ status, body: structuredClone(body), url: req.url })).pipe(delay(LATENCY_MS));
}
function fail(status: number, message: string): Observable<never> {
// delay() only delays values, not errors — so wait first, then fail.
return timer(LATENCY_MS).pipe(
switchMap(() => throwError(() => new HttpErrorResponse({ status, error: { message }, url: req.url }))),
);
}
};
It implements GET /api/issues?q=&status=, GET /api/issues/:id, POST (with a 409 on a
duplicate title), PATCH and DELETE, and returns clones so the app can't accidentally
mutate the "database".
One bug worth knowing about: the first version used throwError(...).pipe(delay(LATENCY_MS))
for failures. A unit test for the 409 path finished in 43 ms instead of 300+ — RxJS
delay delays values, not error notifications, so errors arrived instantly. The
timer(...).pipe(switchMap(() => throwError(...))) version waits first; afterwards the
same test took 350 ms. Fake backends that fail faster than they succeed hide real
loading-state bugs.
HTTP plumbing¶
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { catchError, throwError } from 'rxjs';
export class ApiError extends Error {
constructor(readonly status: number, message: string) {
super(message);
}
}
export const errorInterceptor: HttpInterceptorFn = (req, next) =>
next(req).pipe(
catchError((err: HttpErrorResponse) =>
throwError(() => new ApiError(err.status, err.status === 0 ? 'Network error — check your connection.' : err.error?.message ?? err.statusText)),
),
);
import { HttpInterceptorFn } from '@angular/common/http';
import { Service, computed, inject, signal } from '@angular/core';
import { finalize } from 'rxjs';
@Service()
export class Busy {
private readonly count = signal(0);
readonly active = computed(() => this.count() > 0);
start() { this.count.update((n) => n + 1); }
stop() { this.count.update((n) => n - 1); }
}
export const busyInterceptor: HttpInterceptorFn = (req, next) => {
const busy = inject(Busy);
busy.start();
return next(req).pipe(finalize(() => busy.stop()));
};
Busy exposes a computed boolean rather than the raw counter, so the header's template
can't depend on implementation details.
The API service for writes¶
import { HttpClient } from '@angular/common/http';
import { Service, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { Issue, IssueStatus, NewIssue } from './issue';
@Service()
export class IssueApi {
private readonly http = inject(HttpClient);
create(input: NewIssue) {
return firstValueFrom(this.http.post<Issue>('/api/issues', input));
}
setStatus(id: number, status: IssueStatus) {
return firstValueFrom(this.http.patch<Issue>(`/api/issues/${id}`, { status }));
}
remove(id: number) {
return firstValueFrom(this.http.delete<void>(`/api/issues/${id}`));
}
}
Reads use httpResource in the components; writes go through this service. The methods
return Promises (firstValueFrom) because they're awaited from event handlers and
submit() actions.
A status badge¶
import { Component, input } from '@angular/core';
import { IssueStatus } from './issue';
@Component({
selector: 'app-status-badge',
host: { '[class]': 'status()', role: 'status' },
template: `
@let s = status();
@switch (s) {
@case ('open') { Open }
@case ('in-progress') { In progress }
@case ('closed') { Closed }
@default never;
}
`,
styles: `
:host { display: inline-block; padding: 0 0.5rem; border-radius: 999px; font-size: 0.8rem; }
:host(.open) { background: #dff5e1; }
:host(.in-progress) { background: #fff1c7; }
:host(.closed) { background: #e6e6e6; }
`,
})
export class StatusBadge {
readonly status = input.required<IssueStatus>();
}
The @let before @switch is deliberate: switching directly on status() with
@default never; failed to compile, because TypeScript can't narrow a function call
(Level 1, lesson 05). The host binding puts the status on the element as a class, and
:host(.open) styles it.
The list page: filters in the URL¶
import { Component, inject, input } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { DatePipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { Issue, IssueStatus } from './issue';
import { StatusBadge } from './status-badge';
@Component({
selector: 'app-issue-list',
imports: [RouterLink, DatePipe, StatusBadge],
template: `
<h2>Issues</h2>
<form role="search" (submit)="$event.preventDefault(); search(q.value, st.value)">
<input #q type="search" name="q" aria-label="Search titles" [value]="query() ?? ''" />
<select #st name="status" aria-label="Status" [value]="status() ?? ''">
<option value="">Any status</option>
<option value="open">Open</option>
<option value="in-progress">In progress</option>
<option value="closed">Closed</option>
</select>
<button>Filter</button>
<a routerLink="/issues/new">New issue</a>
</form>
@if (issues.error(); as err) {
<p role="alert">{{ err.message }} <button type="button" (click)="issues.reload()">Retry</button></p>
} @else if (issues.hasValue()) {
<ul class="issues" [attr.aria-busy]="issues.isLoading()">
@for (issue of issues.value(); track issue.id) {
<li>
<a [routerLink]="['/issues', issue.id]">#{{ issue.id }} {{ issue.title }}</a>
<app-status-badge [status]="issue.status" />
<small>{{ issue.priority }} · {{ issue.createdAt | date: 'mediumDate' : 'UTC' }}</small>
</li>
} @empty {
<li>No issues match.</li>
}
</ul>
} @else {
<p>Loading…</p>
}
`,
})
export class IssueList {
// Bound from query params (?q=...&status=...) by withComponentInputBinding().
readonly query = input<string>(undefined, { alias: 'q' });
readonly status = input<IssueStatus>();
private readonly router = inject(Router);
protected readonly issues = httpResource<Issue[]>(() => ({
url: '/api/issues',
params: { q: this.query() ?? '', status: this.status() ?? '' },
}));
protected search(q: string, status: string) {
this.router.navigate([], { queryParams: { q: q || null, status: status || null } });
}
}
The filters are query-parameter inputs (?q=export&status=open), so filtered views
are bookmarkable and survive reloads, and the Back button undoes a filter change.
Submitting the form only navigates; the httpResource notices the new inputs and
refetches. query uses alias: 'q' so the URL stays short while the class property stays
readable.
The detail page¶
import { Component, inject, input, numberAttribute, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { DatePipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { Issue, IssueStatus } from './issue';
import { IssueApi } from './issue-api';
import { StatusBadge } from './status-badge';
@Component({
selector: 'app-issue-detail',
imports: [RouterLink, DatePipe, StatusBadge],
template: `
@if (issue.error(); as err) {
<p role="alert">{{ err.message }}</p>
} @else if (issue.hasValue()) {
@let i = issue.value();
<h2>#{{ i.id }} {{ i.title }} <app-status-badge [status]="i.status" /></h2>
<p>{{ i.description || 'No description.' }}</p>
<p><small>Priority {{ i.priority }} · opened {{ i.createdAt | date: 'medium' : 'UTC' }}</small></p>
<div role="group" aria-label="Change status">
@for (s of statuses; track s) {
<button type="button" [disabled]="s === i.status || saving()" (click)="setStatus(s)">{{ s }}</button>
}
</div>
@if (actionError()) { <p role="alert">{{ actionError() }}</p> }
<button type="button" class="danger" [disabled]="saving()" (click)="remove()">Delete</button>
} @else {
<p>Loading…</p>
}
<p><a routerLink="/issues">← All issues</a></p>
`,
})
export class IssueDetail {
readonly id = input.required({ transform: numberAttribute });
private readonly api = inject(IssueApi);
private readonly router = inject(Router);
protected readonly statuses: IssueStatus[] = ['open', 'in-progress', 'closed'];
protected readonly saving = signal(false);
protected readonly actionError = signal('');
protected readonly issue = httpResource<Issue>(() => `/api/issues/${this.id()}`);
protected async setStatus(status: IssueStatus) {
await this.run(async () => {
const updated = await this.api.setStatus(this.id(), status);
this.issue.value.set(updated); // show the server's version immediately
});
}
protected async remove() {
if (!confirm('Delete this issue?')) return;
await this.run(async () => {
await this.api.remove(this.id());
await this.router.navigate(['/issues']);
});
}
private async run(action: () => Promise<void>) {
this.saving.set(true);
this.actionError.set('');
try {
await action();
} catch (e) {
this.actionError.set((e as Error).message);
} finally {
this.saving.set(false);
}
}
}
After a successful PATCH, issue.value.set(updated) shows the server's copy immediately
(the resource's status becomes local) instead of refetching. The id input uses
numberAttribute because route parameters are always strings.
The form¶
import { Component, inject, signal } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { FormField, form, maxLength, minLength, required, submit } from '@angular/forms/signals';
import { ApiError } from '../http/api-error';
import { NewIssue } from './issue';
import { IssueApi } from './issue-api';
const blank: NewIssue = { title: '', description: '', priority: 'medium' };
@Component({
selector: 'app-new-issue',
imports: [FormField, RouterLink],
template: `
<h2>New issue</h2>
<form (submit)="save($event)" novalidate>
<label>Title <input [formField]="f.title" /></label>
@if (f.title().touched()) {
@for (e of f.title().errors(); track e.kind) { <p class="err" role="alert">{{ e.message }}</p> }
}
<label>Description <textarea rows="4" [formField]="f.description"></textarea></label>
@for (e of f.description().errors(); track e.kind) { <p class="err" role="alert">{{ e.message }}</p> }
<label>Priority
<select [formField]="f.priority">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</label>
<button [disabled]="f().submitting()">{{ f().submitting() ? 'Saving…' : 'Create issue' }}</button>
<a routerLink="/issues">Cancel</a>
</form>
`,
})
export class NewIssuePage {
private readonly api = inject(IssueApi);
private readonly router = inject(Router);
private saved = false;
protected readonly model = signal<NewIssue>({ ...blank });
protected readonly f = form(this.model, (p) => {
required(p.title, { message: 'Give the issue a title.' });
minLength(p.title, 5, { message: 'Titles need at least 5 characters.' });
maxLength(p.title, 120, { message: 'Keep titles under 120 characters.' });
maxLength(p.description, 2000, { message: 'Descriptions are limited to 2000 characters.' });
});
/** Used by the canDeactivate guard. */
hasUnsavedChanges() {
return !this.saved && this.f().dirty();
}
protected async save(event: Event) {
event.preventDefault();
await submit(this.f, {
action: async () => {
try {
const created = await this.api.create(this.model());
this.saved = true;
await this.router.navigate(['/issues', created.id]);
return undefined;
} catch (e) {
if (e instanceof ApiError && e.status === 409) {
return [{ fieldTree: this.f.title, kind: 'server', message: e.message }];
}
throw e;
}
},
});
}
}
import { CanDeactivateFn } from '@angular/router';
export interface HasUnsavedChanges {
hasUnsavedChanges(): boolean;
}
export const unsavedChangesGuard: CanDeactivateFn<HasUnsavedChanges> = (component) =>
!component.hasUnsavedChanges() || confirm('Discard this new issue?');
The submit action translates a 409 into a field error on title, and rethrows
anything else so it isn't silently swallowed. hasUnsavedChanges returns false after a
successful save — otherwise the guard would ask "Discard this new issue?" while navigating
to the issue you just created.
Wiring¶
import { Routes } from '@angular/router';
import { unsavedChangesGuard } from './issues/unsaved-changes.guard';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'issues' },
{ path: 'issues', title: 'Issues', loadComponent: () => import('./issues/issue-list').then((m) => m.IssueList) },
{
path: 'issues/new',
title: 'New issue',
canDeactivate: [unsavedChangesGuard],
loadComponent: () => import('./issues/new-issue').then((m) => m.NewIssuePage),
},
{ path: 'issues/:id', title: 'Issue', loadComponent: () => import('./issues/issue-detail').then((m) => m.IssueDetail) },
{ path: '**', redirectTo: 'issues' },
];
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { busyInterceptor } from './http/busy';
import { errorInterceptor } from './http/api-error';
import { fakeBackendInterceptor } from './http/fake-backend.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
// Order: busy (outermost) → error mapping → fake backend (innermost, replaces the network).
provideHttpClient(withInterceptors([busyInterceptor, errorInterceptor, fakeBackendInterceptor])),
],
};
import { Component, inject } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import { Busy } from './http/busy';
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink],
template: `
<header>
<a routerLink="/issues"><strong>Issue Tracker</strong></a>
@if (busy.active()) { <span class="busy" aria-live="polite">Working…</span> }
</header>
<main><router-outlet /></main>
`,
})
export class App {
protected readonly busy = inject(Busy);
}
Tests¶
import { TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter, Router } from '@angular/router';
import { errorInterceptor } from '../http/api-error';
import { fakeBackendInterceptor, resetFakeDb } from '../http/fake-backend.interceptor';
import { NewIssuePage } from './new-issue';
describe('NewIssuePage', () => {
beforeEach(() => {
resetFakeDb([
{ id: 1, title: 'Existing issue', description: '', status: 'open', priority: 'low', createdAt: '2026-09-01T00:00:00Z' },
]);
TestBed.configureTestingModule({
providers: [
provideRouter([]),
provideHttpClient(withInterceptors([errorInterceptor, fakeBackendInterceptor])),
],
});
});
function fillTitle(el: HTMLElement, value: string) {
const input = el.querySelector('input')!;
input.value = value;
input.dispatchEvent(new Event('input'));
}
it('shows the server error when the title is taken', async () => {
const fixture = TestBed.createComponent(NewIssuePage);
const el: HTMLElement = fixture.nativeElement;
await fixture.whenStable();
fillTitle(el, 'existing ISSUE');
el.querySelector('form')!.dispatchEvent(new Event('submit'));
await fixture.whenStable(); // waits for the (fake) HTTP call — no request is left pending
expect(el.querySelector('.err')?.textContent).toContain('already exists');
expect(fixture.componentInstance.hasUnsavedChanges()).toBe(true);
});
it('creates the issue and navigates to it', async () => {
const router = TestBed.inject(Router);
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(NewIssuePage);
const el: HTMLElement = fixture.nativeElement;
await fixture.whenStable();
fillTitle(el, 'A brand new bug');
el.querySelector('form')!.dispatchEvent(new Event('submit'));
await fixture.whenStable();
expect(navigate).toHaveBeenCalledWith(['/issues', 2]);
expect(fixture.componentInstance.hasUnsavedChanges()).toBe(false);
});
});
Here await fixture.whenStable() does wait for the HTTP call, because the request is
handled by the fake backend inside the test rather than left pending in
HttpTestingController (compare lesson 06).
Checking it works¶
ng test: both tests passed. ng build:
Initial chunk files | Names | Raw size | Estimated transfer size
main-EBRFL3LB.js | main | 286.50 kB | 79.54 kB
styles-5INURTSO.css | styles | 0 bytes | 0 bytes
| Initial total | 286.50 kB | 79.54 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-TCP2IgGb.js | new-issue | 62.52 kB | 15.88 kB
chunk-BJ5wj82Y.js | issue-detail | 2.69 kB | 1.23 kB
chunk-CeHieItW.js | issue-list | 2.64 kB | 1.23 kB
The signal forms code is only needed by the "New issue" page, so it lands in that lazy chunk rather than the initial bundle.
We then served the production build and drove it with Playwright in headless Chromium (dialogs: "Delete this issue?" accepted, "Discard this new issue?" dismissed):
1 /issues title "Issues" #3 Typo in welcome email (Closed), #2 Export to CSV… (In progress), #1 Login button… (Open)
2 /issues?q=export #2 Export to CSV drops the last row
3 /issues?status=closed #3 Typo in welcome email (select shows "closed")
4 empty submit ["Give the issue a title."]
5 title "Typo" ["Titles need at least 5 characters."]
6 Cancel with dirty form dialog "Discard this new issue?" → stayed on /issues/new
7 duplicate title ["An issue with this title already exists."] still on /issues/new
8 while saving button "Saving…", header busy indicator shown
9 created /issues/4 "#4 Search ignores accents Open"
10 status → in-progress badge "In progress"
11 delete back on /issues with the original three issues
12 /issues/99 "Issue 99 not found"
console errors: none
How It Actually Works¶
Trace step 7 — creating an issue whose title already exists:
submit(this.f, ...)marks every field touched; the form is valid, so it setssubmitting()totrueand calls the action. The button re-renders as "Saving…".api.create()callsHttpClient.post. The request passes throughbusyInterceptor(count → 1, header shows "Working…"), thenerrorInterceptor, thenfakeBackendInterceptor, which never callsnext— it is the backend.- After 300 ms the backend emits an
HttpErrorResponsewith status 409.errorInterceptorconverts it toApiError(409, 'An issue with this title already exists.');finalizeinbusyInterceptordecrements the count. firstValueFromrejects with theApiError; the action catches it and returns a field-targeted error. Signal forms attach it totitle,submitting()goes back tofalse, and the template's@foroverf.title().errors()renders it.- When the user edits the title, the server error is cleared automatically (lesson 02).
No component subscribed to anything, and nothing had to be unsubscribed. The data flowed through interceptors as observables and reached the UI as signals.
Extensions¶
- Debounced search. Update
?q=as the user types, 300 ms after the last keystroke, usingreplaceUrl: trueso every keystroke doesn't add a history entry. - Keep the list visible while filtering. The list flashes "Loading…" on each filter
change because a resource clears its value when params change. Keep the previous
value with
linkedSignal(Level 3, lesson 03). - Edit page. Reuse the form for editing: load the issue with a resolver, initialise the model from it, and PATCH on submit.
- Optimistic delete. Remove the issue from the list immediately and restore it if the DELETE fails.
- Real backend. Remove
fakeBackendInterceptorand point the app at a small JSON server of your choice; nothing else should need to change.
Exercise¶
Answer in a few sentences each:
- Why do the list filters live in query parameters rather than in signals inside
IssueList? - Why is
errorInterceptorplaced before (outside)fakeBackendInterceptorin the array? What would the form see if you swapped them? - Why does
NewIssuePagesetsaved = truebefore navigating, and what bug would appear without it? - The detail page sets
issue.valueafter a PATCH instead of callingreload(). Give one advantage and one risk of that choice.