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:
@@ -10,9 +10,11 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
|||||||
* Centered axis pill for a derived life-event or a curated PERSONAL event
|
* 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-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
|
* (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 config = $derived(getAccentConfig(entry));
|
||||||
const dateLabel = $derived(timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd));
|
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
|
// Provenance always shows; the date is an optional prefix so an undated event
|
||||||
// still reads "abgeleitet"/"kuratiert" (REQ-007).
|
// still reads "abgeleitet"/"kuratiert" (REQ-007).
|
||||||
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ describe('EventPill', () => {
|
|||||||
expect(srOnly?.textContent).toBe('Geburt');
|
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, {
|
render(EventPill, {
|
||||||
|
canWrite: true,
|
||||||
entry: makeEntry({
|
entry: makeEntry({
|
||||||
kind: 'EVENT',
|
kind: 'EVENT',
|
||||||
derived: false,
|
derived: false,
|
||||||
@@ -66,11 +67,45 @@ describe('EventPill', () => {
|
|||||||
});
|
});
|
||||||
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null;
|
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null;
|
||||||
expect(edit).not.toBeNull();
|
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, {
|
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({
|
entry: makeEntry({
|
||||||
kind: 'EVENT',
|
kind: 'EVENT',
|
||||||
derived: false,
|
derived: false,
|
||||||
@@ -85,8 +120,8 @@ describe('EventPill', () => {
|
|||||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows no edit affordance for a derived event (REQ-008)', () => {
|
it('shows no edit affordance for a derived event even with canWrite (REQ-008)', () => {
|
||||||
render(EventPill, { entry: derived('MARRIAGE', 'Heirat') });
|
render(EventPill, { canWrite: true, entry: derived('MARRIAGE', 'Heirat') });
|
||||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* 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 };
|
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}`)}
|
{#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
|
||||||
<li>
|
<li>
|
||||||
{#if row.t === 'band'}
|
{#if row.t === 'band'}
|
||||||
<YearBand year={row.year} />
|
<YearBand year={row.year} canWrite={canWrite} />
|
||||||
{:else}
|
{:else}
|
||||||
<GapSpan from={row.from} to={row.to} />
|
<GapSpan from={row.from} to={row.to} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -77,7 +81,7 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
|
|||||||
{#if entry.type === 'HISTORICAL'}
|
{#if entry.type === 'HISTORICAL'}
|
||||||
<WorldBand entry={entry} />
|
<WorldBand entry={entry} />
|
||||||
{:else}
|
{:else}
|
||||||
<EventPill entry={entry} />
|
<EventPill entry={entry} canWrite={canWrite} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<LetterCard entry={entry} />
|
<LetterCard entry={entry} />
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ describe('TimelineView', () => {
|
|||||||
|
|
||||||
it('renders an undated curated EVENT as a pill, not a broken letter card (REQ-008/016)', () => {
|
it('renders an undated curated EVENT as a pill, not a broken letter card (REQ-008/016)', () => {
|
||||||
render(TimelineView, {
|
render(TimelineView, {
|
||||||
|
canWrite: true,
|
||||||
timeline: makeTimelineDTO({
|
timeline: makeTimelineDTO({
|
||||||
undated: [
|
undated: [
|
||||||
makeEntry({
|
makeEntry({
|
||||||
@@ -125,8 +126,8 @@ describe('TimelineView', () => {
|
|||||||
// The event renders inside the undated section…
|
// The event renders inside the undated section…
|
||||||
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
|
expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
|
||||||
expect(document.body.textContent).toContain('Auswanderung');
|
expect(document.body.textContent).toContain('Auswanderung');
|
||||||
// …as an EventPill (its edit affordance), never as a letter card linking
|
// …as an EventPill (its edit affordance, threaded canWrite), never as a
|
||||||
// to /documents/undefined with "Unbekannt → Unbekannt".
|
// letter card linking to /documents/undefined with "Unbekannt → Unbekannt".
|
||||||
expect(document.querySelector('[data-testid="event-edit"]')).not.toBeNull();
|
expect(document.querySelector('[data-testid="event-edit"]')).not.toBeNull();
|
||||||
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
|
expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
|
||||||
expect(document.body.textContent).not.toContain('Unbekannt');
|
expect(document.body.textContent).not.toContain('Unbekannt');
|
||||||
@@ -276,4 +277,39 @@ describe('TimelineView', () => {
|
|||||||
);
|
);
|
||||||
expect(sides).toEqual(['left', 'right', 'left', 'right']);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
|||||||
* the band holds ≤ 12 (REQ-011) or collapse to a single density strip above that
|
* 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).
|
* (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 =
|
type Row =
|
||||||
| { t: 'event'; entry: TimelineEntryDTO }
|
| { t: 'event'; entry: TimelineEntryDTO }
|
||||||
@@ -61,7 +61,7 @@ const rows = $derived.by<Row[]>(() => {
|
|||||||
{#if row.entry.type === 'HISTORICAL'}
|
{#if row.entry.type === 'HISTORICAL'}
|
||||||
<WorldBand entry={row.entry} />
|
<WorldBand entry={row.entry} />
|
||||||
{:else}
|
{:else}
|
||||||
<EventPill entry={row.entry} />
|
<EventPill entry={row.entry} canWrite={canWrite} />
|
||||||
{/if}
|
{/if}
|
||||||
{:else if row.t === 'letter'}
|
{:else if row.t === 'letter'}
|
||||||
<div class="letter-row" data-side={row.side}>
|
<div class="letter-row" data-side={row.side}>
|
||||||
|
|||||||
Reference in New Issue
Block a user