${SUBFIELD})$`,
'i',
),
- build: (g, base) => ({
+ build: (groups, base) => ({
...base,
target: MARC_TARGETS.SUBFIELD,
- subfield: g.subfield.toLowerCase(),
- ind1: g.ind1.toLowerCase(),
- ind2: g.ind2.toLowerCase(),
+ subfield: groups.subfield.toLowerCase(),
+ ind1: groups.ind1.toLowerCase(),
+ ind2: groups.ind2.toLowerCase(),
}),
},
{
@@ -111,8 +109,8 @@ export function parseMarcFieldName(name) {
if (!hit) return null;
- const g = hit.match.groups;
- const result = hit.build(g, { sourcePrefix, tag: g.tag, subfield: null, ind1: null, ind2: 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.
From 990934336a38546ac47926b8b84087d2b72f785e Mon Sep 17 00:00:00 2001
From: Bobby Sharp <97990858+bvsharp@users.noreply.github.com>
Date: Wed, 12 Aug 2026 12:56:23 -0400
Subject: [PATCH 05/13] more naming cleanup
---
src/QueryBuilder/QueryBuilder/helpers/marcFields.js | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFields.js b/src/QueryBuilder/QueryBuilder/helpers/marcFields.js
index 94307bb1..977e3ac1 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/marcFields.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/marcFields.js
@@ -123,7 +123,11 @@ export function parseMarcFieldName(name) {
export const isMarcFieldName = (name) => parseMarcFieldName(name) !== null;
-const blankOr = (v) => (v === '' || v === null || v === undefined ? null : String(v).toLowerCase());
+// 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 normalizedIndicatorValue = (value) => (
+ value === '' || value === null || value === undefined ? null : String(value).toLowerCase()
+);
const indicatorConstraint = (position, value) => (value === null ? '' : `_ind${position}_${value}`);
@@ -150,13 +154,9 @@ export function assembleMarcFieldName({ sourcePrefix = '', tag, target, subfield
if (!builder) return null;
- const suffix = builder({ subfield, c1: blankOr(ind1), c2: blankOr(ind2) });
+ const suffix = builder({ subfield, c1: normalizedIndicatorValue(ind1), c2: normalizedIndicatorValue(ind2) });
return suffix === null ? null : `${sourcePrefix}marc_${tag}${suffix}`;
}
-// The value input is always free text: MARC fields have no enumerated values, so multi-value ($in/$nin) is typed
-// comma-separated and split by getTransformedValue(). The row's value dataType is therefore StringType regardless
-// of target; the operator set (which differs for an indicator target) comes from getOperatorOptions, driven by
-// the MARC field name via marcFieldOperators.
export const MARC_VALUE_DATA_TYPE = DATA_TYPES.StringType;
From f02133a3df3462e3fc030eeb886551785d9b0235 Mon Sep 17 00:00:00 2001
From: Bobby Sharp <97990858+bvsharp@users.noreply.github.com>
Date: Wed, 12 Aug 2026 12:57:40 -0400
Subject: [PATCH 06/13] more
---
src/QueryBuilder/QueryBuilder/helpers/marcFields.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFields.js b/src/QueryBuilder/QueryBuilder/helpers/marcFields.js
index 977e3ac1..2cbc7176 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/marcFields.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/marcFields.js
@@ -125,7 +125,7 @@ export const isMarcFieldName = (name) => parseMarcFieldName(name) !== null;
// 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 normalizedIndicatorValue = (value) => (
+const normalizeIndicatorValue = (value) => (
value === '' || value === null || value === undefined ? null : String(value).toLowerCase()
);
@@ -154,7 +154,7 @@ export function assembleMarcFieldName({ sourcePrefix = '', tag, target, subfield
if (!builder) return null;
- const suffix = builder({ subfield, c1: normalizedIndicatorValue(ind1), c2: normalizedIndicatorValue(ind2) });
+ const suffix = builder({ subfield, c1: normalizeIndicatorValue(ind1), c2: normalizeIndicatorValue(ind2) });
return suffix === null ? null : `${sourcePrefix}marc_${tag}${suffix}`;
}
From 7526ce2df1da39a70bc9488a52787642666116f0 Mon Sep 17 00:00:00 2001
From: Bobby Sharp <97990858+bvsharp@users.noreply.github.com>
Date: Wed, 12 Aug 2026 15:09:41 -0400
Subject: [PATCH 07/13] marcFields test cleanup
---
src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js b/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js
index 7019408f..8f53fb1d 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js
@@ -25,7 +25,7 @@ describe('marcFields helpers', () => {
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 });
+ expect(parseMarcFieldName('marc_245_ind2')).toMatchObject({ target: MARC_TARGETS.IND2, ind1: null, ind2: null });
});
it('parses a one-indicator constrained subfield', () => {
From 8c14d1ad04f1ff8d65c43e2f740a57fc171a006c Mon Sep 17 00:00:00 2001
From: Bobby Sharp <97990858+bvsharp@users.noreply.github.com>
Date: Wed, 12 Aug 2026 15:44:39 -0400
Subject: [PATCH 08/13] cleanup
---
.../QueryBuilder/helpers/marcFieldOperators.js | 8 ++++----
src/QueryBuilder/QueryBuilder/helpers/selectOptions.js | 3 ---
2 files changed, 4 insertions(+), 7 deletions(-)
diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js
index 64ce6be7..c98a7f2c 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js
@@ -12,10 +12,10 @@ const indicatorSlot = (isTarget, constraintValue) => {
};
/**
- * Parse a MARC field name into the {subfield, indicator1, indicator2} shape consumed by
- * isMarcIndicatorTarget/getMarcOperators, or null when the name isn't a MARC field. The MARC grammar itself lives
- * in marcFields.parseMarcFieldName (the single source of truth); this only adapts that result into the operator
- * shape, so the field picker and the operator logic stay in sync with one grammar.
+ * Parse a MARC field name (e.g. marc_245_ind1) into the {subfield, indicator1, indicator2}
+ * shape consumed by isMarcIndicatorTarget/getMarcOperators, or null when the name doesn't
+ * match the MARC field grammar. A composite entity type's source-alias prefix
+ * (marc_bib.marc_245_a) is stripped before matching, same as the backend parser.
*
* @param {string} fieldName
* @returns {{subfield: string|null, indicator1: object|null, indicator2: object|null}|null}
diff --git a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js
index 7a43c888..9e08171c 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js
@@ -127,9 +127,6 @@ export const getOperatorOptions = ({
export const getColumnsWithProperties = (columns = []) => {
return columns
.reduce((acc, item) => {
- // Exclude hidden columns from field options (mirrors the nested-property branch below). The entity type can
- // include hidden columns — e.g. when fetched with includeHidden so MARC capability can be detected — but
- // they're internal metadata/placeholders, not user-selectable fields.
if (item.queryable && !item.hidden) {
acc.push(item);
}
From 031379df08f54380766291017f35ebf109f0bb61 Mon Sep 17 00:00:00 2001
From: Bobby Sharp <97990858+bvsharp@users.noreply.github.com>
Date: Thu, 13 Aug 2026 09:28:02 -0400
Subject: [PATCH 09/13] edit comments
---
.../QueryBuilderModal/MarcFieldControl/MarcFieldControl.js | 2 +-
.../QueryBuilderModal/RepeatableFields/RepeatableFields.js | 2 --
2 files changed, 1 insertion(+), 3 deletions(-)
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js
index 88b94484..0dc1df85 100644
--- a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js
@@ -9,7 +9,7 @@ import { assembleMarcFieldName, parseMarcFieldName, isControlFieldTag, MARC_TARG
// 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.
+// set.
const toDraft = (fieldName) => {
const parsed = parseMarcFieldName(fieldName);
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
index d4f9693f..6ff7f2db 100644
--- a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
@@ -50,8 +50,6 @@ export const getMemoizedValues = ({
);
// Applies a MARC field selection to a row: sets the assembled field name and attaches its operator set.
-// getOperatorOptions derives the set from the field name itself (subfield/whole-tag vs indicator target), so no
-// separate target flag is needed. If the target type flipped, an operator no longer in the new set is cleared.
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);
From 2331aaeaf6f5231c67a45e42a3d14592d26fce08 Mon Sep 17 00:00:00 2001
From: Bobby Sharp <97990858+bvsharp@users.noreply.github.com>
Date: Thu, 13 Aug 2026 10:50:53 -0400
Subject: [PATCH 10/13] cleanup
---
.../QueryBuilderModal/RepeatableFields/RepeatableFields.js | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
index 6ff7f2db..20e1ce14 100644
--- a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
@@ -86,8 +86,7 @@ export const RepeatableFields = memo(({ source, setSource, columns, entityTypeId
const marcSupported = Boolean(marcPlaceholder);
const marcSourcePrefix = getMarcSourcePrefix(marcPlaceholder?.name);
const marcFieldOption = {
- // Label with the fully-qualified (source-aware) name, consistent with every other field option, so a
- // composite ET shows which source the MARC placeholder comes from (e.g. "Instance MARC bibliographic · MARC").
+ // Label with the fully-qualified (source-aware) name
label: marcPlaceholder?.labelAliasFullyQualified
|| marcPlaceholder?.labelAlias
|| intl.formatMessage({ id: 'ui-plugin-query-builder.marc.fieldOption' }),
From cafba48ff0ae8656f2800ad35f8d6415425118ac Mon Sep 17 00:00:00 2001
From: Bobby Sharp <97990858+bvsharp@users.noreply.github.com>
Date: Thu, 13 Aug 2026 11:10:29 -0400
Subject: [PATCH 11/13] more coverage
---
.../MarcFieldControl/MarcFieldControl.test.js | 10 +++++++
.../RepeatableFields/RepeatableFields.js | 24 ++++++++-------
.../RepeatableFields/RepeatableFields.test.js | 30 ++++++++++++++++++-
.../QueryBuilder/helpers/marcFields.test.js | 4 +++
4 files changed, 57 insertions(+), 11 deletions(-)
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.test.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.test.js
index ff1870b9..95914909 100644
--- a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.test.js
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.test.js
@@ -94,4 +94,14 @@ describe('MarcFieldControl', () => {
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('');
+ });
});
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
index 20e1ce14..74eb84fb 100644
--- a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
@@ -49,6 +49,19 @@ 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 });
@@ -152,16 +165,7 @@ export const RepeatableFields = memo(({ source, setSource, columns, entityTypeId
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 {
- [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: '' },
- };
+ return enterMarcFieldMode(item);
}
if (isField) {
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js
index 11683cdd..459cc033 100644
--- a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js
@@ -1,6 +1,7 @@
-import { getMemoizedValues, applyMarcFieldChange } from './RepeatableFields';
+import { getMemoizedValues, applyMarcFieldChange, enterMarcFieldMode } from './RepeatableFields';
import { COLUMN_KEYS } from '../../../../constants/columnKeys';
import { OPERATORS } from '../../../../constants/operators';
+import { MARC_DATA_TYPE } from '../../helpers/marcFields';
const marcIntl = { formatMessage: ({ id }) => id };
@@ -85,3 +86,30 @@ describe('applyMarcFieldChange', () => {
expect(result[COLUMN_KEYS.OPERATOR].current).toBe(OPERATORS.EQUAL);
});
});
+
+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: '',
+ });
+ });
+});
diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js b/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js
index 8f53fb1d..70a25b7a 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js
@@ -56,6 +56,8 @@ describe('marcFields helpers', () => {
['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) => {
@@ -73,6 +75,7 @@ describe('marcFields helpers', () => {
['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);
@@ -83,6 +86,7 @@ describe('marcFields helpers', () => {
['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();
});
From 53179b85f1083d706b3154985ebbb187577ac287 Mon Sep 17 00:00:00 2001
From: Bobby Sharp <97990858+bvsharp@users.noreply.github.com>
Date: Thu, 13 Aug 2026 11:23:30 -0400
Subject: [PATCH 12/13] more tests
---
.../RepeatableFields/RepeatableFields.test.js | 65 ++++++++++++++++++-
1 file changed, 64 insertions(+), 1 deletion(-)
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js
index 459cc033..a133b48c 100644
--- a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js
@@ -1,8 +1,21 @@
-import { getMemoizedValues, applyMarcFieldChange, enterMarcFieldMode } 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';
+afterEach(cleanup);
+
const marcIntl = { formatMessage: ({ id }) => id };
const makeRow = (operatorCurrent = '') => ({
@@ -113,3 +126,53 @@ describe('enterMarcFieldMode', () => {
});
});
});
+
+// 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(
+
+ [] }}>
+
+
+ ,
+);
+
+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();
+ });
+});
From d6ebfb383e1030963b5388dbcd1a426a4e2ae66f Mon Sep 17 00:00:00 2001
From: Bobby Sharp <97990858+bvsharp@users.noreply.github.com>
Date: Thu, 13 Aug 2026 14:15:21 -0400
Subject: [PATCH 13/13] big refactor: Use enumerated dropdowns for indicator
values
---
.../MarcFieldControl/MarcFieldControl.js | 19 +++++---
.../MarcFieldControl/MarcFieldControl.test.js | 26 +++++++++++
.../RepeatableFields/RepeatableFields.js | 19 +++++++-
.../RepeatableFields/RepeatableFields.test.js | 46 ++++++++++++++++++-
.../QueryBuilder/helpers/marcFields.js | 13 ++++++
.../QueryBuilder/helpers/marcFields.test.js | 11 +++++
.../QueryBuilder/helpers/query.js | 12 ++++-
.../QueryBuilder/helpers/query.test.js | 25 +++++++++-
.../QueryBuilder/helpers/selectOptions.js | 12 +++++
.../helpers/selectOptions.test.js | 15 ++++++
translations/ui-plugin-query-builder/en.json | 4 +-
11 files changed, 191 insertions(+), 11 deletions(-)
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js
index 0dc1df85..31027ab6 100644
--- a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.js
@@ -4,6 +4,7 @@ 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
@@ -71,6 +72,12 @@ export const MarcFieldControl = ({ sourcePrefix, value, onFieldChange, index })
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 (
}
+ dataOptions={indicatorConstraintOptions}
value={draft.ind1}
- onChange={(e) => update({ ind1: e.target.value.trim() })}
- maxLength={5}
+ 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.trim() })}
- maxLength={5}
+ onChange={(e) => update({ ind2: e.target.value })}
data-testid={`marc-ind2-${index}`}
/>
)}
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.test.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.test.js
index 95914909..aa9dd82a 100644
--- a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.test.js
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/MarcFieldControl/MarcFieldControl.test.js
@@ -104,4 +104,30 @@ describe('MarcFieldControl', () => {
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/RepeatableFields/RepeatableFields.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
index 74eb84fb..4b933c9b 100644
--- a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js
@@ -22,6 +22,7 @@ import {
getFieldOptions,
getFilteredOptions,
fuzzyOptionFormatter,
+ getMarcIndicatorValueOptions,
getOperatorOptions,
hasValueOptions,
REPEATABLE_FIELD_DELIMITER,
@@ -30,6 +31,7 @@ import {
import {
findMarcPlaceholder,
getMarcSourcePrefix,
+ isMarcIndicatorTargetName,
MARC_DATA_TYPE,
MARC_FIELD_SENTINEL,
MARC_VALUE_DATA_TYPE,
@@ -67,6 +69,12 @@ 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]: {
@@ -80,6 +88,11 @@ export const applyMarcFieldChange = ({ item, name, intl }) => {
options,
current: operatorStillValid ? item[COLUMN_KEYS.OPERATOR].current : '',
},
+ [COLUMN_KEYS.VALUE]: {
+ ...item[COLUMN_KEYS.VALUE],
+ options: valueOptions,
+ current: valueModeFlipped ? '' : item[COLUMN_KEYS.VALUE]?.current,
+ },
};
};
@@ -154,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,
@@ -201,7 +215,10 @@ export const RepeatableFields = memo(({ source, setSource, columns, entityTypeId
// options/source) doesn't crash — it just leaves those undefined, which is correct for free-text values.
return {
[COLUMN_KEYS.VALUE]: {
- options: memorizedField?.values,
+ // 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({
diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js
index a133b48c..900a88c1 100644
--- a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js
+++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.test.js
@@ -14,6 +14,8 @@ 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 };
@@ -98,6 +100,32 @@ describe('applyMarcFieldChange', () => {
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', () => {
@@ -149,7 +177,7 @@ const Harness = () => {
const renderRepeatableFields = () => render(
- [] }}>
+ [], getDataOptionsWithFetching: () => [] }}>
,
@@ -175,4 +203,20 @@ describe('RepeatableFields MARC wiring', () => {
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/marcFields.js b/src/QueryBuilder/QueryBuilder/helpers/marcFields.js
index 2cbc7176..0ccd63e5 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/marcFields.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/marcFields.js
@@ -8,6 +8,11 @@ import { DATA_TYPES } from '../../../constants/dataTypes';
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__';
@@ -123,6 +128,14 @@ export function parseMarcFieldName(name) {
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) => (
diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js b/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js
index 70a25b7a..8270ca6c 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/marcFields.test.js
@@ -2,6 +2,7 @@ import {
assembleMarcFieldName,
parseMarcFieldName,
isMarcFieldName,
+ isMarcIndicatorTargetName,
isControlFieldTag,
findMarcPlaceholder,
entityTypeSupportsMarc,
@@ -116,6 +117,16 @@ describe('marcFields helpers', () => {
});
});
+ 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);
diff --git a/src/QueryBuilder/QueryBuilder/helpers/query.js b/src/QueryBuilder/QueryBuilder/helpers/query.js
index 6dbb8010..3077a648 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/query.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/query.js
@@ -10,6 +10,7 @@ import { findLabelByValue } from '../../ResultViewer/utils';
import {
booleanOptions,
getFieldOptions,
+ getMarcIndicatorValueOptions,
getOperatorOptions,
hasValueOptions,
REPEATABLE_FIELD_DELIMITER,
@@ -17,6 +18,7 @@ import {
} from './selectOptions';
import {
isMarcFieldName,
+ isMarcIndicatorTargetName,
MARC_VALUE_DATA_TYPE,
} from './marcFields';
import { getBooleanOperatorLabel, getOperatorSymbol } from './operatorLabels';
@@ -290,6 +292,14 @@ const getFormattedSourceField = async ({
// 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 },
@@ -298,7 +308,7 @@ const getFormattedSourceField = async ({
options: getOperatorOptions({ dataType: DATA_TYPES.MarcType, fieldName: field, intl }),
current: operator,
},
- value: { current: value, options: undefined },
+ value: { current: marcValue, options: valueOptions },
};
}
diff --git a/src/QueryBuilder/QueryBuilder/helpers/query.test.js b/src/QueryBuilder/QueryBuilder/helpers/query.test.js
index af88174a..c6624bd7 100644
--- a/src/QueryBuilder/QueryBuilder/helpers/query.test.js
+++ b/src/QueryBuilder/QueryBuilder/helpers/query.test.js
@@ -43,6 +43,8 @@ describe('fqlQueryToSource()', () => {
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 () => {
@@ -56,7 +58,28 @@ describe('fqlQueryToSource()', () => {
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);
- expect(result[0].value.current).toEqual(['0', '4']);
+ // 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 = [{
diff --git a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js
index 9e08171c..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))
);
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/translations/ui-plugin-query-builder/en.json b/translations/ui-plugin-query-builder/en.json
index eb782bb2..3f68494e 100644
--- a/translations/ui-plugin-query-builder/en.json
+++ b/translations/ui-plugin-query-builder/en.json
@@ -94,5 +94,7 @@
"marc.target.wholeTag": "Whole tag",
"marc.subfield": "Subfield",
"marc.ind1Filter": "Indicator 1 (filter)",
- "marc.ind2Filter": "Indicator 2 (filter)"
+ "marc.ind2Filter": "Indicator 2 (filter)",
+ "marc.indicator.any": "Any",
+ "marc.indicator.blank": "Blank"
}