Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions frontend/src/__tests__/ladder-timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => { 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<HTMLButtonElement>('[data-event]')!.click();
const reload = document.querySelector<HTMLButtonElement>('.ladder-event-editor [data-reload]')!;
expect(reload.hidden).toBe(true);
document.querySelector<HTMLFormElement>('.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<HTMLButtonElement>('[data-event]')!.click();
document.querySelector<HTMLFormElement>('.ladder-event-editor form')!.dispatchEvent(new Event('submit', { cancelable: true }));
await flush();
expect(document.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('[data-event]')!.click();
(document.getElementById('ladder-event-date') as HTMLInputElement).value = '2030-01-02T00:00:00';
document.querySelector<HTMLFormElement>('.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"]'));
});
12 changes: 9 additions & 3 deletions frontend/src/ladder-timeline-editor.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -27,6 +28,7 @@ export function openLadderEventEditor(event: LadderEvent, run: LadderRun, siblin
<p id="ladder-event-error" role="alert"></p>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-cancel>Cancel</button>
<button type="button" class="btn btn-secondary" data-reset>Undo draft changes</button>
<button type="button" class="btn btn-secondary" data-reload hidden>Reload and discard draft</button>
<button type="submit" class="btn btn-primary">Save planned purchase</button></div>
</form></div>`;
document.body.append(modal);
Expand All @@ -37,6 +39,7 @@ export function openLadderEventEditor(event: LadderEvent, run: LadderRun, siblin
const error = modal.querySelector<HTMLElement>('#ladder-event-error')!;
const save = modal.querySelector<HTMLButtonElement>('button[type="submit"]')!;
const cancel = modal.querySelector<HTMLButtonElement>('[data-cancel]')!;
const reload = modal.querySelector<HTMLButtonElement>('[data-reload]')!;
let pending = false;
const initialDate = new Date(event.scheduled_date).toISOString().slice(0, 19);
const originalTotal = ladderAmountUnits(ladderTotal(siblings));
Expand All @@ -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') {
Expand All @@ -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<HTMLInputElement | HTMLButtonElement>('input,button').forEach(input => { input.disabled = false; });
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/ladder-timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La
<button type="button" data-reset>Clear filters</button></div>
<p>Service, instance type, region and currency filters are unavailable because saved ladder tranches do not contain those fields.</p>
<p data-summary aria-live="polite"></p><p data-snapshot></p>
<div class="ladder-timeline-chart"><canvas tabindex="0" aria-label="Planned purchase timeline. Use the purchase table below to select a purchase with the keyboard." role="img"></canvas></div>
<div class="ladder-timeline-chart"><canvas aria-label="Planned purchase timeline. Use the purchase table below to select a purchase with the keyboard." role="img"></canvas></div>
<div data-table></div></div>`;
const provider = root.querySelector<HTMLSelectElement>('[data-provider]')!;
const account = root.querySelector<HTMLSelectElement>('[data-account]')!;
Expand All @@ -42,14 +42,17 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La
const summary = root.querySelector<HTMLElement>('[data-summary]')!;
const table = root.querySelector<HTMLElement>('[data-table]')!;
root.querySelector<HTMLInputElement>('[data-date="from"]')!.value = filters.from;
const focusEvent = (id: string): void => {
Array.from(table.querySelectorAll<HTMLButtonElement>('[data-event]')).find(button => button.dataset['event'] === id)?.focus();
};
const selectEvent = (event: LadderEvent): void => {
selectedID = event.id;
render();
Array.from(table.querySelectorAll<HTMLButtonElement>('[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 => {
Expand Down
Loading