From 67b225a7055aab6c683fdeacf54f1e3b0709fd14 Mon Sep 17 00:00:00 2001 From: Bobby Sharp <97990858+bvsharp@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:56:43 -0400 Subject: [PATCH 01/13] [UIPQB-287] Support dynamic MARC query construction --- .../MarcFieldControl/MarcFieldControl.js | 133 +++++++++++++++ .../MarcFieldControl/MarcFieldControl.test.js | 97 +++++++++++ .../MarcFieldControl/index.js | 1 + .../RepeatableFields/RepeatableFields.js | 92 +++++++++- .../RepeatableFields/RepeatableFields.test.js | 64 ++++++- .../QueryBuilder/helpers/marcFields.js | 158 ++++++++++++++++++ .../QueryBuilder/helpers/marcFields.test.js | 155 +++++++++++++++++ .../QueryBuilder/helpers/query.js | 27 ++- .../QueryBuilder/helpers/query.test.js | 33 ++++ .../QueryBuilder/helpers/selectOptions.js | 31 +++- .../helpers/selectOptions.test.js | 31 ++++ .../helpers/upgradeInitialValues.js | 6 +- src/QueryBuilder/ResultViewer/helpers.js | 5 +- translations/ui-plugin-query-builder/en.json | 13 +- 14 files changed, 834 insertions(+), 12 deletions(-) create mode 100644 src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js create mode 100644 src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.test.js create mode 100644 src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/index.js create mode 100644 src/QueryBuilder/QueryBuilder/helpers/marcFields.js create mode 100644 src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js new file mode 100644 index 00000000..88b94484 --- /dev/null +++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js @@ -0,0 +1,133 @@ +import { useState } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { Select, TextField } from '@folio/stripes/components'; + +import { assembleMarcFieldName, parseMarcFieldName, isControlFieldTag, MARC_TARGETS } from '../../helpers/marcFields'; + +// Field-cell control for a MARC condition. The user picks a tag and what to query ("search on": a subfield, an +// indicator, or the whole tag) — that's the target, whose value goes in the row's value box. Any remaining +// indicator can be pinned to a single fixed value (a filter). The control owns its draft state and emits the +// canonical field name (or '' while incomplete) plus the chosen target, so the row can attach the right operator +// set. Draft state is seeded from the incoming field name once (round-trip) but not resynced on keystrokes. +const toDraft = (fieldName) => { + const parsed = parseMarcFieldName(fieldName); + + if (parsed) { + return { + tag: parsed.tag, + target: parsed.target, + subfield: parsed.subfield ?? '', + ind1: parsed.ind1 ?? '', + ind2: parsed.ind2 ?? '', + }; + } + + return { tag: '', target: MARC_TARGETS.SUBFIELD, subfield: '', ind1: '', ind2: '' }; +}; + +export const MarcFieldControl = ({ sourcePrefix, value, onFieldChange, index }) => { + const intl = useIntl(); + const [draft, setDraft] = useState(() => toDraft(value)); + + const update = (patch) => { + const next = { ...draft, ...patch }; + + // Control fields (00X) have no indicators or subfields — only the whole tag can be queried. Force the target + // so the user can't build an invalid field like marc_008_a. + if (isControlFieldTag(next.tag)) { + next.target = MARC_TARGETS.TAG; + } + + setDraft(next); + onFieldChange( + assembleMarcFieldName({ + sourcePrefix, + tag: next.tag, + target: next.target, + subfield: next.subfield, + ind1: next.ind1, + ind2: next.ind2, + }) ?? '', + next.target, + ); + }; + + const wholeTagOption = { + value: MARC_TARGETS.TAG, + label: intl.formatMessage({ id: 'ui-plugin-query-builder.marc.target.wholeTag' }), + }; + // Control fields can only be queried as a whole; data fields (010+) offer subfield/indicator targets too. + const targetOptions = isControlFieldTag(draft.tag) + ? [wholeTagOption] + : [ + { value: MARC_TARGETS.SUBFIELD, label: intl.formatMessage({ id: 'ui-plugin-query-builder.marc.target.subfield' }) }, + { value: MARC_TARGETS.IND1, label: intl.formatMessage({ id: 'ui-plugin-query-builder.marc.target.ind1' }) }, + { value: MARC_TARGETS.IND2, label: intl.formatMessage({ id: 'ui-plugin-query-builder.marc.target.ind2' }) }, + wholeTagOption, + ]; + + // Indicator constraint inputs appear for any indicator that is neither the target nor irrelevant (whole tag). + const showInd1Filter = draft.target === MARC_TARGETS.SUBFIELD || draft.target === MARC_TARGETS.IND2; + const showInd2Filter = draft.target === MARC_TARGETS.SUBFIELD || draft.target === MARC_TARGETS.IND1; + + return ( +
+ } + value={draft.tag} + onChange={(e) => update({ tag: e.target.value.trim() })} + maxLength={3} + data-testid={`marc-tag-${index}`} + /> +