-
+
diff --git a/scripts/lib/queryCli.mjs b/scripts/lib/queryCli.mjs
index 98f2d68..6fcc414 100644
--- a/scripts/lib/queryCli.mjs
+++ b/scripts/lib/queryCli.mjs
@@ -79,7 +79,7 @@ function printUsage(stream = process.stdout) {
npm run query:api -- --action ACTION [--payload request.json|-] [--set key=value] [--output response.json]
npm run query:compat -- [--api-url URL] [--json]
npm run query:status -- [--api-url URL] [--json]
- npm run query:dashboard -- [--library CODE] [--item-type CODE] [--active-window-days 90|365|730] [--output dashboard.json]
+ npm run query:dashboard -- [--library CODE] [--item-type CODE] [--active-window-days 90|365|730] [--reporting-period PERIOD] [--output dashboard.json]
npm run query:plan -- --config query.json [--output plan.json]
npm run query:cancel -- --query-id QUERY_ID
npm run query:results -- --query-id QUERY_ID [--format xlsx|csv|json|jsonl] [--output results.xlsx] [--include-duplicates]
@@ -935,6 +935,7 @@ async function runDashboardCommand(options = {}) {
library: String(options.library || 'all'),
item_type: String(options['item-type'] || options.itemType || 'all'),
active_window_days: Number(options['active-window-days'] || options.activeWindowDays || 365),
+ reporting_period: String(options['reporting-period'] || options.reportingPeriod || options['active-window-days'] || options.activeWindowDays || 365),
force_refresh: Boolean(options.refresh)
};
const data = await postJson(apiUrl, payload, options);
diff --git a/src/core/libraryScopes.js b/src/core/libraryScopes.js
index a3d5426..20c88b5 100644
--- a/src/core/libraryScopes.js
+++ b/src/core/libraryScopes.js
@@ -13,9 +13,28 @@ function buildLibraryScopeGroups(systems = [], libraries = []) {
].filter(group => group.options.length > 0);
}
+function buildLibraryScopeSelectorValues(systems = [], libraries = []) {
+ const systemOptions = normalizeLibraryScopeOptions(systems).map(option => ({
+ RawValue: option.value,
+ Display: option.label,
+ Group: 'Library systems'
+ }));
+ const libraryOptions = normalizeLibraryScopeOptions(libraries).map(option => ({
+ RawValue: option.value,
+ Display: option.label,
+ Group: systemCodeForLibraryScope(option.value) || 'Item libraries'
+ }));
+ return [...systemOptions, ...libraryOptions];
+}
+
function systemCodeForLibraryScope(scope = '') {
const normalized = String(scope || '').replace(/^system:/u, '');
return normalized && normalized !== 'all' ? normalized.split('-')[0].toUpperCase() : '';
}
-export { ALL_LIBRARY_SYSTEMS_LABEL, buildLibraryScopeGroups, systemCodeForLibraryScope };
+export {
+ ALL_LIBRARY_SYSTEMS_LABEL,
+ buildLibraryScopeGroups,
+ buildLibraryScopeSelectorValues,
+ systemCodeForLibraryScope
+};
diff --git a/src/styles/app.css b/src/styles/app.css
index 6ca2a25..83579e7 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -7,7 +7,7 @@
@import url("./table.css?v=8bc187669db70079");
@import url("./controls.css?v=30d36ccc65181c21");
@import url("./history.css?v=64598bfd565a29af");
-@import url("./dashboard.css?v=3d11ffe059411be7");
+@import url("./dashboard.css?v=d674dda33be597fe");
@import url("./templates.css?v=1c0a1594bedc65b9");
@import url("./api-settings.css?v=c5f76b60fb8684bb");
@import url("./site-update.css?v=b16b51ef4c7bd942");
diff --git a/src/styles/dashboard.css b/src/styles/dashboard.css
index 65eb5b4..5ad46cc 100644
--- a/src/styles/dashboard.css
+++ b/src/styles/dashboard.css
@@ -102,7 +102,8 @@
backdrop-filter: blur(12px);
}
-.kpi-dashboard-toolbar label {
+.kpi-dashboard-toolbar label,
+.kpi-dashboard-filter {
display: grid;
gap: 0.28rem;
color: var(--theme-text-muted);
@@ -112,6 +113,17 @@
text-transform: uppercase;
}
+.kpi-dashboard-library-selector,
+.kpi-dashboard-library-selector .form-mode-popup-list-control {
+ min-width: 0;
+ width: 100%;
+}
+
+.kpi-dashboard-library-selector .form-mode-popup-list-trigger {
+ min-height: 2.55rem;
+ width: 100%;
+}
+
.kpi-dashboard-toolbar select,
.kpi-dashboard-refresh {
min-height: 2.55rem;
diff --git a/src/ui/dashboard/kpiDashboard.js b/src/ui/dashboard/kpiDashboard.js
index 338659d..5d07756 100644
--- a/src/ui/dashboard/kpiDashboard.js
+++ b/src/ui/dashboard/kpiDashboard.js
@@ -1,8 +1,9 @@
import { BackendApi } from '../../core/backendApi.js';
import { appServices } from '../../core/appServices.js';
import { getClientErrorMessage } from '../../core/clientErrorMessages.js';
-import { ALL_LIBRARY_SYSTEMS_LABEL, buildLibraryScopeGroups, systemCodeForLibraryScope } from '../../core/libraryScopes.js';
+import { ALL_LIBRARY_SYSTEMS_LABEL, buildLibraryScopeSelectorValues, systemCodeForLibraryScope } from '../../core/libraryScopes.js';
import { onDOMReady } from '../../core/domReady.js';
+import { SelectorControls } from '../controls/selectorControls.js';
import { libraryDashboardHasData, normalizeLibraryDashboard } from './libraryDashboardModel.js';
import { downloadLibraryDashboardCsv } from './libraryDashboardExport.js';
import { normalizeDashboardRuns, summarizeDashboardRuns } from './kpiDashboardModel.js';
@@ -47,18 +48,37 @@ function replaceOptions(select, baseLabel, options, selected) {
select.value = normalized.some(option => option.value === selected) ? selected : 'all';
}
-function replaceLibraryOptions(select, systems, libraries, selected) {
- if (!select) return;
- const groups = buildLibraryScopeGroups(systems, libraries);
- const options = groups.flatMap(group => group.options);
- const all = new Option(ALL_LIBRARY_SYSTEMS_LABEL, 'all');
- select.replaceChildren(all, ...groups.map(group => {
- const element = document.createElement('optgroup');
- element.label = group.label;
- element.append(...group.options.map(option => new Option(option.label, option.value)));
- return element;
- }));
- select.value = options.some(option => option.value === selected) ? selected : 'all';
+function selectedLibraryScope(control) {
+ const selected = control?.getSelectedValues?.() || [];
+ return selected[0] || 'all';
+}
+
+function replaceLibraryOptions(container, systems, libraries, selected) {
+ if (!container) return;
+ const values = buildLibraryScopeSelectorValues(systems, libraries);
+ const validSelection = values.some(option => option.RawValue === selected) ? [selected] : [];
+ const signature = JSON.stringify(values.map(option => [option.RawValue, option.Display, option.Group]));
+ if (container.dataset.optionsSignature === signature && container.getSelectedValues) {
+ container.setSelectedValues(validSelection);
+ return;
+ }
+
+ container.querySelector('.form-mode-popup-list-control')?._cleanupPopup?.();
+ const selector = SelectorControls.createGroupedSelector(values, false, validSelection, {
+ enableGrouping: true,
+ allSelectionLabel: ALL_LIBRARY_SYSTEMS_LABEL,
+ allSelectionDescription: 'Include every library system.',
+ containerId: null
+ });
+ const popup = SelectorControls.createPopupListControl(
+ selector,
+ 'Library or system',
+ ALL_LIBRARY_SYSTEMS_LABEL
+ );
+ container.replaceChildren(popup);
+ container.dataset.optionsSignature = signature;
+ container.getSelectedValues = () => popup.getSelectedValues();
+ container.setSelectedValues = valuesToSet => popup.setSelectedValues(valuesToSet);
}
function syncFilterOptions(elements) {
@@ -67,7 +87,7 @@ function syncFilterOptions(elements) {
elements.library,
libraryData.filters.systems,
libraryData.filters.libraries,
- elements.library?.value || 'all'
+ selectedLibraryScope(elements.library)
);
replaceOptions(elements.itemType, 'All item types', libraryData.filters.itemTypes, elements.itemType?.value || 'all');
syncPeriodOptions(elements);
@@ -76,7 +96,7 @@ function syncFilterOptions(elements) {
function syncPeriodOptions(elements) {
if (!elements.period || !libraryData) return;
const selected = elements.period.value || '365';
- const library = elements.library?.value || 'all';
+ const library = selectedLibraryScope(elements.library);
const system = systemCodeForLibraryScope(library);
const rolling = [
{ value: '90', label: 'Last 90 days' },
@@ -139,7 +159,7 @@ function requestPayload(elements) {
const reportingPeriod = elements.period?.value || '365';
return {
action: 'library_dashboard',
- library: elements.library?.value || 'all',
+ library: selectedLibraryScope(elements.library),
item_type: elements.itemType?.value || 'all',
active_window_days: /^\d+$/.test(reportingPeriod) ? Number(reportingPeriod) : 365,
reporting_period: reportingPeriod
diff --git a/tests/browser/browserSmoke.mjs b/tests/browser/browserSmoke.mjs
index 7862beb..25393ef 100644
--- a/tests/browser/browserSmoke.mjs
+++ b/tests/browser/browserSmoke.mjs
@@ -519,8 +519,8 @@ async function runSmokeTest() {
cardValues: Array.from(panel.querySelectorAll('.kpi-card__value')).map(node => node.textContent.trim()),
chartCount: panel.querySelectorAll('.kpi-chart-card').length,
opportunityRows: panel.querySelectorAll('.kpi-opportunity-table tbody tr').length,
- libraryOptions: Array.from(panel.querySelectorAll('#kpi-dashboard-library option')).map(option => option.value),
- libraryGroups: Array.from(panel.querySelectorAll('#kpi-dashboard-library optgroup')).map(group => group.label),
+ librarySelection: panel.querySelector('#kpi-dashboard-library')?.getSelectedValues?.() || [],
+ librarySummary: panel.querySelector('#kpi-dashboard-library .form-mode-popup-list-summary')?.textContent?.trim() || '',
exportVisible: !panel.querySelector('#kpi-dashboard-export')?.classList.contains('hidden'),
comparisonText: panel.querySelector('.kpi-card')?.textContent || '',
selectedTab: panel.querySelector('[data-kpi-view][aria-selected="true"]')?.dataset.kpiView || ''
@@ -531,10 +531,8 @@ async function runSmokeTest() {
|| dashboardState.cardValues[2] !== '2,813,442'
|| dashboardState.chartCount !== 6
|| dashboardState.opportunityRows !== 1
- || !dashboardState.libraryOptions.includes('system:MSU')
- || !dashboardState.libraryOptions.includes('MSU-MAIN')
- || !dashboardState.libraryGroups.includes('Library systems')
- || !dashboardState.libraryGroups.includes('Item libraries')
+ || dashboardState.librarySelection.length !== 0
+ || dashboardState.librarySummary !== 'All library systems'
|| !dashboardState.exportVisible
|| !/up 38,119/iu.test(dashboardState.comparisonText)
|| dashboardState.selectedTab !== 'overview'
@@ -558,7 +556,15 @@ async function runSmokeTest() {
}
await page.locator('#kpi-dashboard-window').selectOption('cy:2026');
await page.waitForFunction(() => document.querySelector('#kpi-dashboard-content .kpi-card')?.textContent?.includes('Calendar Year 2026 to date'));
- await page.locator('#kpi-dashboard-library').selectOption('system:MSU');
+ await page.locator('#kpi-dashboard-library .form-mode-popup-list-trigger').click();
+ const libraryDialog = page.getByRole('dialog', { name: 'Library or system' });
+ if (!await libraryDialog.getByRole('button', { name: 'All library systems' }).count()) {
+ throw new Error('Dashboard should reuse the grouped selector and expose the all-systems choice.');
+ }
+ await libraryDialog.getByPlaceholder('Search options...').fill('Mississippi State University');
+ await libraryDialog.getByText('Mississippi State University', { exact: true }).click();
+ await libraryDialog.getByRole('button', { name: 'Done' }).click();
+ await page.waitForFunction(() => document.querySelector('#kpi-dashboard-library')?.getSelectedValues?.()[0] === 'system:MSU');
await page.waitForFunction(() => Array.from(document.querySelectorAll('#kpi-dashboard-window option')).some(option => option.value === 'fy:MSU:2027'));
if (!await page.locator('#kpi-dashboard-window optgroup[label="Fiscal years"]').count()) {
throw new Error('Dashboard should expose fiscal years as a distinct reporting-period group after choosing a system.');
@@ -2071,7 +2077,7 @@ async function runSmokeTest() {
await mobilePage.locator('#post-filter-value-picker-host .form-mode-popup-list-trigger').click();
await mobilePage.locator('.form-mode-popup-list-popup:not([hidden])').waitFor({ state: 'visible', timeout: 5000 });
await expectElementWithinViewport(mobilePage, '.form-mode-popup-list-popup:not([hidden])', 'Mobile popup list picker');
- await expectLightInput(mobilePage, '.form-mode-popup-list-popup input[type="search"]', 'Mobile popup list search input');
+ await expectLightInput(mobilePage, '.form-mode-popup-list-popup:not([hidden]) input[type="search"]', 'Mobile popup list search input');
const popupAutoFocus = await mobilePage.locator('.form-mode-popup-list-popup:not([hidden])').evaluate(popup => {
const active = document.activeElement;
return {
@@ -2083,10 +2089,10 @@ async function runSmokeTest() {
if (!popupAutoFocus.popupFocused || ['INPUT', 'TEXTAREA', 'SELECT'].includes(popupAutoFocus.activeTag)) {
throw new Error(`Mobile popup list should open without auto-focusing a text control: ${JSON.stringify(popupAutoFocus)}`);
}
- await expectMobileEditableFocusContained(mobilePage, '.form-mode-popup-list-popup input[type="search"]', '.form-mode-popup-list-popup-body', 'Mobile popup list search input');
- await expectMinimumTapTarget(mobilePage, '.form-mode-popup-list-done', 'Mobile popup list done control');
+ await expectMobileEditableFocusContained(mobilePage, '.form-mode-popup-list-popup:not([hidden]) input[type="search"]', '.form-mode-popup-list-popup:not([hidden]) .form-mode-popup-list-popup-body', 'Mobile popup list search input');
+ await expectMinimumTapTarget(mobilePage, '.form-mode-popup-list-popup:not([hidden]) .form-mode-popup-list-done', 'Mobile popup list done control');
await expectNoHorizontalOverflow(mobilePage, 'Mobile popup list picker');
- await mobilePage.locator('.form-mode-popup-list-done').click();
+ await mobilePage.locator('.form-mode-popup-list-popup:not([hidden]) .form-mode-popup-list-done').click();
await mobilePage.locator('#post-filter-done-btn').click();
await expectMobileScrollLockReleased(mobilePage, 'Mobile post filter dialog');
await cleanupMobilePageScroll(mobilePage);
diff --git a/tests/unit/features/queryCliLogic.mjs b/tests/unit/features/queryCliLogic.mjs
index a75afb6..bb37622 100644
--- a/tests/unit/features/queryCliLogic.mjs
+++ b/tests/unit/features/queryCliLogic.mjs
@@ -315,18 +315,20 @@ test('dashboard CLI requests the same scoped aggregate used by the interface', a
const outputPath = join(tmpdir(), `query-cli-dashboard-${Date.now()}.json`);
try {
const result = await runDashboardCommand({
- library: 'MSU',
+ library: 'system:MSU',
'item-type': 'EBOOK',
'active-window-days': '90',
+ 'reporting-period': 'fy:MSU:2027',
output: outputPath,
'api-url': 'https://example.test/query',
sessionStore: { read: async () => ({ token: 'test-session-token' }) }
});
assert.deepEqual(payload, {
action: 'library_dashboard',
- library: 'MSU',
+ library: 'system:MSU',
item_type: 'EBOOK',
active_window_days: 90,
+ reporting_period: 'fy:MSU:2027',
force_refresh: false
});
assert.equal(result.schemaVersion, 1);