diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e540f91..6dc135e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
* [UIPQB-210](https://folio-org.atlassian.net/browse/UIPQB-210) Localize operators, boolean operators, and boolean values in the query builder and user-friendly query
* [UIPQB-296](https://folio-org.atlassian.net/browse/UIPQB-296) Adjust color contrast of queryArea to comply with WCAG AA standard (4.5:1).
* [UIPQB-277](https://folio-org.atlassian.net/browse/UIPQB-277) Define supported operators for marcDataType
+* [UIPQB-287](https://folio-org.atlassian.net/browse/UIPQB-287) Support querying dynamic MARC fields
## [3.0.2](https://github.com/folio-org/ui-plugin-query-builder/tree/v3.0.2) (2026-06-03)
* [UIPQB-279](https://folio-org.atlassian.net/browse/UIPQB-279) Add support for fields containing a valueSourceApi property without a source property
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js
new file mode 100644
index 00000000..31027ab6
--- /dev/null
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js
@@ -0,0 +1,140 @@
+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';
+import { getMarcIndicatorValueOptions } from '../../helpers/selectOptions';
+
+// 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.
+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;
+
+ // A pinned indicator holds exactly one value; "Any" (value '') means no constraint on that indicator.
+ const indicatorConstraintOptions = [
+ { value: '', label: intl.formatMessage({ id: 'ui-plugin-query-builder.marc.indicator.any' }) },
+ ...getMarcIndicatorValueOptions(intl),
+ ];
+
+ return (
+
+ }
+ value={draft.tag}
+ onChange={(e) => update({ tag: e.target.value.trim() })}
+ maxLength={3}
+ data-testid={`marc-tag-${index}`}
+ />
+ }
+ dataOptions={targetOptions}
+ value={draft.target}
+ onChange={(e) => update({ target: e.target.value })}
+ data-testid={`marc-target-${index}`}
+ />
+
+ {draft.target === MARC_TARGETS.SUBFIELD && (
+ }
+ value={draft.subfield}
+ onChange={(e) => update({ subfield: e.target.value.trim() })}
+ maxLength={1}
+ data-testid={`marc-subfield-${index}`}
+ />
+ )}
+
+ {showInd1Filter && (
+ }
+ dataOptions={indicatorConstraintOptions}
+ value={draft.ind1}
+ onChange={(e) => update({ ind1: e.target.value })}
+ data-testid={`marc-ind1-${index}`}
+ />
+ )}
+ {showInd2Filter && (
+ }
+ dataOptions={indicatorConstraintOptions}
+ value={draft.ind2}
+ onChange={(e) => update({ ind2: e.target.value })}
+ data-testid={`marc-ind2-${index}`}
+ />
+ )}
+
+ );
+};
+
+MarcFieldControl.propTypes = {
+ sourcePrefix: PropTypes.string,
+ value: PropTypes.string,
+ onFieldChange: PropTypes.func.isRequired,
+ index: PropTypes.number,
+};
+
+MarcFieldControl.defaultProps = {
+ sourcePrefix: '',
+ value: '',
+};
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.test.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.test.js
new file mode 100644
index 00000000..aa9dd82a
--- /dev/null
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.test.js
@@ -0,0 +1,133 @@
+import { render, fireEvent, cleanup } from '@testing-library/react';
+import Intl from '../../../../../test/jest/__mock__/intlProvider.mock';
+import { MarcFieldControl } from './MarcFieldControl';
+
+const setup = (props = {}) => {
+ const onFieldChange = jest.fn();
+ const utils = render(
+
+
+ ,
+ );
+
+ return { onFieldChange, ...utils };
+};
+
+const change = (el, value) => fireEvent.change(el, { target: { value } });
+
+afterEach(cleanup);
+
+describe('MarcFieldControl', () => {
+ it('defaults to subfield mode and shows tag, target, subfield, and both indicator filters', () => {
+ const { getByTestId } = setup();
+
+ expect(getByTestId('marc-tag-0')).toBeInTheDocument();
+ expect(getByTestId('marc-target-0')).toBeInTheDocument();
+ expect(getByTestId('marc-subfield-0')).toBeInTheDocument();
+ expect(getByTestId('marc-ind1-0')).toBeInTheDocument();
+ expect(getByTestId('marc-ind2-0')).toBeInTheDocument();
+ });
+
+ it('assembles a subfield field name, adding indicator filters as they are entered', () => {
+ const { getByTestId, onFieldChange } = setup();
+
+ change(getByTestId('marc-tag-0'), '245');
+ change(getByTestId('marc-subfield-0'), 'a');
+ expect(onFieldChange).toHaveBeenLastCalledWith('marc_245_a', 'subfield');
+
+ change(getByTestId('marc-ind1-0'), '1');
+ change(getByTestId('marc-ind2-0'), '2');
+ expect(onFieldChange).toHaveBeenLastCalledWith('marc_245_ind1_1_ind2_2_a', 'subfield');
+ });
+
+ it('prepends the composite source prefix', () => {
+ const { getByTestId, onFieldChange } = setup({ sourcePrefix: 'marc_bib.' });
+
+ change(getByTestId('marc-tag-0'), '245');
+ change(getByTestId('marc-subfield-0'), 'a');
+
+ expect(onFieldChange).toHaveBeenLastCalledWith('marc_bib.marc_245_a', 'subfield');
+ });
+
+ it('targets an indicator: hides the subfield and the target indicator, keeps the other as a filter', () => {
+ const { getByTestId, queryByTestId, onFieldChange } = setup();
+
+ change(getByTestId('marc-tag-0'), '245');
+ change(getByTestId('marc-target-0'), 'ind1');
+
+ expect(queryByTestId('marc-subfield-0')).not.toBeInTheDocument();
+ expect(queryByTestId('marc-ind1-0')).not.toBeInTheDocument();
+ expect(getByTestId('marc-ind2-0')).toBeInTheDocument();
+ expect(onFieldChange).toHaveBeenLastCalledWith('marc_245_ind1', 'ind1');
+
+ change(getByTestId('marc-ind2-0'), '0');
+ expect(onFieldChange).toHaveBeenLastCalledWith('marc_245_ind2_0_ind1', 'ind1');
+ });
+
+ it('restricts control-field tags (00X) to the whole tag', () => {
+ const { getByTestId, queryByTestId, onFieldChange } = setup();
+
+ change(getByTestId('marc-tag-0'), '008');
+
+ expect(queryByTestId('marc-subfield-0')).not.toBeInTheDocument();
+ expect(queryByTestId('marc-ind1-0')).not.toBeInTheDocument();
+ expect(queryByTestId('marc-ind2-0')).not.toBeInTheDocument();
+ expect(getByTestId('marc-target-0').querySelectorAll('option')).toHaveLength(1);
+ expect(onFieldChange).toHaveBeenLastCalledWith('marc_008', 'tag');
+ });
+
+ it('round-trips a saved subfield field name into its inputs', () => {
+ const { getByTestId } = setup({ value: 'marc_245_ind1_1_ind2_2_a' });
+
+ expect(getByTestId('marc-tag-0').value).toBe('245');
+ expect(getByTestId('marc-target-0').value).toBe('subfield');
+ expect(getByTestId('marc-subfield-0').value).toBe('a');
+ expect(getByTestId('marc-ind1-0').value).toBe('1');
+ expect(getByTestId('marc-ind2-0').value).toBe('2');
+ });
+
+ it('round-trips a saved indicator-target field name', () => {
+ const { getByTestId, queryByTestId } = setup({ value: 'marc_245_ind2_0_ind1' });
+
+ expect(getByTestId('marc-target-0').value).toBe('ind1');
+ expect(getByTestId('marc-ind2-0').value).toBe('0');
+ expect(queryByTestId('marc-ind1-0')).not.toBeInTheDocument();
+ expect(queryByTestId('marc-subfield-0')).not.toBeInTheDocument();
+ });
+
+ it('round-trips a subfield field name with no indicator constraints (empty indicator filters)', () => {
+ const { getByTestId } = setup({ value: 'marc_245_a' });
+
+ expect(getByTestId('marc-tag-0').value).toBe('245');
+ expect(getByTestId('marc-target-0').value).toBe('subfield');
+ expect(getByTestId('marc-subfield-0').value).toBe('a');
+ expect(getByTestId('marc-ind1-0').value).toBe('');
+ expect(getByTestId('marc-ind2-0').value).toBe('');
+ });
+
+ it('pins an indicator constraint via the dropdown, including the Blank option', () => {
+ const { getByTestId, onFieldChange } = setup();
+
+ change(getByTestId('marc-tag-0'), '245');
+ change(getByTestId('marc-subfield-0'), 'a');
+ change(getByTestId('marc-ind1-0'), 'blank');
+
+ expect(onFieldChange).toHaveBeenLastCalledWith('marc_245_ind1_blank_a', 'subfield');
+ });
+
+ it('treats the "Any" option (empty value) as no constraint', () => {
+ const { getByTestId, onFieldChange } = setup({ value: 'marc_245_ind1_1_a' });
+
+ // Starts pinned to ind1=1; selecting Any clears the constraint.
+ expect(getByTestId('marc-ind1-0').value).toBe('1');
+ change(getByTestId('marc-ind1-0'), '');
+
+ expect(onFieldChange).toHaveBeenLastCalledWith('marc_245_a', 'subfield');
+ });
+
+ it('round-trips a blank indicator constraint into the dropdown', () => {
+ const { getByTestId } = setup({ value: 'marc_245_ind1_blank_a' });
+
+ expect(getByTestId('marc-ind1-0').value).toBe('blank');
+ });
+});
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/index.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/index.js
new file mode 100644
index 00000000..64488e9f
--- /dev/null
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/index.js
@@ -0,0 +1 @@
+export { MarcFieldControl } from './MarcFieldControl';
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
index 3c3cf82b..4b933c9b 100644
--- a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
@@ -14,6 +14,7 @@ import PropTypes from 'prop-types';
import { FormattedMessage, useIntl } from 'react-intl';
import { COLUMN_KEYS } from '../../../../constants/columnKeys';
import { BOOLEAN_OPERATORS } from '../../../../constants/operators';
+import { DATA_TYPES } from '../../../../constants/dataTypes';
import { RootContext } from '../../../../context/RootContext';
import { findMissingValues } from '../../helpers/query';
import {
@@ -21,15 +22,25 @@ import {
getFieldOptions,
getFilteredOptions,
fuzzyOptionFormatter,
+ getMarcIndicatorValueOptions,
getOperatorOptions,
hasValueOptions,
REPEATABLE_FIELD_DELIMITER,
sourceTemplate,
} from '../../helpers/selectOptions';
+import {
+ findMarcPlaceholder,
+ getMarcSourcePrefix,
+ isMarcIndicatorTargetName,
+ MARC_DATA_TYPE,
+ MARC_FIELD_SENTINEL,
+ MARC_VALUE_DATA_TYPE,
+} from '../../helpers/marcFields';
import { retainValueOnOperatorChange } from '../../helpers/valueBuilder';
import { getBooleanOperatorLabel } from '../../helpers/operatorLabels';
import { QueryBuilderTitle } from '../../QueryBuilderTitle';
import { DataTypeInput } from '../DataTypeInput';
+import { MarcFieldControl } from '../MarcFieldControl';
import css from '../QueryBuilderModal.css';
export const getMemoizedValues = ({
@@ -40,6 +51,51 @@ export const getMemoizedValues = ({
currentOptions || getDataOptions(rowField)
);
+// Switches a row into MARC mode when the "MARC field" option is picked: there's no real field name yet
+// (MarcFieldControl fills it in), so blank the field, mark it MARC, and clear the operator and value cells.
+export const enterMarcFieldMode = (item) => ({
+ [COLUMN_KEYS.FIELD]: {
+ ...item[COLUMN_KEYS.FIELD],
+ current: '',
+ isMarc: true,
+ dataType: MARC_DATA_TYPE,
+ },
+ [COLUMN_KEYS.OPERATOR]: { options: [], current: '' },
+ [COLUMN_KEYS.VALUE]: { options: undefined, source: undefined, valueSourceApi: undefined, current: '' },
+});
+
+// Applies a MARC field selection to a row: sets the assembled field name and attaches its operator set.
+export const applyMarcFieldChange = ({ item, name, intl }) => {
+ const options = getOperatorOptions({ dataType: DATA_TYPES.MarcType, fieldName: name, intl });
+ const operatorStillValid = options.some((option) => option.value === item[COLUMN_KEYS.OPERATOR].current);
+
+ // An indicator target's value is one of a fixed set (blank + 0-9), so offer it as a pick-list; a subfield or
+ // whole-tag target stays free-text (no options). Reset the value only when this free-text/pick-list mode flips,
+ // so tweaking indicator filters mid-edit doesn't wipe a value the user already typed.
+ const valueOptions = isMarcIndicatorTargetName(name) ? getMarcIndicatorValueOptions(intl) : undefined;
+ const valueModeFlipped = Boolean(item[COLUMN_KEYS.VALUE]?.options) !== Boolean(valueOptions);
+
+ return {
+ ...item,
+ [COLUMN_KEYS.FIELD]: {
+ ...item[COLUMN_KEYS.FIELD],
+ current: name,
+ isMarc: true,
+ dataType: MARC_VALUE_DATA_TYPE,
+ },
+ [COLUMN_KEYS.OPERATOR]: {
+ ...item[COLUMN_KEYS.OPERATOR],
+ options,
+ current: operatorStillValid ? item[COLUMN_KEYS.OPERATOR].current : '',
+ },
+ [COLUMN_KEYS.VALUE]: {
+ ...item[COLUMN_KEYS.VALUE],
+ options: valueOptions,
+ current: valueModeFlipped ? '' : item[COLUMN_KEYS.VALUE]?.current,
+ },
+ };
+};
+
export const RepeatableFields = memo(({ source, setSource, columns, entityTypeId }) => {
const intl = useIntl();
const callout = useShowCallout();
@@ -49,6 +105,20 @@ export const RepeatableFields = memo(({ source, setSource, columns, entityTypeId
const fieldOptions = getFieldOptions(columns);
+ // MARC fields aren't enumerable columns; a MARC-capable entity type is signaled by the generic marcType
+ // placeholder column. When present, the field dropdown offers a "MARC field" entry that switches the row into
+ // MARC mode (MarcFieldControl builds the actual field name).
+ const marcPlaceholder = findMarcPlaceholder(columns);
+ const marcSupported = Boolean(marcPlaceholder);
+ const marcSourcePrefix = getMarcSourcePrefix(marcPlaceholder?.name);
+ const marcFieldOption = {
+ // Label with the fully-qualified (source-aware) name
+ label: marcPlaceholder?.labelAliasFullyQualified
+ || marcPlaceholder?.labelAlias
+ || intl.formatMessage({ id: 'ui-plugin-query-builder.marc.fieldOption' }),
+ value: MARC_FIELD_SENTINEL,
+ };
+
const handleAdd = () => {
setSource(res => ([
...res,
@@ -97,6 +167,7 @@ export const RepeatableFields = memo(({ source, setSource, columns, entityTypeId
const memoizedFieldSource = source[index].value.source;
const memoizedFieldValueSourceApi = source[index].value.valueSourceApi;
const memorizedField = fieldOptions.find(o => o.value === rowField);
+ const rowIsMarc = source[index].field.isMarc;
const memorizedOperator = source[index].operator.current;
const memoizedValues = getMemoizedValues({
currentOptions: source[index].value.options,
@@ -106,11 +177,17 @@ export const RepeatableFields = memo(({ source, setSource, columns, entityTypeId
const memorizedValue = source[index].value.current;
const modifications = (item) => {
+ // Entering MARC mode: no real field name yet (MarcFieldControl fills it in), so reset operator/value.
+ if (isField && value === MARC_FIELD_SENTINEL) {
+ return enterMarcFieldMode(item);
+ }
+
if (isField) {
return {
[COLUMN_KEYS.FIELD]: {
...item[COLUMN_KEYS.FIELD],
current: value,
+ isMarc: false,
dataType: field.dataType,
},
[COLUMN_KEYS.OPERATOR]: {
@@ -133,11 +210,17 @@ export const RepeatableFields = memo(({ source, setSource, columns, entityTypeId
}
if (isOperator) {
+ // memorizedField is looked up in fieldOptions by field name; a MARC field (marc_245_a) isn't enumerable
+ // there, so it's undefined. Optional-chain so an operator change on a MARC row (which has no value
+ // options/source) doesn't crash — it just leaves those undefined, which is correct for free-text values.
return {
[COLUMN_KEYS.VALUE]: {
- options: memorizedField.values,
- source: memorizedField.source,
- valueSourceApi: memorizedField.valueSourceApi,
+ // A MARC row's field name isn't in fieldOptions, so memorizedField is undefined; keep the row's own
+ // value options (the indicator pick-list, for an indicator target) instead of clearing them, so
+ // switching equals<->in doesn't drop the picker back to free text.
+ options: rowIsMarc ? source[index].value.options : memorizedField?.values,
+ source: memorizedField?.source,
+ valueSourceApi: memorizedField?.valueSourceApi,
current: retainValueOnOperatorChange({
source: memoizedFieldSource,
valueSourceApi: memoizedFieldValueSourceApi,
@@ -170,6 +253,14 @@ export const RepeatableFields = memo(({ source, setSource, columns, entityTypeId
}));
};
+ // MarcFieldControl emits the assembled field name (or '' while incomplete). Store the field and attach its
+ // operator set, which getOperatorOptions derives from the name.
+ const handleMarcFieldChange = (name, index) => {
+ setSource(prev => prev.map((item, i) => (
+ i === index ? applyMarcFieldChange({ item, name, intl }) : item
+ )));
+ };
+
useEffect(() => {
if (calloutCalledRef.current) return;
@@ -226,12 +317,20 @@ export const RepeatableFields = memo(({ source, setSource, columns, entityTypeId
id={`field-option-${index}`}
emptyMessage={<>>}
placeholder={intl.formatMessage({ id: 'ui-plugin-query-builder.control.selection.placeholder' })}
- dataOptions={row.field.options}
- value={row.field.current}
+ dataOptions={marcSupported ? [marcFieldOption, ...row.field.options] : row.field.options}
+ value={row.field.isMarc ? MARC_FIELD_SENTINEL : row.field.current}
onFilter={getFilteredOptions}
formatter={fuzzyOptionFormatter}
onChange={(value) => handleChange(value, index, COLUMN_KEYS.FIELD)}
/>
+ {row.field.isMarc && (
+ handleMarcFieldChange(name, index)}
+ />
+ )}
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js
index 94aa318c..900a88c1 100644
--- a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js
@@ -1,4 +1,33 @@
-import { getMemoizedValues } from './RepeatableFields';
+import { useState } from 'react';
+import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import Intl from '../../../../../test/jest/__mock__/intlProvider.mock';
+import { RootContext } from '../../../../context/RootContext';
+import {
+ getMemoizedValues,
+ applyMarcFieldChange,
+ enterMarcFieldMode,
+ RepeatableFields,
+} from './RepeatableFields';
+import { sourceTemplate, getFieldOptions } from '../../helpers/selectOptions';
+import { COLUMN_KEYS } from '../../../../constants/columnKeys';
+import { OPERATORS } from '../../../../constants/operators';
+import { MARC_DATA_TYPE } from '../../helpers/marcFields';
+
+jest.mock('../../../../hooks/useTenantTimezone', () => jest.fn(() => ({ tenantTimezone: 'UTC' })));
+
+afterEach(cleanup);
+
+const marcIntl = { formatMessage: ({ id }) => id };
+
+const makeRow = (operatorCurrent = '') => ({
+ [COLUMN_KEYS.FIELD]: { options: [], current: '', dataType: undefined },
+ [COLUMN_KEYS.OPERATOR]: { options: [], current: operatorCurrent },
+ [COLUMN_KEYS.VALUE]: { current: '' },
+});
+
+// Operator option values, minus the leading placeholder ('').
+const operatorValues = (row) => row[COLUMN_KEYS.OPERATOR].options.map((option) => option.value).filter(Boolean);
describe('getMemoizedValues', () => {
it('uses cached options when they exist', () => {
@@ -27,3 +56,167 @@ describe('getMemoizedValues', () => {
expect(getDataOptions).toHaveBeenCalledWith('status');
});
});
+
+describe('applyMarcFieldChange', () => {
+ it('sets the MARC field and the subfield (free-text) operator set for a subfield field name', () => {
+ const result = applyMarcFieldChange({ item: makeRow(), name: 'marc_245_a', intl: marcIntl });
+
+ expect(result[COLUMN_KEYS.FIELD].current).toBe('marc_245_a');
+ expect(result[COLUMN_KEYS.FIELD].isMarc).toBe(true);
+ expect(result[COLUMN_KEYS.FIELD].dataType).toBe('stringType');
+ expect(operatorValues(result)).toEqual([
+ OPERATORS.EQUAL,
+ OPERATORS.NOT_EQUAL,
+ OPERATORS.CONTAINS,
+ OPERATORS.STARTS_WITH,
+ OPERATORS.EMPTY,
+ ]);
+ });
+
+ it('uses the restricted indicator operator set for an indicator-target field name', () => {
+ const result = applyMarcFieldChange({ item: makeRow(), name: 'marc_245_ind1', intl: marcIntl });
+
+ expect(operatorValues(result)).toEqual([OPERATORS.EQUAL, OPERATORS.NOT_EQUAL, OPERATORS.IN, OPERATORS.NOT_IN]);
+ expect(operatorValues(result)).not.toContain(OPERATORS.CONTAINS);
+ expect(operatorValues(result)).not.toContain(OPERATORS.EMPTY);
+ });
+
+ it('clears an operator that is not valid for the new target type', () => {
+ const result = applyMarcFieldChange({
+ item: makeRow(OPERATORS.CONTAINS),
+ name: 'marc_245_ind1',
+ intl: marcIntl,
+ });
+
+ expect(result[COLUMN_KEYS.OPERATOR].current).toBe('');
+ });
+
+ it('keeps an operator that is still valid for the new target type', () => {
+ const result = applyMarcFieldChange({
+ item: makeRow(OPERATORS.EQUAL),
+ name: 'marc_245_ind1',
+ intl: marcIntl,
+ });
+
+ expect(result[COLUMN_KEYS.OPERATOR].current).toBe(OPERATORS.EQUAL);
+ });
+
+ it('offers the enumerated value pick-list for an indicator target', () => {
+ const result = applyMarcFieldChange({ item: makeRow(), name: 'marc_245_ind1', intl: marcIntl });
+
+ expect(result[COLUMN_KEYS.VALUE].options).toHaveLength(11);
+ });
+
+ it('leaves the value free-text (no options) for a subfield target', () => {
+ const result = applyMarcFieldChange({ item: makeRow(), name: 'marc_245_a', intl: marcIntl });
+
+ expect(result[COLUMN_KEYS.VALUE].options).toBeUndefined();
+ });
+
+ it('resets the value when the target flips from free-text to pick-list', () => {
+ const item = { ...makeRow(), [COLUMN_KEYS.VALUE]: { current: 'Shakespeare' } };
+ const result = applyMarcFieldChange({ item, name: 'marc_245_ind1', intl: marcIntl });
+
+ expect(result[COLUMN_KEYS.VALUE].current).toBe('');
+ });
+
+ it('preserves the value while the target stays free-text', () => {
+ const item = { ...makeRow(), [COLUMN_KEYS.VALUE]: { current: 'Shakespeare' } };
+ const result = applyMarcFieldChange({ item, name: 'marc_245_a', intl: marcIntl });
+
+ expect(result[COLUMN_KEYS.VALUE].current).toBe('Shakespeare');
+ });
+});
+
+describe('enterMarcFieldMode', () => {
+ it('blanks the field into MARC mode and clears the operator and value cells', () => {
+ const item = {
+ [COLUMN_KEYS.FIELD]: { options: ['x'], current: 'title', dataType: 'stringType' },
+ [COLUMN_KEYS.OPERATOR]: { options: [{ value: OPERATORS.EQUAL }], current: OPERATORS.EQUAL },
+ [COLUMN_KEYS.VALUE]: { options: ['v'], source: {}, valueSourceApi: {}, current: 'hello' },
+ };
+
+ const result = enterMarcFieldMode(item);
+
+ // Field cell keeps its other props (options), blanks the name, and flips into MARC mode.
+ expect(result[COLUMN_KEYS.FIELD]).toEqual({
+ options: ['x'],
+ current: '',
+ isMarc: true,
+ dataType: MARC_DATA_TYPE,
+ });
+ expect(result[COLUMN_KEYS.OPERATOR]).toEqual({ options: [], current: '' });
+ expect(result[COLUMN_KEYS.VALUE]).toEqual({
+ options: undefined,
+ source: undefined,
+ valueSourceApi: undefined,
+ current: '',
+ });
+ });
+});
+
+// A MARC-capable entity type: a normal queryable column plus the hidden generic marcType placeholder.
+const marcColumns = [
+ { name: 'title', labelAlias: 'Title', dataType: { dataType: 'stringType' }, queryable: true, visibleByDefault: true },
+ {
+ name: 'marc',
+ labelAlias: 'MARC',
+ labelAliasFullyQualified: 'MARC bibliographic',
+ dataType: { dataType: 'marcType' },
+ queryable: false,
+ hidden: true,
+ },
+];
+
+// Holds source in state so setSource actually re-renders the rows, the way QueryBuilderModal drives it.
+const Harness = () => {
+ const [source, setSource] = useState([sourceTemplate(getFieldOptions(marcColumns))]);
+
+ return ;
+};
+
+const renderRepeatableFields = () => render(
+
+ [], getDataOptionsWithFetching: () => [] }}>
+
+
+ ,
+);
+
+describe('RepeatableFields MARC wiring', () => {
+ it('enters MARC mode from the field dropdown and applies MarcFieldControl edits to the row', async () => {
+ renderRepeatableFields();
+
+ // Pick the "MARC field" option -> handleChange's sentinel branch runs enterMarcFieldMode.
+ await userEvent.click(await screen.findByText('ui-plugin-query-builder.control.selection.placeholder'));
+ await userEvent.click(await screen.findByText('MARC bibliographic'));
+
+ // MARC mode is on: the MarcFieldControl is now rendered.
+ const tag = await screen.findByTestId('marc-tag-0');
+
+ expect(tag).toBeInTheDocument();
+
+ // Completing the field makes MarcFieldControl emit a name -> handleMarcFieldChange applies it to the row,
+ // which surfaces the operator dropdown for that row.
+ fireEvent.change(tag, { target: { value: '245' } });
+ fireEvent.change(screen.getByTestId('marc-subfield-0'), { target: { value: 'a' } });
+
+ expect(await screen.findByTestId('operator-option-0')).toBeInTheDocument();
+ });
+
+ it('renders a multi-select value cell for an indicator target under the "in" operator', async () => {
+ renderRepeatableFields();
+
+ await userEvent.click(await screen.findByText('ui-plugin-query-builder.control.selection.placeholder'));
+ await userEvent.click(await screen.findByText('MARC bibliographic'));
+
+ // Build an indicator target: tag 245, "search on" indicator 1.
+ fireEvent.change(await screen.findByTestId('marc-tag-0'), { target: { value: '245' } });
+ fireEvent.change(screen.getByTestId('marc-target-0'), { target: { value: 'ind1' } });
+
+ // Choosing the "in" operator should surface the enumerated multi-select (options preserved across the change).
+ fireEvent.change(await screen.findByTestId('operator-option-0'), { target: { value: OPERATORS.IN } });
+
+ expect(await screen.findByTestId('data-input-select-multi-stringType')).toBeInTheDocument();
+ });
+});
diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js
index db903234..c98a7f2c 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js
@@ -1,27 +1,15 @@
import { OPERATORS, getDiscreteOrTextOperators } from '../../../constants/operators';
+import { parseMarcFieldName, MARC_TARGETS } from './marcFields';
-// Mirrors lib-fqm-query-processor's MarcFieldFactory grammar for dynamic MARC field names, so the
-// frontend can tell an indicator target (marc_245_ind1) apart from a subfield (marc_245_a) or a
-// tag-only reference (marc_245) using nothing but the field's name. Control field tags (001-009)
-// have no subfields/indicators and are excluded, matching the backend's isControlFieldTag check.
-const MARC_CORE_PATTERNS = {
- subfield: /^marc_(\d{3})_([a-z0-9])$/i,
- dualIndicatorSubfield: /^marc_(\d{3})_ind1_(blank|[a-z0-9])_ind2_(blank|[a-z0-9])_([a-z0-9])$/i,
- constrainedSubfield: /^marc_(\d{3})_ind([12])_(blank|[a-z0-9])_([a-z0-9])$/i,
- constrainedIndicatorTarget: /^marc_(\d{3})_ind([12])_(blank|[a-z0-9])_ind([12])$/i,
- indicatorTarget: /^marc_(\d{3})_ind([12])$/i,
- tagOnly: /^marc_(\d{3})$/i,
-};
-
-const isControlFieldTag = (tag) => tag.startsWith('00');
-
-const constrainedIndicator = (slot, constraintSlot, constraintValue) => (
- slot === constraintSlot ? { isTarget: false, value: constraintValue.toLowerCase() } : null
-);
+// One indicator slot's contribution to a MARC selector: the query target (its value is chosen in the row's value
+// box), a fixed constraint value, or null when the slot isn't part of the field name.
+const indicatorSlot = (isTarget, constraintValue) => {
+ if (isTarget) {
+ return { isTarget: true, value: null };
+ }
-const targetIndicator = (slot, targetSlot) => (
- slot === targetSlot ? { isTarget: true, value: null } : null
-);
+ return constraintValue === null ? null : { isTarget: false, value: constraintValue };
+};
/**
* Parse a MARC field name (e.g. marc_245_ind1) into the {subfield, indicator1, indicator2}
@@ -33,69 +21,19 @@ const targetIndicator = (slot, targetSlot) => (
* @returns {{subfield: string|null, indicator1: object|null, indicator2: object|null}|null}
*/
export const parseMarcSelector = (fieldName) => {
- if (typeof fieldName !== 'string') {
- return null;
- }
-
- const lastDotIndex = fieldName.lastIndexOf('.');
- const core = lastDotIndex > 0 ? fieldName.slice(lastDotIndex + 1) : fieldName;
-
- let match = MARC_CORE_PATTERNS.subfield.exec(core);
-
- if (match && !isControlFieldTag(match[1])) {
- return { subfield: match[2].toLowerCase(), indicator1: null, indicator2: null };
- }
+ const parsed = parseMarcFieldName(fieldName);
- match = MARC_CORE_PATTERNS.dualIndicatorSubfield.exec(core);
- if (match && !isControlFieldTag(match[1])) {
- return {
- subfield: match[4].toLowerCase(),
- indicator1: { isTarget: false, value: match[2].toLowerCase() },
- indicator2: { isTarget: false, value: match[3].toLowerCase() },
- };
- }
-
- match = MARC_CORE_PATTERNS.constrainedSubfield.exec(core);
- if (match && !isControlFieldTag(match[1])) {
- const [, , constraintSlot, constraintValue, subfield] = match;
-
- return {
- subfield: subfield.toLowerCase(),
- indicator1: constrainedIndicator('1', constraintSlot, constraintValue),
- indicator2: constrainedIndicator('2', constraintSlot, constraintValue),
- };
- }
-
- match = MARC_CORE_PATTERNS.constrainedIndicatorTarget.exec(core);
- if (match && !isControlFieldTag(match[1])) {
- const [, , constraintSlot, constraintValue, targetSlot] = match;
-
- if (constraintSlot !== targetSlot) {
- return {
- subfield: null,
- indicator1: constrainedIndicator('1', constraintSlot, constraintValue) || targetIndicator('1', targetSlot),
- indicator2: constrainedIndicator('2', constraintSlot, constraintValue) || targetIndicator('2', targetSlot),
- };
- }
- }
-
- match = MARC_CORE_PATTERNS.indicatorTarget.exec(core);
- if (match && !isControlFieldTag(match[1])) {
- const [, , targetSlot] = match;
-
- return {
- subfield: null,
- indicator1: targetIndicator('1', targetSlot),
- indicator2: targetIndicator('2', targetSlot),
- };
+ if (!parsed) {
+ return null;
}
- match = MARC_CORE_PATTERNS.tagOnly.exec(core);
- if (match) {
- return { subfield: null, indicator1: null, indicator2: null };
- }
+ const { target, subfield, ind1, ind2 } = parsed;
- return null;
+ return {
+ subfield: subfield ?? null,
+ indicator1: indicatorSlot(target === MARC_TARGETS.IND1, ind1),
+ indicator2: indicatorSlot(target === MARC_TARGETS.IND2, ind2),
+ };
};
/**
diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFields.js b/src/QueryBuilder/QueryBuilder/helpers/marcFields.js
new file mode 100644
index 00000000..0ccd63e5
--- /dev/null
+++ b/src/QueryBuilder/QueryBuilder/helpers/marcFields.js
@@ -0,0 +1,175 @@
+import { DATA_TYPES } from '../../../constants/dataTypes';
+
+// A MARC field is referenced by name (e.g. marc_245_a), not enumerated as a column. These helpers assemble a
+// canonical field name from the picker's state and parse one back (for editing a saved query). The grammar
+// mirrors the backend (lib-fqm-query-processor MarcFieldFactory); ind1 always precedes ind2 in the canonical
+// form when both are constraints.
+
+export const MARC_DATA_TYPE = DATA_TYPES.MarcType;
+export const MARC_BLANK_INDICATOR = 'blank';
+
+// The indicator values the UI offers as a pick-list: the blank code plus 0-9. MARC 21 also permits a lowercase
+// letter, but those are effectively unused and omitted here; a saved query containing one still round-trips
+// through the parser/grammar.
+export const MARC_INDICATOR_VALUES = [MARC_BLANK_INDICATOR, '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
+
+// Sentinel used as the field-dropdown option value for "MARC field". Selecting it puts the row into MARC mode,
+// where MarcFieldControl builds the real field name. It's never sent to the backend.
+export const MARC_FIELD_SENTINEL = '__marcField__';
+
+// The generic MARC placeholder column ('marc', or '.marc' on a composite). Its presence is what marks
+// an entity type as MARC-capable.
+export const findMarcPlaceholder = (columns = []) => {
+ return columns.find((column) => column?.dataType?.dataType === MARC_DATA_TYPE) ?? null;
+};
+
+export const entityTypeSupportsMarc = (entityType) => Boolean(findMarcPlaceholder(entityType?.columns));
+
+// The source-alias prefix a synthesized MARC field must carry, derived from the placeholder name:
+// 'marc' -> '' (simple ET); 'marc_bib.marc' -> 'marc_bib.' (composite).
+export const getMarcSourcePrefix = (placeholderName = '') => (placeholderName.endsWith('marc') ? placeholderName.slice(0, -'marc'.length) : '');
+
+// MARC control fields (tags 00X) have no indicators or subfields — only the whole-tag form is valid. Mirrors the
+// backend rule (tag starts with "00").
+export const isControlFieldTag = (tag) => typeof tag === 'string' && tag.startsWith('00');
+
+// What the query is targeting (the multi-valued part). Everything else is a single-valued constraint.
+export const MARC_TARGETS = {
+ TAG: 'tag',
+ SUBFIELD: 'subfield',
+ IND1: 'ind1',
+ IND2: 'ind2',
+};
+
+const TAG = String.raw`\d{3}`;
+const SUBFIELD = '[a-z0-9]';
+const IND_VALUE = `${MARC_BLANK_INDICATOR}|[a-z0-9]`;
+
+// Indicator-target form (one indicator constrained, the other targeted). The two indicators must differ.
+const indicatorTargetFrom = (groups, base) => {
+ if (groups.constraintInd === groups.targetInd) return null;
+
+ const targetKey = groups.targetInd === '1' ? MARC_TARGETS.IND1 : MARC_TARGETS.IND2;
+ const constraintKey = groups.constraintInd === '1' ? 'ind1' : 'ind2';
+
+ return { ...base, target: targetKey, [constraintKey]: groups.val.toLowerCase() };
+};
+
+const PATTERNS = [
+ {
+ re: new RegExp(`^marc_(?${TAG})$`, 'i'),
+ build: (groups, base) => ({ ...base, target: MARC_TARGETS.TAG }),
+ },
+ {
+ re: new RegExp(`^marc_(?${TAG})_(?${SUBFIELD})$`, 'i'),
+ build: (groups, base) => ({ ...base, target: MARC_TARGETS.SUBFIELD, subfield: groups.subfield.toLowerCase() }),
+ },
+ {
+ re: new RegExp(`^marc_(?${TAG})_ind(?[12])$`, 'i'),
+ build: (groups, base) => ({ ...base, target: groups.ind === '1' ? MARC_TARGETS.IND1 : MARC_TARGETS.IND2 }),
+ },
+ {
+ re: new RegExp(`^marc_(?${TAG})_ind(?[12])_(?${IND_VALUE})_(?${SUBFIELD})$`, 'i'),
+ build: (groups, base) => ({
+ ...base,
+ target: MARC_TARGETS.SUBFIELD,
+ subfield: groups.subfield.toLowerCase(),
+ [groups.ind === '1' ? 'ind1' : 'ind2']: groups.val.toLowerCase(),
+ }),
+ },
+ {
+ re: new RegExp(
+ `^marc_(?${TAG})_ind1_(?${IND_VALUE})_ind2_(?${IND_VALUE})_(?${SUBFIELD})$`,
+ 'i',
+ ),
+ build: (groups, base) => ({
+ ...base,
+ target: MARC_TARGETS.SUBFIELD,
+ subfield: groups.subfield.toLowerCase(),
+ ind1: groups.ind1.toLowerCase(),
+ ind2: groups.ind2.toLowerCase(),
+ }),
+ },
+ {
+ re: new RegExp(`^marc_(?${TAG})_ind(?[12])_(?${IND_VALUE})_ind(?[12])$`, 'i'),
+ build: indicatorTargetFrom,
+ },
+];
+
+/**
+ * Parse a MARC field name into picker state, or null if it isn't a MARC field name.
+ * Shape: { sourcePrefix, tag, target, subfield, ind1, ind2 } where ind1/ind2 are constraint values (or null),
+ * and for an indicator target the targeted indicator carries no value.
+ */
+export function parseMarcFieldName(name) {
+ if (typeof name !== 'string') return null;
+
+ const lastDot = name.lastIndexOf('.');
+ const sourcePrefix = lastDot >= 0 ? name.slice(0, lastDot + 1) : '';
+ const core = lastDot >= 0 ? name.slice(lastDot + 1) : name;
+
+ const hit = PATTERNS
+ .map(({ build, re }) => ({ build, match: re.exec(core) }))
+ .find(({ match }) => Boolean(match));
+
+ if (!hit) return null;
+
+ const groups = hit.match.groups;
+ const result = hit.build(groups, { sourcePrefix, tag: groups.tag, subfield: null, ind1: null, ind2: null });
+
+ // Control fields (00X) have no subfields or indicators — only the whole tag is a valid target, so reject any
+ // subfield/indicator form on a control tag (e.g. marc_008_a). This keeps the grammar authoritative.
+ if (result && result.target !== MARC_TARGETS.TAG && isControlFieldTag(result.tag)) {
+ return null;
+ }
+
+ return result;
+}
+
+export const isMarcFieldName = (name) => parseMarcFieldName(name) !== null;
+
+// True when the field name targets an indicator (its value(s) go in the row's value box), as opposed to a subfield
+// or the whole tag. Used to decide when the value input should offer the enumerated indicator pick-list.
+export const isMarcIndicatorTargetName = (name) => {
+ const target = parseMarcFieldName(name)?.target;
+
+ return target === MARC_TARGETS.IND1 || target === MARC_TARGETS.IND2;
+};
+
+// Normalize an indicator input to its stored value: null when nothing was entered (no constraint on that
+// indicator), otherwise the lowercased value. Note a literal 'blank' is a real value here, not an empty input.
+const normalizeIndicatorValue = (value) => (
+ value === '' || value === null || value === undefined ? null : String(value).toLowerCase()
+);
+
+const indicatorConstraint = (position, value) => (value === null ? '' : `_ind${position}_${value}`);
+
+// Builds the part of the field name after `marc_` for each target, or null if the state is invalid for that
+// target. Kept as a lookup so assembleMarcFieldName stays flat.
+const SUFFIX_BUILDERS = {
+ [MARC_TARGETS.TAG]: () => '',
+ [MARC_TARGETS.SUBFIELD]: ({ subfield, c1, c2 }) => {
+ if (!/^[a-z0-9]$/i.test(subfield ?? '')) return null;
+
+ return `${indicatorConstraint(1, c1)}${indicatorConstraint(2, c2)}_${String(subfield).toLowerCase()}`;
+ },
+ [MARC_TARGETS.IND1]: ({ c2 }) => (c2 === null ? '_ind1' : `_ind2_${c2}_ind1`),
+ [MARC_TARGETS.IND2]: ({ c1 }) => (c1 === null ? '_ind2' : `_ind1_${c1}_ind2`),
+};
+
+/**
+ * Assemble a canonical MARC field name from picker state, or null if the state isn't complete enough to be valid.
+ */
+export function assembleMarcFieldName({ sourcePrefix = '', tag, target, subfield, ind1, ind2 } = {}) {
+ if (!/^\d{3}$/.test(tag ?? '')) return null;
+
+ const builder = SUFFIX_BUILDERS[target];
+
+ if (!builder) return null;
+
+ const suffix = builder({ subfield, c1: normalizeIndicatorValue(ind1), c2: normalizeIndicatorValue(ind2) });
+
+ return suffix === null ? null : `${sourcePrefix}marc_${tag}${suffix}`;
+}
+
+export const MARC_VALUE_DATA_TYPE = DATA_TYPES.StringType;
diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js b/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js
new file mode 100644
index 00000000..8270ca6c
--- /dev/null
+++ b/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js
@@ -0,0 +1,160 @@
+import {
+ assembleMarcFieldName,
+ parseMarcFieldName,
+ isMarcFieldName,
+ isMarcIndicatorTargetName,
+ isControlFieldTag,
+ findMarcPlaceholder,
+ entityTypeSupportsMarc,
+ getMarcSourcePrefix,
+ MARC_TARGETS,
+} from './marcFields';
+
+describe('marcFields helpers', () => {
+ describe('parseMarcFieldName', () => {
+ it('parses a tag-only field', () => {
+ expect(parseMarcFieldName('marc_245')).toEqual({
+ sourcePrefix: '', tag: '245', target: MARC_TARGETS.TAG, subfield: null, ind1: null, ind2: null,
+ });
+ });
+
+ it('parses a subfield field', () => {
+ expect(parseMarcFieldName('marc_245_a')).toEqual({
+ sourcePrefix: '', tag: '245', target: MARC_TARGETS.SUBFIELD, subfield: 'a', ind1: null, ind2: null,
+ });
+ });
+
+ it('parses an indicator-only (target) field', () => {
+ expect(parseMarcFieldName('marc_245_ind1')).toMatchObject({ target: MARC_TARGETS.IND1, ind1: null, ind2: null });
+ expect(parseMarcFieldName('marc_245_ind2')).toMatchObject({ target: MARC_TARGETS.IND2, ind1: null, ind2: null });
+ });
+
+ it('parses a one-indicator constrained subfield', () => {
+ expect(parseMarcFieldName('marc_245_ind1_7_a')).toMatchObject({
+ target: MARC_TARGETS.SUBFIELD, subfield: 'a', ind1: '7', ind2: null,
+ });
+ expect(parseMarcFieldName('marc_245_ind2_blank_a')).toMatchObject({
+ target: MARC_TARGETS.SUBFIELD, subfield: 'a', ind1: null, ind2: 'blank',
+ });
+ });
+
+ it('parses a dual-indicator constrained subfield', () => {
+ expect(parseMarcFieldName('marc_245_ind1_1_ind2_2_a')).toMatchObject({
+ target: MARC_TARGETS.SUBFIELD, subfield: 'a', ind1: '1', ind2: '2',
+ });
+ });
+
+ it('parses a constrained indicator target (both orderings)', () => {
+ expect(parseMarcFieldName('marc_245_ind1_1_ind2')).toMatchObject({ target: MARC_TARGETS.IND2, ind1: '1', ind2: null });
+ expect(parseMarcFieldName('marc_245_ind2_0_ind1')).toMatchObject({ target: MARC_TARGETS.IND1, ind1: null, ind2: '0' });
+ });
+
+ it('captures a composite source prefix', () => {
+ expect(parseMarcFieldName('marc_bib.marc_245_a')).toMatchObject({ sourcePrefix: 'marc_bib.', tag: '245', subfield: 'a' });
+ });
+
+ it.each([
+ ['not a marc field', 'instance.title'],
+ ['two-digit tag', 'marc_24'],
+ ['same constrained + target indicator', 'marc_245_ind1_1_ind1'],
+ ['control-field tag with a subfield', 'marc_008_a'],
+ ['control-field tag with an indicator', 'marc_008_ind1'],
+ ['empty string', ''],
+ ['null', null],
+ ])('returns null for %s', (_desc, input) => {
+ expect(parseMarcFieldName(input)).toBeNull();
+ });
+ });
+
+ describe('assembleMarcFieldName', () => {
+ it.each([
+ ['tag only', { tag: '245', target: MARC_TARGETS.TAG }, 'marc_245'],
+ ['subfield', { tag: '245', target: MARC_TARGETS.SUBFIELD, subfield: 'a' }, 'marc_245_a'],
+ ['dual constrained subfield', { tag: '245', target: MARC_TARGETS.SUBFIELD, subfield: 'a', ind1: '1', ind2: '2' }, 'marc_245_ind1_1_ind2_2_a'],
+ ['ind2-only constrained subfield', { tag: '245', target: MARC_TARGETS.SUBFIELD, subfield: 'a', ind2: '2' }, 'marc_245_ind2_2_a'],
+ ['blank constraint', { tag: '245', target: MARC_TARGETS.SUBFIELD, subfield: 'a', ind1: 'blank' }, 'marc_245_ind1_blank_a'],
+ ['indicator target, no constraint', { tag: '245', target: MARC_TARGETS.IND1 }, 'marc_245_ind1'],
+ ['indicator target ind1 with ind2 constraint', { tag: '245', target: MARC_TARGETS.IND1, ind2: '0' }, 'marc_245_ind2_0_ind1'],
+ ['indicator target ind2 with ind1 constraint', { tag: '245', target: MARC_TARGETS.IND2, ind1: '1' }, 'marc_245_ind1_1_ind2'],
+ ['indicator target ind2, no constraint', { tag: '245', target: MARC_TARGETS.IND2 }, 'marc_245_ind2'],
+ ['composite prefix', { sourcePrefix: 'marc_bib.', tag: '245', target: MARC_TARGETS.SUBFIELD, subfield: 'a' }, 'marc_bib.marc_245_a'],
+ ])('assembles %s', (_desc, parts, expected) => {
+ expect(assembleMarcFieldName(parts)).toBe(expected);
+ });
+
+ it.each([
+ ['bad tag', { tag: '24', target: MARC_TARGETS.SUBFIELD, subfield: 'a' }],
+ ['subfield target without subfield', { tag: '245', target: MARC_TARGETS.SUBFIELD }],
+ ['unknown target', { tag: '245', target: 'bogus' }],
+ ['no tag', { target: MARC_TARGETS.TAG }],
+ ['called with no arguments', undefined],
+ ])('returns null for %s', (_desc, parts) => {
+ expect(assembleMarcFieldName(parts)).toBeNull();
+ });
+ });
+
+ describe('round-trips (parse -> assemble)', () => {
+ it.each([
+ 'marc_245',
+ 'marc_245_a',
+ 'marc_245_ind1_7_a',
+ 'marc_245_ind1_1_ind2_2_a',
+ 'marc_245_ind1_blank_a',
+ 'marc_245_ind1',
+ 'marc_245_ind1_1_ind2',
+ 'marc_245_ind2_0_ind1',
+ 'marc_bib.marc_245_ind1_1_ind2_2_a',
+ ])('%s survives a parse/assemble round-trip', (name) => {
+ expect(assembleMarcFieldName(parseMarcFieldName(name))).toBe(name);
+ });
+ });
+
+ describe('isMarcFieldName', () => {
+ it('is true for a marc field and false otherwise', () => {
+ expect(isMarcFieldName('marc_245_a')).toBe(true);
+ expect(isMarcFieldName('marc_bib.marc_245_ind1_1_ind2_2_a')).toBe(true);
+ expect(isMarcFieldName('instance.title')).toBe(false);
+ });
+ });
+
+ describe('isMarcIndicatorTargetName', () => {
+ it('is true when an indicator is the target (either slot), false for subfield/tag/non-marc', () => {
+ expect(isMarcIndicatorTargetName('marc_245_ind1')).toBe(true);
+ expect(isMarcIndicatorTargetName('marc_245_ind1_1_ind2')).toBe(true);
+ expect(isMarcIndicatorTargetName('marc_245_a')).toBe(false);
+ expect(isMarcIndicatorTargetName('marc_245')).toBe(false);
+ expect(isMarcIndicatorTargetName('instance.title')).toBe(false);
+ });
+ });
+
+ describe('isControlFieldTag', () => {
+ it.each(['001', '005', '008', '009'])('%s is a control field', (tag) => {
+ expect(isControlFieldTag(tag)).toBe(true);
+ });
+
+ it.each(['010', '100', '245', '999'])('%s is a data field', (tag) => {
+ expect(isControlFieldTag(tag)).toBe(false);
+ });
+ });
+
+ describe('detection', () => {
+ const marcColumn = { name: 'marc', dataType: { dataType: 'marcType' } };
+ const stringColumn = { name: 'title', dataType: { dataType: 'stringType' } };
+
+ it('finds the marc placeholder column', () => {
+ expect(findMarcPlaceholder([stringColumn, marcColumn])).toBe(marcColumn);
+ expect(findMarcPlaceholder([stringColumn])).toBeNull();
+ });
+
+ it('reports whether an entity type supports marc', () => {
+ expect(entityTypeSupportsMarc({ columns: [stringColumn, marcColumn] })).toBe(true);
+ expect(entityTypeSupportsMarc({ columns: [stringColumn] })).toBe(false);
+ expect(entityTypeSupportsMarc(undefined)).toBe(false);
+ });
+
+ it('derives the source prefix from the placeholder name', () => {
+ expect(getMarcSourcePrefix('marc')).toBe('');
+ expect(getMarcSourcePrefix('marc_bib.marc')).toBe('marc_bib.');
+ });
+ });
+});
diff --git a/src/QueryBuilder/QueryBuilder/helpers/query.js b/src/QueryBuilder/QueryBuilder/helpers/query.js
index 65a30298..3077a648 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/query.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/query.js
@@ -10,11 +10,17 @@ import { findLabelByValue } from '../../ResultViewer/utils';
import {
booleanOptions,
getFieldOptions,
+ getMarcIndicatorValueOptions,
getOperatorOptions,
hasValueOptions,
REPEATABLE_FIELD_DELIMITER,
sourceTemplate,
} from './selectOptions';
+import {
+ isMarcFieldName,
+ isMarcIndicatorTargetName,
+ MARC_VALUE_DATA_TYPE,
+} from './marcFields';
import { getBooleanOperatorLabel, getOperatorSymbol } from './operatorLabels';
import upgradeInitialValues from './upgradeInitialValues';
import { valueBuilder } from './valueBuilder';
@@ -283,6 +289,29 @@ const getFormattedSourceField = async ({
const fieldItem = fieldOptions.find(f => f.value === field);
+ // MARC fields aren't in fieldOptions (not enumerable). Recognize the field name, repopulate MARC mode, and
+ // attach the MARC operator set so editing a saved MARC query round-trips instead of being dropped as deleted.
+ if (!fieldItem && isMarcFieldName(field)) {
+ // An indicator target queries a fixed value set, so offer the pick-list and shape a saved $in array into the
+ // {value,label} form the multi-select expects; a single value stays the raw token, and a subfield/whole-tag
+ // target stays free-text with no options.
+ const valueOptions = isMarcIndicatorTargetName(field) ? getMarcIndicatorValueOptions(intl) : undefined;
+ const marcValue = valueOptions && Array.isArray(value)
+ ? formatArrayValue(value, true, valueOptions)
+ : value;
+
+ return {
+ boolean: { options: booleanOptions, current: boolean },
+ field: { options: fieldOptions, current: field, isMarc: true, dataType: MARC_VALUE_DATA_TYPE },
+ operator: {
+ dataType: MARC_VALUE_DATA_TYPE,
+ options: getOperatorOptions({ dataType: DATA_TYPES.MarcType, fieldName: field, intl }),
+ current: operator,
+ },
+ value: { current: marcValue, options: valueOptions },
+ };
+ }
+
// Exceptional case, when queried field was deleted
if (!fieldItem) {
return createDeletedFieldResponse(boolean, fieldOptions);
@@ -417,7 +446,8 @@ export const findMissingValues = (
for (const secondaryItem of secondaryArray) {
const currentValue = secondaryItem.field.current;
- if (currentValue && !mainValues.has(currentValue)) {
+ // MARC fields aren't in fieldOptions by design, so don't treat them as deleted/missing.
+ if (currentValue && !mainValues.has(currentValue) && !isMarcFieldName(currentValue)) {
missingValues.push(currentValue);
}
}
diff --git a/src/QueryBuilder/QueryBuilder/helpers/query.test.js b/src/QueryBuilder/QueryBuilder/helpers/query.test.js
index 9b68ccf7..c6624bd7 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/query.test.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/query.test.js
@@ -26,6 +26,62 @@ describe('fqlQueryToSource()', () => {
expect(result).toEqual([]);
});
+ it('round-trips a MARC subfield field (not in fieldOptions) into a MARC-mode row', async () => {
+ const result = await fqlQueryToSource({
+ initialValues: { marc_245_ind1_1_a: { $eq: 'Shakespeare' } },
+ fieldOptions,
+ intl: { formatMessage: jest.fn() },
+ getParamsSource: jest.fn(),
+ });
+
+ expect(result).toHaveLength(1);
+ expect(result[0].field).toMatchObject({
+ current: 'marc_245_ind1_1_a',
+ isMarc: true,
+ dataType: DATA_TYPES.StringType,
+ });
+ expect(result[0].operator.current).toBe(OPERATORS.EQUAL);
+ expect(result[0].operator.options).toEqual(expect.any(Array));
+ expect(result[0].value.current).toBe('Shakespeare');
+ // Subfield target stays free-text: no enumerated value options.
+ expect(result[0].value.options).toBeUndefined();
+ });
+
+ it('round-trips a MARC indicator-target field with the restricted operator set and array value', async () => {
+ const result = await fqlQueryToSource({
+ initialValues: { marc_245_ind1_1_ind2: { $in: ['0', '4'] } },
+ fieldOptions,
+ intl: { formatMessage: jest.fn() },
+ getParamsSource: jest.fn(),
+ });
+
+ expect(result[0].field).toMatchObject({ current: 'marc_245_ind1_1_ind2', isMarc: true });
+ expect(result[0].operator.current).toBe(OPERATORS.IN);
+ expect(result[0].operator.options.map((option) => option.value)).not.toContain(OPERATORS.CONTAINS);
+ // Indicator target: the value cell gets the enumerated pick-list, and the saved $in array is shaped into the
+ // {value,label} form the multi-select expects.
+ expect(result[0].value.options).toHaveLength(11);
+ expect(result[0].value.current).toEqual([
+ { value: '0', label: '0' },
+ { value: '4', label: '4' },
+ ]);
+ });
+
+ it('round-trips a single-value indicator target as a raw token with the pick-list', async () => {
+ const result = await fqlQueryToSource({
+ initialValues: { marc_245_ind1: { $eq: '0' } },
+ fieldOptions,
+ intl: { formatMessage: jest.fn() },
+ getParamsSource: jest.fn(),
+ });
+
+ expect(result[0].field).toMatchObject({ current: 'marc_245_ind1', isMarc: true });
+ expect(result[0].operator.current).toBe(OPERATORS.EQUAL);
+ expect(result[0].value.options).toHaveLength(11);
+ // equals keeps a single raw token (the single-select matches the option by value).
+ expect(result[0].value.current).toBe('0');
+ });
+
const singleSource = [{
boolean: { options: [{ label: 'AND', value: '$and' }], current: '' },
field: { options: fieldOptions, current: 'user_first_name', dataType: 'stringType' },
diff --git a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js
index 0e360f36..7bb427e8 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js
@@ -6,6 +6,7 @@ import { BOOLEAN_OPERATORS, OPERATORS, getDiscreteOrTextOperators } from '../../
import { COLUMN_KEYS } from '../../../constants/columnKeys';
import { getOperatorLabel } from './operatorLabels';
import { getMarcOperators, parseMarcSelector } from './marcFieldOperators';
+import { MARC_INDICATOR_VALUES, MARC_BLANK_INDICATOR } from './marcFields';
export const REPEATABLE_FIELD_DELIMITER = '[*]->';
@@ -65,6 +66,17 @@ export const hasValueOptions = ({ values, source, valueSourceApi } = {}) => (
Boolean(values || source || valueSourceApi)
);
+// {value,label} options for an indicator pick-list (blank + 0-9). Used both as the row value-cell options when an
+// indicator is the query target and as the basis for the constraint selects in MarcFieldControl.
+export const getMarcIndicatorValueOptions = (intl) => (
+ MARC_INDICATOR_VALUES.map((value) => ({
+ value,
+ label: value === MARC_BLANK_INDICATOR
+ ? intl.formatMessage({ id: 'ui-plugin-query-builder.marc.indicator.blank' })
+ : value,
+ }))
+);
+
const stringOperators = (hasSourceOrValues, intl) => (
getDiscreteOrTextOperators(hasSourceOrValues).map((operator) => op(operator, intl))
);
@@ -127,7 +139,7 @@ export const getOperatorOptions = ({
export const getColumnsWithProperties = (columns = []) => {
return columns
.reduce((acc, item) => {
- if (item.queryable) {
+ if (item.queryable && !item.hidden) {
acc.push(item);
}
diff --git a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.test.js b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.test.js
index c87f7d5b..5173aefc 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.test.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.test.js
@@ -3,6 +3,7 @@ import {
getFieldOptions,
getFilteredOptions,
fuzzyOptionFormatter,
+ getMarcIndicatorValueOptions,
getOperatorOptions,
} from './selectOptions';
import { DATA_TYPES } from '../../../constants/dataTypes';
@@ -841,3 +842,17 @@ describe('getColumnsWithProperties', () => {
expect(res.map((i) => i.name)).toEqual(['noItemDataType', 'noDataType']);
});
});
+
+describe('getMarcIndicatorValueOptions', () => {
+ const intl = { formatMessage: ({ id }) => id };
+
+ it('returns blank + 0-9, localizing only the blank label', () => {
+ const options = getMarcIndicatorValueOptions(intl);
+
+ expect(options).toHaveLength(11);
+ expect(options[0]).toEqual({ value: 'blank', label: 'ui-plugin-query-builder.marc.indicator.blank' });
+ expect(options.slice(1)).toEqual(
+ ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].map((value) => ({ value, label: value })),
+ );
+ });
+});
diff --git a/src/QueryBuilder/QueryBuilder/helpers/upgradeInitialValues.js b/src/QueryBuilder/QueryBuilder/helpers/upgradeInitialValues.js
index eb1d1d8b..81f764e6 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/upgradeInitialValues.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/upgradeInitialValues.js
@@ -1,4 +1,5 @@
import { getColumnsWithProperties } from './selectOptions';
+import { isMarcFieldName } from './marcFields';
/**
* Filters the single array property in an `initialValues` object so that
@@ -26,13 +27,14 @@ export function filterByEntityColumns(initialValues, entityTypes) {
[arrayProp]: initialValues[arrayProp].filter(item => {
const key = Object.keys(item)[0];
- return allowedKeys.includes(key);
+ // MARC fields aren't enumerable columns, so they're absent from allowedKeys — keep them explicitly.
+ return allowedKeys.includes(key) || isMarcFieldName(key);
}),
};
}
return Object.fromEntries(
- entries.filter(([key]) => allowedKeys.includes(key)),
+ entries.filter(([key]) => allowedKeys.includes(key) || isMarcFieldName(key)),
);
}
diff --git a/src/QueryBuilder/ResultViewer/helpers.js b/src/QueryBuilder/ResultViewer/helpers.js
index 688bb110..032d6dcf 100644
--- a/src/QueryBuilder/ResultViewer/helpers.js
+++ b/src/QueryBuilder/ResultViewer/helpers.js
@@ -5,7 +5,10 @@ import { formatValueByDataType } from './utils';
const MIN_CONTROLLABLE_WIDTH = 30;
export const getTableMetadata = (entityType, forcedVisibleValues, intl) => {
- const defaultColumns = (entityType?.columns?.map((cell) => ({
+ // Exclude hidden columns from the table/column-picker. The entity type may include hidden columns (e.g. when
+ // fetched with includeHidden so MARC capability can be detected); they are internal metadata/placeholders and
+ // should be neither shown nor offered as toggleable columns.
+ const defaultColumns = (entityType?.columns?.filter((cell) => !cell.hidden).map((cell) => ({
label: cell.labelAlias,
value: cell.name,
disabled: false,
diff --git a/translations/ui-plugin-query-builder/en.json b/translations/ui-plugin-query-builder/en.json
index cc57e92c..3f68494e 100644
--- a/translations/ui-plugin-query-builder/en.json
+++ b/translations/ui-plugin-query-builder/en.json
@@ -83,5 +83,18 @@
"ariaLabel.columnFilter": "Column filter input",
"noOptionsAvailable.organization": "No matches! Use the “Organization look-up” below to add organizations",
- "noOptionsAvailable.donor_organization": "No matches! Use the “Donor organization look-up” below to add organizations"
+ "noOptionsAvailable.donor_organization": "No matches! Use the “Donor organization look-up” below to add organizations",
+
+ "marc.fieldOption": "MARC field",
+ "marc.tag": "MARC tag",
+ "marc.searchOn": "Search on",
+ "marc.target.subfield": "Subfield",
+ "marc.target.ind1": "Indicator 1",
+ "marc.target.ind2": "Indicator 2",
+ "marc.target.wholeTag": "Whole tag",
+ "marc.subfield": "Subfield",
+ "marc.ind1Filter": "Indicator 1 (filter)",
+ "marc.ind2Filter": "Indicator 2 (filter)",
+ "marc.indicator.any": "Any",
+ "marc.indicator.blank": "Blank"
}