diff --git a/cache-bust.json b/cache-bust.json index b786e1d..202ce76 100644 --- a/cache-bust.json +++ b/cache-bust.json @@ -1,5 +1,5 @@ { - "version": "1b1cb56d846246bf", + "version": "8d0fd41f9ff22a6e", "generatedBy": "scripts/updateCacheBusting.mjs", "assets": { "assets/app-icon-16.png": "6645731d86da1071", @@ -54,9 +54,9 @@ "src/core/formatting/valueFormatting.js": "3f1e24d4cdc46608", "src/core/hydrationEta.js": "250ec4ee2633be0f", "src/core/icons.js": "51f534abf118b7e1", - "src/core/libraryScopes.js": "73010d8dc269a106", + "src/core/libraryScopes.js": "13658c87cf33b7c8", "src/core/mobileScrollLock.js": "d197a1dc4a683409", - "src/core/mockQueryBackend.js": "9147b5dea4adbced", + "src/core/mockQueryBackend.js": "4ebaa2df67601d45", "src/core/operatorSelectUtils.js": "4986a94dce50dcd9", "src/core/queryErrorDetails.js": "5c26531918b7a2c8", "src/core/queryExecution.js": "5fddde4c241c8699", @@ -233,11 +233,11 @@ "src/ui/cliPairing.js": "972206be3731df5f", "src/ui/controls/customDatePicker.js": "88e4eb3245ac256c", "src/ui/controls/searchUI.js": "9d46afc995531905", - "src/ui/controls/selectorControls.js": "ee4bde178cbce906", + "src/ui/controls/selectorControls.js": "39d4ddef21f28a52", "src/ui/controls/selectorListPasteInput.js": "4e7f16d036dfd795", "src/ui/controls/tableNameInput.js": "b6fc884c36961de3", "src/ui/controls/virtualList.js": "bd7a8d24c7c48493", - "src/ui/dashboard/kpiDashboard.js": "e2688fe53204cea3", + "src/ui/dashboard/kpiDashboard.js": "0f0651a7a9ebb27a", "src/ui/dashboard/kpiDashboardModel.js": "fa4c42b18c1245e5", "src/ui/dashboard/kpiDashboardView.js": "82508c550975ada6", "src/ui/dashboard/libraryDashboardExport.js": "b756c909cbadc7e9", diff --git a/scripts/lib/queryCli.mjs b/scripts/lib/queryCli.mjs index 6fcc414..3f5ffc5 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] [--reporting-period PERIOD] [--output dashboard.json] + npm run query:dashboard -- [--library CODE | --libraries CODE,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] @@ -930,6 +930,11 @@ async function runApiCommand(options = {}) { async function runDashboardCommand(options = {}) { const apiUrl = getApiUrl({}, options); + const libraries = (Array.isArray(options.libraries) ? options.libraries : [options.libraries]) + .filter(value => value !== undefined && value !== null) + .flatMap(value => String(value).split(',')) + .map(value => value.trim()) + .filter(Boolean); const payload = { action: 'library_dashboard', library: String(options.library || 'all'), @@ -938,6 +943,7 @@ async function runDashboardCommand(options = {}) { reporting_period: String(options['reporting-period'] || options.reportingPeriod || options['active-window-days'] || options.activeWindowDays || 365), force_refresh: Boolean(options.refresh) }; + if (libraries.length) payload.libraries = [...new Set(libraries)]; const data = await postJson(apiUrl, payload, options); const outputPath = options.output ? resolve(String(options.output)) : ''; await writeTextOutput({ outputPath, text: `${JSON.stringify(data, null, 2)}\n` }); diff --git a/src/core/libraryScopes.js b/src/core/libraryScopes.js index 20c88b5..6951839 100644 --- a/src/core/libraryScopes.js +++ b/src/core/libraryScopes.js @@ -14,17 +14,15 @@ function buildLibraryScopeGroups(systems = [], libraries = []) { } function buildLibraryScopeSelectorValues(systems = [], libraries = []) { - const systemOptions = normalizeLibraryScopeOptions(systems).map(option => ({ - RawValue: option.value, - Display: option.label, - Group: 'Library systems' - })); + const systemLabels = new Map(normalizeLibraryScopeOptions(systems).map(option => [ + systemCodeForLibraryScope(option.value), option.label + ])); const libraryOptions = normalizeLibraryScopeOptions(libraries).map(option => ({ RawValue: option.value, Display: option.label, - Group: systemCodeForLibraryScope(option.value) || 'Item libraries' + Group: systemLabels.get(systemCodeForLibraryScope(option.value)) || systemCodeForLibraryScope(option.value) || 'Item libraries' })); - return [...systemOptions, ...libraryOptions]; + return libraryOptions; } function systemCodeForLibraryScope(scope = '') { diff --git a/src/core/mockQueryBackend.js b/src/core/mockQueryBackend.js index fbad2a6..4ef60b4 100644 --- a/src/core/mockQueryBackend.js +++ b/src/core/mockQueryBackend.js @@ -103,8 +103,11 @@ function demoCalendarPeriods(now = new Date()) { function buildDemoLibraryDashboard(payload = {}, data = {}) { const library = payload.library || 'all'; + const selectedLibraries = Array.isArray(payload.libraries) ? payload.libraries : []; const itemType = payload.item_type || 'all'; - const scopeFactor = library === 'all' ? 1 : library.startsWith('system:') ? 0.25 : 0.08; + const scopeFactor = selectedLibraries.length + ? Math.min(1, selectedLibraries.length * 0.08) + : library === 'all' ? 1 : library.startsWith('system:') ? 0.25 : 0.08; const typeFactor = itemType === 'all' ? 1 : 0.22; const factor = scopeFactor * typeFactor; const reportingPeriod = String(payload.reporting_period || payload.active_window_days || 365); @@ -149,6 +152,7 @@ function buildDemoLibraryDashboard(payload = {}, data = {}) { ], libraries: [ { value: 'MSU-MAIN', label: 'MSU Main Library' }, + { value: 'MSU-MERIDIAN', label: 'MSU Meridian Library' }, { value: 'MMRLS-CARTHAGE', label: 'MMRLS Carthage' }, { value: 'FRL-HERNANDO', label: 'First Regional Hernando' }, { value: 'LILS-TUPELO', label: 'Lee-Itawamba Tupelo' } @@ -163,7 +167,9 @@ function buildDemoLibraryDashboard(payload = {}, data = {}) { sample_data: true, scope: { library, - library_label: library === 'all' ? 'All MLP libraries' : ([...filters.systems, ...filters.libraries].find(entry => entry.value === library)?.label || library), + library_label: selectedLibraries.length + ? `${selectedLibraries.length} selected libraries` + : library === 'all' ? 'All MLP libraries' : ([...filters.systems, ...filters.libraries].find(entry => entry.value === library)?.label || library), item_type: itemType, item_type_label: itemType === 'all' ? 'All item types' : itemType, active_window_days: Number(payload.active_window_days || 365), diff --git a/src/ui/controls/selectorControls.js b/src/ui/controls/selectorControls.js index e86aad9..d0e7915 100644 --- a/src/ui/controls/selectorControls.js +++ b/src/ui/controls/selectorControls.js @@ -331,8 +331,9 @@ function createGroupedSelector(values, isMultiSelect, currentValues = [], option return; } + const groupMatches = Boolean(searchTerm) && entry.name.toLowerCase().includes(searchTerm); const matchedOptions = sortOptions(entry.options).filter(option => { - return !searchTerm || option.searchText.includes(searchTerm); + return !searchTerm || groupMatches || option.searchText.includes(searchTerm); }); if (!matchedOptions.length) { @@ -344,6 +345,7 @@ function createGroupedSelector(values, isMultiSelect, currentValues = [], option type: 'group', groupName: entry.name, groupEntry: entry, + matchedOptions, height: GROUP_HEADER_HEIGHT }); @@ -458,7 +460,9 @@ function createGroupedSelector(values, isMultiSelect, currentValues = [], option header.appendChild(groupActionLabel); } - const visibleCount = row.groupEntry.options.filter(option => !searchTerm || option.searchText.includes(searchTerm)).length; + const visibleCount = Array.isArray(row.matchedOptions) + ? row.matchedOptions.length + : row.groupEntry.options.filter(option => !searchTerm || option.searchText.includes(searchTerm)).length; const groupCount = document.createElement('span'); groupCount.className = 'group-count'; groupCount.textContent = String(visibleCount); diff --git a/src/ui/dashboard/kpiDashboard.js b/src/ui/dashboard/kpiDashboard.js index 5d07756..4d54381 100644 --- a/src/ui/dashboard/kpiDashboard.js +++ b/src/ui/dashboard/kpiDashboard.js @@ -48,15 +48,15 @@ function replaceOptions(select, baseLabel, options, selected) { select.value = normalized.some(option => option.value === selected) ? selected : 'all'; } -function selectedLibraryScope(control) { - const selected = control?.getSelectedValues?.() || []; - return selected[0] || 'all'; +function selectedLibraryScopes(control) { + return control?.getSelectedValues?.() || []; } function replaceLibraryOptions(container, systems, libraries, selected) { if (!container) return; const values = buildLibraryScopeSelectorValues(systems, libraries); - const validSelection = values.some(option => option.RawValue === selected) ? [selected] : []; + const available = new Set(values.map(option => option.RawValue)); + const validSelection = (Array.isArray(selected) ? selected : []).filter(value => available.has(value)); const signature = JSON.stringify(values.map(option => [option.RawValue, option.Display, option.Group])); if (container.dataset.optionsSignature === signature && container.getSelectedValues) { container.setSelectedValues(validSelection); @@ -64,10 +64,12 @@ function replaceLibraryOptions(container, systems, libraries, selected) { } container.querySelector('.form-mode-popup-list-control')?._cleanupPopup?.(); - const selector = SelectorControls.createGroupedSelector(values, false, validSelection, { + const selector = SelectorControls.createGroupedSelector(values, true, validSelection, { enableGrouping: true, allSelectionLabel: ALL_LIBRARY_SYSTEMS_LABEL, allSelectionDescription: 'Include every library system.', + groupSelectionLabel: 'Entire system', + groupSelectionDescription: 'Select every library in this system.', containerId: null }); const popup = SelectorControls.createPopupListControl( @@ -87,7 +89,7 @@ function syncFilterOptions(elements) { elements.library, libraryData.filters.systems, libraryData.filters.libraries, - selectedLibraryScope(elements.library) + selectedLibraryScopes(elements.library) ); replaceOptions(elements.itemType, 'All item types', libraryData.filters.itemTypes, elements.itemType?.value || 'all'); syncPeriodOptions(elements); @@ -96,8 +98,9 @@ function syncFilterOptions(elements) { function syncPeriodOptions(elements) { if (!elements.period || !libraryData) return; const selected = elements.period.value || '365'; - const library = selectedLibraryScope(elements.library); - const system = systemCodeForLibraryScope(library); + const selectedLibraries = selectedLibraryScopes(elements.library); + const selectedSystems = new Set(selectedLibraries.map(systemCodeForLibraryScope).filter(Boolean)); + const system = selectedSystems.size === 1 ? [...selectedSystems][0] : ''; const rolling = [ { value: '90', label: 'Last 90 days' }, { value: '365', label: 'Last 12 months' }, @@ -157,13 +160,25 @@ function render() { function requestPayload(elements) { const reportingPeriod = elements.period?.value || '365'; - return { + const libraries = selectedLibraryScopes(elements.library); + const selectedSystems = new Set(libraries.map(systemCodeForLibraryScope).filter(Boolean)); + const selectedSystem = selectedSystems.size === 1 ? [...selectedSystems][0] : ''; + const systemLibraryCount = selectedSystem + ? (libraryData?.filters?.libraries || []).filter(option => { + const value = typeof option === 'string' ? option : option.value ?? option.code; + return systemCodeForLibraryScope(value) === selectedSystem; + }).length + : 0; + const wholeSystemSelected = libraries.length > 1 && libraries.length === systemLibraryCount; + const payload = { action: 'library_dashboard', - library: selectedLibraryScope(elements.library), + library: wholeSystemSelected ? `system:${selectedSystem}` : libraries.length === 1 ? libraries[0] : 'all', item_type: elements.itemType?.value || 'all', active_window_days: /^\d+$/.test(reportingPeriod) ? Number(reportingPeriod) : 365, reporting_period: reportingPeriod }; + if (libraries.length > 1 && !wholeSystemSelected) payload.libraries = libraries; + return payload; } async function loadOperations() { diff --git a/tests/browser/browserSmoke.mjs b/tests/browser/browserSmoke.mjs index 9d09daf..e9c98b3 100644 --- a/tests/browser/browserSmoke.mjs +++ b/tests/browser/browserSmoke.mjs @@ -561,10 +561,22 @@ async function runSmokeTest() { 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.'); } + const initialLibrarySelectorText = await libraryDialog.textContent(); await libraryDialog.getByPlaceholder('Search options...').fill('Mississippi State University'); - await libraryDialog.getByText('Mississippi State University', { exact: true }).click(); + const wholeSystemCheckbox = libraryDialog.getByRole('checkbox', { name: 'Entire system: Mississippi State University' }); + if (!await wholeSystemCheckbox.count()) { + const selectorState = await libraryDialog.evaluate(dialog => ({ + text: dialog.textContent, + checkboxLabels: Array.from(dialog.querySelectorAll('input[type="checkbox"]')).map(input => input.getAttribute('aria-label')) + })); + throw new Error(`Dashboard should expose the same entire-system checkbox action as the main Item Library selector: initial=${JSON.stringify(initialLibrarySelectorText)} filtered=${JSON.stringify(selectorState)}`); + } + await wholeSystemCheckbox.check(); await libraryDialog.getByRole('button', { name: 'Done' }).click(); - await page.waitForFunction(() => document.querySelector('#kpi-dashboard-library')?.getSelectedValues?.()[0] === 'system:MSU'); + await page.waitForFunction(() => { + const values = document.querySelector('#kpi-dashboard-library')?.getSelectedValues?.() || []; + return values.length === 2 && values.includes('MSU-MAIN') && values.includes('MSU-MERIDIAN'); + }); 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.'); diff --git a/tests/browser/support/browserSmokeSupport.mjs b/tests/browser/support/browserSmokeSupport.mjs index b7818fd..c234fc4 100644 --- a/tests/browser/support/browserSmokeSupport.mjs +++ b/tests/browser/support/browserSmokeSupport.mjs @@ -309,7 +309,7 @@ function buildDefaultQueryApiResponse(payload) { patron_city_breakdown: [{ label: 'Tupelo, MS', patrons: 49220 }, { label: 'Other / unknown', patrons: 420700 }], patron_state_breakdown: [{ label: 'Mississippi', patrons: 582400 }, { label: 'Other / unknown', patrons: 36020 }], opportunities: [{ label: 'Older items with no recorded use', count: 618220, detail: 'Created more than five years ago with zero lifetime checkouts.' }], - filters: { systems: [{ value: 'system:MSU', code: 'MSU', label: 'Mississippi State University' }], libraries: [{ value: 'MSU-MAIN', label: 'MSU Main Library' }], item_types: ['BOOK', 'EBOOK'], calendar_periods: [{ value: 'cy:2026', label: 'Calendar Year 2026 to date' }, { value: 'cy:2025', label: 'Calendar Year 2025' }], fiscal_periods_by_system: { MSU: [{ value: 'fy:MSU:2027', label: 'FY 2027 to date (Jul 1, 2026–Aug 21, 2026)', date_span: 'Jul 1, 2026–Aug 21, 2026' }, { value: 'fy:MSU:2026', label: 'FY 2026 (Jul 1, 2025–Jun 30, 2026)', date_span: 'Jul 1, 2025–Jun 30, 2026' }] } }, + filters: { systems: [{ value: 'system:MSU', code: 'MSU', label: 'Mississippi State University' }], libraries: [{ value: 'MSU-MAIN', label: 'MSU Main Library' }, { value: 'MSU-MERIDIAN', label: 'MSU Meridian Library' }], item_types: ['BOOK', 'EBOOK'], calendar_periods: [{ value: 'cy:2026', label: 'Calendar Year 2026 to date' }, { value: 'cy:2025', label: 'Calendar Year 2025' }], fiscal_periods_by_system: { MSU: [{ value: 'fy:MSU:2027', label: 'FY 2027 to date (Jul 1, 2026–Aug 21, 2026)', date_span: 'Jul 1, 2026–Aug 21, 2026' }, { value: 'fy:MSU:2026', label: 'FY 2026 (Jul 1, 2025–Jun 30, 2026)', date_span: 'Jul 1, 2025–Jun 30, 2026' }] } }, privacy: { suppression_threshold: 10 }, sources: [{ label: 'Current item snapshot', detail: 'Aggregated test data.' }] }), diff --git a/tests/unit/features/libraryScopes.mjs b/tests/unit/features/libraryScopes.mjs new file mode 100644 index 0000000..ed5ac22 --- /dev/null +++ b/tests/unit/features/libraryScopes.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildLibraryScopeSelectorValues, + systemCodeForLibraryScope +} from '../../../src/core/libraryScopes.js'; + +test('dashboard library values use the same system groups as the main multi-selector', () => { + const values = buildLibraryScopeSelectorValues( + [ + { value: 'system:MSU', label: 'Mississippi State University' }, + { value: 'system:FRL', label: 'First Regional Library' } + ], + [ + { value: 'MSU-MAIN', label: 'MSU-MAIN' }, + { value: 'MSU-MERIDIAN', label: 'MSU-MERIDIAN' }, + { value: 'FRL-OXF', label: 'FRL-OXF' } + ] + ); + + assert.deepEqual(values, [ + { RawValue: 'MSU-MAIN', Display: 'MSU-MAIN', Group: 'Mississippi State University' }, + { RawValue: 'MSU-MERIDIAN', Display: 'MSU-MERIDIAN', Group: 'Mississippi State University' }, + { RawValue: 'FRL-OXF', Display: 'FRL-OXF', Group: 'First Regional Library' } + ]); + assert.ok(values.every(value => !value.RawValue.startsWith('system:'))); +}); + +test('library policy codes resolve to their system for fiscal-period filtering', () => { + assert.equal(systemCodeForLibraryScope('MSU-MAIN'), 'MSU'); + assert.equal(systemCodeForLibraryScope('system:MSU'), 'MSU'); +}); diff --git a/tests/unit/features/queryCliLogic.mjs b/tests/unit/features/queryCliLogic.mjs index bb37622..919a19b 100644 --- a/tests/unit/features/queryCliLogic.mjs +++ b/tests/unit/features/queryCliLogic.mjs @@ -339,6 +339,26 @@ test('dashboard CLI requests the same scoped aggregate used by the interface', a } }); +test('dashboard CLI accepts comma-separated multi-library scope', async () => { + const originalFetch = globalThis.fetch; + let payload; + globalThis.fetch = async (_apiUrl, init = {}) => { + payload = JSON.parse(init.body || '{}'); + return Response.json({ schema_version: 1 }); + }; + try { + await runDashboardCommand({ + libraries: 'MSU-MAIN, MSU-MERIDIAN,MSU-MAIN', + 'api-url': 'https://example.test/query', + sessionStore: { read: async () => ({ token: 'test-session-token' }) } + }); + assert.deepEqual(payload.libraries, ['MSU-MAIN', 'MSU-MERIDIAN']); + assert.equal(payload.library, 'all'); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('smart-plan CLI sends the same query payload without running it', async () => { const originalFetch = globalThis.fetch; let payload;