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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-15 08:03:53 +02:00
parent ec0e4dfa45
commit cd238285ae
5 changed files with 92 additions and 15 deletions

View File

@@ -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);
</script>
<div class="flex justify-center">

View File

@@ -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();
});

View File

@@ -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 <main> — 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}`)}
<li>
{#if row.t === 'band'}
<YearBand year={row.year} />
<YearBand year={row.year} canWrite={canWrite} />
{:else}
<GapSpan from={row.from} to={row.to} />
{/if}
@@ -77,7 +81,7 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
{#if entry.type === 'HISTORICAL'}
<WorldBand entry={entry} />
{:else}
<EventPill entry={entry} />
<EventPill entry={entry} canWrite={canWrite} />
{/if}
{:else}
<LetterCard entry={entry} />

View File

@@ -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);
});
});

View File

@@ -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<Row[]>(() => {
{#if row.entry.type === 'HISTORICAL'}
<WorldBand entry={row.entry} />
{:else}
<EventPill entry={row.entry} />
<EventPill entry={row.entry} canWrite={canWrite} />
{/if}
{:else if row.t === 'letter'}
<div class="letter-row" data-side={row.side}>