- @if (detailResource.value(); as u) {
- {{ u.name }} — {{ u.email }}
+ @let detail = data();
+
+ @if (detail.tag === 'Success' && detail.value) {
+ {{ detail.value.name }} — {{ detail.value.email }}
}
diff --git a/src/app/users/ui/users.page.spec.ts b/src/app/users/ui/users.page.spec.ts
index f02ac9f..c8c8c86 100644
--- a/src/app/users/ui/users.page.spec.ts
+++ b/src/app/users/ui/users.page.spec.ts
@@ -1,21 +1,55 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
-import { UsersPage } from './users.page';
+import { provideRouter, withComponentInputBinding } from '@angular/router';
+import { RouterTestingHarness } from '@angular/router/testing';
+import { usersRoutes } from './users.routes';
function stubFetch() {
vi.stubGlobal(
'fetch',
vi.fn((url: string) => {
if (url.endsWith('/users')) {
- return Promise.resolve({ json: () => Promise.resolve([{ id: 1, name: 'Ada' }]) });
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve([{ id: 1, name: 'Ada' }]),
+ });
}
return Promise.resolve({
+ ok: true,
+ status: 200,
json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'ada@example.com' }),
});
}),
);
}
+function stubFetchFailure() {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: false, status: 500, json: () => Promise.resolve({}) }),
+ );
+}
+
+function stubFetchEmpty() {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve([]) }),
+ );
+}
+
+async function renderUsersAt(url: string) {
+ TestBed.configureTestingModule({
+ providers: [
+ provideRouter([{ path: 'users', children: usersRoutes }], withComponentInputBinding()),
+ ],
+ });
+ const harness = await RouterTestingHarness.create(url);
+ await harness.fixture.whenStable();
+ harness.detectChanges();
+ return harness;
+}
+
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -23,44 +57,50 @@ afterEach(() => {
describe('UsersPage', () => {
it('renders the fetched users as a list', async () => {
stubFetch();
- await TestBed.configureTestingModule({ imports: [UsersPage] }).compileComponents();
- const fixture = TestBed.createComponent(UsersPage);
- fixture.detectChanges();
- await fixture.whenStable();
- fixture.detectChanges();
- expect(fixture.nativeElement.textContent).toContain('Ada');
+ const harness = await renderUsersAt('/users');
+ expect(harness.routeNativeElement?.textContent).toContain('Ada');
+ });
+
+ it('shows the failure state when the request fails', async () => {
+ stubFetchFailure();
+ const harness = await renderUsersAt('/users');
+ expect(harness.routeNativeElement?.querySelector('[role="alert"]')).toBeTruthy();
+ });
+
+ it('shows the empty state when there are no users', async () => {
+ stubFetchEmpty();
+ const harness = await renderUsersAt('/users');
+ expect(harness.routeNativeElement?.textContent).toContain('No data.');
});
it("shows a user's details after clicking their name", async () => {
stubFetch();
- await TestBed.configureTestingModule({ imports: [UsersPage] }).compileComponents();
- const fixture = TestBed.createComponent(UsersPage);
- fixture.detectChanges();
- await fixture.whenStable();
- fixture.detectChanges();
- fixture.nativeElement.querySelector('button').click();
- fixture.detectChanges();
- await fixture.whenStable();
- fixture.detectChanges();
- expect(fixture.nativeElement.textContent).toContain('ada@example.com');
+ const harness = await renderUsersAt('/users');
+ harness.routeNativeElement!.querySelector('button')!.click();
+ await harness.fixture.whenStable();
+ harness.detectChanges();
+ expect(harness.routeNativeElement?.textContent).toContain('ada@example.com');
});
it('returns to the list when the detail view is closed', async () => {
stubFetch();
- await TestBed.configureTestingModule({ imports: [UsersPage] }).compileComponents();
- const fixture = TestBed.createComponent(UsersPage);
- fixture.detectChanges();
- await fixture.whenStable();
- fixture.detectChanges();
- fixture.nativeElement.querySelector('button').click();
- fixture.detectChanges();
- await fixture.whenStable();
- fixture.detectChanges();
- const backButton = Array.from(fixture.nativeElement.querySelectorAll('button')).find(
+ const harness = await renderUsersAt('/users');
+ harness.routeNativeElement!.querySelector('button')!.click();
+ await harness.fixture.whenStable();
+ harness.detectChanges();
+ const backButton = Array.from(harness.routeNativeElement!.querySelectorAll('button')).find(
(b) => (b as HTMLElement).textContent === 'Back',
) as HTMLElement;
backButton.click();
- fixture.detectChanges();
- expect(fixture.nativeElement.querySelector('app-user-detail')).toBeFalsy();
+ await harness.fixture.whenStable();
+ harness.detectChanges();
+ expect(harness.routeNativeElement?.querySelector('app-user-detail')).toBeFalsy();
+ expect(harness.routeNativeElement?.textContent).toContain('Ada');
+ });
+
+ it('deep-links directly to a user detail view', async () => {
+ stubFetch();
+ const harness = await renderUsersAt('/users/1');
+ expect(harness.routeNativeElement?.textContent).toContain('ada@example.com');
});
});
diff --git a/src/app/users/ui/users.page.ts b/src/app/users/ui/users.page.ts
index 5a2de52..c627cf1 100644
--- a/src/app/users/ui/users.page.ts
+++ b/src/app/users/ui/users.page.ts
@@ -1,4 +1,5 @@
-import { Component, computed, signal } from '@angular/core';
+import { Component, computed, inject, input } from '@angular/core';
+import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/ui/templates/page-shell.component';
import { AsyncComponent } from '@shared/ui/molecules/async.component';
import { fromResource } from '@shared/application/remote-data';
@@ -13,11 +14,12 @@ import { isEmptyUserList } from '@users/domain/user';
template: `
@if (selectedUserId(); as id) {
-
+
} @else {
-
- @if (usersResource.hasValue()) {
-
+ @let list = listData();
+
+ @if (list.tag === 'Success' && list.value) {
+
}
}
@@ -25,7 +27,12 @@ import { isEmptyUserList } from '@users/domain/user';
`,
})
export class UsersPage {
- protected selectedUserId = signal(null);
+ protected router = inject(Router);
+ userId = input();
+ protected selectedUserId = computed(() => {
+ const id = this.userId();
+ return id ? Number(id) : null;
+ });
protected usersResource = usersResource();
protected listData = computed(() => fromResource(this.usersResource, isEmptyUserList));
}
diff --git a/src/app/users/ui/users.routes.ts b/src/app/users/ui/users.routes.ts
new file mode 100644
index 0000000..3bc9f3a
--- /dev/null
+++ b/src/app/users/ui/users.routes.ts
@@ -0,0 +1,7 @@
+import { Routes } from '@angular/router';
+import { UsersPage } from './users.page';
+
+export const usersRoutes: Routes = [
+ { path: '', component: UsersPage },
+ { path: ':userId', component: UsersPage },
+];
diff --git a/src/index.html b/src/index.html
index 3aa7e70..69531f2 100644
--- a/src/index.html
+++ b/src/index.html
@@ -1,13 +1,13 @@
-
-
- NgSignalsTemplate
-
-
-
-
-
-
-
+
+
+ NgSignalsTemplate
+
+
+
+
+
+
+
diff --git a/tsconfig.app.json b/tsconfig.app.json
index cb151e1..1eb42f4 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -5,10 +5,6 @@
"compilerOptions": {
"types": []
},
- "include": [
- "src/**/*.ts"
- ],
- "exclude": [
- "src/**/*.spec.ts"
- ]
+ "include": ["src/**/*.ts"],
+ "exclude": ["src/**/*.spec.ts"]
}
diff --git a/tsconfig.json b/tsconfig.json
index 4b42e3f..abed8ff 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,6 +3,7 @@
{
"compileOnSave": false,
"compilerOptions": {
+ "strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
@@ -21,6 +22,7 @@
}
},
"angularCompilerOptions": {
+ "strictTemplates": true,
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
diff --git a/tsconfig.spec.json b/tsconfig.spec.json
index 9c8efb9..aecce35 100644
--- a/tsconfig.spec.json
+++ b/tsconfig.spec.json
@@ -3,12 +3,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
- "types": [
- "vitest/globals"
- ]
+ "types": ["vitest/globals"]
},
- "include": [
- "src/**/*.d.ts",
- "src/**/*.spec.ts"
- ]
+ "include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
}