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
10 changes: 5 additions & 5 deletions cache-bust.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "1b1cb56d846246bf",
"version": "8d0fd41f9ff22a6e",
"generatedBy": "scripts/updateCacheBusting.mjs",
"assets": {
"assets/app-icon-16.png": "6645731d86da1071",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion scripts/lib/queryCli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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'),
Expand 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` });
Expand Down
12 changes: 5 additions & 7 deletions src/core/libraryScopes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '') {
Expand Down
10 changes: 8 additions & 2 deletions src/core/mockQueryBackend.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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' }
Expand All @@ -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),
Expand Down
8 changes: 6 additions & 2 deletions src/ui/controls/selectorControls.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -344,6 +345,7 @@ function createGroupedSelector(values, isMultiSelect, currentValues = [], option
type: 'group',
groupName: entry.name,
groupEntry: entry,
matchedOptions,
height: GROUP_HEADER_HEIGHT
});

Expand Down Expand Up @@ -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);
Expand Down
35 changes: 25 additions & 10 deletions src/ui/dashboard/kpiDashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,26 +48,28 @@ 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);
return;
}

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(
Expand All @@ -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);
Expand All @@ -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' },
Expand Down Expand Up @@ -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() {
Expand Down
16 changes: 14 additions & 2 deletions tests/browser/browserSmoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
2 changes: 1 addition & 1 deletion tests/browser/support/browserSmokeSupport.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.' }]
}),
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/features/libraryScopes.mjs
Original file line number Diff line number Diff line change
@@ -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');
});
20 changes: 20 additions & 0 deletions tests/unit/features/queryCliLogic.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down