feat(fp): WP-06 — kill $any() in templates (18x)

Make AsyncLoadedDirective generic with a static ngTemplateContextGuard for
AsyncComponent's own internal typing. That can't propagate to consumer
`<ng-template appAsyncLoaded let-p>` sites though -- Angular only infers a
structural directive's type parameter from an input bound on that same
node, not from a sibling input on the parent component -- so the ~9
root-cause consumers (dashboard, registration-detail, aanvraag-detail,
registratie-wizard) instead unwrap the RemoteData Success value via a
typed computed() and narrow it locally with `@if (x(); as p)`. The
remaining union-narrowing casts (registration-summary, showcase concepts
page) are replaced with a stable @let binding and a direct resource read,
respectively. Documented as a deviation in WP-06's backlog file.
This commit is contained in:
2026-07-03 21:27:01 +02:00
parent 34d34512b3
commit 199cbe1f8c
10 changed files with 514 additions and 328 deletions

View File

@@ -45,7 +45,7 @@ for its existing violations, so every WP ends green.
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done | | [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done | | [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done | | [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo | | [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo | | [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo | | [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo | | [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo |

View File

@@ -1,6 +1,6 @@
# WP-06 — Generic async template contexts: kill `$any()` (18×) # WP-06 — Generic async template contexts: kill `$any()` (18×)
Status: todo Status: done
Phase: 1 — FP/DDD core Phase: 1 — FP/DDD core
## Why ## Why
@@ -44,19 +44,43 @@ let-p>` consumer must cast. The rest are template union-narrowing workarounds.
## Acceptance criteria ## Acceptance criteria
- [ ] `grep -rn '\$any(' src/app` → zero hits. - [x] `grep -rn '\$any(' src/app` → zero hits.
- [ ] No `as` casts added to compensate in component classes (typed getters are fine). - [x] No `as` casts added to compensate in component classes (typed getters are fine).
- [ ] Build green with strict template checking. - [x] Build green with strict template checking.
## Verification ## Verification
GREEN + `npm run test-storybook:ci`. Smoke: dashboard + registration detail render. GREEN + `npm run test-storybook:ci` (one unrelated flake on `review-section.stories.ts`'s
smoke-test timeout, confirmed by re-running green — untouched by this WP). Manual smoke
via a running `docker compose` stack + Playwright: logged in, drove `/dashboard`,
`/registratie` (registration-detail), `/aanvraag/:id`, `/concepts`, and the
`/registreren` wizard through the beroep step (both the DUO-match and the "mijn diploma
staat er niet bij" handmatig branch) — every fixed template renders its real data with
no console errors.
## Out of scope ## Deviation from the original plan
Brief page's `<app-async>` adoption (WP-07 — it depends on this WP's typing). `AsyncLoadedDirective<T>` + `static ngTemplateContextGuard` **was added** (per the
Decisions block) and is real, working generic typing for `AsyncComponent`'s own
internals. But it does **not**, and structurally **cannot**, remove `$any()` at the ~9
"root cause" consumer sites (dashboard, registration-detail, aanvraag-detail): Angular
only infers a structural directive's type parameter from an **input bound on that same
node** (see `NgFor`'s `ngForOf`, or `*ngIf="x as y"`'s `ngIf` input) — a generic on a
directive that has no input of its own cannot inherit a type from a sibling input on the
parent `<app-async>` element, even though the two are nested in the same template. This
is a hard limitation of Angular's template type-checker, not a gap in this
implementation (confirmed against the documented `ngTemplateContextGuard` pattern and by
the compiler continuing to type `let-p` as `unknown` after the generic was added).
## Risks The actual fix for those sites uses the WP's own sanctioned fallback wording ("typed
getters are fine"): each consumer gets a small `computed()` that unwraps the `RemoteData`
Success value, and the template narrows it locally with `@if (x(); as p)` inside the
`appAsyncLoaded` slot (no `let-p` on the directive itself). `registratie-wizard` reused
its existing `duoData` computed instead of adding a new one. The registration-summary
union-narrowing case used the anticipated `@switch` fix, but needed a `@let status =
reg().status` binding first — `@switch`/`@case` only narrows a stable local, not a
repeated `reg().status` function call. The showcase fake-resource case (`successRes`)
just reads `successRes.value()` directly in the `@for`, skipping `let-v` entirely.
Angular generic-component inference edge cases — the documented fallback keeps the WP `AsyncComponent`'s public API (`[data]`/`[resource]` inputs) is unchanged, so this
bounded. deviation is contained to consumer templates, as the WP intended.

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
@@ -30,24 +30,26 @@ import { detailRows } from '@registratie/domain/aanvraag-view';
backLink="/dashboard" backLink="/dashboard"
> >
<app-async [data]="store.applications()"> <app-async [data]="store.applications()">
<ng-template appAsyncLoaded let-list> <ng-template appAsyncLoaded>
@let a = find($any(list)); @if (applications(); as list) {
@if (a) { @let a = find(list);
<app-data-block @if (a) {
i18n-ariaLabel="@@aanvraagDetail.ariaLabel" <app-data-block
ariaLabel="Aanvraaggegevens" i18n-ariaLabel="@@aanvraagDetail.ariaLabel"
> ariaLabel="Aanvraaggegevens"
@for (row of rows(a); track row.key) { >
<div app-data-row [key]="row.key" [value]="row.value"></div> @for (row of rows(a); track row.key) {
} <div app-data-row [key]="row.key" [value]="row.value"></div>
</app-data-block> }
<app-alert class="app-section" type="info" i18n="@@aanvraagDetail.stub"> </app-data-block>
De volledige afhandeling van deze aanvraag is nog niet beschikbaar in deze POC. <app-alert class="app-section" type="info" i18n="@@aanvraagDetail.stub">
</app-alert> De volledige afhandeling van deze aanvraag is nog niet beschikbaar in deze POC.
} @else { </app-alert>
<app-alert type="warning" i18n="@@aanvraagDetail.nietGevonden" } @else {
>Deze aanvraag is niet gevonden.</app-alert <app-alert type="warning" i18n="@@aanvraagDetail.nietGevonden"
> >Deze aanvraag is niet gevonden.</app-alert
>
}
} }
</ng-template> </ng-template>
<ng-template appAsyncLoading> <ng-template appAsyncLoading>
@@ -63,4 +65,10 @@ export class AanvraagDetailPage {
protected find = (list: Aanvraag[]): Aanvraag | undefined => list.find((a) => a.id === this.id); protected find = (list: Aanvraag[]): Aanvraag | undefined => list.find((a) => a.id === this.id);
protected rows = detailRows; protected rows = detailRows;
/** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */
protected readonly applications = computed(() => {
const rd = this.store.applications();
return rd.tag === 'Success' ? rd.value : undefined;
});
} }

View File

@@ -87,59 +87,61 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
} }
<app-async [data]="store.profile()"> <app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p> <ng-template appAsyncLoaded>
@let tasks = tasksFor($any(p).registration); @if (profile(); as p) {
@let tasks = tasksFor(p.registration);
<section> <section>
@if (tasks.length) { @if (tasks.length) {
<app-task-list <app-task-list
class="app-section" class="app-section"
i18n-listHeading="@@dashboard.watMoetIkRegelen" i18n-listHeading="@@dashboard.watMoetIkRegelen"
listHeading="Wat moet ik regelen" listHeading="Wat moet ik regelen"
[tasks]="tasks" [tasks]="tasks"
/> />
} @else { } @else {
<app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen" <app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen"
>Wat moet ik regelen</app-heading >Wat moet ik regelen</app-heading
>
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan">
U heeft op dit moment niets openstaan.
</p>
}
</section>
<section>
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie"
>Mijn registratie</app-heading
> >
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan"> <div class="app-section">
U heeft op dit moment niets openstaan. <app-registration-summary [reg]="p.registration" />
</p> </div>
} <app-data-block
</section> class="app-section"
i18n-heading="@@dashboard.persoonsgegevens"
<section> heading="Persoonsgegevens (BRP)"
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie" >
>Mijn registratie</app-heading <div
> app-data-row
<div class="app-section"> i18n-key="@@dashboard.straat"
<app-registration-summary [reg]="$any(p).registration" /> key="Straat"
</div> [value]="p.person.adres.straat"
<app-data-block ></div>
class="app-section" <div
i18n-heading="@@dashboard.persoonsgegevens" app-data-row
heading="Persoonsgegevens (BRP)" i18n-key="@@dashboard.postcode"
> key="Postcode"
<div [value]="p.person.adres.postcode"
app-data-row ></div>
i18n-key="@@dashboard.straat" <div
key="Straat" app-data-row
[value]="$any(p).person.adres.straat" i18n-key="@@dashboard.woonplaats"
></div> key="Woonplaats"
<div [value]="p.person.adres.woonplaats"
app-data-row ></div>
i18n-key="@@dashboard.postcode" </app-data-block>
key="Postcode" </section>
[value]="$any(p).person.adres.postcode" }
></div>
<div
app-data-row
i18n-key="@@dashboard.woonplaats"
key="Woonplaats"
[value]="$any(p).person.adres.woonplaats"
></div>
</app-data-block>
</section>
</ng-template> </ng-template>
<ng-template appAsyncLoading> <ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" /> <app-skeleton height="2.5rem" [count]="6" />
@@ -152,8 +154,10 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
> >
<div class="app-section"> <div class="app-section">
<app-async [data]="store.aantekeningen()"> <app-async [data]="store.aantekeningen()">
<ng-template appAsyncLoaded let-r> <ng-template appAsyncLoaded>
<app-registration-table [rows]="$any(r)" /> @if (aantekeningen(); as r) {
<app-registration-table [rows]="r" />
}
</ng-template> </ng-template>
<ng-template appAsyncLoading> <ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" /> <app-skeleton height="2.5rem" [count]="3" />
@@ -239,6 +243,19 @@ export class DashboardPage {
return tasksFromProfile(reg, this.eligible()); return tasksFromProfile(reg, this.eligible());
} }
/** Typed narrowing for the `<app-async>` loaded slot — `<ng-template>`'s own
context can't inherit a generic from a sibling host input (Angular only infers
a structural directive's type parameter from an input on that same node), so
the Success value is unwrapped here instead of through `let-`. */
protected readonly profile = computed(() => {
const rd = this.store.profile();
return rd.tag === 'Success' ? rd.value : undefined;
});
protected readonly aantekeningen = computed(() => {
const rd = this.store.aantekeningen();
return rd.tag === 'Success' ? rd.value : undefined;
});
/** Primary transactional actions, as an "aanvragen" list (see CIBG's /** Primary transactional actions, as an "aanvragen" list (see CIBG's
componenten/aanvragen). The core portal sections live in the header nav now; componenten/aanvragen). The core portal sections live in the header nav now;
the teaching pages (concepts/brief) are only reachable from here. */ the teaching pages (concepts/brief) are only reachable from here. */

View File

@@ -169,83 +169,85 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
} }
@case ('beroep') { @case ('beroep') {
<app-async [data]="lookupRd()"> <app-async [data]="lookupRd()">
<ng-template appAsyncLoaded let-data> <ng-template appAsyncLoaded>
<app-form-field @if (duoData(); as data) {
i18n-label="@@regWizard.diplomaLabel"
label="Kies het diploma waarmee u zich wilt registreren"
fieldId="diploma"
required
[error]="err('diploma')"
>
<app-radio-group
name="diploma"
[options]="diplomaOptions($any(data))"
[invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()"
(ngModelChange)="onDiplomaKeuze($any(data), $event)"
/>
</app-form-field>
@if (handmatigActief()) {
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
>Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw
beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig
beoordeeld.</app-alert
>
<app-form-field <app-form-field
i18n-label="@@regWizard.beroepLabel" i18n-label="@@regWizard.diplomaLabel"
label="Voor welk beroep wilt u zich registreren?" label="Kies het diploma waarmee u zich wilt registreren"
fieldId="hm-beroep" fieldId="diploma"
required
[error]="err('diploma')" [error]="err('diploma')"
> >
<app-radio-group <app-radio-group
name="hm-beroep" name="diploma"
[options]="beroepOptions($any(data))" [options]="diplomaOptions(data)"
[invalid]="!!err('diploma')" [invalid]="!!err('diploma')"
[ngModel]="draft().beroep ?? ''" [ngModel]="diplomaKeuze()"
(ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })" (ngModelChange)="onDiplomaKeuze(data, $event)"
/> />
</app-form-field> </app-form-field>
} @else if (draft().beroep) {
<dl class="mb-0 app-section">
<div
app-data-row
i18n-key="@@regWizard.beroepAfgeleid"
key="Beroep (afgeleid uit diploma)"
[value]="draft().beroep ?? ''"
></div>
</dl>
}
@for (q of actieveVragen($any(data)); track q.id) { @if (handmatigActief()) {
<app-form-field <app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
[label]="q.vraag" >Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies
[fieldId]="'vraag-' + q.id" uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna
[error]="vraagErr(q.id)" handmatig beoordeeld.</app-alert
> >
@if (q.type === 'ja-nee') { <app-form-field
i18n-label="@@regWizard.beroepLabel"
label="Voor welk beroep wilt u zich registreren?"
fieldId="hm-beroep"
[error]="err('diploma')"
>
<app-radio-group <app-radio-group
[name]="'vraag-' + q.id" name="hm-beroep"
[options]="jaNee" [options]="beroepOptions(data)"
[invalid]="!!vraagErr(q.id)" [invalid]="!!err('diploma')"
[ngModel]="antwoord(q.id)" [ngModel]="draft().beroep ?? ''"
(ngModelChange)=" (ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
"
[ngModelOptions]="{ standalone: true }"
/> />
} @else { </app-form-field>
<app-text-input } @else if (draft().beroep) {
[inputId]="'vraag-' + q.id" <dl class="mb-0 app-section">
[invalid]="!!vraagErr(q.id)" <div
[ngModel]="antwoord(q.id)" app-data-row
(ngModelChange)=" i18n-key="@@regWizard.beroepAfgeleid"
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event }) key="Beroep (afgeleid uit diploma)"
" [value]="draft().beroep ?? ''"
[ngModelOptions]="{ standalone: true }" ></div>
/> </dl>
} }
</app-form-field>
@for (q of actieveVragen(data); track q.id) {
<app-form-field
[label]="q.vraag"
[fieldId]="'vraag-' + q.id"
[error]="vraagErr(q.id)"
>
@if (q.type === 'ja-nee') {
<app-radio-group
[name]="'vraag-' + q.id"
[options]="jaNee"
[invalid]="!!vraagErr(q.id)"
[ngModel]="antwoord(q.id)"
(ngModelChange)="
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
"
[ngModelOptions]="{ standalone: true }"
/>
} @else {
<app-text-input
[inputId]="'vraag-' + q.id"
[invalid]="!!vraagErr(q.id)"
[ngModel]="antwoord(q.id)"
(ngModelChange)="
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
"
[ngModelOptions]="{ standalone: true }"
/>
}
</app-form-field>
}
} }
</ng-template> </ng-template>
<ng-template appAsyncLoading> <ng-template appAsyncLoading>
@@ -485,8 +487,11 @@ export class RegistratieWizardComponent {
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup; protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the /** Parsed lookup as a plain value (or null) — used outside the beroep step (the
controle summary) where the <app-async> template variable isn't in scope. */ controle summary) where the <app-async> template variable isn't in scope, and
private duoData = computed<DuoLookupDto | null>(() => { inside it too: `<ng-template appAsyncLoaded>`'s own context can't inherit a
generic from the sibling [data] input (Angular only infers a structural
directive's type parameter from an input on that same node). */
protected duoData = computed<DuoLookupDto | null>(() => {
const rd = this.lookupRd(); const rd = this.lookupRd();
return rd.tag === 'Success' ? rd.value : null; return rd.tag === 'Success' ? rd.value : null;
}); });

View File

@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core'; import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/async/async.component';
@@ -22,8 +22,10 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
backLink="/dashboard" backLink="/dashboard"
> >
<app-async [data]="store.profile()"> <app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p> <ng-template appAsyncLoaded>
<app-registration-summary [reg]="$any(p).registration" /> @if (profile(); as p) {
<app-registration-summary [reg]="p.registration" />
}
</ng-template> </ng-template>
<ng-template appAsyncLoading> <ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" /> <app-skeleton height="2.5rem" [count]="6" />
@@ -38,4 +40,10 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
}) })
export class RegistrationDetailPage { export class RegistrationDetailPage {
protected store = inject(BigProfileStore); protected store = inject(BigProfileStore);
/** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */
protected readonly profile = computed(() => {
const rd = this.store.profile();
return rd.tag === 'Success' ? rd.value : undefined;
});
} }

View File

@@ -30,14 +30,17 @@ import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
key="Registratiedatum" key="Registratiedatum"
[value]="reg().registratiedatum | date: 'longDate'" [value]="reg().registratiedatum | date: 'longDate'"
></div> ></div>
<!-- Each status variant renders only the row its own data supports. --> <!-- Each status variant renders only the row its own data supports. A single
@switch (reg().status.tag) { @let binds status once so the @switch narrows its union by tag -- calling
reg().status again per case would give the checker a fresh, unnarrowed call. -->
@let status = reg().status;
@switch (status.tag) {
@case ('Geregistreerd') { @case ('Geregistreerd') {
<div <div
app-data-row app-data-row
i18n-key="@@summary.uiterste" i18n-key="@@summary.uiterste"
key="Uiterste herregistratie" key="Uiterste herregistratie"
[value]="$any(reg().status).herregistratieDatum | date: 'longDate'" [value]="status.herregistratieDatum | date: 'longDate'"
></div> ></div>
} }
@case ('Geschorst') { @case ('Geschorst') {
@@ -45,28 +48,18 @@ import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
app-data-row app-data-row
i18n-key="@@summary.geschorstTot" i18n-key="@@summary.geschorstTot"
key="Geschorst tot" key="Geschorst tot"
[value]="$any(reg().status).geschorstTot | date: 'longDate'" [value]="status.geschorstTot | date: 'longDate'"
></div>
<div
app-data-row
i18n-key="@@summary.reden"
key="Reden"
[value]="$any(reg().status).reden"
></div> ></div>
<div app-data-row i18n-key="@@summary.reden" key="Reden" [value]="status.reden"></div>
} }
@case ('Doorgehaald') { @case ('Doorgehaald') {
<div <div
app-data-row app-data-row
i18n-key="@@summary.doorgehaaldOp" i18n-key="@@summary.doorgehaaldOp"
key="Doorgehaald op" key="Doorgehaald op"
[value]="$any(reg().status).doorgehaaldOp | date: 'longDate'" [value]="status.doorgehaaldOp | date: 'longDate'"
></div>
<div
app-data-row
i18n-key="@@summary.reden"
key="Reden"
[value]="$any(reg().status).reden"
></div> ></div>
<div app-data-row i18n-key="@@summary.reden" key="Reden" [value]="status.reden"></div>
} }
} }
</app-data-block> </app-data-block>

View File

@@ -6,10 +6,20 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data'; import { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';
/* Slot markers. Put on <ng-template> children of <app-async>. */ /* Slot markers. Put on <ng-template> children of <app-async>. Generic so the
$implicit context is typed as the resource's T instead of unknown — see
AsyncComponent's contentChild<AsyncLoadedDirective<T>> below, which threads the
host's own T through the query result type. */
@Directive({ selector: '[appAsyncLoaded]' }) @Directive({ selector: '[appAsyncLoaded]' })
export class AsyncLoadedDirective { export class AsyncLoadedDirective<T = unknown> {
constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {} constructor(public tpl: TemplateRef<{ $implicit: T }>) {}
static ngTemplateContextGuard<T>(
_dir: AsyncLoadedDirective<T>,
_ctx: unknown,
): _ctx is { $implicit: T } {
return true;
}
} }
@Directive({ selector: '[appAsyncLoading]' }) @Directive({ selector: '[appAsyncLoading]' })
export class AsyncLoadingDirective { export class AsyncLoadingDirective {
@@ -88,7 +98,7 @@ export class AsyncComponent<T> {
retryText = input($localize`:@@async.retry:Opnieuw proberen`); retryText = input($localize`:@@async.retry:Opnieuw proberen`);
emptyText = input($localize`:@@async.empty:Geen gegevens gevonden.`); emptyText = input($localize`:@@async.empty:Geen gegevens gevonden.`);
loadedTpl = contentChild.required(AsyncLoadedDirective); loadedTpl = contentChild.required<AsyncLoadedDirective<T>>(AsyncLoadedDirective);
loadingTpl = contentChild(AsyncLoadingDirective); loadingTpl = contentChild(AsyncLoadingDirective);
emptyTpl = contentChild(AsyncEmptyDirective); emptyTpl = contentChild(AsyncEmptyDirective);
errorTpl = contentChild(AsyncErrorDirective); errorTpl = contentChild(AsyncErrorDirective);

View File

@@ -223,9 +223,9 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
> >
<p class="tag plain">Success</p> <p class="tag plain">Success</p>
<app-async [resource]="successRes" [isEmpty]="isEmpty" <app-async [resource]="successRes" [isEmpty]="isEmpty"
><ng-template appAsyncLoaded let-v ><ng-template appAsyncLoaded
><ul> ><ul>
@for (i of v; track i) { @for (i of successRes.value(); track i) {
<li>{{ i }}</li> <li>{{ i }}</li>
} }
</ul></ng-template </ul></ng-template
@@ -258,16 +258,17 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
placeholder="Typ een postcode, bijv. 1234 AB" placeholder="Typ een postcode, bijv. 1234 AB"
/> />
</div> </div>
<div class="card" [class.card--good]="parsed().ok" [class.card--bad]="!parsed().ok"> @let r = parsed();
@if (parsed().ok) { <div class="card" [class.card--good]="r.ok" [class.card--bad]="!r.ok">
@if (r.ok) {
<p class="tag good">ok</p> <p class="tag good">ok</p>
<pre>Postcode ="{{ $any(parsed()).value }}"</pre> <pre>Postcode ="{{ r.value }}"</pre>
<p class="note"> <p class="note">
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string. Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
</p> </p>
} @else { } @else {
<p class="tag bad">err</p> <p class="tag bad">err</p>
<pre>{{ $any(parsed()).error }}</pre> <pre>{{ r.error }}</pre>
} }
</div> </div>
</div> </div>