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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ Hydration's browser interface is open source and intended for adoption by other

The comparer reports edition evidence from title/responsibility, ISBN, edition statement, publication year, and physical description before describing a record as hydration-ready. When multiple WorldCat records match, their selector remains available after comparison begins so staff can switch candidates without repeating the local-record search. MARC 521 and 526 counts are shown separately because a general audience note is not automatically Lexile data. The Symphony record and the selected OCLC or Library of Congress record can be downloaded from their summary cards as binary MARC (`.mrc`), MARCXML (`.xml`), readable MARC text (`.mrk`), or JSON. The workspace is read-only and does not execute catalog hydration.

The table also supports sorting, expand/collapse layout, manual column resizing with live row/header alignment, a draggable scrollbar thumb, and post filters that only affect the loaded result set. Text post filters include candidate finders for numeric or spelled-out number markers, Roman numeral markers, and alphabetic series markers such as `A is for ...`. Post filters are intentionally client-side and are cleared between query runs.
The table also supports sorting, expand/collapse layout, manual column resizing with live row/header alignment, a draggable scrollbar thumb, and post filters that only affect the loaded result set. A row context menu can load a read-only complete record view by exact item, call-number, or catalog identifier; this fetches every concrete field the signed-in user is authorized to see, including fields that were not selected as table columns. Text post filters include candidate finders for numeric or spelled-out number markers, Roman numeral markers, and alphabetic series markers such as `A is for ...`. Post filters are intentionally client-side and are cleared between query runs.

The virtual table is also exposed as a reusable ES module component in `src/components/`: hosts can either mount the DOM-backed table with its packaged virtual scroller or use the headless projection/render-plan API in their own framework. Column drag/drop, XLSX generation, the custom date picker, and the tooltip system have public component entrypoints too.

Expand Down
22 changes: 12 additions & 10 deletions cache-bust.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "a84285368b00ee95",
"version": "234a55185339ec0e",
"generatedBy": "scripts/updateCacheBusting.mjs",
"assets": {
"assets/app-icon-16.png": "6645731d86da1071",
Expand Down Expand Up @@ -56,7 +56,7 @@
"src/core/icons.js": "51f534abf118b7e1",
"src/core/libraryScopes.js": "88a7459ae976c5d3",
"src/core/mobileScrollLock.js": "d197a1dc4a683409",
"src/core/mockQueryBackend.js": "4ebaa2df67601d45",
"src/core/mockQueryBackend.js": "faf5097dfe4a2c9e",
"src/core/operatorSelectUtils.js": "4986a94dce50dcd9",
"src/core/queryErrorDetails.js": "5c26531918b7a2c8",
"src/core/queryExecution.js": "5fddde4c241c8699",
Expand Down Expand Up @@ -116,7 +116,7 @@
"src/features/history/view/queryHistoryRows.js": "de474b69a780b15a",
"src/features/history/view/queryHistoryTooltips.js": "e8823ba1d844643d",
"src/features/history/view/queryHistoryViewHelpers.js": "9424ea75f3961c2d",
"src/features/table/contextMenu.js": "3e2ab9ce6f4778d7",
"src/features/table/contextMenu.js": "42beaabdbcc4b0e5",
"src/features/table/contextMenuPreview.js": "647d0446a7ab46b2",
"src/features/table/drag-drop/columnManager.js": "3cfe04dd5c964186",
"src/features/table/drag-drop/columnResizeController.js": "f9623a02aad91c76",
Expand All @@ -140,8 +140,10 @@
"src/features/table/post-filters/postFilterSnapshotMutations.js": "d8512577fdfb74e2",
"src/features/table/post-filters/postFilterStreamedEqualsSelector.js": "6bb29619142f11bf",
"src/features/table/post-filters/postFilterValueFormatting.js": "25adc8ebe8649689",
"src/features/table/recordDetailsDialog.js": "b74ceaf6ad092e09",
"src/features/table/recordDetailsModel.js": "454d562f1513a67e",
"src/features/table/recordDetailsAction.js": "ed45a3c7c9e0f25d",
"src/features/table/recordDetailsApi.js": "42f4b6c0cbb42c8f",
"src/features/table/recordDetailsDialog.js": "89772c9c9c66f1ad",
"src/features/table/recordDetailsModel.js": "11147dd84d739a5a",
"src/features/table/tableQueryState.js": "8e4a58757d3b753d",
"src/features/table/tableServices.js": "c653c3bc07f80f6d",
"src/features/table/tableToast.js": "2b8bc648f99e7a29",
Expand Down Expand Up @@ -192,18 +194,18 @@
"src/lib/workbook-export/workbookXmlUtils.js": "2cc0fcdda2a1d411",
"src/lib/workbook-export/xlsxZipWriter.js": "5f4c1f6e98fbcb06",
"src/styles/api-settings.css": "c5f76b60fb8684bb",
"src/styles/app.css": "9db55e7080cccda4",
"src/styles/app.css": "e6b1b3adffb9e823",
"src/styles/base.css": "8a925806001990b6",
"src/styles/bib-compare.css": "144f2a1093e00dcf",
"src/styles/controls.css": "30d36ccc65181c21",
"src/styles/dashboard.css": "d79fa67fe87bb96e",
"src/styles/dashboard.css": "7aed1832e6cabd24",
"src/styles/filters.css": "9e7297e65dd68a3c",
"src/styles/form-mode.css": "7de8927db30d30f6",
"src/styles/help.css": "1e75cc7af857e66a",
"src/styles/history.css": "64598bfd565a29af",
"src/styles/interaction.css": "57124a9332ac9605",
"src/styles/site-update.css": "b16b51ef4c7bd942",
"src/styles/table.css": "f5753ada512e978a",
"src/styles/table.css": "9a49bacfc430ce95",
"src/styles/templates.css": "1c0a1594bedc65b9",
"src/styles/theme.css": "1a78e7eb470d0cf6",
"src/styles/tokens.css": "d20a2992e5150f85",
Expand Down Expand Up @@ -244,8 +246,8 @@
"src/ui/dashboard/kpiDashboardView.js": "82508c550975ada6",
"src/ui/dashboard/libraryDashboardCoverage.js": "d2da9d84bf50899f",
"src/ui/dashboard/libraryDashboardExport.js": "ae9057c966126624",
"src/ui/dashboard/libraryDashboardModel.js": "34c866b3a028dbe2",
"src/ui/dashboard/libraryDashboardView.js": "c6debdb8b5e1ae1f",
"src/ui/dashboard/libraryDashboardModel.js": "1c036eaacc95d14e",
"src/ui/dashboard/libraryDashboardView.js": "63676db31c2a28ad",
"src/ui/dashboard/reportingPeriodPicker.js": "334aea95b73dee72",
"src/ui/field-picker/buildableFieldPreview.js": "8abd486bb0a87fe2",
"src/ui/field-picker/fieldPicker.js": "526a1bf934f5dd74",
Expand Down
1 change: 1 addition & 0 deletions docs/PROJECT_HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ This is a non-redundant summary of the work represented by the repository commit

## Results Table

- Added an on-demand complete record-details view for result rows. It resolves an exact item, call-number, or catalog identifier and retrieves every concrete field authorized for that record scope, including blank and non-displayed fields, without bloating ordinary query results.
- Added virtualized result rendering so large result sets render only the visible rows plus an overscan buffer.
- Added native and custom table scrolling behavior, including a draggable scrollbar thumb.
- Fixed scroll boundaries so the table cannot scroll above headers or below the result range.
Expand Down
27 changes: 25 additions & 2 deletions docs/schemas/query-api.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"schema": {
"oneOf": [
{ "$ref": "#/components/schemas/GetFieldsRequest" },
{ "$ref": "#/components/schemas/RecordDetailsRequest" },
{ "$ref": "#/components/schemas/RunRequest" },
{ "$ref": "#/components/schemas/StatusRequest" },
{ "$ref": "#/components/schemas/CancelRequest" },
Expand Down Expand Up @@ -65,8 +66,9 @@
"application/json": {
"schema": {
"oneOf": [
{ "$ref": "#/components/schemas/GetFieldsResponse" },
{ "$ref": "#/components/schemas/StatusResponse" },
{ "$ref": "#/components/schemas/GetFieldsResponse" },
{ "$ref": "#/components/schemas/RecordDetailsResponse" },
{ "$ref": "#/components/schemas/StatusResponse" },
{ "$ref": "#/components/schemas/TemplatesResponse" },
{ "$ref": "#/components/schemas/GenericJsonResponse" }
]
Expand Down Expand Up @@ -253,6 +255,27 @@
},
"additionalProperties": true
},
"RecordDetailsRequest": {
"type": "object",
"required": ["action", "lookup_type", "lookup_value"],
"properties": {
"action": { "const": "record_details" },
"lookup_type": { "type": "string", "enum": ["item_id", "item_key", "call_number_key", "catalog_key"] },
"lookup_value": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"RecordDetailsResponse": {
"type": "object",
"required": ["kind", "lookup", "fields", "source_row_count"],
"properties": {
"kind": { "type": "object", "additionalProperties": true },
"lookup": { "type": "object", "additionalProperties": true },
"fields": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
"source_row_count": { "type": "integer", "minimum": 1 }
},
"additionalProperties": true
},
"RunRequest": {
"type": "object",
"required": ["action", "result_format", "display_fields", "filters"],
Expand Down
23 changes: 23 additions & 0 deletions docs/schemas/query-api.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,27 @@
}
]
},
"recordDetailsRequest": {
"type": "object",
"required": ["action", "lookup_type", "lookup_value"],
"properties": {
"action": { "const": "record_details" },
"lookup_type": { "type": "string", "enum": ["item_id", "item_key", "call_number_key", "catalog_key"] },
"lookup_value": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
},
"recordDetailsResponse": {
"type": "object",
"required": ["kind", "lookup", "fields", "source_row_count"],
"properties": {
"kind": { "type": "object", "required": ["key", "label"], "properties": { "key": { "type": "string" }, "label": { "type": "string" } }, "additionalProperties": true },
"lookup": { "type": "object", "required": ["type", "field", "value"], "properties": { "type": { "type": "string" }, "field": { "type": "string" }, "value": { "type": "string" } }, "additionalProperties": true },
"fields": { "type": "array", "items": { "type": "object", "required": ["name", "values"], "properties": { "name": { "type": "string" }, "category": { "type": "string" }, "description": { "type": "string" }, "values": { "type": "array", "items": { "$ref": "#/$defs/scalar" } } }, "additionalProperties": true } },
"source_row_count": { "type": "integer", "minimum": 1 }
},
"additionalProperties": true
},
"runRequest": {
"type": "object",
"required": ["action", "display_fields", "filters"],
Expand Down Expand Up @@ -659,6 +680,8 @@
"anyOf": [
{ "$ref": "#/$defs/getFieldsRequest" },
{ "$ref": "#/$defs/getFieldsResponse" },
{ "$ref": "#/$defs/recordDetailsRequest" },
{ "$ref": "#/$defs/recordDetailsResponse" },
{ "$ref": "#/$defs/runRequest" },
{ "$ref": "#/$defs/queryResult" },
{ "$ref": "#/$defs/jsonlEvent" },
Expand Down
19 changes: 17 additions & 2 deletions scripts/lib/queryCli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -898,7 +898,22 @@ async function buildApiPayload(options = {}) {
async function runApiCommand(options = {}) {
const apiUrl = getApiUrl({}, options);
const payload = await buildApiPayload(options);
const headers = await getCliAuthorizationHeaders(apiUrl, options);
const authApiUrl = String(options['auth-api-url'] || options.authApiUrl || apiUrl).trim();
let requestOrigin;
let authOrigin;
try {
requestOrigin = new URL(apiUrl).origin;
authOrigin = new URL(authApiUrl).origin;
} catch (_error) {
throw new Error('API and authentication URLs must be valid HTTPS URLs.');
}
if (!apiUrl.startsWith('https://') || !authApiUrl.startsWith('https://')) {
throw new Error('API and authentication URLs must use HTTPS.');
}
if (requestOrigin !== authOrigin) {
throw new Error('Refusing to send a Query CLI session to a different origin.');
}
const headers = await getCliAuthorizationHeaders(authApiUrl, options);
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
Expand All @@ -925,7 +940,7 @@ async function runApiCommand(options = {}) {
process.stdout.write(output);
if (output.length && output[output.length - 1] !== 10) process.stdout.write('\n');
}
return { action: payload.action, apiUrl, bytes: output.length, contentType, outputPath };
return { action: payload.action, apiUrl, authApiUrl, bytes: output.length, contentType, outputPath };
}

async function runDashboardCommand(options = {}) {
Expand Down
32 changes: 32 additions & 0 deletions src/core/mockQueryBackend.js
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,32 @@ function runQuery(payload, data) {
});
}

function loadDemoRecordDetails(payload, data) {
const fields = data.fields || [];
const lookupDefinition = fields.find(field => field.recordLookupType === payload.lookup_type);
if (!lookupDefinition) return null;
const lookupIndex = fields.indexOf(lookupDefinition);
if (lookupIndex < 0) return null;
const row = (data.rows || []).find(values => String(values[lookupIndex] ?? '') === String(payload.lookup_value ?? ''));
if (!row) return null;
const kind = payload.lookup_type.startsWith('item_')
? { key: 'item', label: 'Item record' }
: payload.lookup_type === 'call_number_key'
? { key: 'call_number', label: 'Call number record' }
: { key: 'bibliographic', label: 'Bibliographic record' };
return {
kind,
lookup: { type: payload.lookup_type, field: lookupDefinition.name, value: payload.lookup_value },
fields: fields.map((field, index) => ({
name: field.name,
category: field.category || 'Other',
description: field.desc || '',
values: Array.isArray(row[index]) ? row[index] : [String(row[index] ?? '')]
})),
source_row_count: 1
};
}

function searchDemoBibs(payload, data) {
const query = String(payload.query || '').trim().toLocaleLowerCase();
const lookupType = payload.lookup_type || 'title';
Expand Down Expand Up @@ -617,6 +643,12 @@ async function handleDemoQueryRequest(options = {}) {
case 'change_password': return json({ error: 'The shared demo password cannot be changed.' }, 403);
case 'get_fields': return json({ fields: data.fields || [] });
case 'run': return runQuery(payload, data);
case 'record_details': {
const details = loadDemoRecordDetails(payload, data);
return details
? json(details)
: json({ error: 'The selected record was not found.' }, 404);
}
case 'query_plan': return json({
ok: true,
data: {
Expand Down
35 changes: 14 additions & 21 deletions src/features/table/contextMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import { createTableContextPreview } from './contextMenuPreview.js';
import { openCollapsedRowsViewer } from './virtual-table/collapsedRowsViewer.js';
import { OclcBibCompare } from '../../ui/bib-compare/oclcBibCompare.js';
import { resolveBibCompareLookup } from '../../ui/bib-compare/bibCompareLaunch.js';
import { buildRecordDetailsModel } from './recordDetailsModel.js';
import { openRecordDetails } from './recordDetailsDialog.js';
import { buildRecordDetailsModel, resolveRecordDetailsLookup } from './recordDetailsModel.js';
import { createRecordDetailsAction } from './recordDetailsAction.js';

(() => {
let menuEl = null;
Expand Down Expand Up @@ -166,8 +166,6 @@ import { openRecordDetails } from './recordDetailsDialog.js';
<path d="m6.5 8.5-1.5 1.5 1.5 1.5"/>
</svg>`;

const RECORD_DETAILS_ICON = `<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 1.5h7l3 3V14a.5.5 0 0 1-.5.5h-9A.5.5 0 0 1 3 14V1.5z"/><path d="M10 1.5V5h3"/><path d="M5.5 8h5M5.5 10.5h5M5.5 13h3"/></svg>`;

const {
previewCell,
previewColumn,
Expand Down Expand Up @@ -468,7 +466,10 @@ import { openRecordDetails } from './recordDetailsDialog.js';
? resolveBibCompareLookup(fields, getRowValues(rowIndex))
: null;
const virtualTableData = getVT();
const recordDetails = hasRow ? buildRecordDetailsModel({ headers: virtualTableData?.headers || [], row: virtualTableData?.rows?.[rowIndex] || [], displayedFields: fields }) : null;
const recordHeaders = virtualTableData?.headers || [];
const recordRow = virtualTableData?.rows?.[rowIndex] || [];
const recordDetails = hasRow ? buildRecordDetailsModel({ headers: recordHeaders, row: recordRow, displayedFields: fields }) : null;
const recordLookup = hasRow ? resolveRecordDetailsLookup(recordHeaders, recordRow) : null;
const collapsedRowGroup = hasRow ? getCollapsedRowGroup(rowIndex) : null;
const hasCollapsedRowGroup = Boolean(collapsedRowGroup);
const isHeaderTarget = Boolean(headerCell && !bodyCell);
Expand Down Expand Up @@ -541,22 +542,14 @@ import { openRecordDetails } from './recordDetailsDialog.js';
});
}
}] : []),
...(recordDetails?.fields?.length ? [{
icon: RECORD_DETAILS_ICON,
label: 'View Record Details',
hint: recordDetails.kind.label,
preview() {
return hasRow ? previewRow(tr) : null;
},
run() {
openRecordDetails({
record: recordDetails,
trigger: bodyCell,
bibLookup: bibCompareLookup,
onOpenBib: lookup => OclcBibCompare.openForLookup(lookup)
});
}
}] : []),
...[createRecordDetailsAction({
bodyCell,
fields,
recordDetails,
recordLookup,
bibCompareLookup,
preview: () => hasRow ? previewRow(tr) : null
})].filter(Boolean),
{
icon: CELL_ICON,
label: 'Copy Cell',
Expand Down
37 changes: 37 additions & 0 deletions src/features/table/recordDetailsAction.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { getClientErrorMessage } from '../../core/clientErrorMessages.js';
import { OclcBibCompare } from '../../ui/bib-compare/oclcBibCompare.js';
import { showToastMessage } from './tableToast.js';
import { fetchCompleteRecordDetails } from './recordDetailsApi.js';
import { closeActiveRecordDetails, openRecordDetails, openRecordDetailsLoading } from './recordDetailsDialog.js';
import { buildRecordDetailsModelFromResponse } from './recordDetailsModel.js';

function createRecordDetailsAction({ bodyCell, fields, recordDetails, recordLookup, bibCompareLookup, preview }) {
if (!recordDetails?.fields?.length) return null;
return {
icon: '<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 1.5h7l3 3V14a.5.5 0 0 1-.5.5h-9A.5.5 0 0 1 3 14V1.5z"/><path d="M10 1.5V5h3"/><path d="M5.5 8h5M5.5 10.5h5M5.5 13h3"/></svg>',
label: 'View Record Details',
hint: recordDetails.kind.label,
preview,
async run() {
if (!recordLookup) {
showToastMessage('This row does not include an item, call number, or catalog identifier needed to load complete details.', 'warning');
return;
}
openRecordDetailsLoading({ trigger: bodyCell });
try {
const payload = await fetchCompleteRecordDetails(recordLookup);
openRecordDetails({
record: buildRecordDetailsModelFromResponse(payload, fields),
trigger: bodyCell,
bibLookup: bibCompareLookup,
onOpenBib: lookup => OclcBibCompare.openForLookup(lookup)
});
} catch (error) {
closeActiveRecordDetails();
showToastMessage(getClientErrorMessage(error, { fallback: 'The complete record details could not be loaded. Try again.' }), 'error');
}
}
};
}

export { createRecordDetailsAction };
Loading