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
102 changes: 102 additions & 0 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3434,4 +3434,106 @@ describe('Plans Module', () => {
expect(badge?.getAttribute('title')).toContain(maliciousNote);
});
});
describe('inline schedule preview', () => {
const restartNotice = 'restarts from today for a plan already in progress; executed purchases are not shown';
const host = (): HTMLElement => document.getElementById('plan-editor-panel')!;
const points = (): number => host().querySelectorAll('[data-preview-step]').length;
const backendPlan = (ramp: Record<string, unknown>, coverage: number | null = 80) => ({
id: 'plan-1', name: 'Plan', enabled: true, auto_purchase: false, notification_days_before: 3,
services: { ec2: { provider: 'aws', service: 'ec2', term: 1, payment: 'all-upfront', ...(coverage === null ? {} : { coverage }) } },
ramp_schedule: ramp,
});
const openEdit = async (plan: unknown): Promise<void> => {
(api.getPlans as jest.Mock).mockResolvedValue({ plans: [{ id: 'plan-1', name: 'Plan', enabled: true, auto_purchase: false, services: {} }] });
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] });
(api.getPlan as jest.Mock).mockResolvedValue(plan);
await loadPlans();
expect(points()).toBe(0);
(document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement).click();
await jest.advanceTimersByTimeAsync(50);
};
beforeEach(() => {
jest.useFakeTimers({ now: new Date('2026-10-15T12:00:00Z') });
const form = document.getElementById('plan-form')!.innerHTML;
document.body.innerHTML = `<div id="plans-list"></div><div id="planned-purchases-list"></div>
<div id="plan-modal" class="modal hidden"><div class="modal-content"><h2 id="plan-modal-title">Create Purchase Plan</h2>
<div class="plan-editor-layout"><section id="plan-editor-panel" hidden></section><form id="plan-form">${form}</form></div>
<div class="modal-buttons"><button type="button" id="close-plan-modal-btn">Cancel</button><button type="submit" form="plan-form">Save</button></div>
</div></div><div id="purchase-modal" class="hidden"></div>`;
_resetRampHandlersForTest();
});
afterEach(() => { closePlanModal(); jest.useRealTimers(); });

test.each([
['weekly 25%', { type: 'weekly', percent_per_step: 25, step_interval_days: 7 }, 4],
['custom 33% / 14 days', { type: 'custom', percent_per_step: 33, step_interval_days: 14 }, 3],
['monthly 10%', { type: 'monthly', percent_per_step: 10, step_interval_days: 30 }, 10],
])('Edit shows the %s schedule preview and the restart notice', async (_name, ramp, count) => {
await openEdit(backendPlan(ramp));
expect(points()).toBe(count);
expect(host().hidden).toBe(false);
expect(host().querySelector('.editor-draft-notice')!.textContent).toContain(restartNotice);
expect(api.createPlan).not.toHaveBeenCalled(); expect(api.updatePlan).not.toHaveBeenCalled();
expect(api.createPlannedPurchases).not.toHaveBeenCalled();
});

test('weekly 20% plan currently prefills as immediate and previews 1 step (#1156)', async () => {
// Documents current behaviour of the Edit prefill, which maps weekly != 25% to `immediate`; see #1156.
// Invert this expectation (weekly 20% should preview 5 steps) when #1156 is fixed.
await openEdit(backendPlan({ type: 'weekly', percent_per_step: 20, step_interval_days: 7 }));
expect(points()).toBe(1);
});

test('Edit with absent coverage shows the validation message and no points', async () => {
await openEdit(backendPlan({ type: 'weekly', percent_per_step: 25, step_interval_days: 7 }, null));
expect(points()).toBe(0);
expect(host().querySelector('[data-editor="summary"]')!.textContent).not.toBe('');
});

// Panel and modal listeners on the form (input/change bubble, invalid/submit capture), keyed by type:capture.
const trackFormListeners = () => {
const form = document.getElementById('plan-form')!;
const owned = new Set(['input:false', 'change:false', 'invalid:true', 'submit:true']);
const key = (type: string, o?: boolean | AddEventListenerOptions): string => `${type}:${o === true || (typeof o === 'object' && o.capture === true)}`;
const entries: { key: string; fn: unknown }[] = [];
const add = form.addEventListener.bind(form), remove = form.removeEventListener.bind(form);
jest.spyOn(form, 'addEventListener').mockImplementation((type: string, fn: EventListenerOrEventListenerObject, o?: boolean | AddEventListenerOptions) => {
if (owned.has(key(type, o))) entries.push({ key: key(type, o), fn });
add(type, fn, o);
});
jest.spyOn(form, 'removeEventListener').mockImplementation((type: string, fn: EventListenerOrEventListenerObject, o?: boolean | AddEventListenerOptions) => {
const index = entries.findIndex(entry => entry.key === key(type, o) && entry.fn === fn);
if (index >= 0) entries.splice(index, 1);
remove(type, fn, o);
});
return entries;
};

test('closing the Edit modal removes every listener the panel and the modal added', async () => {
const entries = trackFormListeners();
await openEdit(backendPlan({ type: 'weekly', percent_per_step: 25, step_interval_days: 7 }));
expect(entries.map(entry => entry.key).sort()).toEqual(['change:false', 'input:false', 'invalid:true', 'submit:true']);
closePlanModal();
expect(entries).toEqual([]);
expect(host().hidden).toBe(true); expect(host().childElementCount).toBe(0);
expect(host().closest('.plan-editor-layout')!.classList.contains('has-editor-draft')).toBe(false);
});

test('Edit then Create without closing leaves exactly one panel listening and no Edit notice', async () => {
const entries = trackFormListeners();
await openEdit(backendPlan({ type: 'weekly', percent_per_step: 25, step_interval_days: 7 }));
openCreatePlanModal();
expect(entries.map(entry => entry.key).sort()).toEqual(['change:false', 'input:false', 'invalid:true', 'submit:true']);
expect(host().querySelector('.editor-draft-notice')!.textContent).not.toContain('restarts from today');
const coverage = document.getElementById('plan-coverage') as HTMLInputElement;
coverage.value = '60'; coverage.dispatchEvent(new Event('input', { bubbles: true }));
expect(host().querySelector('[data-editor="summary"]')!.textContent).toContain('Target 60%');
});

test('Create previews without the restart notice', () => {
openCreatePlanModal();
expect(host().hidden).toBe(false);
expect(host().querySelector('.editor-draft-notice')!.textContent).not.toContain('restarts from today');
});
});
});
2 changes: 1 addition & 1 deletion frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -820,7 +820,6 @@ <h2>User Management</h2>
<div class="modal-content modal-wide">
<h2 id="plan-modal-title">Create Purchase Plan</h2>
<div class="plan-editor-layout">
<section id="plan-editor-panel" aria-label="Proposed purchase dates" hidden></section>
<form id="plan-form">
<input type="hidden" id="plan-id">
<div class="plan-editor-settings">
Expand Down Expand Up @@ -976,6 +975,7 @@ <h3>Automation Settings</h3>

</div>
</form>
<section id="plan-editor-panel" aria-label="Proposed purchase dates" hidden></section>
</div>

<div class="modal-buttons">
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/plan-editor-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,20 @@ export function readPlanScheduleInput(form: HTMLFormElement): PlanScheduleIntent
export interface PlanEditorPanelOptions {
// Create Save does not persist step dates yet, so the graph stays display-only until it does.
editableDates?: boolean;
// Edit previews hypothetical dates from today, so say that executed purchases are not part of them.
restartsFromToday?: boolean;
}

const readOnlyNotice = 'Hypothetical dates counted from today with the schedule settings. They are not saved or scheduled.';
const restartNotice = 'This preview restarts from today for a plan already in progress; executed purchases are not shown. Dates are hypothetical and not saved or scheduled.';
const scheduleSettingIds = ['plan-coverage', 'ramp-step-percent', 'ramp-interval-days', 'plan-notify-days'];

export function mountPlanEditorPanel(host: HTMLElement, form: HTMLFormElement, options: PlanEditorPanelOptions = {}): () => void {
const editable = options.editableDates ?? false;
host.hidden = false;
host.closest('.plan-editor-layout')?.classList.add('has-editor-draft');
const statusId = `${host.id || 'plan-editor'}-status`;
host.innerHTML = `<h3>Proposed purchase dates</h3><p class="editor-draft-notice">${editable ? 'Local draft. These dates are not scheduled. Save Plan does not save date changes.' : readOnlyNotice}</p>
host.innerHTML = `<h3>Proposed purchase dates</h3><p class="editor-draft-notice">${editable ? 'Local draft. These dates are not scheduled. Save Plan does not save date changes.' : options.restartsFromToday ? restartNotice : readOnlyNotice}</p>
${editable ? '' : `<p class="editor-status" data-editor="summary" id="${statusId}"></p><p class="sr-only" data-editor="status" role="status" aria-live="polite"></p>`}
<div class="schedule-preview-scroll" role="region" tabindex="0" aria-label="Proposed purchase calendar"${editable ? '' : ` aria-describedby="${statusId}"`}></div>
<p class="editor-target help-text"></p>${editable ? `<p class="help-text">Drag a point to change its date, or Start/End to space the dates.</p>
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1256,7 +1256,7 @@ async function togglePlan(planId: string, enabled: boolean): Promise<void> {
}

let disposePlanEditor: (() => void) | undefined;
function openPlanForm(modal: HTMLElement, create: boolean): void {
function openPlanForm(modal: HTMLElement, restartsFromToday = false): void {
disposePlanEditor?.(); disposePlanEditor = undefined;
const host = modal.querySelector<HTMLElement>('#plan-editor-panel'), form = modal.querySelector<HTMLFormElement>('#plan-form');
const advanced = modal.querySelector<HTMLDetailsElement>('#plan-advanced-settings');
Expand All @@ -1265,7 +1265,7 @@ function openPlanForm(modal: HTMLElement, create: boolean): void {
if (advanced?.contains(event.target as Node)) advanced.open = true;
};
form?.addEventListener('invalid', revealInvalid, true);
const disposePanel = create && host && form ? mountPlanEditorPanel(host, form) : undefined;
const disposePanel = host && form ? mountPlanEditorPanel(host, form, { restartsFromToday }) : undefined;
disposePlanEditor = () => {
try { disposePanel?.(); }
finally { form?.removeEventListener('invalid', revealInvalid, true); }
Expand Down Expand Up @@ -1364,7 +1364,7 @@ async function editPlan(planId: string): Promise<boolean> {
// Wire live range validation on all five numeric inputs (#702).
wirePlanRangeInputs();
const planModal = document.getElementById('plan-modal');
if (planModal) openPlanForm(planModal, false);
if (planModal) openPlanForm(planModal, true);
return true;
} catch (error) {
console.error('Failed to load plan:', error);
Expand Down Expand Up @@ -1823,7 +1823,7 @@ export function openCreatePlanModal(snapshot?: readonly api.Recommendation[]): v

const planModal = document.getElementById('plan-modal');
if (planModal) {
openPlanForm(planModal, true);
openPlanForm(planModal);
}
}

Expand Down Expand Up @@ -1855,7 +1855,7 @@ export function openNewPlanModal(): void {

const planModal = document.getElementById('plan-modal');
if (planModal) {
openPlanForm(planModal, true);
openPlanForm(planModal);
}
}

Expand Down
10 changes: 8 additions & 2 deletions frontend/src/styles/plans.css
Original file line number Diff line number Diff line change
Expand Up @@ -284,11 +284,17 @@
#plan-modal .modal-content { width: min(1280px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); display: flex; flex-direction: column; overflow: hidden; }
#plan-form { min-width: 0; display: flex; flex-direction: column; min-height: 0; }
.plan-editor-layout { overflow: auto; min-height: 0; }
.plan-editor-layout.has-editor-draft { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 24px; }
.plan-editor-layout.has-editor-draft { display: grid; grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); grid-template-areas: "form panel"; grid-auto-rows: max-content; gap: 24px; align-items: start; }
.plan-editor-layout.has-editor-draft #plan-form { grid-area: form; }
#plan-editor-panel { grid-area: panel; position: sticky; top: 0; align-self: start; max-height: calc(100dvh - 230px); overflow: auto; }
.plan-editor-settings, #plan-editor-panel { min-width: 0; }
#plan-modal .modal-buttons { flex-shrink: 0; }
#plan-editor-panel [data-selected] .preview-marker { fill: var(--cudly-primary); stroke: var(--cudly-text); stroke-width: 3; }
@media (max-width: 800px) { .plan-editor-layout.has-editor-draft { display: block; } }
@media (max-width: 960px) {
.plan-editor-layout.has-editor-draft { grid-template-columns: minmax(0, 1fr); grid-template-areas: "form" "panel"; }
#plan-editor-panel { position: static; max-height: none; overflow: visible; }
}
@media (max-width: 480px) { #plan-modal .modal-content { padding: 12px; } }

#plan-advanced-settings { border-top: 1px solid var(--cudly-border); margin-top: 16px; padding-top: 16px; }
#plan-advanced-settings > summary, #plan-editor-panel summary { cursor: pointer; padding: 8px 0; }
Expand Down
48 changes: 37 additions & 11 deletions frontend/tests-e2e/plan-schedule-preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { test, expect, type Locator } from '@playwright/test';
test.use({ hasTouch: true });
import { mockApi, seedAuth } from './fixtures/recs';

for (const width of [1440, 1280, 390, 320]) test.describe(`viewport ${width}px`, () => {
for (const [width, height] of [[1440, 900], [1280, 900], [1280, 768], [1024, 900], [390, 900], [320, 900]] as const) test.describe(`viewport ${width}x${height}`, () => {
test.use({ isMobile: width <= 390 });
test(`local Create/Edit preview at ${width}px (mock API, no writes)`, async ({ page, isMobile }, testInfo) => {
test(`local Create/Edit preview at ${width}x${height} (mock API, no writes)`, async ({ page, isMobile }, testInfo) => {
test.setTimeout(90_000);
await page.setViewportSize({ width, height: 900 });
await page.setViewportSize({ width, height });
const errors: string[] = [], writes: string[] = [];
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
Expand Down Expand Up @@ -85,13 +85,14 @@ for (const width of [1440, 1280, 390, 320]) test.describe(`viewport ${width}px`,
await panel.locator('.editor-draft-notice').scrollIntoViewIfNeeded(); await page.screenshot({ path: testInfo.outputPath('create-inline-notice.png'), fullPage: true });
await parent.locator('#close-plan-modal-btn').scrollIntoViewIfNeeded();
const footer = (await parent.locator('.modal-buttons').boundingBox())!;
expect(footer.y + footer.height).toBeLessThanOrEqual(900);
expect(footer.y + footer.height).toBeLessThanOrEqual(page.viewportSize()!.height);
await page.screenshot({ path: testInfo.outputPath('create-inline-actions.png'), fullPage: true });
await parent.locator('#preview-plan-schedule-btn').focus(); await page.keyboard.press('Tab');
await expect(parent.locator('button[type="submit"]')).toBeFocused(); await page.keyboard.press('Tab');
await expect(panel.locator('.schedule-preview-scroll')).toBeFocused();
await page.keyboard.press('Shift+Tab'); await expect(parent.locator('button[type="submit"]')).toBeFocused();
await page.keyboard.press('Shift+Tab'); await expect(parent.locator('#preview-plan-schedule-btn')).toBeFocused();
expect(await panel.evaluate(element => !!(element.previousElementSibling as HTMLElement | null)?.matches('#plan-form'))).toBe(true);
await summary.focus(); await page.keyboard.press('Tab');
await expect(panel.locator('.schedule-preview-scroll')).toBeFocused(); await page.keyboard.press('Tab');
await expect(parent.locator('#close-plan-modal-btn')).toBeFocused();
await page.keyboard.press('Shift+Tab'); await expect(panel.locator('.schedule-preview-scroll')).toBeFocused();
await page.keyboard.press('Shift+Tab'); await expect(summary).toBeFocused();
await summary.click(); await expect(advanced).toHaveAttribute('open', '');
await parent.locator('#close-plan-modal-btn').click(); await expect(parent).toBeHidden();
await page.locator('#new-plan-btn').click(); await parent.locator('#plan-name').fill('Capacity ladder');
Expand All @@ -101,9 +102,34 @@ for (const width of [1440, 1280, 390, 320]) test.describe(`viewport ${width}px`,
await expect(panel.locator('[data-editor="controls"]')).toBeHidden();

} else {
await expect(parent.locator('#plan-editor-panel')).toBeHidden();
await expect(parent.locator('#plan-editor-panel')).toBeEmpty();
const panel = parent.locator('#plan-editor-panel');
await expect(panel).toBeVisible();
await expect(panel.locator('[data-preview-step]')).toHaveCount(4);
await expect(panel.locator('.editor-draft-notice')).toContainText('restarts from today for a plan already in progress; executed purchases are not shown');
await expect(panel.locator('[data-editor="controls"]')).toBeHidden();
await page.screenshot({ path: testInfo.outputPath('edit-inline-graph.png'), fullPage: true });
}
const layoutForm = (await parent.locator('#plan-form').boundingBox())!, layoutPanel = (await parent.locator('#plan-editor-panel').boundingBox())!;
if (width > 960) {
expect(layoutForm.width).toBeGreaterThanOrEqual(360); expect(layoutPanel.x).toBeGreaterThanOrEqual(layoutForm.x + layoutForm.width);
expect(layoutPanel.width).toBeGreaterThan(layoutForm.width);
expect(await parent.locator('#plan-editor-panel').evaluate(element => getComputedStyle(element).position)).toBe('sticky');
} else expect(layoutPanel.y).toBeGreaterThanOrEqual(layoutForm.y + layoutForm.height - 1);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
if (width > 960) {
const reach = await parent.evaluate(() => {
const scroller = document.querySelector('.plan-editor-layout')!, sticky = document.getElementById('plan-editor-panel')!;
sticky.scrollTop = sticky.scrollHeight;
const bounds = scroller.getBoundingClientRect(), last = Array.from(sticky.querySelectorAll('.help-text')).pop()!.getBoundingClientRect(), own = sticky.getBoundingClientRect();
return { panelFits: own.height <= bounds.height + 1, lastVisible: last.top >= bounds.top - 1 && last.bottom <= bounds.bottom + 1 };
});
expect(reach).toEqual({ panelFits: true, lastVisible: true });
}
await parent.locator('#close-plan-modal-btn').scrollIntoViewIfNeeded();
const cancel = (await parent.locator('#close-plan-modal-btn').boundingBox())!;
expect(cancel.x).toBeGreaterThanOrEqual(0); expect(cancel.x + cancel.width).toBeLessThanOrEqual(width);
const footerBox = (await parent.locator('.modal-buttons').boundingBox())!;
expect(footerBox.y + footerBox.height).toBeLessThanOrEqual(page.viewportSize()!.height);
const before = await parent.locator('input, select, textarea').evaluateAll(elements => elements.map(element => (element as HTMLInputElement).value));
const trigger = page.locator('#preview-plan-schedule-btn');
await trigger.click();
Expand Down
Loading