feat(timeline): add a WRITE_ALL-gated edit pencil to WorldBand
A curated HISTORICAL band had no edit affordance at all. Mirror the EventPill pencil inline at the end of the band (data-testid="event-edit", /edit href, aria-hidden glyph + sr-only Bearbeiten), gated on canWrite && eventId != null. Thread canWrite to WorldBand through both the year-band path and the undated bucket. Refs #842 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -79,7 +79,7 @@ const isEmpty = $derived(timeline.years.length === 0 && timeline.undated.length
|
||||
<li>
|
||||
{#if entry.kind === 'EVENT'}
|
||||
{#if entry.type === 'HISTORICAL'}
|
||||
<WorldBand entry={entry} />
|
||||
<WorldBand entry={entry} canWrite={canWrite} />
|
||||
{:else}
|
||||
<EventPill entry={entry} canWrite={canWrite} />
|
||||
{/if}
|
||||
|
||||
@@ -312,4 +312,33 @@ describe('TimelineView', () => {
|
||||
render(TimelineView, { canWrite: false, timeline: bothPaths() });
|
||||
expect(document.querySelectorAll('[data-testid="event-edit"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('threads canWrite to a curated HISTORICAL world band in both paths (REQ-006/009)', () => {
|
||||
const world = (eventId: string, title: string) =>
|
||||
makeEntry({
|
||||
kind: 'EVENT',
|
||||
type: 'HISTORICAL',
|
||||
derived: false,
|
||||
eventId,
|
||||
precision: 'YEAR',
|
||||
eventDate: '1929-01-01',
|
||||
eventDateEnd: undefined,
|
||||
title,
|
||||
senderName: '',
|
||||
receiverName: '',
|
||||
documentId: undefined
|
||||
});
|
||||
render(TimelineView, {
|
||||
canWrite: true,
|
||||
timeline: makeTimelineDTO({
|
||||
years: [makeYear(1929, [world('wb', 'Weltwirtschaftskrise')])],
|
||||
undated: [world('wu', 'Unbekanntes Weltereignis')]
|
||||
})
|
||||
});
|
||||
const hrefs = Array.from(document.querySelectorAll('[data-testid="event-edit"]')).map((a) =>
|
||||
a.getAttribute('href')
|
||||
);
|
||||
expect(hrefs).toContain('/zeitstrahl/events/wb/edit');
|
||||
expect(hrefs).toContain('/zeitstrahl/events/wu/edit');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,9 +11,11 @@ type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
* (REQ-009). A RANGE carries a visible span pill ("1914–1918") with a Zeitraum
|
||||
* aria-label; a RANGE with no end degrades to the start year, no pill (REQ-010).
|
||||
* The glyph is a redundant non-color cue with an sr-only label (REQ-018); text
|
||||
* uses text-ink-2 to stay AA in both themes (REQ-019).
|
||||
* uses text-ink-2 to stay AA in both themes (REQ-019). A curator (`canWrite`,
|
||||
* gate-closed by default) gets an inline edit pencil for a curated event with an
|
||||
* eventId — #842 REQ-006/007/008; UX gate only, the #781 route guard is the boundary.
|
||||
*/
|
||||
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,6 +26,9 @@ const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYe
|
||||
// Every WorldBand is a HISTORICAL band, so the visible "historisch" register
|
||||
// always trails the subtitle as plain text — never a second pill (REQ-009).
|
||||
const historical = $derived(m.timeline_layer_historical_suffix());
|
||||
// A HISTORICAL event is never derived, so the edit gate is just the curator
|
||||
// flag plus a real eventId (#842 REQ-006/008).
|
||||
const canEdit = $derived(canWrite && entry.eventId != null);
|
||||
</script>
|
||||
|
||||
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">
|
||||
@@ -46,4 +51,14 @@ const historical = $derived(m.timeline_layer_historical_suffix());
|
||||
<!-- Single trailing "· historisch" register, after the title and any
|
||||
span pill / date — one render site, consistent separator (REQ-009). -->
|
||||
<span class="ml-2 font-sans text-xs text-ink-3">· {historical}</span>
|
||||
{#if canEdit}
|
||||
<a
|
||||
data-testid="event-edit"
|
||||
href="/zeitstrahl/events/{entry.eventId}/edit"
|
||||
class="ml-2 rounded-sm px-1 font-sans text-xs text-ink-3 hover:text-brand-navy focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||||
>
|
||||
<span aria-hidden="true">✎</span>
|
||||
<span class="sr-only">{m.btn_edit()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -73,4 +73,35 @@ describe('WorldBand', () => {
|
||||
expect(pill?.textContent).not.toContain(m.timeline_layer_historical_suffix());
|
||||
expect(document.body.textContent).toContain(m.timeline_layer_historical_suffix());
|
||||
});
|
||||
|
||||
const HIST_EVENT_ID = '44444444-4444-4444-4444-444444444444';
|
||||
|
||||
it('shows an edit affordance for a curated HISTORICAL event when canWrite is true (REQ-006)', () => {
|
||||
render(WorldBand, { canWrite: true, entry: historical({ eventId: HIST_EVENT_ID }) });
|
||||
const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null;
|
||||
expect(edit).not.toBeNull();
|
||||
expect(edit?.getAttribute('href')).toBe(`/zeitstrahl/events/${HIST_EVENT_ID}/edit`);
|
||||
});
|
||||
|
||||
it('mirrors the EventPill pencil: aria-hidden ✎ glyph + sr-only Bearbeiten label (REQ-006)', () => {
|
||||
render(WorldBand, { canWrite: true, entry: historical({ eventId: HIST_EVENT_ID }) });
|
||||
const edit = document.querySelector('[data-testid="event-edit"]');
|
||||
expect(edit?.querySelector('[aria-hidden="true"]')?.textContent).toBe('✎');
|
||||
expect(edit?.querySelector('.sr-only')?.textContent).toBe(m.btn_edit());
|
||||
});
|
||||
|
||||
it('renders no edit affordance for a curated HISTORICAL event when canWrite is false (REQ-007)', () => {
|
||||
render(WorldBand, { canWrite: false, entry: historical({ eventId: HIST_EVENT_ID }) });
|
||||
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(WorldBand, { entry: historical({ eventId: HIST_EVENT_ID }) });
|
||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no edit affordance when eventId is null even with canWrite (REQ-008)', () => {
|
||||
render(WorldBand, { canWrite: true, entry: historical({ eventId: undefined }) });
|
||||
expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ const rows = $derived.by<Row[]>(() => {
|
||||
{#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))}
|
||||
{#if row.t === 'event'}
|
||||
{#if row.entry.type === 'HISTORICAL'}
|
||||
<WorldBand entry={row.entry} />
|
||||
<WorldBand entry={row.entry} canWrite={canWrite} />
|
||||
{:else}
|
||||
<EventPill entry={row.entry} canWrite={canWrite} />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user