diff --git a/frontend/src/__tests__/ladder-timeline.test.ts b/frontend/src/__tests__/ladder-timeline.test.ts index 72c2a0f59..0cab49bca 100644 --- a/frontend/src/__tests__/ladder-timeline.test.ts +++ b/frontend/src/__tests__/ladder-timeline.test.ts @@ -147,3 +147,43 @@ test('save errors retain draft and invalid dates do not reach the API', async () expect(document.getElementById('ladder-event-error')!.textContent).toContain('draft is retained'); expect(date.value).toBe('2030-01-02T00:00'); }); + +const flush = async (): Promise => { for (let i = 0; i < 6; i++) await Promise.resolve(); }; + +test('a 409 offers reload, which discards the draft, reloads and refocuses the purchase', async () => { + jest.mocked(amendLadderEvent).mockRejectedValue(Object.assign(new Error('Plan changed'), { status: 409 })); + await initLadderTimeline(document.getElementById('timeline')!, [], false); + document.querySelector('[data-event]')!.click(); + const reload = document.querySelector('.ladder-event-editor [data-reload]')!; + expect(reload.hidden).toBe(true); + document.querySelector('.ladder-event-editor form')!.dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + expect(reload.hidden).toBe(false); + expect(document.getElementById('ladder-event-error')!.textContent).toContain('reload it'); + reload.click(); + await flush(); + expect(document.querySelector('.ladder-event-editor')).toBeNull(); + expect(getLadderEvents).toHaveBeenCalledTimes(2); + expect(document.activeElement).toBe(document.querySelector('[data-event="one"]')); +}); + +test('a non-conflict save error does not offer reload', async () => { + jest.mocked(amendLadderEvent).mockRejectedValue(Object.assign(new Error('Server error'), { status: 500 })); + await initLadderTimeline(document.getElementById('timeline')!, [], false); + document.querySelector('[data-event]')!.click(); + document.querySelector('.ladder-event-editor form')!.dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + expect(document.querySelector('.ladder-event-editor [data-reload]')!.hidden).toBe(true); +}); + +test('focus returns to the edited purchase after a successful save and the canvas is not a tab stop', async () => { + jest.mocked(amendLadderEvent).mockResolvedValue({ id: 'one', revision: 1, amount_usd_hr: '1.000000', scheduled_date: '2030-01-02T00:00:00.000Z', run_total_usd_hr: '1.000000' }); + await initLadderTimeline(document.getElementById('timeline')!, [], false); + expect(document.querySelector('canvas')!.hasAttribute('tabindex')).toBe(false); + document.querySelector('[data-event]')!.click(); + (document.getElementById('ladder-event-date') as HTMLInputElement).value = '2030-01-02T00:00:00'; + document.querySelector('.ladder-event-editor form')!.dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + expect(document.querySelector('.ladder-event-editor')).toBeNull(); + expect(document.activeElement).toBe(document.querySelector('[data-event="one"]')); +}); diff --git a/frontend/src/ladder-timeline-editor.ts b/frontend/src/ladder-timeline-editor.ts index 3b217abd0..677a32c0e 100644 --- a/frontend/src/ladder-timeline-editor.ts +++ b/frontend/src/ladder-timeline-editor.ts @@ -1,9 +1,10 @@ +import type { ApiError } from './api/types'; import { amendLadderEvent, type LadderEvent, type LadderRun } from './api/ladder'; import { openModal, closeModal } from './modal'; import { escapeHtml } from './utils'; import { formatLadderUnits, ladderAmountUnits, ladderTotal } from './ladder-timeline-filters'; -export function openLadderEventEditor(event: LadderEvent, run: LadderRun, siblings: readonly LadderEvent[], onSaved: () => void): void { +export function openLadderEventEditor(event: LadderEvent, run: LadderRun, siblings: readonly LadderEvent[], onReload: () => void): void { const modal = document.createElement('div'); modal.className = 'modal hidden ladder-event-editor'; modal.setAttribute('role', 'dialog'); @@ -27,6 +28,7 @@ export function openLadderEventEditor(event: LadderEvent, run: LadderRun, siblin `; document.body.append(modal); @@ -37,6 +39,7 @@ export function openLadderEventEditor(event: LadderEvent, run: LadderRun, siblin const error = modal.querySelector('#ladder-event-error')!; const save = modal.querySelector('button[type="submit"]')!; const cancel = modal.querySelector('[data-cancel]')!; + const reload = modal.querySelector('[data-reload]')!; let pending = false; const initialDate = new Date(event.scheduled_date).toISOString().slice(0, 19); const originalTotal = ladderAmountUnits(ladderTotal(siblings)); @@ -61,6 +64,7 @@ export function openLadderEventEditor(event: LadderEvent, run: LadderRun, siblin const initialControlDate = date.value; amount.addEventListener('input', updatePreview); modal.querySelector('[data-reset]')!.addEventListener('click', restore); + reload.addEventListener('click', () => { closeModal(modal); onReload(); }); modal.querySelector('[data-cancel]')!.addEventListener('click', () => { if (!pending) closeModal(modal); }); modal.addEventListener('keydown', e => { if (pending && e.key === 'Escape') { @@ -87,9 +91,11 @@ export function openLadderEventEditor(event: LadderEvent, run: LadderRun, siblin try { await amendLadderEvent(event.id, { expected_revision: event.revision, scheduled_date: date.value === initialControlDate ? event.scheduled_date : proposed.toISOString(), amount_usd_hr: amount.value }); closeModal(modal); - onSaved(); + onReload(); } catch (err) { - error.textContent = `${err instanceof Error ? err.message : 'Save failed'}. Your draft is retained. Reload the timeline before retrying a changed plan.`; + const conflict = (err as ApiError).status === 409; + reload.hidden = !conflict; + error.textContent = `${err instanceof Error ? err.message : 'Save failed'}. Your draft is retained.${conflict ? ' The plan changed; reload it to edit the current values.' : ''}`; } finally { pending = false; form.querySelectorAll('input,button').forEach(input => { input.disabled = false; }); diff --git a/frontend/src/ladder-timeline.ts b/frontend/src/ladder-timeline.ts index fa0e7e2d8..485472943 100644 --- a/frontend/src/ladder-timeline.ts +++ b/frontend/src/ladder-timeline.ts @@ -33,7 +33,7 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La

Service, instance type, region and currency filters are unavailable because saved ladder tranches do not contain those fields.

-
+
`; const provider = root.querySelector('[data-provider]')!; const account = root.querySelector('[data-account]')!; @@ -42,14 +42,17 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La const summary = root.querySelector('[data-summary]')!; const table = root.querySelector('[data-table]')!; root.querySelector('[data-date="from"]')!.value = filters.from; + const focusEvent = (id: string): void => { + Array.from(table.querySelectorAll('[data-event]')).find(button => button.dataset['event'] === id)?.focus(); + }; const selectEvent = (event: LadderEvent): void => { selectedID = event.id; render(); - Array.from(table.querySelectorAll('[data-event]')).find(button => button.dataset['event'] === event.id)?.focus(); + focusEvent(event.id); const reason = ladderEditReason(event); const run = runs.find(item => item.id === event.run_id); if (!reason && run && canAccess('update', 'config')) { - openLadderEventEditor(event, run, events.filter(item => item.run_id === event.run_id), () => { void load(); }); + openLadderEventEditor(event, run, events.filter(item => item.run_id === event.run_id), () => { void load().then(() => focusEvent(event.id)); }); } }; const render = (): void => {