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
68 changes: 67 additions & 1 deletion frontend/src/__tests__/recommendations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,9 @@ describe('Recommendations Module', () => {
});

test('shows empty-state message when no recommendations', async () => {
// Issue #700: zero rows now render an empty <tbody> with a hint cell
// rather than replacing the entire table with a <p>. The <thead> stays
// so column headers remain visible. The hint text lives inside the tbody.
(api.getRecommendations as jest.Mock).mockResolvedValue({
summary: {},
recommendations: [],
Expand All @@ -596,7 +599,12 @@ describe('Recommendations Module', () => {
await loadRecommendations();

const list = document.getElementById('recommendations-list');
expect(list?.innerHTML).toContain('No recommendations match');
// The table (including <thead>) must still be rendered.
expect(list?.querySelector('thead')).not.toBeNull();
// The hint cell must be present inside the tbody.
const emptyCell = list?.querySelector('tbody td.empty');
expect(emptyCell).not.toBeNull();
expect(emptyCell?.textContent).toMatch(/No rows match/);
});

test('stores recommendations in state', async () => {
Expand Down Expand Up @@ -2222,6 +2230,64 @@ describe('Bundle B: column header filter triggers', () => {
expect(state.setRecommendationsColumnFilter).toHaveBeenCalledWith('provider', { kind: 'set', values: [] });
});

test('Issue #700: Clear resets (All) checkbox to unchecked (not indeterminate)', async () => {
// Bug: the old Clear branch set cb.checked = false on individual boxes but
// never called updateAllTriState(), so the (All) checkbox kept its prior
// state (checked or indeterminate). After the fix, commitAllRef(false) is
// used which calls updateAllTriState() and leaves (All) unchecked.
//
// Simulate real state-store behaviour: setRecommendationsColumnFilter
// updates the store so the next getRecommendationsColumnFilters() call
// sees the cleared filter. Without this the resyncOpenPopover() call
// triggered by the rerender would re-apply the stale filter and overwrite
// the tri-state that updateAllTriState() just set.
(state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({
provider: { kind: 'set', values: ['aws'] },
});
(state.setRecommendationsColumnFilter as jest.Mock).mockImplementation(
(col: string, val: unknown) => {
(state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue(
val === null ? {} : { [col]: val },
);
},
);
await loadRecommendations();
const providerBtn = document.querySelector<HTMLButtonElement>('th .column-filter-btn[data-column="provider"]');
providerBtn?.click();

// (All) starts in a known state before Clear.
const allBox = document.querySelector<HTMLInputElement>('.column-filter-popover .column-filter-all input[type="checkbox"]');
expect(allBox).not.toBeNull();

const clearBtn = document.querySelector<HTMLButtonElement>('.column-filter-popover .column-filter-clear');
clearBtn?.click();

// After Clear: (All) must be unchecked and not indeterminate.
expect(allBox!.checked).toBe(false);
expect(allBox!.indeterminate).toBe(false);
expect(state.setRecommendationsColumnFilter).toHaveBeenCalledWith('provider', { kind: 'set', values: [] });
});

test('Issue #700: table <thead> survives a filter that yields zero rows', async () => {
// Bug: renderRecommendationsList replaced the entire <table> with a <p>
// when no rows matched, removing <thead>. After the fix, the header row
// remains visible (with an empty <tbody> + hint cell) so columns are still
// readable while the user adjusts filters.
(state.getRecommendationsColumnFilters as jest.Mock).mockReturnValue({
provider: { kind: 'set', values: [] },
});
await loadRecommendations();

const container = document.getElementById('recommendations-list');
expect(container?.querySelector('thead')).not.toBeNull();
// The empty hint cell must be present inside the table body.
const emptyCell = container?.querySelector('tbody td.empty');
expect(emptyCell).not.toBeNull();
expect(emptyCell?.textContent).toMatch(/No rows match/);
// No standalone <p class="empty"> should replace the table.
expect(container?.querySelector('p.empty')).toBeNull();
});

test('Clear button on a numeric column clears the expression filter (null)', async () => {
// Numeric columns still use null on Clear: the empty-set semantic
// only applies to categorical filters because the set-membership
Expand Down
50 changes: 38 additions & 12 deletions frontend/src/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1636,6 +1636,10 @@ function buildPopoverContent(
const checkboxes = new Map<string, HTMLInputElement>();
let input: HTMLInputElement | null = null;
let errorEl: HTMLElement | null = null;
// commitAllRef is set inside the categorical else-branch so the Clear
// button handler (which lives outside that branch) can call commitAll()
// and thereby invoke updateAllTriState() — fixing issue #700.
let commitAllRef: ((target: boolean) => void) | null = null;

if (NUMERIC_COLUMNS.has(column)) {
const label = document.createElement('label');
Expand Down Expand Up @@ -1794,6 +1798,8 @@ function buildPopoverContent(
updateSPTriState();
rerenderRecommendations();
};
// Expose commitAll to the Clear button handler outside this else-branch.
commitAllRef = commitAll;

checkboxes.forEach((cb) => {
cb.addEventListener('change', commit);
Expand Down Expand Up @@ -1833,15 +1839,18 @@ function buildPopoverContent(
state.setRecommendationsColumnFilter(column, null);
input.value = '';
if (errorEl) errorEl.textContent = '';
rerenderRecommendations();
} else {
// Issue #482: Clear on a categorical filter sets an explicit empty
// allow-list rather than null, so it's distinguishable from "no
// filter applied" (which renders as all-checked). The popover's
// checkboxes flip unchecked; the table renders 0 rows.
checkboxes.forEach((cb) => { cb.checked = false; });
state.setRecommendationsColumnFilter(column, { kind: 'set', values: [] });
// Issue #700: call commitAllRef(false) (the same as commitAll(false)
// inside the categorical branch) so updateAllTriState() is invoked and
// the (All) checkbox reflects the cleared state. commitAllRef also
// calls rerenderRecommendations() internally.
commitAllRef?.(false);
}
rerenderRecommendations();
});
footer.appendChild(clearBtn);
popover.appendChild(footer);
Expand Down Expand Up @@ -3553,13 +3562,9 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void {
// Mount once; update is per-render below.
mountBottomActionBox();

if (!recommendations || recommendations.length === 0) {
const emptyResult = !recommendations || recommendations.length === 0;
if (emptyResult) {
lastVisibleGroupKeys = [];
// Filter status bar renders even for the empty case (live-region count).
renderFilterStatusBar(loadedRecs?.length ?? 0, 0);
container.innerHTML = '<p class="empty">No recommendations match these filters. Try clearing filters or refreshing.</p>';
updateBottomActionBox(0, loadedRecs?.length ?? 0);
return;
}

// Compute the visible column set once per render — passed to buildListMarkup
Expand All @@ -3571,15 +3576,36 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void {
// escapeHtml or is a number. The string is built in buildListMarkup.
// NOTE: buildListMarkup also populates lastVisibleGroupKeys, so it MUST
// run before renderFilterStatusBar (which reads it for the Expand-All button).
container.innerHTML = buildListMarkup(recommendations, selectedIDs, visibleCols);
container.innerHTML = buildListMarkup(recommendations ?? [], selectedIDs, visibleCols);

// Issue #700: when the filter yields zero rows, preserve the <thead> by
// injecting a hint row into the empty <tbody> rather than replacing the
// entire table with a <p>. The column headers remain visible so the user
// can see which columns are active while they adjust filters.
if (emptyResult) {
const tbody = container.querySelector('tbody');
if (tbody) {
// colspan = 1 (checkbox col) + all visible data columns.
const colspan = 1 + visibleCols.length;
const tr = document.createElement('tr');
const td = document.createElement('td');
td.setAttribute('colspan', String(colspan));
td.className = 'empty';
td.textContent = 'No rows match these filters.';
tr.appendChild(td);
tbody.appendChild(tr);
}
}

const visibleCount = recommendations?.length ?? 0;

// Filter status: Clear-filters badge + aria-live count + Expand-All toggle.
// Rendered AFTER buildListMarkup so lastVisibleGroupKeys is populated.
// Mounted as a sibling above the table so it survives the container's
// innerHTML rewrite without losing aria-live announcements.
renderFilterStatusBar(loadedRecs?.length ?? 0, recommendations.length);
renderFilterStatusBar(loadedRecs?.length ?? 0, visibleCount);

updateBottomActionBox(recommendations.length, loadedRecs?.length ?? recommendations.length);
updateBottomActionBox(visibleCount, loadedRecs?.length ?? visibleCount);


// Per-column filter button: trigger opens the popover anchored to the
Expand Down
Loading