feat(fp): WP-25 — server-rendered letter HTML preview
Adds LetterHtml.Render, a pure composer mirroring the FE letter canvas'
class vocabulary, behind two ExcludeFromDescription()'d endpoints
(GET /brief/preview, GET /admin/org-template/{subOrgId}/preview).
Auto-resolvable placeholders pull from seed/case data; unresolved
manual ones render as "[NOG IN TE VULLEN: label]". A sent brief
archives its composed HTML (BriefEntity.ArchivedHtml) so a later
org-template republish never changes it. FE gets a hand-written fetch
adapter (text/html, not JSON) and a "Voorbeeld" button that opens the
preview in a new tab.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Brief, BriefDecisions } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||
import { BriefStore } from './brief.store';
|
||||
|
||||
const decisions: BriefDecisions = {
|
||||
@@ -103,3 +104,43 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BriefStore.previewLetter', () => {
|
||||
// vi.spyOn reuses an existing spy (and its call history) if one is already on
|
||||
// the property — window.open/URL.createObjectURL must be restored between tests.
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('opens the composed letter in a new tab on success', async () => {
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
});
|
||||
await store.load();
|
||||
const blob = new Blob(['<html></html>'], { type: 'text/html' });
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||
ok: true,
|
||||
value: blob,
|
||||
});
|
||||
|
||||
await store.previewLetter();
|
||||
expect(open).toHaveBeenCalledWith('blob:mock', '_blank');
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces the error without opening a tab on failure', async () => {
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
});
|
||||
await store.load();
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||
ok: false,
|
||||
error: 'De voorvertoning kon niet worden geopend.',
|
||||
});
|
||||
|
||||
await store.previewLetter();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(store.lastError()).toBe('De voorvertoning kon niet worden geopend.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||
|
||||
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
|
||||
instead of a busy boolean + a nullable error sitting side by side. */
|
||||
@@ -35,6 +36,7 @@ type LoadedBriefState = Extract<BriefState, { tag: 'loaded' }>;
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BriefStore {
|
||||
private adapter = inject(BriefAdapter);
|
||||
private previewAdapter = inject(LetterPreviewAdapter);
|
||||
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
||||
|
||||
readonly model = this.store.model;
|
||||
@@ -147,6 +149,20 @@ export class BriefStore {
|
||||
reject = (comments: string) => this.transition(() => this.adapter.reject(comments));
|
||||
send = () => this.transition(() => this.adapter.send());
|
||||
|
||||
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
|
||||
letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and
|
||||
the tab outlives this call; not worth a teardown hook for a POC. */
|
||||
async previewLetter() {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
const r = await this.previewAdapter.preview();
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
window.open(URL.createObjectURL(r.value), '_blank');
|
||||
}
|
||||
|
||||
// A transition: flush any pending save, call the server (authoritative), then mirror
|
||||
// the returned status through the pure reducer's guarded transition.
|
||||
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
||||
|
||||
37
src/app/brief/infrastructure/letter-preview.adapter.ts
Normal file
37
src/app/brief/infrastructure/letter-preview.adapter.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { currentRole } from '@shared/infrastructure/role';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning kon niet worden geopend.`;
|
||||
|
||||
/**
|
||||
* `/brief/preview` returns `text/html`, not JSON, and is `.ExcludeFromDescription()`'d
|
||||
* to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a
|
||||
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
|
||||
* `roleInterceptor`, so `X-Role` is set here explicitly.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LetterPreviewAdapter {
|
||||
async preview(): Promise<Result<string, Blob>> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
|
||||
headers: { 'X-Role': currentRole() },
|
||||
});
|
||||
} catch {
|
||||
return err(PREVIEW_FAILED);
|
||||
}
|
||||
if (!res.ok) return err(await errorMessage(res));
|
||||
return ok(await res.blob());
|
||||
}
|
||||
}
|
||||
|
||||
async function errorMessage(res: Response): Promise<string> {
|
||||
try {
|
||||
return problemDetail(await res.json(), PREVIEW_FAILED);
|
||||
} catch {
|
||||
return PREVIEW_FAILED;
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
(approve)="store.approve()"
|
||||
(reject)="store.reject($event)"
|
||||
(send)="store.send()"
|
||||
(preview)="store.previewLetter()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,11 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
flex-wrap: wrap;
|
||||
margin-block-end: var(--rhc-space-max-lg);
|
||||
}
|
||||
.head-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
}
|
||||
.panel {
|
||||
margin-block: var(--rhc-space-max-xl);
|
||||
}
|
||||
@@ -56,7 +61,12 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
template: `
|
||||
<div class="head">
|
||||
<app-heading [level]="2">{{ title() }}</app-heading>
|
||||
<app-status-badge [label]="statusLabel()" [color]="statusColor()" />
|
||||
<div class="head-end">
|
||||
<app-button variant="subtle" [disabled]="busy()" (click)="preview.emit()">{{
|
||||
previewLabel()
|
||||
}}</app-button>
|
||||
<app-status-badge [label]="statusLabel()" [color]="statusColor()" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (status() === 'rejected') {
|
||||
@@ -143,9 +153,11 @@ export class LetterComposerComponent {
|
||||
approve = output<void>();
|
||||
reject = output<string>();
|
||||
send = output<void>();
|
||||
preview = output<void>();
|
||||
locate = output<Diagnostic>();
|
||||
|
||||
title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
|
||||
previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
|
||||
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
|
||||
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
|
||||
submitHint = input(
|
||||
|
||||
Reference in New Issue
Block a user