diff --git a/cache-bust.json b/cache-bust.json index d032c79..c6ff2cf 100644 --- a/cache-bust.json +++ b/cache-bust.json @@ -1,5 +1,5 @@ { - "version": "4571b2de4aa33cd0", + "version": "edb06878dc13225f", "generatedBy": "scripts/updateCacheBusting.mjs", "assets": { "assets/app-icon-16.png": "6645731d86da1071", @@ -14,7 +14,7 @@ "assets/favicon-32.png": "c9b411f8fdc302f2", "assets/favicon.svg": "8596af3b51a53409", "backgroundNotificationServiceWorker.js": "2f8933df91d32a1a", - "index.html": "120ac11359474cd7", + "index.html": "4529b829b6a02986", "site.webmanifest": "7641f91a81ee63a2", "src/appModules.js": "f4135fb45be489bb", "src/components/date-picker/index.js": "c9a9363a3fdae5ee", @@ -54,7 +54,7 @@ "src/core/formatting/valueFormatting.js": "3f1e24d4cdc46608", "src/core/hydrationEta.js": "250ec4ee2633be0f", "src/core/icons.js": "51f534abf118b7e1", - "src/core/libraryScopes.js": "9fd73251bf6e3fcc", + "src/core/libraryScopes.js": "73010d8dc269a106", "src/core/mobileScrollLock.js": "d197a1dc4a683409", "src/core/mockQueryBackend.js": "9147b5dea4adbced", "src/core/operatorSelectUtils.js": "4986a94dce50dcd9", @@ -190,11 +190,11 @@ "src/lib/workbook-export/workbookXmlUtils.js": "2cc0fcdda2a1d411", "src/lib/workbook-export/xlsxZipWriter.js": "5f4c1f6e98fbcb06", "src/styles/api-settings.css": "c5f76b60fb8684bb", - "src/styles/app.css": "410351104445b653", + "src/styles/app.css": "faab68de08b308c6", "src/styles/base.css": "8a925806001990b6", "src/styles/bib-compare.css": "144f2a1093e00dcf", "src/styles/controls.css": "30d36ccc65181c21", - "src/styles/dashboard.css": "3d11ffe059411be7", + "src/styles/dashboard.css": "d674dda33be597fe", "src/styles/filters.css": "9e7297e65dd68a3c", "src/styles/form-mode.css": "7f332bdcf58c2df0", "src/styles/help.css": "1e75cc7af857e66a", @@ -237,7 +237,7 @@ "src/ui/controls/selectorListPasteInput.js": "4e7f16d036dfd795", "src/ui/controls/tableNameInput.js": "b6fc884c36961de3", "src/ui/controls/virtualList.js": "bd7a8d24c7c48493", - "src/ui/dashboard/kpiDashboard.js": "6f73f5e9f2b62c49", + "src/ui/dashboard/kpiDashboard.js": "e2688fe53204cea3", "src/ui/dashboard/kpiDashboardModel.js": "fa4c42b18c1245e5", "src/ui/dashboard/kpiDashboardView.js": "82508c550975ada6", "src/ui/dashboard/libraryDashboardExport.js": "b756c909cbadc7e9", diff --git a/index.html b/index.html index 56753a4..312325b 100644 --- a/index.html +++ b/index.html @@ -833,7 +833,7 @@

- +
Library or system
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);