From cd238285ae82fc039d5be8acb723bb06cf6de109 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 15 Jun 2026 08:03:53 +0200 Subject: [PATCH] feat(timeline): gate the EventPill edit pencil behind canWrite Thread a gate-closed canWrite prop through TimelineView -> YearBand -> EventPill and the undated bucket so a Reader never sees a dead-end edit link. canEdit now also requires canWrite; the default false keeps an un-threaded caller closed. The real boundary stays the #781 route guard plus the backend permission -- this only hides the link. Refs #842 Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/timeline/EventPill.svelte | 8 ++-- .../src/lib/timeline/EventPill.svelte.spec.ts | 45 ++++++++++++++++--- frontend/src/lib/timeline/TimelineView.svelte | 10 +++-- .../lib/timeline/TimelineView.svelte.spec.ts | 40 ++++++++++++++++- frontend/src/lib/timeline/YearBand.svelte | 4 +- 5 files changed, 92 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/timeline/EventPill.svelte b/frontend/src/lib/timeline/EventPill.svelte index 55f274f3..b8573119 100644 --- a/frontend/src/lib/timeline/EventPill.svelte +++ b/frontend/src/lib/timeline/EventPill.svelte @@ -10,9 +10,11 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; * Centered axis pill for a derived life-event or a curated PERSONAL event * (REQ-007/008). The glyph is wrapped aria-hidden with an sr-only label sibling * (REQ-018). An edit affordance shows only for a curated event with an eventId - * (never derived, never null — REQ-008). + * (never derived, never null — REQ-008) and only for a curator who holds + * WRITE_ALL (`canWrite`, gate-closed by default — #842 REQ-005/007/008). The + * gate is UX only; the real boundary is the #781 route guard + backend permission. */ -let { entry }: { entry: TimelineEntryDTO } = $props(); +let { entry, canWrite = false }: { entry: TimelineEntryDTO; canWrite?: boolean } = $props(); const config = $derived(getAccentConfig(entry)); const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd)); @@ -24,7 +26,7 @@ const provenance = $derived( // Provenance always shows; the date is an optional prefix so an undated event // still reads "abgeleitet"/"kuratiert" (REQ-007). const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance); -const canEdit = $derived(!entry.derived && entry.eventId != null); +const canEdit = $derived(canWrite && !entry.derived && entry.eventId != null);
diff --git a/frontend/src/lib/timeline/EventPill.svelte.spec.ts b/frontend/src/lib/timeline/EventPill.svelte.spec.ts index 9a9ed577..82d48d6e 100644 --- a/frontend/src/lib/timeline/EventPill.svelte.spec.ts +++ b/frontend/src/lib/timeline/EventPill.svelte.spec.ts @@ -51,8 +51,9 @@ describe('EventPill', () => { expect(srOnly?.textContent).toBe('Geburt'); }); - it('shows an edit affordance for a curated PERSONAL event with an eventId (REQ-008)', () => { + it('shows an edit affordance for a curated PERSONAL event when canWrite is true (REQ-005)', () => { render(EventPill, { + canWrite: true, entry: makeEntry({ kind: 'EVENT', derived: false, @@ -66,11 +67,45 @@ describe('EventPill', () => { }); const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null; expect(edit).not.toBeNull(); - expect(edit?.getAttribute('href')).toContain(EVENT_ID); + expect(edit?.getAttribute('href')).toBe(`/zeitstrahl/events/${EVENT_ID}/edit`); }); - it('shows no edit affordance when eventId is null (REQ-008)', () => { + it('renders no edit affordance for a curated PERSONAL event when canWrite is false (REQ-007)', () => { render(EventPill, { + canWrite: false, + entry: makeEntry({ + kind: 'EVENT', + derived: false, + type: 'PERSONAL', + eventId: EVENT_ID, + title: 'Auswanderung', + senderName: '', + receiverName: '', + documentId: undefined + }) + }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); + + it('renders no edit affordance when the canWrite prop is omitted (gate-closed default) (REQ-007)', () => { + render(EventPill, { + entry: makeEntry({ + kind: 'EVENT', + derived: false, + type: 'PERSONAL', + eventId: EVENT_ID, + title: 'Auswanderung', + senderName: '', + receiverName: '', + documentId: undefined + }) + }); + expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); + }); + + it('shows no edit affordance when eventId is null even with canWrite (REQ-008)', () => { + render(EventPill, { + canWrite: true, entry: makeEntry({ kind: 'EVENT', derived: false, @@ -85,8 +120,8 @@ describe('EventPill', () => { expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); }); - it('shows no edit affordance for a derived event (REQ-008)', () => { - render(EventPill, { entry: derived('MARRIAGE', 'Heirat') }); + it('shows no edit affordance for a derived event even with canWrite (REQ-008)', () => { + render(EventPill, { canWrite: true, entry: derived('MARRIAGE', 'Heirat') }); expect(document.querySelector('[data-testid="event-edit"]')).toBeNull(); }); diff --git a/frontend/src/lib/timeline/TimelineView.svelte b/frontend/src/lib/timeline/TimelineView.svelte index a82a112a..ee37b682 100644 --- a/frontend/src/lib/timeline/TimelineView.svelte +++ b/frontend/src/lib/timeline/TimelineView.svelte @@ -19,7 +19,11 @@ type TimelineYearDTO = components['schemas']['TimelineYearDTO']; * for the per-person rail (issue #10) and is undefined here; it is not passed to * leaf cards (REQ-025). Owns no
— the layout does. */ -let { timeline, personId = undefined }: { timeline: TimelineDTO; personId?: string } = $props(); +let { + timeline, + personId = undefined, + canWrite = false +}: { timeline: TimelineDTO; personId?: string; canWrite?: boolean } = $props(); type Row = { t: 'band'; year: TimelineYearDTO } | { t: 'gap'; from: number; to: number }; @@ -50,7 +54,7 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length {#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
  • {#if row.t === 'band'} - + {:else} {/if} @@ -77,7 +81,7 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length {#if entry.type === 'HISTORICAL'} {:else} - + {/if} {:else} diff --git a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts index 6eccf324..f5f1187e 100644 --- a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts +++ b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts @@ -105,6 +105,7 @@ describe('TimelineView', () => { it('renders an undated curated EVENT as a pill, not a broken letter card (REQ-008/016)', () => { render(TimelineView, { + canWrite: true, timeline: makeTimelineDTO({ undated: [ makeEntry({ @@ -125,8 +126,8 @@ describe('TimelineView', () => { // The event renders inside the undated section… expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull(); expect(document.body.textContent).toContain('Auswanderung'); - // …as an EventPill (its edit affordance), never as a letter card linking - // to /documents/undefined with "Unbekannt → Unbekannt". + // …as an EventPill (its edit affordance, threaded canWrite), never as a + // letter card linking to /documents/undefined with "Unbekannt → Unbekannt". expect(document.querySelector('[data-testid="event-edit"]')).not.toBeNull(); expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull(); expect(document.body.textContent).not.toContain('Unbekannt'); @@ -276,4 +277,39 @@ describe('TimelineView', () => { ); expect(sides).toEqual(['left', 'right', 'left', 'right']); }); + + // A curated PERSONAL event reachable through both dispatch paths: the year-band + // path (TimelineView → YearBand → EventPill) and the undated bucket + // (TimelineView → EventPill). canWrite must thread to both (REQ-009). + const curated = (eventId: string, title: string) => + makeEntry({ + kind: 'EVENT', + type: 'PERSONAL', + derived: false, + eventId, + title, + senderName: '', + receiverName: '', + documentId: undefined + }); + + const bothPaths = () => + makeTimelineDTO({ + years: [makeYear(1924, [curated('banded', 'Umzug nach Berlin')])], + undated: [curated('undated', 'Auswanderung')] + }); + + it('threads canWrite to a curated event in both a year band and the undated bucket (REQ-009)', () => { + render(TimelineView, { canWrite: true, timeline: bothPaths() }); + const hrefs = Array.from(document.querySelectorAll('[data-testid="event-edit"]')).map((a) => + a.getAttribute('href') + ); + expect(hrefs).toContain('/zeitstrahl/events/banded/edit'); + expect(hrefs).toContain('/zeitstrahl/events/undated/edit'); + }); + + it('renders no edit links in either path when canWrite is false (REQ-007/009)', () => { + render(TimelineView, { canWrite: false, timeline: bothPaths() }); + expect(document.querySelectorAll('[data-testid="event-edit"]')).toHaveLength(0); + }); }); diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte index 6efa30d8..7a62147f 100644 --- a/frontend/src/lib/timeline/YearBand.svelte +++ b/frontend/src/lib/timeline/YearBand.svelte @@ -16,7 +16,7 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO']; * the band holds ≤ 12 (REQ-011) or collapse to a single density strip above that * (REQ-012). Entries are never re-sorted — DTO order is preserved (REQ-003). */ -let { year }: { year: TimelineYearDTO } = $props(); +let { year, canWrite = false }: { year: TimelineYearDTO; canWrite?: boolean } = $props(); type Row = | { t: 'event'; entry: TimelineEntryDTO } @@ -61,7 +61,7 @@ const rows = $derived.by(() => { {#if row.entry.type === 'HISTORICAL'} {:else} - + {/if} {:else if row.t === 'letter'}