feat(brief): locked sections, list formatting, auto/manual placeholder chips
- brief.machine: reducer refuses edits to locked (predefined) sections as
defense-in-depth; LetterSection gains a `locked` flag
- rich-text: paragraphs gain optional `list` kind; editor gets bullet/numbered
list buttons, keyboard shortcuts, and backspace-deletes-adjacent-chip
- placeholder chips distinguish auto-resolvable (grey) vs manual (yellow), in
both the editor and the read-only preview
- fix: preview chip now renders matching {…} braces (was a one-sided ⌗ glyph),
aligned with the editor's chip styling
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -115,7 +115,8 @@ public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatus
|
|||||||
// rich-text semantics and re-validates at its parse* boundary.
|
// rich-text semantics and re-validates at its parse* boundary.
|
||||||
|
|
||||||
public sealed record RichTextNodeDto(string Type, string? Text = null, IReadOnlyList<string>? Marks = null, string? Key = null);
|
public sealed record RichTextNodeDto(string Type, string? Text = null, IReadOnlyList<string>? Marks = null, string? Key = null);
|
||||||
public sealed record ParagraphDto(IReadOnlyList<RichTextNodeDto> Nodes);
|
// List is null for a plain paragraph, "bullet" or "number" for a list item.
|
||||||
|
public sealed record ParagraphDto(IReadOnlyList<RichTextNodeDto> Nodes, string? List = null);
|
||||||
public sealed record RichTextBlockDto(IReadOnlyList<ParagraphDto> Paragraphs);
|
public sealed record RichTextBlockDto(IReadOnlyList<ParagraphDto> Paragraphs);
|
||||||
|
|
||||||
public sealed record PlaceholderDefDto(string Key, string Label, bool AutoResolvable, bool? Fillable = null, bool? Deprecated = null);
|
public sealed record PlaceholderDefDto(string Key, string Label, bool AutoResolvable, bool? Fillable = null, bool? Deprecated = null);
|
||||||
@@ -125,7 +126,8 @@ public sealed record LetterBlockDto(
|
|||||||
string Type, string BlockId, RichTextBlockDto Content,
|
string Type, string BlockId, RichTextBlockDto Content,
|
||||||
string? SourcePassageId = null, int? SourceVersion = null, bool? Edited = null);
|
string? SourcePassageId = null, int? SourceVersion = null, bool? Edited = null);
|
||||||
|
|
||||||
public sealed record LetterSectionDto(string SectionKey, string Title, bool Required, IReadOnlyList<LetterBlockDto> Blocks);
|
// Locked sections (aanhef, slot) are predefined template text the drafter cannot edit.
|
||||||
|
public sealed record LetterSectionDto(string SectionKey, string Title, bool Required, IReadOnlyList<LetterBlockDto> Blocks, bool Locked = false);
|
||||||
|
|
||||||
// BriefStatus union, flattened by Tag (draft | submitted | approved | rejected | sent).
|
// BriefStatus union, flattened by Tag (draft | submitted | approved | rejected | sent).
|
||||||
public sealed record BriefStatusDto(
|
public sealed record BriefStatusDto(
|
||||||
|
|||||||
@@ -95,6 +95,19 @@ public static class BriefStore
|
|||||||
lock (_gate) _byOwner.Clear();
|
lock (_gate) _byOwner.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Demo affordance: drop the owner's brief and create a fresh one. No guards — a
|
||||||
|
/// "start over" for the showcase, not a real workflow transition.
|
||||||
|
public static BriefEntity ResetAndCreate(string owner)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_byOwner.Remove(owner);
|
||||||
|
var created = BriefSeed.NewBrief(owner);
|
||||||
|
_byOwner[owner] = created;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Approve/reject share the guard: must be submitted, and the approver must differ
|
// Approve/reject share the guard: must be submitted, and the approver must differ
|
||||||
// from the drafter (a drafter cannot approve their own letter).
|
// from the drafter (a drafter cannot approve their own letter).
|
||||||
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next)
|
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next)
|
||||||
@@ -142,11 +155,20 @@ public static class BriefSeed
|
|||||||
TemplateId = TemplateId,
|
TemplateId = TemplateId,
|
||||||
DrafterId = BriefStore.DrafterId,
|
DrafterId = BriefStore.DrafterId,
|
||||||
Placeholders = Placeholders,
|
Placeholders = Placeholders,
|
||||||
|
// aanhef + slot are predefined, locked template text (prefilled); the drafter
|
||||||
|
// composes only the unlocked `kern`. Save trusts the FE not to mutate locked
|
||||||
|
// sections (FE-authoritative for this POC — the FE reducer also refuses it).
|
||||||
Sections = new()
|
Sections = new()
|
||||||
{
|
{
|
||||||
new("aanhef", "Aanhef", true, new List<LetterBlockDto>()),
|
new("aanhef", "Aanhef", true, new List<LetterBlockDto>
|
||||||
|
{
|
||||||
|
new("freeText", "aanhef-1", Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(","))),
|
||||||
|
}, Locked: true),
|
||||||
new("kern", "Kern van het besluit", true, new List<LetterBlockDto>()),
|
new("kern", "Kern van het besluit", true, new List<LetterBlockDto>()),
|
||||||
new("slot", "Slot", false, new List<LetterBlockDto>()),
|
new("slot", "Slot", false, new List<LetterBlockDto>
|
||||||
|
{
|
||||||
|
new("freeText", "slot-1", Block(T("Met vriendelijke groet,"))),
|
||||||
|
}, Locked: true),
|
||||||
},
|
},
|
||||||
Status = new BriefStatusDto("draft"),
|
Status = new BriefStatusDto("draft"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -304,6 +304,15 @@ api.MapPost("/brief/send", () =>
|
|||||||
.Produces<BriefDto>()
|
.Produces<BriefDto>()
|
||||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
|
api.MapPost("/brief/reset", () =>
|
||||||
|
{
|
||||||
|
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
|
||||||
|
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
|
||||||
|
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
|
||||||
|
})
|
||||||
|
.WithName("briefReset")
|
||||||
|
.Produces<BriefViewDto>();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
|
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
|
||||||
|
|||||||
@@ -863,6 +863,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/brief/reset": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"operationId": "briefReset",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BriefViewDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@@ -1389,6 +1409,9 @@
|
|||||||
"$ref": "#/components/schemas/LetterBlockDto"
|
"$ref": "#/components/schemas/LetterBlockDto"
|
||||||
},
|
},
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -1455,6 +1478,10 @@
|
|||||||
"$ref": "#/components/schemas/RichTextNodeDto"
|
"$ref": "#/components/schemas/RichTextNodeDto"
|
||||||
},
|
},
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|||||||
@@ -49,7 +49,14 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
|
|||||||
var brief = await Get();
|
var brief = await Get();
|
||||||
Assert.Equal("draft", brief.Status.Tag);
|
Assert.Equal("draft", brief.Status.Tag);
|
||||||
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
|
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
|
||||||
Assert.All(brief.Sections, s => Assert.Empty(s.Blocks));
|
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
|
||||||
|
var aanhef = brief.Sections.Single(s => s.SectionKey == "aanhef");
|
||||||
|
Assert.True(aanhef.Locked);
|
||||||
|
Assert.NotEmpty(aanhef.Blocks);
|
||||||
|
Assert.True(brief.Sections.Single(s => s.SectionKey == "slot").Locked);
|
||||||
|
var kern = brief.Sections.Single(s => s.SectionKey == "kern");
|
||||||
|
Assert.False(kern.Locked);
|
||||||
|
Assert.Empty(kern.Blocks);
|
||||||
|
|
||||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||||
// global passages + the arts-scoped one; no other-beroep passages leak in.
|
// global passages + the arts-scoped one; no other-beroep passages leak in.
|
||||||
@@ -134,4 +141,22 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
|
|||||||
res.EnsureSuccessStatusCode();
|
res.EnsureSuccessStatusCode();
|
||||||
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections()
|
||||||
|
{
|
||||||
|
var brief = await Get();
|
||||||
|
// Advance out of draft so the reset back to draft is observable.
|
||||||
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||||
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||||
|
|
||||||
|
var res = await _client.SendAsync(Post("/api/v1/brief/reset"));
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var view = await res.Content.ReadFromJsonAsync<BriefViewDto>();
|
||||||
|
Assert.Equal("draft", view!.Brief.Status.Tag);
|
||||||
|
var aanhef = view.Brief.Sections.Single(s => s.SectionKey == "aanhef");
|
||||||
|
Assert.True(aanhef.Locked);
|
||||||
|
Assert.NotEmpty(aanhef.Blocks);
|
||||||
|
Assert.Empty(view.Brief.Sections.Single(s => s.SectionKey == "kern").Blocks);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -21,6 +21,8 @@ export class BriefStore {
|
|||||||
readonly role: Role = currentRole();
|
readonly role: Role = currentRole();
|
||||||
readonly busy = signal(false);
|
readonly busy = signal(false);
|
||||||
readonly lastError = signal<string | null>(null);
|
readonly lastError = signal<string | null>(null);
|
||||||
|
/** Surfaced autosave state for the indicator + aria-live region. */
|
||||||
|
readonly saveState = signal<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
|
|
||||||
private brief = computed<Brief | null>(() => {
|
private brief = computed<Brief | null>(() => {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
@@ -63,8 +65,26 @@ export class BriefStore {
|
|||||||
private async flushSave() {
|
private async flushSave() {
|
||||||
const b = this.brief();
|
const b = this.brief();
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
|
this.saveState.set('saving');
|
||||||
const r = await this.adapter.save(b.sections);
|
const r = await this.adapter.save(b.sections);
|
||||||
if (!r.ok) this.lastError.set(r.error);
|
if (r.ok) {
|
||||||
|
this.saveState.set('saved');
|
||||||
|
} else {
|
||||||
|
this.lastError.set(r.error);
|
||||||
|
this.saveState.set('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
||||||
|
async resetDemo() {
|
||||||
|
this.busy.set(true);
|
||||||
|
this.lastError.set(null);
|
||||||
|
clearTimeout(this.saveTimer);
|
||||||
|
const r = await this.adapter.reset();
|
||||||
|
this.busy.set(false);
|
||||||
|
this.saveState.set('idle');
|
||||||
|
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', brief: r.value.brief, availablePassages: r.value.availablePassages });
|
||||||
|
else this.lastError.set(r.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
submit = () => this.transition(() => this.adapter.submit());
|
submit = () => this.transition(() => this.adapter.submit());
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
|
|||||||
templateId: 't1',
|
templateId: 't1',
|
||||||
placeholders,
|
placeholders,
|
||||||
sections: sections ?? [
|
sections: sections ?? [
|
||||||
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, blocks: [] },
|
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: false, blocks: [] },
|
||||||
{ sectionKey: 'slot', title: 'Slot', required: false, blocks: [] },
|
{ sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
|
||||||
],
|
],
|
||||||
status,
|
status,
|
||||||
drafterId: 'u1',
|
drafterId: 'u1',
|
||||||
@@ -92,6 +92,24 @@ describe('brief.machine reduce', () => {
|
|||||||
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-1']);
|
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('edits to a locked section are no-ops (insert, free-text, content, remove, move)', () => {
|
||||||
|
const lockedSections: Brief['sections'] = [
|
||||||
|
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: true, blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }] },
|
||||||
|
{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] },
|
||||||
|
];
|
||||||
|
const s = loaded({ tag: 'draft' }, lockedSections);
|
||||||
|
// The brief value is left untouched (withEdit reallocates state, but the guard returns
|
||||||
|
// the same brief), so assert on deep equality of the section contents.
|
||||||
|
expect(reduce(s, { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef')] })).toEqual(s);
|
||||||
|
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' })).toEqual(s);
|
||||||
|
expect(reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') })).toEqual(s);
|
||||||
|
expect(reduce(s, { tag: 'BlockRemoved', blockId: 'local-1' })).toEqual(s);
|
||||||
|
expect(reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 0 })).toEqual(s);
|
||||||
|
// the unlocked section still accepts edits
|
||||||
|
const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||||
|
expect(sectionBlocks(edited, 'kern')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('edits are no-ops once submitted (status invariant)', () => {
|
it('edits are no-ops once submitted (status invariant)', () => {
|
||||||
const s = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
const s = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||||
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' })).toBe(s);
|
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' })).toBe(s);
|
||||||
|
|||||||
@@ -63,6 +63,17 @@ function mapSection(brief: Brief, sectionKey: string, f: (s: LetterSection) => L
|
|||||||
return { ...brief, sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)) };
|
return { ...brief, sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The section a block currently lives in, or undefined if the block is gone. */
|
||||||
|
function sectionKeyOfBlock(brief: Brief, blockId: string): string | undefined {
|
||||||
|
return brief.sections.find((s) => s.blocks.some((b) => b.blockId === blockId))?.sectionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A section accepts edits only when it is not a locked (predefined) template section. */
|
||||||
|
function isSectionEditable(brief: Brief, sectionKey: string | undefined): boolean {
|
||||||
|
const section = brief.sections.find((s) => s.sectionKey === sectionKey);
|
||||||
|
return !!section && !section.locked;
|
||||||
|
}
|
||||||
|
|
||||||
function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBlock[]): Brief {
|
function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBlock[]): Brief {
|
||||||
return { ...brief, sections: brief.sections.map((s) => ({ ...s, blocks: f(s.blocks) })) };
|
return { ...brief, sections: brief.sections.map((s) => ({ ...s, blocks: f(s.blocks) })) };
|
||||||
}
|
}
|
||||||
@@ -126,19 +137,29 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
|||||||
case 'Seed':
|
case 'Seed':
|
||||||
return m.state;
|
return m.state;
|
||||||
|
|
||||||
|
// Section-level guard (defense-in-depth): locked sections never accept edits, even if a
|
||||||
|
// Msg reaches the reducer. The UI already hides controls for locked sections.
|
||||||
case 'PassagesInserted':
|
case 'PassagesInserted':
|
||||||
return withEdit(s, (b) => insertPassages(b, m.sectionKey, m.passages));
|
return withEdit(s, (b) => (isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b));
|
||||||
case 'FreeTextBlockAdded':
|
case 'FreeTextBlockAdded':
|
||||||
return withEdit(s, (b) => addFreeText(b, m.sectionKey));
|
return withEdit(s, (b) => (isSectionEditable(b, m.sectionKey) ? addFreeText(b, m.sectionKey) : b));
|
||||||
case 'BlockContentEdited':
|
case 'BlockContentEdited':
|
||||||
return withEdit(s, (b) => editBlockContent(b, m.blockId, m.content));
|
return withEdit(s, (b) =>
|
||||||
|
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId)) ? editBlockContent(b, m.blockId, m.content) : b,
|
||||||
|
);
|
||||||
case 'BlockRemoved':
|
case 'BlockRemoved':
|
||||||
return withEdit(s, (b) => mapBlocks(b, (blocks) => blocks.filter((x) => x.blockId !== m.blockId)));
|
return withEdit(s, (b) =>
|
||||||
|
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
|
||||||
|
? mapBlocks(b, (blocks) => blocks.filter((x) => x.blockId !== m.blockId))
|
||||||
|
: b,
|
||||||
|
);
|
||||||
case 'BlockMovedWithinSection':
|
case 'BlockMovedWithinSection':
|
||||||
return withEdit(s, (b) =>
|
return withEdit(s, (b) =>
|
||||||
mapBlocks(b, (blocks) =>
|
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
|
||||||
blocks.some((x) => x.blockId === m.blockId) ? moveWithinSection(blocks, m.blockId, m.toIndex) : [...blocks],
|
? mapBlocks(b, (blocks) =>
|
||||||
),
|
blocks.some((x) => x.blockId === m.blockId) ? moveWithinSection(blocks, m.blockId, m.toIndex) : [...blocks],
|
||||||
|
)
|
||||||
|
: b,
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'Submitted':
|
case 'Submitted':
|
||||||
|
|||||||
@@ -25,15 +25,15 @@ function brief(sections: Brief['sections']): Brief {
|
|||||||
describe('brief selectors', () => {
|
describe('brief selectors', () => {
|
||||||
it('unresolvedPlaceholders returns deduped manual keys only (auto excluded)', () => {
|
it('unresolvedPlaceholders returns deduped manual keys only (auto excluded)', () => {
|
||||||
const b = brief([
|
const b = brief([
|
||||||
{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1', 'naam', 'reden')] },
|
{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1', 'naam', 'reden')] },
|
||||||
{ sectionKey: 's2', title: 'S2', required: false, blocks: [passage('local-2', 'reden')] },
|
{ sectionKey: 's2', title: 'S2', required: false, locked: false, blocks: [passage('local-2', 'reden')] },
|
||||||
]);
|
]);
|
||||||
expect(unresolvedPlaceholders(b)).toEqual(['reden']); // 'naam' is auto; 'reden' deduped
|
expect(unresolvedPlaceholders(b)).toEqual(['reden']); // 'naam' is auto; 'reden' deduped
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allDiagnostics flattens across sections and blocks', () => {
|
it('allDiagnostics flattens across sections and blocks', () => {
|
||||||
const b = brief([
|
const b = brief([
|
||||||
{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1', 'reden', 'onbekend')] },
|
{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1', 'reden', 'onbekend')] },
|
||||||
]);
|
]);
|
||||||
const codes = allDiagnostics(b).map((d) => d.code);
|
const codes = allDiagnostics(b).map((d) => d.code);
|
||||||
expect(codes).toContain('unresolved-at-send'); // reden
|
expect(codes).toContain('unresolved-at-send'); // reden
|
||||||
@@ -42,8 +42,8 @@ describe('brief selectors', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('canSubmit is false when a required section is empty, true otherwise', () => {
|
it('canSubmit is false when a required section is empty, true otherwise', () => {
|
||||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, blocks: [] }]))).toBe(false);
|
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [] }]))).toBe(false);
|
||||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: false, blocks: [] }]))).toBe(true);
|
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: false, locked: false, blocks: [] }]))).toBe(true);
|
||||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1')] }]))).toBe(true);
|
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1')] }]))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export interface LetterSection {
|
|||||||
readonly sectionKey: string;
|
readonly sectionKey: string;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
readonly required: boolean;
|
readonly required: boolean;
|
||||||
|
// Predefined template sections (aanhef, slot) arrive locked and prefilled — the drafter
|
||||||
|
// composes only the unlocked section(s). The reducer refuses edits to locked sections.
|
||||||
|
readonly locked: boolean;
|
||||||
readonly blocks: readonly LetterBlock[];
|
readonly blocks: readonly LetterBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,40 @@ describe('brief.adapter parse boundary', () => {
|
|||||||
expect(parseStatus({ tag: 'draft' }).ok).toBe(true);
|
expect(parseStatus({ tag: 'draft' }).ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads section.locked (default false) and paragraph.list', () => {
|
||||||
|
const r = parseBrief({
|
||||||
|
...view.brief,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
sectionKey: 'aanhef',
|
||||||
|
title: 'Aanhef',
|
||||||
|
required: true,
|
||||||
|
locked: true,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'freeText',
|
||||||
|
blockId: 'b1',
|
||||||
|
content: {
|
||||||
|
paragraphs: [
|
||||||
|
{ nodes: [{ type: 'text', text: 'een' }], list: 'bullet' },
|
||||||
|
{ nodes: [{ type: 'text', text: 'twee' }], list: 'number' },
|
||||||
|
{ nodes: [{ type: 'text', text: 'plat' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ sectionKey: 'kern', title: 'Kern', required: true, blocks: [] }, // no `locked` → false
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
const [aanhef, kern] = r.value.sections;
|
||||||
|
expect(aanhef.locked).toBe(true);
|
||||||
|
expect(kern.locked).toBe(false);
|
||||||
|
expect(aanhef.blocks[0].content.paragraphs.map((p) => p.list)).toEqual(['bullet', 'number', undefined]);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a passage block missing provenance', () => {
|
it('rejects a passage block missing provenance', () => {
|
||||||
const r = parseBrief({
|
const r = parseBrief({
|
||||||
...view.brief,
|
...view.brief,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from '@shared/infrastructure/api-client';
|
} from '@shared/infrastructure/api-client';
|
||||||
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, PassageScope } from '@brief/domain/brief';
|
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, PassageScope } from '@brief/domain/brief';
|
||||||
import { PlaceholderDef } from '@brief/domain/placeholders';
|
import { PlaceholderDef } from '@brief/domain/placeholders';
|
||||||
import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire
|
* The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire
|
||||||
@@ -66,6 +66,12 @@ export class BriefAdapter {
|
|||||||
const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED);
|
const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED);
|
||||||
return r.ok ? parseBrief(r.value) : r;
|
return r.ok ? parseBrief(r.value) : r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Demo "start over" — recreate a fresh brief server-side and return the new view. */
|
||||||
|
async reset(): Promise<Result<string, BriefView>> {
|
||||||
|
const r = await runSubmit(() => this.client.briefReset(), BRIEF_ACTION_FAILED);
|
||||||
|
return r.ok ? parseBriefView(r.value) : r;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- parse: wire (flat) → domain (discriminated unions), validating at the boundary ---
|
// --- parse: wire (flat) → domain (discriminated unions), validating at the boundary ---
|
||||||
@@ -90,7 +96,7 @@ export function parseNode(dto: RichTextNodeDto): Result<string, RichTextNode> {
|
|||||||
|
|
||||||
export function parseBlockContent(dto: RichTextBlockDto | undefined): Result<string, RichTextBlock> {
|
export function parseBlockContent(dto: RichTextBlockDto | undefined): Result<string, RichTextBlock> {
|
||||||
if (!dto || !Array.isArray(dto.paragraphs)) return err('content: paragraphs not an array');
|
if (!dto || !Array.isArray(dto.paragraphs)) return err('content: paragraphs not an array');
|
||||||
const paragraphs = [];
|
const paragraphs: Paragraph[] = [];
|
||||||
for (const p of dto.paragraphs) {
|
for (const p of dto.paragraphs) {
|
||||||
const nodes: RichTextNode[] = [];
|
const nodes: RichTextNode[] = [];
|
||||||
for (const n of p.nodes ?? []) {
|
for (const n of p.nodes ?? []) {
|
||||||
@@ -98,7 +104,8 @@ export function parseBlockContent(dto: RichTextBlockDto | undefined): Result<str
|
|||||||
if (!parsed.ok) return parsed;
|
if (!parsed.ok) return parsed;
|
||||||
nodes.push(parsed.value);
|
nodes.push(parsed.value);
|
||||||
}
|
}
|
||||||
paragraphs.push({ nodes });
|
const list = p.list === 'bullet' || p.list === 'number' ? p.list : undefined;
|
||||||
|
paragraphs.push(list ? { nodes, list } : { nodes });
|
||||||
}
|
}
|
||||||
return ok({ paragraphs });
|
return ok({ paragraphs });
|
||||||
}
|
}
|
||||||
@@ -135,7 +142,7 @@ function parseSection(dto: LetterSectionDto): Result<string, LetterSection> {
|
|||||||
if (!parsed.ok) return parsed;
|
if (!parsed.ok) return parsed;
|
||||||
blocks.push(parsed.value);
|
blocks.push(parsed.value);
|
||||||
}
|
}
|
||||||
return ok({ sectionKey: dto.sectionKey, title: dto.title, required: dto.required, blocks });
|
return ok({ sectionKey: dto.sectionKey, title: dto.title, required: dto.required, locked: dto.locked ?? false, blocks });
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePlaceholderDef(dto: PlaceholderDefDto): Result<string, PlaceholderDef> {
|
function parsePlaceholderDef(dto: PlaceholderDefDto): Result<string, PlaceholderDef> {
|
||||||
@@ -245,7 +252,7 @@ function nodeToDto(n: RichTextNode): RichTextNodeDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function contentToDto(content: RichTextBlock): RichTextBlockDto {
|
function contentToDto(content: RichTextBlock): RichTextBlockDto {
|
||||||
return { paragraphs: content.paragraphs.map((p) => ({ nodes: p.nodes.map(nodeToDto) })) };
|
return { paragraphs: content.paragraphs.map((p) => ({ nodes: p.nodes.map(nodeToDto), ...(p.list ? { list: p.list } : {}) })) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function blockToDto(b: LetterBlock): LetterBlockDto {
|
function blockToDto(b: LetterBlock): LetterBlockDto {
|
||||||
@@ -255,5 +262,5 @@ function blockToDto(b: LetterBlock): LetterBlockDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sectionToDto(s: LetterSection): LetterSectionDto {
|
function sectionToDto(s: LetterSection): LetterSectionDto {
|
||||||
return { sectionKey: s.sectionKey, title: s.title, required: s.required, blocks: s.blocks.map(blockToDto) };
|
return { sectionKey: s.sectionKey, title: s.title, required: s.required, locked: s.locked, blocks: s.blocks.map(blockToDto) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
@@ -12,6 +12,10 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-brief-page',
|
selector: 'app-brief-page',
|
||||||
imports: [PageShellComponent, SpinnerComponent, AlertComponent, ButtonComponent, LetterComposerComponent],
|
imports: [PageShellComponent, SpinnerComponent, AlertComponent, ButtonComponent, LetterComposerComponent],
|
||||||
|
styles: [`
|
||||||
|
.brief-toolbar{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);margin-block-end:var(--rhc-space-max-lg)}
|
||||||
|
.save{color:var(--rhc-color-foreground-subtle);font-size:0.9em}
|
||||||
|
`],
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell
|
<app-page-shell
|
||||||
[heading]="heading"
|
[heading]="heading"
|
||||||
@@ -26,6 +30,10 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
|||||||
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||||
}
|
}
|
||||||
@case ('loaded') {
|
@case ('loaded') {
|
||||||
|
<div class="brief-toolbar">
|
||||||
|
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||||
|
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{ resetLabel }}</app-button>
|
||||||
|
</div>
|
||||||
<app-letter-composer
|
<app-letter-composer
|
||||||
[brief]="brief()!"
|
[brief]="brief()!"
|
||||||
[availablePassages]="availablePassages()"
|
[availablePassages]="availablePassages()"
|
||||||
@@ -53,11 +61,30 @@ export class BriefPage {
|
|||||||
protected intro = $localize`:@@brief.page.intro:Stel de brief aan de zorgverlener samen uit standaardteksten en vrije tekst.`;
|
protected intro = $localize`:@@brief.page.intro:Stel de brief aan de zorgverlener samen uit standaardteksten en vrije tekst.`;
|
||||||
protected failedText = $localize`:@@brief.page.failed:De brief kon niet worden geladen.`;
|
protected failedText = $localize`:@@brief.page.failed:De brief kon niet worden geladen.`;
|
||||||
protected retryText = $localize`:@@brief.page.retry:Opnieuw proberen`;
|
protected retryText = $localize`:@@brief.page.retry:Opnieuw proberen`;
|
||||||
|
protected resetLabel = $localize`:@@brief.page.reset:Opnieuw beginnen (demo)`;
|
||||||
|
|
||||||
|
private savingText = $localize`:@@brief.page.saving:Concept opslaan…`;
|
||||||
|
private savedText = $localize`:@@brief.page.saved:Concept opgeslagen`;
|
||||||
|
private saveErrorText = $localize`:@@brief.page.saveError:Opslaan mislukt`;
|
||||||
|
|
||||||
|
/** Debounced-save state, surfaced in a polite live region. */
|
||||||
|
protected saveText = computed(() => {
|
||||||
|
switch (this.store.saveState()) {
|
||||||
|
case 'saving': return this.savingText;
|
||||||
|
case 'saved': return this.savedText;
|
||||||
|
case 'error': return this.saveErrorText;
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
void this.store.load();
|
void this.store.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected resetDemo() {
|
||||||
|
void this.store.resetDemo();
|
||||||
|
}
|
||||||
|
|
||||||
// Narrow the loaded state for the template.
|
// Narrow the loaded state for the template.
|
||||||
protected brief() {
|
protected brief() {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
|||||||
[section]="section"
|
[section]="section"
|
||||||
[availablePassages]="availablePassages()"
|
[availablePassages]="availablePassages()"
|
||||||
[placeholders]="menu()"
|
[placeholders]="menu()"
|
||||||
[editable]="true"
|
[editable]="!section.locked"
|
||||||
(edit)="edit.emit($event)" />
|
(edit)="edit.emit($event)" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -123,7 +123,9 @@ export class LetterComposerComponent {
|
|||||||
// The insert menu offers only valid, fillable, non-deprecated fields — inserting an
|
// The insert menu offers only valid, fillable, non-deprecated fields — inserting an
|
||||||
// unknown/retired key is structurally impossible.
|
// unknown/retired key is structurally impossible.
|
||||||
protected menu = computed<PlaceholderOption[]>(() =>
|
protected menu = computed<PlaceholderOption[]>(() =>
|
||||||
this.brief().placeholders.filter((p) => p.fillable !== false && !p.deprecated).map((p) => ({ key: p.key, label: p.label })),
|
this.brief()
|
||||||
|
.placeholders.filter((p) => p.fillable !== false && !p.deprecated)
|
||||||
|
.map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),
|
||||||
);
|
);
|
||||||
|
|
||||||
protected statusLabel = computed(() => {
|
protected statusLabel = computed(() => {
|
||||||
|
|||||||
@@ -24,15 +24,17 @@ function brief(status: BriefStatus): Brief {
|
|||||||
sectionKey: 'aanhef',
|
sectionKey: 'aanhef',
|
||||||
title: 'Aanhef',
|
title: 'Aanhef',
|
||||||
required: true,
|
required: true,
|
||||||
|
locked: true,
|
||||||
blocks: [{ type: 'passage', blockId: 'local-1', sourcePassageId: 'p1', sourceVersion: 1, edited: false, content: passages[0].content }],
|
blocks: [{ type: 'passage', blockId: 'local-1', sourcePassageId: 'p1', sourceVersion: 1, edited: false, content: passages[0].content }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sectionKey: 'kern',
|
sectionKey: 'kern',
|
||||||
title: 'Kern van het besluit',
|
title: 'Kern van het besluit',
|
||||||
required: true,
|
required: true,
|
||||||
|
locked: false,
|
||||||
blocks: [{ type: 'freeText', blockId: 'local-2', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Wij hebben besloten om reden ' }, { type: 'placeholder', key: 'reden_besluit' }, { type: 'text', text: '.' }] }] } }],
|
blocks: [{ type: 'freeText', blockId: 'local-2', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Wij hebben besloten om reden ' }, { type: 'placeholder', key: 'reden_besluit' }, { type: 'text', text: '.' }] }] } }],
|
||||||
},
|
},
|
||||||
{ sectionKey: 'slot', title: 'Slot', required: false, blocks: [] },
|
{ sectionKey: 'slot', title: 'Slot', required: false, locked: true, blocks: [{ type: 'freeText', blockId: 'local-3', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Met vriendelijke groet,' }] }] } }] },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,83 @@
|
|||||||
import { Component, computed, input } from '@angular/core';
|
import { Component, computed, input, signal } from '@angular/core';
|
||||||
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
||||||
import { Brief } from '@brief/domain/brief';
|
import { Paragraph } from '@shared/kernel/rich-text';
|
||||||
|
import { Brief, LetterBlock } from '@brief/domain/brief';
|
||||||
import { Diagnostic } from '@brief/domain/placeholders';
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
|
|
||||||
|
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
||||||
|
type PreviewSegment = { readonly list: 'bullet' | 'number' | null; readonly items: readonly Paragraph[] };
|
||||||
|
|
||||||
|
function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
|
||||||
|
const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];
|
||||||
|
for (const para of paras) {
|
||||||
|
const kind = para.list ?? null;
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (kind && last && last.list === kind) last.items.push(para);
|
||||||
|
else out.push({ list: kind, items: [para] });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
|
||||||
|
const SAMPLE_VALUES: Record<string, string> = {
|
||||||
|
naam_zorgverlener: 'J. Jansen',
|
||||||
|
big_nummer: '12345678901',
|
||||||
|
};
|
||||||
|
|
||||||
/** Organism: read-only rendering of the letter as the approver/recipient sees it.
|
/** Organism: read-only rendering of the letter as the approver/recipient sees it.
|
||||||
Placeholders show as labelled chips (values are resolved server-side only at send);
|
Placeholders show as labelled chips (values are resolved server-side only at send);
|
||||||
a chip flagged by the linter shows its error/warning state. */
|
a chip flagged by the linter shows its error/warning state. The "Voorbeeld" toggle
|
||||||
|
fills auto-resolvable placeholders with sample values to preview the sent result. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-letter-preview',
|
selector: 'app-letter-preview',
|
||||||
imports: [HeadingComponent, PlaceholderChipComponent],
|
imports: [NgTemplateOutlet, HeadingComponent, ButtonComponent, PlaceholderChipComponent],
|
||||||
styles: [`
|
styles: [`
|
||||||
:host{display:block}
|
:host{display:block}
|
||||||
|
.toolbar{display:flex;justify-content:flex-end;margin-block-end:var(--rhc-space-max-sm)}
|
||||||
.letter{background:var(--rhc-color-wit);border:1px solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-md);padding:var(--rhc-space-max-2xl)}
|
.letter{background:var(--rhc-color-wit);border:1px solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-md);padding:var(--rhc-space-max-2xl)}
|
||||||
section{margin-block-end:var(--rhc-space-max-xl)}
|
section{margin-block-end:var(--rhc-space-max-xl)}
|
||||||
p{margin:0 0 var(--rhc-space-max-sm)}
|
p{margin:0 0 var(--rhc-space-max-sm)}
|
||||||
|
ul,ol{margin:0 0 var(--rhc-space-max-sm);padding-inline-start:1.4em}
|
||||||
`],
|
`],
|
||||||
template: `
|
template: `
|
||||||
|
<ng-template #line let-nodes>
|
||||||
|
@for (node of nodes; track $index) {
|
||||||
|
@switch (node.type) {
|
||||||
|
@case ('text') { <span>{{ node.text }}</span> }
|
||||||
|
@case ('lineBreak') { <br> }
|
||||||
|
@case ('placeholder') {
|
||||||
|
@if (showSample() && autoFor(node.key)) {
|
||||||
|
<span>{{ sampleFor(node.key) }}</span>
|
||||||
|
} @else {
|
||||||
|
<app-placeholder-chip [label]="labelFor(node.key)" [autoResolvable]="autoFor(node.key)" [state]="stateFor(node.key)" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<app-button variant="subtle" (click)="showSample.set(!showSample())" [attr.aria-pressed]="showSample()">
|
||||||
|
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="letter">
|
<div class="letter">
|
||||||
@for (section of brief().sections; track section.sectionKey) {
|
@for (section of brief().sections; track section.sectionKey) {
|
||||||
<section>
|
<section>
|
||||||
<app-heading [level]="3">{{ section.title }}</app-heading>
|
<app-heading [level]="3">{{ section.title }}</app-heading>
|
||||||
@for (block of section.blocks; track block.blockId) {
|
@for (block of section.blocks; track block.blockId) {
|
||||||
@for (para of block.content.paragraphs; track $index) {
|
@for (seg of segmentsOf(block); track $index) {
|
||||||
<p>
|
@if (seg.list === 'bullet') {
|
||||||
@for (node of para.nodes; track $index) {
|
<ul>@for (para of seg.items; track $index) { <li><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: para.nodes }" /></li>}</ul>
|
||||||
@switch (node.type) {
|
} @else if (seg.list === 'number') {
|
||||||
@case ('text') { <span>{{ node.text }}</span> }
|
<ol>@for (para of seg.items; track $index) { <li><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: para.nodes }" /></li>}</ol>
|
||||||
@case ('lineBreak') { <br> }
|
} @else {
|
||||||
@case ('placeholder') {
|
<p><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }" /></p>
|
||||||
<app-placeholder-chip
|
}
|
||||||
[label]="labelFor(node.key)"
|
|
||||||
[autoResolvable]="autoFor(node.key)"
|
|
||||||
[state]="stateFor(node.key)" />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
@@ -48,6 +89,12 @@ export class LetterPreviewComponent {
|
|||||||
brief = input.required<Brief>();
|
brief = input.required<Brief>();
|
||||||
diagnostics = input<readonly Diagnostic[]>([]);
|
diagnostics = input<readonly Diagnostic[]>([]);
|
||||||
|
|
||||||
|
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
||||||
|
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
||||||
|
|
||||||
|
protected showSample = signal(false);
|
||||||
|
private today = new Date().toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
|
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
|
||||||
private worst = computed(() => {
|
private worst = computed(() => {
|
||||||
const m = new Map<string, 'error' | 'warning'>();
|
const m = new Map<string, 'error' | 'warning'>();
|
||||||
@@ -59,7 +106,9 @@ export class LetterPreviewComponent {
|
|||||||
return m;
|
return m;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
|
||||||
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
|
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
|
||||||
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
|
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
|
||||||
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
|
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
|
||||||
|
protected sampleFor = (key: string) => SAMPLE_VALUES[key] ?? (key === 'datum' ? this.today : this.labelFor(key));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1207,6 +1207,42 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
return Promise.resolve<BriefDto>(null as any);
|
return Promise.resolve<BriefDto>(null as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
briefReset(): Promise<BriefViewDto> {
|
||||||
|
let url_ = this.baseUrl + "/api/v1/brief/reset";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processBriefReset(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processBriefReset(response: Response): Promise<BriefViewDto> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<BriefViewDto>(null as any);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AantekeningDto {
|
export interface AantekeningDto {
|
||||||
@@ -1369,6 +1405,7 @@ export interface LetterSectionDto {
|
|||||||
title?: string | undefined;
|
title?: string | undefined;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
blocks?: LetterBlockDto[] | undefined;
|
blocks?: LetterBlockDto[] | undefined;
|
||||||
|
locked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryPassageDto {
|
export interface LibraryPassageDto {
|
||||||
@@ -1388,6 +1425,7 @@ export interface ManualDiplomaPolicyDto {
|
|||||||
|
|
||||||
export interface ParagraphDto {
|
export interface ParagraphDto {
|
||||||
nodes?: RichTextNodeDto[] | undefined;
|
nodes?: RichTextNodeDto[] | undefined;
|
||||||
|
list?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonDto {
|
export interface PersonDto {
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export type RichTextNode =
|
|||||||
|
|
||||||
export interface Paragraph {
|
export interface Paragraph {
|
||||||
readonly nodes: readonly RichTextNode[];
|
readonly nodes: readonly RichTextNode[];
|
||||||
|
// A line can be a plain paragraph (undefined) or an item in a bullet/numbered list.
|
||||||
|
// Consecutive lines with the same list kind render as one <ul>/<ol>.
|
||||||
|
readonly list?: 'bullet' | 'number';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RichTextBlock {
|
export interface RichTextBlock {
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import { Component, computed, input } from '@angular/core';
|
|||||||
border-radius:var(--rhc-border-radius-sm);
|
border-radius:var(--rhc-border-radius-sm);
|
||||||
padding:0 0.35em;line-height:1.6;border:1px solid transparent;white-space:nowrap;
|
padding:0 0.35em;line-height:1.6;border:1px solid transparent;white-space:nowrap;
|
||||||
}
|
}
|
||||||
.chip::before{content:'⌗';opacity:0.6;font-weight:700}
|
/* Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */
|
||||||
|
.chip::before{content:'\\7B';opacity:0.6;font-weight:700}
|
||||||
|
.chip::after{content:'\\7D';opacity:0.6;font-weight:700}
|
||||||
.chip--auto{background:var(--rhc-color-cool-grey-100);color:var(--rhc-color-foreground-default)}
|
.chip--auto{background:var(--rhc-color-cool-grey-100);color:var(--rhc-color-foreground-default)}
|
||||||
.chip--manual{background:var(--rhc-color-geel-100);color:var(--rhc-color-foreground-default)}
|
.chip--manual{background:var(--rhc-color-geel-100);color:var(--rhc-color-foreground-default)}
|
||||||
.chip--warning{background:var(--rhc-color-geel-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)}
|
.chip--warning{background:var(--rhc-color-geel-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
import { readBlock, renderInto } from './rich-text-dom';
|
import { adjacentChip, readBlock, renderInto } from './rich-text-dom';
|
||||||
|
|
||||||
const labelFor = (key: string) => ({ naam: 'Naam', datum: 'Datum' })[key] ?? key;
|
const labelFor = (key: string) => ({ naam: 'Naam', datum: 'Datum' })[key] ?? key;
|
||||||
|
|
||||||
@@ -48,4 +48,70 @@ describe('rich-text DOM boundary', () => {
|
|||||||
root.innerHTML = '<p><em><strong>x</strong></em></p>'; // italic wrapping bold
|
root.innerHTML = '<p><em><strong>x</strong></em></p>'; // italic wrapping bold
|
||||||
expect(readBlock(root)).toEqual({ paragraphs: [{ nodes: [{ type: 'text', text: 'x', marks: ['bold', 'italic'] }] }] });
|
expect(readBlock(root)).toEqual({ paragraphs: [{ nodes: [{ type: 'text', text: 'x', marks: ['bold', 'italic'] }] }] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trips bullet and numbered lists mixed with paragraphs', () => {
|
||||||
|
const block: RichTextBlock = {
|
||||||
|
paragraphs: [
|
||||||
|
{ nodes: [{ type: 'text', text: 'Intro' }] },
|
||||||
|
{ nodes: [{ type: 'text', text: 'een' }], list: 'bullet' },
|
||||||
|
{ nodes: [{ type: 'text', text: 'twee' }], list: 'bullet' },
|
||||||
|
{ nodes: [{ type: 'text', text: 'eerst' }], list: 'number' },
|
||||||
|
{ nodes: [{ type: 'text', text: 'dan' }], list: 'number' },
|
||||||
|
{ nodes: [{ type: 'text', text: 'Slot' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(roundTrip(block)).toEqual(block);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups consecutive same-kind list lines into one <ul>/<ol>', () => {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
renderInto(
|
||||||
|
root,
|
||||||
|
{
|
||||||
|
paragraphs: [
|
||||||
|
{ nodes: [{ type: 'text', text: 'a' }], list: 'bullet' },
|
||||||
|
{ nodes: [{ type: 'text', text: 'b' }], list: 'bullet' },
|
||||||
|
{ nodes: [{ type: 'text', text: 'c' }], list: 'number' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
labelFor,
|
||||||
|
);
|
||||||
|
expect(root.querySelectorAll('ul').length).toBe(1);
|
||||||
|
expect(root.querySelectorAll('ul > li').length).toBe(2);
|
||||||
|
expect(root.querySelectorAll('ol > li').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adjacentChip finds a chip next to a collapsed caret so Backspace/Delete can remove it', () => {
|
||||||
|
// <p>aa{chip}bb</p>
|
||||||
|
const p = document.createElement('p');
|
||||||
|
const before = document.createTextNode('aa');
|
||||||
|
const chip = document.createElement('span');
|
||||||
|
chip.dataset['phKey'] = 'naam';
|
||||||
|
const after = document.createTextNode('bb');
|
||||||
|
p.append(before, chip, after);
|
||||||
|
|
||||||
|
// Backspace with caret just after the chip (start of "bb") → the chip.
|
||||||
|
expect(adjacentChip(after, 0, -1)).toBe(chip);
|
||||||
|
// Delete with caret just before the chip (end of "aa") → the chip.
|
||||||
|
expect(adjacentChip(before, before.length, 1)).toBe(chip);
|
||||||
|
// Element-level caret between chip and "bb" (offset 2), Backspace → the chip.
|
||||||
|
expect(adjacentChip(p, 2, -1)).toBe(chip);
|
||||||
|
// Caret mid-text → nothing to remove.
|
||||||
|
expect(adjacentChip(after, 1, -1)).toBeNull();
|
||||||
|
// At the text edge but the sibling is not a chip → null.
|
||||||
|
expect(adjacentChip(before, 0, -1)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks auto-resolvable vs manual chips with data-auto for styling', () => {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
renderInto(
|
||||||
|
root,
|
||||||
|
{ paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }, { type: 'placeholder', key: 'reden' }] }] },
|
||||||
|
labelFor,
|
||||||
|
(key) => key === 'naam',
|
||||||
|
);
|
||||||
|
const chips = root.querySelectorAll('.rte-chip');
|
||||||
|
expect((chips[0] as HTMLElement).dataset['auto']).toBe('true');
|
||||||
|
expect((chips[1] as HTMLElement).dataset['auto']).toBe('false');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The quarantined boundary between the imperative `contenteditable` DOM and the
|
* The quarantined boundary between the imperative `contenteditable` DOM and the
|
||||||
@@ -14,35 +14,71 @@ import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
|||||||
const ORDER: readonly Mark[] = ['bold', 'italic', 'underline'];
|
const ORDER: readonly Mark[] = ['bold', 'italic', 'underline'];
|
||||||
const MARK_TAG: Record<Mark, string> = { bold: 'strong', italic: 'em', underline: 'u' };
|
const MARK_TAG: Record<Mark, string> = { bold: 'strong', italic: 'em', underline: 'u' };
|
||||||
|
|
||||||
export function renderInto(root: HTMLElement, block: RichTextBlock, labelFor: (key: string) => string): void {
|
export function renderInto(
|
||||||
|
root: HTMLElement,
|
||||||
|
block: RichTextBlock,
|
||||||
|
labelFor: (key: string) => string,
|
||||||
|
autoFor?: (key: string) => boolean,
|
||||||
|
): void {
|
||||||
const doc = root.ownerDocument;
|
const doc = root.ownerDocument;
|
||||||
root.replaceChildren();
|
root.replaceChildren();
|
||||||
for (const para of block.paragraphs) {
|
const paras = block.paragraphs;
|
||||||
const p = doc.createElement('p');
|
let i = 0;
|
||||||
p.className = 'rte-para';
|
while (i < paras.length) {
|
||||||
if (para.nodes.length === 0) {
|
const para = paras[i];
|
||||||
p.appendChild(doc.createElement('br')); // keep the empty line focusable
|
if (para.list) {
|
||||||
|
// Group consecutive lines of the same list kind into one <ul>/<ol>.
|
||||||
|
const listEl = doc.createElement(para.list === 'bullet' ? 'ul' : 'ol');
|
||||||
|
listEl.className = 'rte-list';
|
||||||
|
while (i < paras.length && paras[i].list === para.list) {
|
||||||
|
const li = doc.createElement('li');
|
||||||
|
fillLine(li, paras[i], labelFor, doc, autoFor);
|
||||||
|
listEl.appendChild(li);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
root.appendChild(listEl);
|
||||||
} else {
|
} else {
|
||||||
for (const node of para.nodes) p.appendChild(renderNode(node, labelFor, doc));
|
const p = doc.createElement('p');
|
||||||
|
p.className = 'rte-para';
|
||||||
|
fillLine(p, para, labelFor, doc, autoFor);
|
||||||
|
root.appendChild(p);
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
root.appendChild(p);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fill one line element (<p> or <li>) with a paragraph's nodes; empty lines keep a
|
||||||
|
focusable <br>. Shared by paragraph and list-item rendering. */
|
||||||
|
function fillLine(
|
||||||
|
el: HTMLElement,
|
||||||
|
para: Paragraph,
|
||||||
|
labelFor: (key: string) => string,
|
||||||
|
doc: Document,
|
||||||
|
autoFor?: (key: string) => boolean,
|
||||||
|
): void {
|
||||||
|
if (para.nodes.length === 0) {
|
||||||
|
el.appendChild(doc.createElement('br'));
|
||||||
|
} else {
|
||||||
|
for (const node of para.nodes) el.appendChild(renderNode(node, labelFor, doc, autoFor));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build one non-editable placeholder chip element (shared by initial render and
|
/** Build one non-editable placeholder chip element (shared by initial render and
|
||||||
live caret insertion). setAttribute reflects reliably in jsdom + browsers. */
|
live caret insertion). `data-auto` distinguishes auto-resolvable vs manual fields
|
||||||
export function createChip(doc: Document, key: string, label: string): HTMLElement {
|
for styling; it is a render hint only and is ignored on read-back. */
|
||||||
|
export function createChip(doc: Document, key: string, label: string, auto = false): HTMLElement {
|
||||||
const span = doc.createElement('span');
|
const span = doc.createElement('span');
|
||||||
span.dataset['phKey'] = key;
|
span.dataset['phKey'] = key;
|
||||||
|
span.dataset['auto'] = String(auto);
|
||||||
span.setAttribute('contenteditable', 'false');
|
span.setAttribute('contenteditable', 'false');
|
||||||
span.className = 'rte-chip';
|
span.className = 'rte-chip';
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc: Document): Node {
|
function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc: Document, autoFor?: (key: string) => boolean): Node {
|
||||||
if (node.type === 'lineBreak') return doc.createElement('br');
|
if (node.type === 'lineBreak') return doc.createElement('br');
|
||||||
if (node.type === 'placeholder') return createChip(doc, node.key, labelFor(node.key));
|
if (node.type === 'placeholder') return createChip(doc, node.key, labelFor(node.key), autoFor?.(node.key) ?? false);
|
||||||
let el: Node = doc.createTextNode(node.text);
|
let el: Node = doc.createTextNode(node.text);
|
||||||
// Nest marks in a canonical order so read-back is deterministic.
|
// Nest marks in a canonical order so read-back is deterministic.
|
||||||
for (const m of ORDER.filter((x) => node.marks?.includes(x))) {
|
for (const m of ORDER.filter((x) => node.marks?.includes(x))) {
|
||||||
@@ -54,21 +90,37 @@ function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function readBlock(root: HTMLElement): RichTextBlock {
|
export function readBlock(root: HTMLElement): RichTextBlock {
|
||||||
const paragraphs: { nodes: RichTextNode[] }[] = [];
|
const paragraphs: Paragraph[] = [];
|
||||||
const blockEls = Array.from(root.children).filter((c) => c.tagName === 'P' || c.tagName === 'DIV');
|
const blockEls = Array.from(root.children).filter(
|
||||||
|
(c) => c.tagName === 'P' || c.tagName === 'DIV' || c.tagName === 'UL' || c.tagName === 'OL',
|
||||||
|
);
|
||||||
const containers = blockEls.length ? blockEls : [root];
|
const containers = blockEls.length ? blockEls : [root];
|
||||||
for (const el of containers) {
|
for (const el of containers) {
|
||||||
const kids = Array.from(el.childNodes);
|
// ponytail: only top-level lists are read as lists; a nested <ul> inside an <li>
|
||||||
const nodes: RichTextNode[] = [];
|
// flattens into its item's text (fine for this POC's flat lists).
|
||||||
// A lone <br> is the empty-line filler, not a content line break.
|
if (el.tagName === 'UL' || el.tagName === 'OL') {
|
||||||
if (!(kids.length === 1 && kids[0].nodeName === 'BR')) {
|
const list = el.tagName === 'UL' ? 'bullet' : 'number';
|
||||||
for (const child of kids) collect(child, [], nodes);
|
for (const li of Array.from(el.children).filter((c) => c.tagName === 'LI')) {
|
||||||
|
paragraphs.push({ ...readLine(li as HTMLElement), list });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
paragraphs.push(readLine(el as HTMLElement));
|
||||||
}
|
}
|
||||||
paragraphs.push({ nodes });
|
|
||||||
}
|
}
|
||||||
return { paragraphs: paragraphs.length ? paragraphs : [{ nodes: [] }] };
|
return { paragraphs: paragraphs.length ? paragraphs : [{ nodes: [] }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read one line element (<p>, <div>, or <li>) into a paragraph's nodes. */
|
||||||
|
function readLine(el: HTMLElement): { nodes: RichTextNode[] } {
|
||||||
|
const kids = Array.from(el.childNodes);
|
||||||
|
const nodes: RichTextNode[] = [];
|
||||||
|
// A lone <br> is the empty-line filler, not a content line break.
|
||||||
|
if (!(kids.length === 1 && kids[0].nodeName === 'BR')) {
|
||||||
|
for (const child of kids) collect(child, [], nodes);
|
||||||
|
}
|
||||||
|
return { nodes };
|
||||||
|
}
|
||||||
|
|
||||||
function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
|
function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
const text = node.textContent ?? '';
|
const text = node.textContent ?? '';
|
||||||
@@ -91,6 +143,32 @@ function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
|
|||||||
for (const child of Array.from(el.childNodes)) collect(child, next, out);
|
for (const child of Array.from(el.childNodes)) collect(child, next, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isChip(node: Node | null | undefined): node is HTMLElement {
|
||||||
|
return !!node && node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset?.['phKey'] != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The placeholder chip immediately adjacent to a collapsed caret in the given direction
|
||||||
|
* (-1 = before / Backspace, +1 = after / Delete), or null. Chips are contenteditable=false,
|
||||||
|
* which some browsers won't delete on Backspace; the editor uses this to remove them itself.
|
||||||
|
*/
|
||||||
|
export function adjacentChip(container: Node, offset: number, direction: -1 | 1): HTMLElement | null {
|
||||||
|
let sibling: Node | null | undefined;
|
||||||
|
if (container.nodeType === Node.TEXT_NODE) {
|
||||||
|
// Only adjacent when the caret sits at the text edge (otherwise there are chars to delete first).
|
||||||
|
if (direction === -1) {
|
||||||
|
if (offset > 0) return null;
|
||||||
|
sibling = container.previousSibling;
|
||||||
|
} else {
|
||||||
|
if (offset < (container.textContent?.length ?? 0)) return null;
|
||||||
|
sibling = container.nextSibling;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sibling = direction === -1 ? container.childNodes[offset - 1] : container.childNodes[offset];
|
||||||
|
}
|
||||||
|
return isChip(sibling) ? sibling : null;
|
||||||
|
}
|
||||||
|
|
||||||
function markOf(el: HTMLElement): Mark | null {
|
function markOf(el: HTMLElement): Mark | null {
|
||||||
switch (el.tagName) {
|
switch (el.tagName) {
|
||||||
case 'STRONG':
|
case 'STRONG':
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';
|
import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';
|
||||||
import { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';
|
import { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||||
import { createChip, readBlock, renderInto } from './rich-text-dom';
|
import { adjacentChip, createChip, readBlock, renderInto } from './rich-text-dom';
|
||||||
|
|
||||||
/** A menu entry for the insert-placeholder control — a plain {key,label}, so the
|
/** A menu entry for the insert-placeholder control — a plain {key,label}, so the
|
||||||
editor stays domain-free (it never sees the brief's PlaceholderDef). */
|
editor stays domain-free (it never sees the brief's PlaceholderDef). */
|
||||||
export interface PlaceholderOption {
|
export interface PlaceholderOption {
|
||||||
readonly key: string;
|
readonly key: string;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
// Auto-resolvable fields are filled server-side at send; manual fields need a value.
|
||||||
|
// Drives the chip's styling so the two read apart at a glance.
|
||||||
|
readonly autoResolvable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,13 +34,20 @@ export interface PlaceholderOption {
|
|||||||
}
|
}
|
||||||
.rte-editable[contenteditable='false']{background:var(--rhc-color-cool-grey-100)}
|
.rte-editable[contenteditable='false']{background:var(--rhc-color-cool-grey-100)}
|
||||||
.rte-editable :is(p){margin:0 0 var(--rhc-space-max-sm)}
|
.rte-editable :is(p){margin:0 0 var(--rhc-space-max-sm)}
|
||||||
/* Placeholder chips inside the editor: one neutral highlight (the read-only
|
.rte-editable :is(ul,ol){margin:0 0 var(--rhc-space-max-sm);padding-inline-start:1.4em}
|
||||||
preview distinguishes auto/manual/error via app-placeholder-chip). */
|
/* Placeholder chips read as fill-in fields: auto-resolvable (grey, filled server-side)
|
||||||
.rte-editable .rte-chip{
|
vs manual (yellow, still needs a value). The read-only preview adds error/warning states.
|
||||||
background:var(--rhc-color-cool-grey-100);border-radius:var(--rhc-border-radius-sm);
|
Chips are created imperatively (createChip) inside contenteditable, so they never receive
|
||||||
padding:0 0.35em;white-space:nowrap;
|
Angular's _ngcontent scoping attribute — ::ng-deep is required or the rules won't match them.
|
||||||
|
Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */
|
||||||
|
:host ::ng-deep .rte-chip{
|
||||||
|
border:1px dashed var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-sm);
|
||||||
|
padding:0 0.3em;white-space:nowrap;
|
||||||
}
|
}
|
||||||
.rte-editable .rte-chip::before{content:'⌗';opacity:0.6;font-weight:700;margin-inline-end:0.15em}
|
:host ::ng-deep .rte-chip[data-auto='true']{background:var(--rhc-color-cool-grey-100)}
|
||||||
|
:host ::ng-deep .rte-chip[data-auto='false']{background:var(--rhc-color-geel-100)}
|
||||||
|
:host ::ng-deep .rte-chip::before{content:'\\7B';opacity:0.6;font-weight:700;margin-inline-end:0.1em}
|
||||||
|
:host ::ng-deep .rte-chip::after{content:'\\7D';opacity:0.6;font-weight:700;margin-inline-start:0.1em}
|
||||||
`],
|
`],
|
||||||
template: `
|
template: `
|
||||||
@if (editable()) {
|
@if (editable()) {
|
||||||
@@ -45,6 +55,9 @@ export interface PlaceholderOption {
|
|||||||
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('bold')" [attr.aria-label]="boldLabel()"><b>B</b></button>
|
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('bold')" [attr.aria-label]="boldLabel()"><b>B</b></button>
|
||||||
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('italic')" [attr.aria-label]="italicLabel()"><i>I</i></button>
|
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('italic')" [attr.aria-label]="italicLabel()"><i>I</i></button>
|
||||||
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('underline')" [attr.aria-label]="underlineLabel()"><u>U</u></button>
|
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('underline')" [attr.aria-label]="underlineLabel()"><u>U</u></button>
|
||||||
|
<span class="rte-sep" aria-hidden="true"></span>
|
||||||
|
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="list('bullet')" [attr.aria-label]="bulletListLabel()">•</button>
|
||||||
|
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="list('number')" [attr.aria-label]="numberListLabel()">1.</button>
|
||||||
@if (placeholders().length) {
|
@if (placeholders().length) {
|
||||||
<span class="rte-sep" aria-hidden="true"></span>
|
<span class="rte-sep" aria-hidden="true"></span>
|
||||||
<label>
|
<label>
|
||||||
@@ -59,7 +72,7 @@ export interface PlaceholderOption {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div #editor class="rte-editable" [attr.contenteditable]="editable()" (input)="emit()"
|
<div #editor class="rte-editable" [attr.contenteditable]="editable()" (input)="emit()" (keydown)="onKeydown($event)"
|
||||||
role="textbox" aria-multiline="true" [attr.aria-label]="fieldLabel()"></div>
|
role="textbox" aria-multiline="true" [attr.aria-label]="fieldLabel()"></div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -77,11 +90,14 @@ export class RichTextEditorComponent {
|
|||||||
underlineLabel = input($localize`:@@richTextEditor.underline:Onderstreept`);
|
underlineLabel = input($localize`:@@richTextEditor.underline:Onderstreept`);
|
||||||
insertLabel = input($localize`:@@richTextEditor.insert:Veld invoegen:`);
|
insertLabel = input($localize`:@@richTextEditor.insert:Veld invoegen:`);
|
||||||
insertPrompt = input($localize`:@@richTextEditor.insertPrompt:Kies…`);
|
insertPrompt = input($localize`:@@richTextEditor.insertPrompt:Kies…`);
|
||||||
|
bulletListLabel = input($localize`:@@richTextEditor.bulletList:Opsomming`);
|
||||||
|
numberListLabel = input($localize`:@@richTextEditor.numberList:Genummerde lijst`);
|
||||||
|
|
||||||
private editorEl = viewChild<ElementRef<HTMLElement>>('editor');
|
private editorEl = viewChild<ElementRef<HTMLElement>>('editor');
|
||||||
private lastEmitted = '';
|
private lastEmitted = '';
|
||||||
|
|
||||||
private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key;
|
private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key;
|
||||||
|
private autoFor = (key: string) => this.placeholders().find((p) => p.key === key)?.autoResolvable ?? false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Render when content arrives/changes from OUTSIDE. Skip our own emitted value
|
// Render when content arrives/changes from OUTSIDE. Skip our own emitted value
|
||||||
@@ -92,7 +108,7 @@ export class RichTextEditorComponent {
|
|||||||
if (!el) return;
|
if (!el) return;
|
||||||
const serialized = JSON.stringify(content);
|
const serialized = JSON.stringify(content);
|
||||||
if (serialized === this.lastEmitted) return;
|
if (serialized === this.lastEmitted) return;
|
||||||
renderInto(el, content, this.labelFor);
|
renderInto(el, content, this.labelFor, this.autoFor);
|
||||||
this.lastEmitted = serialized;
|
this.lastEmitted = serialized;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -115,11 +131,51 @@ export class RichTextEditorComponent {
|
|||||||
this.emit();
|
this.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bullet / numbered lists via execCommand — same deprecated-but-universal path as
|
||||||
|
bold/italic (ponytail-noted on `format`); readBlock reads the resulting <ul>/<ol>. */
|
||||||
|
protected list(kind: 'bullet' | 'number') {
|
||||||
|
const el = this.editorEl()?.nativeElement;
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
el.ownerDocument.execCommand(kind === 'bullet' ? 'insertUnorderedList' : 'insertOrderedList');
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ctrl/Cmd+B/I/U → our format() (+ preventDefault) so serialization runs and behaviour
|
||||||
|
is consistent across browsers rather than relying on the native handler. */
|
||||||
|
protected onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||||
|
this.deleteAdjacentChip(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
|
||||||
|
const cmd = { b: 'bold', i: 'italic', u: 'underline' }[e.key.toLowerCase()] as 'bold' | 'italic' | 'underline' | undefined;
|
||||||
|
if (!cmd) return;
|
||||||
|
e.preventDefault();
|
||||||
|
this.format(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backspace/Delete next to a contenteditable=false chip removes it ourselves —
|
||||||
|
browsers otherwise leave these atomic chips undeletable. Selections fall through. */
|
||||||
|
private deleteAdjacentChip(e: KeyboardEvent) {
|
||||||
|
const el = this.editorEl()?.nativeElement;
|
||||||
|
if (!el) return;
|
||||||
|
const sel = el.ownerDocument.getSelection();
|
||||||
|
if (!sel || !sel.isCollapsed || !sel.rangeCount) return;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
if (!el.contains(range.startContainer)) return;
|
||||||
|
const chip = adjacentChip(range.startContainer, range.startOffset, e.key === 'Backspace' ? -1 : 1);
|
||||||
|
if (!chip) return;
|
||||||
|
e.preventDefault();
|
||||||
|
chip.remove();
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
protected insert(key: string) {
|
protected insert(key: string) {
|
||||||
const el = this.editorEl()?.nativeElement;
|
const el = this.editorEl()?.nativeElement;
|
||||||
if (!key || !el) return;
|
if (!key || !el) return;
|
||||||
el.focus();
|
el.focus();
|
||||||
const chip = createChip(el.ownerDocument, key, this.labelFor(key));
|
const chip = createChip(el.ownerDocument, key, this.labelFor(key), this.autoFor(key));
|
||||||
const sel = el.ownerDocument.getSelection();
|
const sel = el.ownerDocument.getSelection();
|
||||||
if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {
|
if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {
|
||||||
const range = sel.getRangeAt(0);
|
const range = sel.getRangeAt(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user