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
19 changes: 19 additions & 0 deletions frontend/src/__tests__/ladder-timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,25 @@ test('a non-conflict save error does not offer reload', async () => {
expect(document.querySelector<HTMLButtonElement>('.ladder-event-editor [data-reload]')!.hidden).toBe(true);
});

test('the header close-X is disabled and inert while a save is pending, and re-enabled after', async () => {
let rejectSave!: (e: Error) => void;
jest.mocked(amendLadderEvent).mockReturnValue(new Promise((_, reject) => { rejectSave = reject; }));
await initLadderTimeline(document.getElementById('timeline')!, [], false);
document.querySelector<HTMLButtonElement>('[data-event]')!.click();
const closeX = document.querySelector<HTMLButtonElement>('.ladder-event-editor .modal-header .modal-close')!;
expect(closeX.disabled).toBe(false);
document.querySelector<HTMLFormElement>('.ladder-event-editor form')!.dispatchEvent(new Event('submit', { cancelable: true }));
expect(amendLadderEvent).toHaveBeenCalledTimes(1);
expect(closeX.disabled).toBe(true);
closeX.click();
expect(document.querySelector('.ladder-event-editor')).not.toBeNull();
rejectSave(new Error('Server error'));
await flush();
expect(closeX.disabled).toBe(false);
closeX.click();
expect(document.querySelector('.ladder-event-editor')).toBeNull();
});

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);
Expand Down
247 changes: 247 additions & 0 deletions frontend/src/__tests__/modal-header-close.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
/**
* Tests for the dialog header close-X (modal.ts: modalCloseButtonHtml,
* createModalCloseButton, createModalHeader and the delegated
* `data-modal-dismiss` click handler that openModal installs).
*
* The X stands in for the dialog's own Cancel/Close button: with a target
* id it clicks that button, so the dialog's own close path (and its
* guards) runs; with no target it closes through closeModal, the Escape
* path, which hides the dialog and runs onClose.
*/

import { openModal, closeModal, modalCloseButtonHtml, createModalCloseButton, createModalHeader } from '../modal';

/** Parse markup without an innerHTML write (the security hook flags those, even in tests). */
function fromHtml(html: string): HTMLElement {
const el = new DOMParser().parseFromString(html, 'text/html').body.firstElementChild;
if (!el) throw new Error('no element parsed');
return document.adoptNode(el) as HTMLElement;
}

interface Dialog {
modal: HTMLDivElement;
trigger: HTMLButtonElement;
header: HTMLDivElement;
field: HTMLInputElement;
footer: HTMLDivElement;
}

/** A framed dialog: trigger outside it, header (title + the given X), a field, a footer. */
function buildDialog(closeX: HTMLElement): Dialog {
const trigger = document.createElement('button');
trigger.id = 'trigger';
trigger.textContent = 'Open';
document.body.appendChild(trigger);

const modal = document.createElement('div');
modal.className = 'modal hidden';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const content = document.createElement('div');
content.className = 'modal-content';

const header = document.createElement('div');
header.className = 'modal-header';
const h2 = document.createElement('h2');
h2.textContent = 'Dialog';
header.append(h2, closeX);

const field = document.createElement('input');
field.id = 'field';

const footer = document.createElement('div');
footer.className = 'modal-buttons';

content.append(header, field, footer);
modal.appendChild(content);
document.body.appendChild(modal);
return { modal, trigger, header, field, footer };
}

function addFooterButton(d: Dialog, id: string, text: string): HTMLButtonElement {
const btn = document.createElement('button');
btn.type = 'button';
btn.id = id;
btn.textContent = text;
d.footer.appendChild(btn);
return btn;
}

function closeXOf(d: Dialog): HTMLButtonElement {
return d.header.querySelector<HTMLButtonElement>('button.modal-close')!;
}

afterEach(() => {
document.body.replaceChildren();
});

describe('dialog header close-X', () => {
describe('with a target button (modalCloseButtonHtml(id))', () => {
test('marks the X with the target id and the "Close" label', () => {
const x = fromHtml(modalCloseButtonHtml('cancel-btn'));
expect(x.getAttribute('data-modal-dismiss')).toBe('cancel-btn');
expect(x.getAttribute('aria-label')).toBe('Close');
expect(x.getAttribute('type')).toBe('button');
});

test('clicking the X runs the target button\'s click handler, and only that', () => {
const d = buildDialog(fromHtml(modalCloseButtonHtml('cancel-btn')));
const cancel = addFooterButton(d, 'cancel-btn', 'Cancel');
const onCancel = jest.fn();
cancel.addEventListener('click', onCancel);
const onClose = jest.fn();
openModal(d.modal, { onClose });

closeXOf(d).click();

expect(onCancel).toHaveBeenCalledTimes(1);
// The X does not close the dialog by itself: closing is the target's job.
expect(d.modal.classList.contains('hidden')).toBe(false);
expect(onClose).not.toHaveBeenCalled();
});

test('when the target closes the dialog, the X closes it through the same path', () => {
const d = buildDialog(fromHtml(modalCloseButtonHtml('cancel-btn')));
const cancel = addFooterButton(d, 'cancel-btn', 'Cancel');
cancel.addEventListener('click', () => closeModal(d.modal));
const onClose = jest.fn();
d.trigger.focus();
openModal(d.modal, { onClose });

closeXOf(d).click();

expect(d.modal.classList.contains('hidden')).toBe(true);
expect(onClose).toHaveBeenCalledTimes(1);
expect(document.activeElement).toBe(d.trigger);
});

test('a disabled target (e.g. Cancel locked during a save) keeps the dialog open', () => {
const d = buildDialog(fromHtml(modalCloseButtonHtml('cancel-btn')));
const cancel = addFooterButton(d, 'cancel-btn', 'Cancel');
const onCancel = jest.fn(() => closeModal(d.modal));
cancel.addEventListener('click', onCancel);
const onClose = jest.fn();
openModal(d.modal, { onClose });
cancel.disabled = true;

closeXOf(d).click();

expect(onCancel).not.toHaveBeenCalled();
expect(d.modal.classList.contains('hidden')).toBe(false);
expect(onClose).not.toHaveBeenCalled();
});

test('the DOM-built X (createModalCloseButton(id)) behaves the same', () => {
const d = buildDialog(createModalCloseButton('cancel-btn'));
const cancel = addFooterButton(d, 'cancel-btn', 'Cancel');
const onCancel = jest.fn();
cancel.addEventListener('click', onCancel);
openModal(d.modal);

closeXOf(d).click();

expect(onCancel).toHaveBeenCalledTimes(1);
});

test('a click on the X icon (not the button itself) is still handled', () => {
const d = buildDialog(fromHtml(modalCloseButtonHtml('cancel-btn')));
const cancel = addFooterButton(d, 'cancel-btn', 'Cancel');
const onCancel = jest.fn();
cancel.addEventListener('click', onCancel);
openModal(d.modal);

const icon = closeXOf(d).querySelector('svg')!;
icon.dispatchEvent(new MouseEvent('click', { bubbles: true }));

expect(onCancel).toHaveBeenCalledTimes(1);
});
});

describe('without a target (modalCloseButtonHtml())', () => {
test('clicking the X closes through closeModal: hidden, onClose, focus returned', () => {
const d = buildDialog(fromHtml(modalCloseButtonHtml()));
expect(closeXOf(d).getAttribute('data-modal-dismiss')).toBe('');
const onClose = jest.fn();
d.trigger.focus();
openModal(d.modal, { onClose });

closeXOf(d).click();

expect(d.modal.classList.contains('hidden')).toBe(true);
expect(onClose).toHaveBeenCalledTimes(1);
expect(document.activeElement).toBe(d.trigger);
});

test('a dialog that must be acknowledged (data-require-ack) is not closed by the X', () => {
const d = buildDialog(fromHtml(modalCloseButtonHtml()));
d.modal.dataset.requireAck = 'true';
const onClose = jest.fn();
openModal(d.modal, { onClose });

closeXOf(d).click();

expect(d.modal.classList.contains('hidden')).toBe(false);
expect(onClose).not.toHaveBeenCalled();
});
});

describe('unknown dismiss target', () => {
test('logs a console error and still closes through closeModal', () => {
const err = jest.spyOn(console, 'error').mockImplementation(() => undefined);
const d = buildDialog(fromHtml(modalCloseButtonHtml('no-such-button')));
openModal(d.modal);
closeXOf(d).click();
expect(err).toHaveBeenCalledTimes(1);
expect(String(err.mock.calls[0]?.[0])).toContain('no-such-button');
expect(d.modal.classList.contains('hidden')).toBe(true);
err.mockRestore();
});
});

describe('createModalHeader', () => {
test('closable: false (dialogs that must be acknowledged) renders no X', () => {
const h2 = document.createElement('h2');
const header = createModalHeader(h2, { closable: false });
expect(header.className).toBe('modal-header');
expect(header.querySelector('button')).toBeNull();
});
});

describe('lifecycle', () => {
test('initial focus skips the header X and lands on the first field', () => {
const d = buildDialog(fromHtml(modalCloseButtonHtml()));
openModal(d.modal);
expect(document.activeElement).toBe(d.field);
});

test('the click handler is removed on close: the X does nothing afterwards', () => {
const d = buildDialog(fromHtml(modalCloseButtonHtml('cancel-btn')));
const cancel = addFooterButton(d, 'cancel-btn', 'Cancel');
const onCancel = jest.fn();
cancel.addEventListener('click', onCancel);
const onClose = jest.fn();
openModal(d.modal, { onClose });
closeModal(d.modal);
expect(onClose).toHaveBeenCalledTimes(1);

d.modal.classList.remove('hidden');
closeXOf(d).click();

expect(onCancel).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
});

test('re-opening does not stack click handlers', () => {
const d = buildDialog(fromHtml(modalCloseButtonHtml('cancel-btn')));
const cancel = addFooterButton(d, 'cancel-btn', 'Cancel');
const onCancel = jest.fn();
cancel.addEventListener('click', onCancel);
openModal(d.modal);
openModal(d.modal);

closeXOf(d).click();

expect(onCancel).toHaveBeenCalledTimes(1);
});
});
});
10 changes: 10 additions & 0 deletions frontend/src/__tests__/riexchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ describe('openExchangeModal', () => {
expect(modal.classList.contains('hidden')).toBe(false);
});

it('renders a header close-X labelled "Close dialog" that closes the dialog', () => {
openExchangeModal('ri-abc123', 2);
const x = modal.querySelector<HTMLButtonElement>('.modal-header button.modal-close[data-modal-dismiss]');
expect(x).not.toBeNull();
expect(x!.getAttribute('aria-label')).toBe('Close dialog');
expect(modal.querySelectorAll('.modal-header .modal-close')).toHaveLength(1);
x!.click();
expect(modal.classList.contains('hidden')).toBe(true);
});

it('displays the RI ID in the modal content', () => {
openExchangeModal('ri-abc123', 2);
expect(modal.textContent).toContain('ri-abc123');
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/apikeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ export function showKeyCreatedModal(apiKey: string): void {
modal.dataset.requireAck = 'true';
modal.innerHTML = `
<div class="modal-content">
<h2>API Key Created Successfully</h2>
<div class="modal-header"><h2>API Key Created Successfully</h2></div>
<div class="warning-box">
<strong>Important:</strong> This is the only time you'll see this API key.
Please copy it now and store it securely.
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { DashboardSummary, UpcomingPurchase, ServiceSavings, LocalRecommend
import type { SavingsDataPoint } from './api';
import { showToast } from './toast';
import { confirmDialog } from './confirmDialog';
import { openModal, closeModal } from './modal';
import { openModal, closeModal, createModalHeader } from './modal';
import { canAccess } from './permissions';
import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefreshIfStale, startRecommendationsRefresh } from './recommendations';
import { getRecommendationsFreshness } from './api/recommendations';
Expand Down Expand Up @@ -710,7 +710,7 @@ function buildUpcomingDetailsModal(p: UpcomingPurchase, executionId: string): HT
const h2 = document.createElement('h2');
h2.id = 'purchase-details-modal-title';
h2.textContent = 'Upcoming Purchase Details';
content.appendChild(h2);
content.appendChild(createModalHeader(h2, { dismissTargetId: 'close-purchase-details-btn' }));

const section = document.createElement('div');
section.className = 'form-section';
Expand Down Expand Up @@ -763,7 +763,8 @@ function buildUpcomingDetailsModal(p: UpcomingPurchase, executionId: string): HT
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.id = 'cancel-purchase-detail-btn';
cancelBtn.className = 'danger';
// Outlined, not filled: the confirm dialog it opens carries the solid red action.
cancelBtn.className = 'btn-danger';
cancelBtn.textContent = 'Cancel Purchase';
// Same confirm copy and request path as the card's Cancel button (#731).
cancelBtn.addEventListener('click', () => {
Expand Down
Loading
Loading