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
46 changes: 45 additions & 1 deletion frontend/src/__tests__/plans-column-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,54 @@ describe('Planned Purchases column filters (issue #166 follow-up)', () => {
);
const cols = Array.from(buttons).map((b) => b.dataset['column']);
expect(cols.sort()).toEqual(
['count', 'estimated_savings', 'payment', 'provider', 'resource_type', 'service', 'status', 'term', 'upfront_cost'].sort(),
['count', 'estimated_savings', 'payment', 'plan_name', 'provider', 'resource_type', 'scheduled_date', 'service', 'status', 'term', 'upfront_cost'].sort(),
);
});

test('plan name and scheduled date filter narrow rows (#1680)', async () => {
await loadPlans();
const uncheck = (column: string, value: string): void => {
document.querySelector<HTMLButtonElement>(
`#planned-purchases-list th .column-filter-btn[data-column="${column}"]`,
)?.click();
const cb = Array.from(document.querySelectorAll<HTMLInputElement>('.column-filter-popover input[type="checkbox"]'))
.find((i) => i.dataset['value'] === value);
expect(cb).toBeDefined();
cb!.checked = false;
cb!.dispatchEvent(new Event('change'));
};
uncheck('plan_name', 'Plan B');
expect(countRows()).toBe(2);
uncheck('scheduled_date', '2026-06-02');
expect(countRows()).toBe(1);
});

test('scheduled date options are one per displayed day', async () => {
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [
{ ...seedPurchases[0], id: 'd1', scheduled_date: '2026-06-01T10:00:00' },
{ ...seedPurchases[1], id: 'd2', scheduled_date: '2026-06-01T15:00:00' },
] });
await loadPlans();
document.querySelector<HTMLButtonElement>(
'#planned-purchases-list th .column-filter-btn[data-column="scheduled_date"]',
)?.click();
expect(document.querySelectorAll('.column-filter-popover .column-filter-item')).toHaveLength(1);
});

test('scheduled date options are in chronological order, labelled by date', async () => {
const dates = ['2026-12-01', '2026-01-10', '2026-01-02', '2026-06-02'];
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: dates.map((d, i) => (
{ ...seedPurchases[0], id: `o${i}`, scheduled_date: d }
)) });
await loadPlans();
document.querySelector<HTMLButtonElement>(
'#planned-purchases-list th .column-filter-btn[data-column="scheduled_date"]',
)?.click();
const labels = Array.from(document.querySelectorAll('.column-filter-popover .column-filter-item'))
.map((l) => l.textContent?.trim());
expect(labels).toEqual(['Jan 2, 2026', 'Jan 10, 2026', 'Jun 2, 2026', 'Dec 1, 2026']);
});

test('clicking a trigger opens a popover detached to document.body', async () => {
await loadPlans();
const providerBtn = document.querySelector<HTMLButtonElement>(
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/__tests__/plans-ramp-label.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,19 @@ describe('custom ramp inputs when Edit is the first plan modal (#1536)', () => {
expect(intervalInput().disabled).toBe(true);
});
});

describe('ramp step wording says percent of target (#1680)', () => {
beforeEach(() => { document.body.innerHTML = loadRealIndexBody(); });

test('custom step field label names the target', () => {
const label = document.getElementById('ramp-step-percent')?.closest('label')?.textContent ?? '';
expect(label).toContain('% of target');
});

test('preset descriptions say each step is a share of the target', () => {
const desc = (value: string): string =>
document.querySelector(`input[value="${value}"]`)?.closest('label')?.querySelector('.ramp-desc')?.textContent ?? '';
expect(desc('weekly-25pct')).toContain('25% of target each week');
expect(desc('monthly-10pct')).toContain('10% of target each month');
});
});
42 changes: 39 additions & 3 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -816,17 +816,53 @@ describe('Plans Module', () => {
await loadPlans();
});

test('renders the run button disabled with an explanatory title', () => {
test('renders the run button aria-disabled (still focusable) with an explanatory title', () => {
const runBtn = document.querySelector('[data-action="run"]') as HTMLButtonElement;
expect(runBtn.disabled).toBe(true);
expect(runBtn.disabled).toBe(false);
expect(runBtn.getAttribute('aria-disabled')).toBe('true');
expect(runBtn.classList.contains('is-disabled')).toBe(true);
expect(runBtn.title).toBe('Run now: this step has no recommendations, so there is nothing to run');
expect(runBtn.getAttribute('aria-label')).toBe('Run now: this step has no recommendations, so there is nothing to run');
expect(runBtn.getAttribute('data-id')).toBe('purchase-bare');
});

test('placeholder dashes share one muted style in the numeric columns (#1680)', () => {
const cells = Array.from(document.querySelectorAll('tr.planned-purchase-row td.num'));
expect(cells).toHaveLength(3);
cells.forEach(td => {
expect(td.innerHTML.trim()).toBe('<span class="cell-placeholder">\u2014</span>');
});
});

test('the plan name is not clamped to the small truncate width (#1680)', () => {
const name = document.querySelector('.plan-name') as HTMLElement;
expect(name.classList.contains('cell-truncate--sm')).toBe(false);
expect(name.classList.contains('plan-name-cell')).toBe(true);
expect(name.title).toBe('Azure compute 80% weekly');
});

test('clicking the aria-disabled run button keeps focus and does not re-fetch', async () => {
const runBtn = document.querySelector('[data-action="run"]') as HTMLButtonElement;
runBtn.focus();
(api.getPlannedPurchases as jest.Mock).mockClear();
runBtn.click();
await flushPromises();

expect(api.getPlannedPurchases).not.toHaveBeenCalled();
expect(document.activeElement).toBe(runBtn);
});

test('an all-upfront step with $0 upfront shows the placeholder dash', async () => {
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [
{ ...bare, has_recommendations: true, payment: 'all-upfront', upfront_cost: 0, count: 2, estimated_savings: 5 },
] });
await loadPlans();
const cells = document.querySelectorAll('tr.planned-purchase-row td.num');
expect(cells[1]!.innerHTML.trim()).toBe('<span class="cell-placeholder">\u2014</span>');
});

test('the run action never calls the API or opens a confirm', async () => {
const runBtn = document.querySelector('[data-action="run"]') as HTMLButtonElement;
runBtn.disabled = false;
runBtn.click();
await flushPromises();

Expand Down
6 changes: 3 additions & 3 deletions frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1075,12 +1075,12 @@ <h3>Ramp-up Schedule</h3>
<label class="ramp-option">
<input type="radio" name="ramp-schedule" value="weekly-25pct">
<span class="ramp-label">Weekly 25%</span>
<span class="ramp-desc">4 weeks to full coverage</span>
<span class="ramp-desc">25% of target each week, 4 weeks to full coverage</span>
</label>
<label class="ramp-option">
<input type="radio" name="ramp-schedule" value="monthly-10pct">
<span class="ramp-label">Monthly 10%</span>
<span class="ramp-desc">10 months to full coverage</span>
<span class="ramp-desc">10% of target each month, 10 months to full coverage</span>
</label>
<label class="ramp-option">
<input type="radio" name="ramp-schedule" value="custom">
Expand All @@ -1090,7 +1090,7 @@ <h3>Ramp-up Schedule</h3>
</div>
<div id="custom-ramp-config" class="hidden">
<div class="form-row">
<label>Coverage Per Step (%): <input type="number" id="ramp-step-percent" value="20" min="1" max="100" inputmode="decimal" autocomplete="off"></label>
<label>Coverage per step (% of target): <input type="number" id="ramp-step-percent" value="20" min="1" max="100" inputmode="decimal" autocomplete="off"></label>
<label>Interval (Days): <input type="number" id="ramp-interval-days" value="7" min="1" max="365" inputmode="numeric" pattern="[0-9]*" autocomplete="off"></label>
</div>
</div>
Expand Down
47 changes: 35 additions & 12 deletions frontend/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ function renderPlannedPurchasesInternal(): void {
<table class="planned-purchases-table table-compact table-sticky-end">
<thead>
<tr>
<th>Plan</th>
<th>Scheduled Date</th>
<th><span>Plan</span>${filterBtn('plan_name', 'Plan')}</th>
<th><span>Scheduled Date</span>${filterBtn('scheduled_date', 'Scheduled Date')}</th>
<th><span>Provider</span>${filterBtn('provider', 'Provider')}</th>
<th><span>Service</span>${filterBtn('service', 'Service')}</th>
<th><span>Resource Type</span>${filterBtn('resource_type', 'Resource Type')}</th>
Expand Down Expand Up @@ -249,6 +249,9 @@ function applyPlansColumnFilters(

function categoricalCellValueForPlan(p: PlannedPurchase, col: state.PlansColumnId): string {
switch (col) {
case 'plan_name': return p.plan_name ?? '';
// Keyed by local YYYY-MM-DD: same-day steps share one option and keys sort chronologically.
case 'scheduled_date': return localDayKey(p.scheduled_date);
case 'provider': return p.provider ?? '';
case 'service': return p.service ?? '';
case 'resource_type': return p.resource_type ?? '';
Expand All @@ -263,11 +266,22 @@ function categoricalCellValueForPlan(p: PlannedPurchase, col: state.PlansColumnI
}
}

function localDayKey(date: string | null | undefined): string {
if (!date) return '';
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) return date;
const d = new Date(date);
if (isNaN(d.getTime())) return '';
const pad = (n: number): string => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}

function numericCellValueForPlan(p: PlannedPurchase, col: state.PlansColumnId): number {
switch (col) {
case 'count': return p.count ?? 0;
case 'upfront_cost': return p.upfront_cost ?? 0;
case 'estimated_savings': return p.estimated_savings ?? 0;
case 'plan_name':
case 'scheduled_date':
case 'provider':
case 'service':
case 'resource_type':
Expand All @@ -289,6 +303,8 @@ function displayPrecisionForPlan(col: state.PlansColumnId): number {
case 'upfront_cost':
case 'estimated_savings':
return CURRENCY_DEFAULT_DIGITS;
case 'plan_name':
case 'scheduled_date':
case 'provider':
case 'service':
case 'resource_type':
Expand Down Expand Up @@ -319,6 +335,8 @@ const PLANS_NUMERIC_COLUMNS: ReadonlySet<state.PlansColumnId> = new Set<state.Pl

function plansColumnLabel(column: state.PlansColumnId): string {
switch (column) {
case 'plan_name': return 'Plan';
case 'scheduled_date': return 'Scheduled Date';
case 'provider': return 'Provider';
case 'service': return 'Service';
case 'resource_type': return 'Resource Type';
Expand All @@ -333,6 +351,7 @@ function plansColumnLabel(column: state.PlansColumnId): string {

function plansCategoricalDisplayLabel(column: state.PlansColumnId, value: string): string {
if (value === '') return '(empty)';
if (column === 'scheduled_date') return formatDate(value);
if (column === 'term') {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? formatTermShort(n) : value;
Expand Down Expand Up @@ -421,9 +440,10 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string {
const resourceCell = hasNoRecommendations
? '<span class="no-recommendations" title="This step has no recommendations attached, so it cannot buy anything.">No recommendations attached</span>'
: `${escapeHtml(purchase.resource_type)} <span class="cell-sub">(${escapeHtml(purchase.region)})</span>`;
const countCell = hasNoRecommendations ? '—' : escapeHtml(String(purchase.count ?? ''));
const upfrontShown = hasNoRecommendations ? '—' : upfrontCell;
const savingsCell = hasNoRecommendations ? '—' : `${formatCurrency(purchase.estimated_savings)}/mo`;
const placeholder = `<span class="cell-placeholder">${NO_VALUE}</span>`;
const countCell = hasNoRecommendations ? placeholder : escapeHtml(String(purchase.count ?? ''));
const upfrontShown = hasNoRecommendations || upfrontCell === NO_VALUE ? placeholder : upfrontCell;
const savingsCell = hasNoRecommendations ? placeholder : `${formatCurrency(purchase.estimated_savings)}/mo`;

// Issue #365: gate row actions by the same plan-management permissions
// a click on each button would require. Readonly users see no buttons
Expand Down Expand Up @@ -466,7 +486,7 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string {
return `
<tr class="planned-purchase-row ${statusClass}">
<td>
<span class="plan-name cell-truncate cell-truncate--sm" title="${escapeHtmlAttr(purchase.plan_name)}">${escapeHtml(purchase.plan_name)}</span>
<span class="plan-name plan-name-cell" title="${escapeHtmlAttr(purchase.plan_name)}">${escapeHtml(purchase.plan_name)}</span>
<span class="step-info">Step ${purchase.step_number}/${purchase.total_steps}</span>
</td>
<td>${formatDate(purchase.scheduled_date)}</td>
Expand All @@ -480,7 +500,7 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string {
<td class="num savings">${savingsCell}</td>
<td><span class="status-badges">${statusBadgeHtml(purchase.status)}${overdueBadge}</span>${overdueHintHtml}</td>
<td class="actions"><span class="row-actions">
${canRunPurchase ? `<button data-action="run" data-id="${escapeHtmlAttr(purchase.id)}" class="btn-small primary icon-label row-action-run"${hasNoRecommendations ? ' disabled' : ''} title="${runLabel}" aria-label="${runLabel}">${iconSvg('play')}<span>Run now</span></button>` : ''}
${canRunPurchase ? `<button data-action="run" data-id="${escapeHtmlAttr(purchase.id)}" class="btn-small primary icon-label row-action-run${hasNoRecommendations ? ' is-disabled' : ''}"${hasNoRecommendations ? ' aria-disabled="true"' : ''} title="${runLabel}" aria-label="${runLabel}">${iconSvg('play')}<span>Run now</span></button>` : ''}
${canPauseOrResumePurchase && isPending ? `<button data-action="pause" data-id="${escapeHtmlAttr(purchase.id)}" class="btn-small icon-btn" title="Pause" aria-label="Pause">${iconSvg('pause')}</button>` : ''}
${canPauseOrResumePurchase && isPaused ? `<button data-action="resume" data-id="${escapeHtmlAttr(purchase.id)}" class="btn-small icon-btn" title="Resume" aria-label="Resume">${iconSvg('play')}</button>` : ''}
${canEditPlan ? `<button data-action="edit" data-id="${escapeHtmlAttr(purchase.id)}" data-plan-id="${escapeHtmlAttr(purchase.plan_id)}" class="btn-small icon-btn" title="Edit Plan" aria-label="Edit Plan">${iconSvg('edit')}</button>` : ''}
Expand Down Expand Up @@ -526,24 +546,26 @@ function runNowBody(target: PlannedPurchase | undefined): string | HTMLElement {
return withIntro(`This immediately buys the commitment below for plan "${target.plan_name}", scheduled for ${formatDate(target.scheduled_date)}. It cannot be undone.`, summary);
}

async function runPlannedPurchaseNow(purchaseId: string): Promise<void> {
if (runNowInFlight.has(purchaseId)) return;
/** Resolves false when nothing ran and the list needs no reload. */
async function runPlannedPurchaseNow(purchaseId: string): Promise<boolean> {
if (runNowInFlight.has(purchaseId)) return true;
runNowInFlight.add(purchaseId);
try {
const target = lastLoadedPurchases.find(p => p.id === purchaseId);
if (target?.has_recommendations === false) {
showToast({ message: 'This step has no recommendations attached, so there is nothing to run.', kind: 'info', timeout: 5_000 });
return;
return false;
}
const ok = await confirmDialog({
title: target ? `Run step ${target.step_number}/${target.total_steps} of "${target.plan_name}" now?` : 'Run purchase now?',
body: runNowBody(target),
confirmLabel: 'Run now',
destructive: true,
});
if (!ok) return;
if (!ok) return true;
await api.runPlannedPurchase(purchaseId);
showToast({ message: 'Purchase executed successfully', kind: 'success', timeout: 5_000 });
return true;
} finally {
runNowInFlight.delete(purchaseId);
}
Expand All @@ -556,7 +578,8 @@ async function handlePlannedPurchaseAction(action: string, purchaseId: string, p
try {
switch (action) {
case 'run':
await runPlannedPurchaseNow(purchaseId);
// An empty step only toasts; a reload would drop keyboard focus.
if (!(await runPlannedPurchaseNow(purchaseId))) return;
break;
case 'pause':
// Pause is reversible and scoped to a single execution: the plan stays
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ export function setHiddenColumns(hidden: ReadonlySet<RecommendationsColumnId>):
// ---------------------------------------------------------------------------

export type PlansColumnId =
| 'provider' | 'service' | 'resource_type' | 'term' | 'payment' | 'status'
| 'plan_name' | 'scheduled_date' | 'provider' | 'service' | 'resource_type' | 'term' | 'payment' | 'status'
| 'count' | 'upfront_cost' | 'estimated_savings';

export type PlansColumnFilter =
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/styles/tables.css
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,29 @@ tr.history-row-highlight:hover {
text-align: right;
}

/* A plain length: auto table layout ignores a percentage max-width, so a
100-char name would widen the column. Longer names truncate (full name in
title). */
.planned-purchase-row .plan-name-cell {
display: inline-block;
max-width: 24rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}

/* One muted dash for every empty numeric cell (td.num right-aligns it). */
.planned-purchases-table .cell-placeholder {
color: var(--cudly-text-subtle);
}

/* aria-disabled keeps the button focusable so keyboard users can read why. */
.planned-purchases-table button.is-disabled {
opacity: 0.55;
cursor: not-allowed;
}

.planned-purchases-table tbody tr:hover {
background: var(--cudly-surface-muted);
}
Expand Down
24 changes: 24 additions & 0 deletions frontend/tests-e2e/plan-name-width.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { test, expect } from '@playwright/test';
import { mockApi, seedAuth } from './fixtures/recs';

// A percentage max-width is ignored by auto table layout, so a 100-char plan
// name must be capped with a plain length to keep the table in its wrapper (#1680).
test('a 100-char plan name keeps Planned Purchases inside its wrapper', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1000 });
await seedAuth(page);
await mockApi(page);
await page.route('**/api/purchases/planned', route => route.fulfill({ json: { purchases: [{
id: 'pp-1', plan_id: 'plan-1', plan_name: 'RDS-'.repeat(25), scheduled_date: '2026-11-01T00:00:00Z',
provider: 'aws', service: 'ec2', resource_type: 'm5.large', region: 'us-east-1', count: 3, term: 1,
payment: 'all-upfront', estimated_savings: 1234, upfront_cost: 25000, has_recommendations: true,
status: 'pending', step_number: 1, total_steps: 2, created_by_user_id: 'user-smoke',
}] } }));
await page.goto('/plans');
await expect(page.locator('#planned-purchases-list tbody tr.planned-purchase-row')).toHaveCount(1);
const fits = await page.evaluate(() => {
const table = document.querySelector('#planned-purchases-list table') as HTMLElement;
const wrap = table.parentElement as HTMLElement;
return { scroll: wrap.scrollWidth, client: wrap.clientWidth };
});
expect(fits.scroll).toBeLessThanOrEqual(fits.client + 1);
});
Loading
Loading