From 36a8be5634c599bf1b5885fba93630cdbe6d7e0d Mon Sep 17 00:00:00 2001 From: vashjs Date: Tue, 11 Aug 2026 12:59:33 +0200 Subject: [PATCH 1/5] UIPQB-277 Define supported operators for marcDataType --- CHANGELOG.md | 1 + .../helpers/marcFieldOperators.js | 43 +++++++ .../helpers/marcFieldOperators.test.js | 106 ++++++++++++++++++ .../QueryBuilder/helpers/selectOptions.js | 28 +++-- .../helpers/selectOptions.test.js | 55 +++++++++ src/constants/dataTypes.js | 1 + src/constants/operators.js | 12 +- 7 files changed, 230 insertions(+), 16 deletions(-) create mode 100644 src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js create mode 100644 src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index aaa671d7..2d2f858a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * [UIPQB-282](https://folio-org.atlassian.net/browse/UIPQB-282) Stop hiding fields used as idColumnName * [UIPQB-286](https://folio-org.atlassian.net/browse/UIPQB-286) Fix incorrect value display for 'Organization - Code' queries * [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-277](https://folio-org.atlassian.net/browse/UIPQB-277) Define supported operators for marcDataType ## [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/helpers/marcFieldOperators.js b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js new file mode 100644 index 00000000..560ee964 --- /dev/null +++ b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js @@ -0,0 +1,43 @@ +import { OPERATORS, getDiscreteOrTextOperators } from '../../../constants/operators'; + +// mod-fqm-manager always reports dataType: "marcType" for every synthetic MARC +// column (tag-only, indicator, subfield, constrained-subfield, ...), so the +// operator set has to be derived from the shape of the MARC selector itself +// (see MarcFieldFactory.parse() in lib-fqm-query-processor) rather than from dataType. + +/** + * True when the MARC selector's query target is an indicator (a single coded character, + * e.g. marc_245_ind1) rather than a subfield or the whole tag. An indicator target behaves + * like a discrete/fixed value -- the same split StringType makes via hasSourceOrValues -- so + * it gets eq/ne/in/nin instead of eq/ne/contains/starts_with. + * + * @param {object} marcSelector + * @param {string|null} [marcSelector.subfield] subfield code, if selected + * @param {{isTarget: boolean, value: string|null}|null} [marcSelector.indicator1] + * @param {{isTarget: boolean, value: string|null}|null} [marcSelector.indicator2] + * @returns {boolean} + */ +export const isMarcIndicatorTarget = (marcSelector = {}) => { + const { subfield, indicator1, indicator2 } = marcSelector; + + if (subfield) { + return false; + } + + return Boolean(indicator1?.isTarget) || Boolean(indicator2?.isTarget); +}; + +// An indicator always holds a defined code (or the blank code) -- unlike a free-text subfield/tag, +// there's no "empty" state to query, so the empty operator is dropped for indicator targets. +export const getMarcOperators = (marcSelector) => { + const isIndicatorTarget = isMarcIndicatorTarget(marcSelector); + const operators = getDiscreteOrTextOperators(isIndicatorTarget); + + return isIndicatorTarget ? operators.filter((operator) => operator !== OPERATORS.EMPTY) : operators; +}; + +// Lets selector-UI code reset a stale operator (e.g. `in` selected, then a +// subfield is added) when the shape changes underneath it. +export const isMarcOperatorValid = (operator, marcSelector) => ( + getMarcOperators(marcSelector).includes(operator) +); diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js new file mode 100644 index 00000000..de7baa3f --- /dev/null +++ b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js @@ -0,0 +1,106 @@ +import { + isMarcIndicatorTarget, + getMarcOperators, + isMarcOperatorValid, +} from './marcFieldOperators'; +import { OPERATORS, getDiscreteOrTextOperators } from '../../../constants/operators'; + +describe('marcFieldOperators', () => { + describe('isMarcIndicatorTarget', () => { + it('returns false for a tag-only selector (marc_245)', () => { + expect(isMarcIndicatorTarget({})).toBe(false); + }); + + it('returns false for a subfield-only selector (marc_245_a)', () => { + expect(isMarcIndicatorTarget({ subfield: 'a' })).toBe(false); + }); + + it('returns true for an indicator-only selector (marc_245_ind1)', () => { + expect(isMarcIndicatorTarget({ + indicator1: { isTarget: true, value: null }, + })).toBe(true); + }); + + it('returns false for a constrained subfield with one indicator fixed (marc_245_ind1_7_a)', () => { + expect(isMarcIndicatorTarget({ + indicator1: { isTarget: false, value: '7' }, + subfield: 'a', + })).toBe(false); + }); + + it('returns false for a dual-indicator subfield with both fixed (marc_245_ind1_1_ind2_2_a)', () => { + expect(isMarcIndicatorTarget({ + indicator1: { isTarget: false, value: '1' }, + indicator2: { isTarget: false, value: '2' }, + subfield: 'a', + })).toBe(false); + }); + + it('returns true for a constrained indicator target with the other indicator fixed (marc_245_ind1_1_ind2)', () => { + expect(isMarcIndicatorTarget({ + indicator1: { isTarget: false, value: '1' }, + indicator2: { isTarget: true, value: null }, + })).toBe(true); + }); + + it('is unaffected by a blank indicator value (marc_245_ind1_blank_a)', () => { + expect(isMarcIndicatorTarget({ + indicator1: { isTarget: false, value: 'blank' }, + subfield: 'a', + })).toBe(false); + }); + + it('is unaffected by a blank indicator value when the indicator is the target', () => { + expect(isMarcIndicatorTarget({ + indicator1: { isTarget: true, value: 'blank' }, + })).toBe(true); + }); + + it('subfield takes priority even if an indicator is also a target', () => { + expect(isMarcIndicatorTarget({ + indicator1: { isTarget: true, value: null }, + subfield: 'a', + })).toBe(false); + }); + }); + + describe('getMarcOperators', () => { + it('returns text operators for non-indicator-target selectors', () => { + expect(getMarcOperators({ subfield: 'a' })).toEqual(getDiscreteOrTextOperators(false)); + }); + + it('returns discrete operators without empty for indicator-target selectors', () => { + expect(getMarcOperators({ indicator1: { isTarget: true, value: null } })).toEqual([ + OPERATORS.EQUAL, + OPERATORS.NOT_EQUAL, + OPERATORS.IN, + OPERATORS.NOT_IN, + ]); + }); + }); + + describe('isMarcOperatorValid', () => { + it('is true for an operator that belongs to the current shape', () => { + expect(isMarcOperatorValid(OPERATORS.IN, { indicator1: { isTarget: true, value: null } })).toBe(true); + }); + + it('is false once the shape changes underneath the operator (indicator target -> subfield added)', () => { + const marcSelector = { subfield: 'a' }; + + expect(isMarcOperatorValid(OPERATORS.IN, marcSelector)).toBe(false); + }); + + it('is true when the operator is shared by both operator sets (e.g. `==`)', () => { + expect(isMarcOperatorValid(OPERATORS.EQUAL, {})).toBe(true); + expect(isMarcOperatorValid(OPERATORS.EQUAL, { indicator1: { isTarget: true, value: null } })).toBe(true); + }); + + it('is false for empty on an indicator target, since an indicator always has a value', () => { + expect(isMarcOperatorValid(OPERATORS.EMPTY, { indicator1: { isTarget: true, value: null } })).toBe(false); + }); + + it('is true for empty on a non-indicator-target selector', () => { + expect(isMarcOperatorValid(OPERATORS.EMPTY, { subfield: 'a' })).toBe(true); + }); + }); +}); diff --git a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js index 676e54d1..c59a1087 100644 --- a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js +++ b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js @@ -2,9 +2,10 @@ import fuzzysort from 'fuzzysort'; import { FormattedMessage } from 'react-intl'; import { OptionSegment } from '@folio/stripes/components'; import { DATA_TYPES } from '../../../constants/dataTypes'; -import { BOOLEAN_OPERATORS, OPERATORS } from '../../../constants/operators'; +import { BOOLEAN_OPERATORS, OPERATORS, getDiscreteOrTextOperators } from '../../../constants/operators'; import { COLUMN_KEYS } from '../../../constants/columnKeys'; import { getOperatorLabel } from './operatorLabels'; +import { getMarcOperators } from './marcFieldOperators'; export const REPEATABLE_FIELD_DELIMITER = '[*]->'; @@ -64,20 +65,9 @@ export const hasValueOptions = ({ values, source, valueSourceApi } = {}) => ( Boolean(values || source || valueSourceApi) ); -const stringOperators = (hasSourceOrValues, intl) => { - return [ - op(OPERATORS.EQUAL, intl), - op(OPERATORS.NOT_EQUAL, intl), - ...(hasSourceOrValues ? [ - op(OPERATORS.IN, intl), - op(OPERATORS.NOT_IN, intl), - ] : [ - op(OPERATORS.CONTAINS, intl), - op(OPERATORS.STARTS_WITH, intl), - ]), - op(OPERATORS.EMPTY, intl), - ]; -}; +const stringOperators = (hasSourceOrValues, intl) => ( + getDiscreteOrTextOperators(hasSourceOrValues).map((operator) => op(operator, intl)) +); const booleanOperators = (isFromNestedField, intl) => [ op(OPERATORS.EQUAL, intl), @@ -85,16 +75,24 @@ const booleanOperators = (isFromNestedField, intl) => [ op(OPERATORS.EMPTY, intl), ]; +const marcOperators = (marcSelector, intl) => ( + getMarcOperators(marcSelector).map((operator) => op(operator, intl)) +); + export const getOperatorOptions = ({ dataType, hasSourceOrValues, isFromNestedField, + marcSelector, intl, }) => { switch (dataType) { case DATA_TYPES.StringType: return getOperatorsWithPlaceholder(stringOperators(hasSourceOrValues, intl), intl); + case DATA_TYPES.MarcType: + return getOperatorsWithPlaceholder(marcOperators(marcSelector, intl), intl); + case DATA_TYPES.RangedUUIDType: case DATA_TYPES.OpenUUIDType: case DATA_TYPES.StringUUIDType: diff --git a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.test.js b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.test.js index dca7f623..05df76e8 100644 --- a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.test.js +++ b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.test.js @@ -455,6 +455,61 @@ describe('select options', () => { expect(intlMock.formatMessage).not.toHaveBeenCalled(); }); + + it('should return text operators with placeholder for marc type when no marcSelector is given (tag-only)', () => { + const options = getOperatorOptions({ + dataType: DATA_TYPES.MarcType, + intl: intlMock, + }); + + expectFn({ + options, + operators: [ + { label: OPERATORS_LABELS.EQUAL, value: OPERATORS.EQUAL }, + { label: OPERATORS_LABELS.NOT_EQUAL, value: OPERATORS.NOT_EQUAL }, + { label: OPERATORS_LABELS.CONTAINS, value: OPERATORS.CONTAINS }, + { label: OPERATORS_LABELS.STARTS_WITH, value: OPERATORS.STARTS_WITH }, + { label: OPERATORS_LABELS.EMPTY, value: OPERATORS.EMPTY }, + ], + }); + }); + + it('should return text operators with placeholder for marc type when a subfield is selected', () => { + const options = getOperatorOptions({ + dataType: DATA_TYPES.MarcType, + marcSelector: { subfield: 'a' }, + intl: intlMock, + }); + + expectFn({ + options, + operators: [ + { label: OPERATORS_LABELS.EQUAL, value: OPERATORS.EQUAL }, + { label: OPERATORS_LABELS.NOT_EQUAL, value: OPERATORS.NOT_EQUAL }, + { label: OPERATORS_LABELS.CONTAINS, value: OPERATORS.CONTAINS }, + { label: OPERATORS_LABELS.STARTS_WITH, value: OPERATORS.STARTS_WITH }, + { label: OPERATORS_LABELS.EMPTY, value: OPERATORS.EMPTY }, + ], + }); + }); + + it('should return coded operators without empty, with placeholder, for marc type when an indicator is the target', () => { + const options = getOperatorOptions({ + dataType: DATA_TYPES.MarcType, + marcSelector: { indicator1: { isTarget: true, value: null } }, + intl: intlMock, + }); + + expectFn({ + options, + operators: [ + { label: OPERATORS_LABELS.EQUAL, value: OPERATORS.EQUAL }, + { label: OPERATORS_LABELS.NOT_EQUAL, value: OPERATORS.NOT_EQUAL }, + { label: OPERATORS_LABELS.IN, value: OPERATORS.IN }, + { label: OPERATORS_LABELS.NOT_IN, value: OPERATORS.NOT_IN }, + ], + }); + }); }); }); diff --git a/src/constants/dataTypes.js b/src/constants/dataTypes.js index ef190263..4ede2223 100644 --- a/src/constants/dataTypes.js +++ b/src/constants/dataTypes.js @@ -12,6 +12,7 @@ export const DATA_TYPES = { ObjectType: 'objectType', ArrayType: 'arrayType', JsonbArrayType: 'jsonbArrayType', + MarcType: 'marcType', }; export const ORGANIZATIONS_TYPES = ['organization', 'donor_organization']; diff --git a/src/constants/operators.js b/src/constants/operators.js index 4501b20c..a7ed309d 100644 --- a/src/constants/operators.js +++ b/src/constants/operators.js @@ -15,4 +15,14 @@ export const OPERATORS = { export const BOOLEAN_OPERATORS = { AND: '$and', -}; \ No newline at end of file +}; + +// Shared eq/ne + (in/nin for a discrete/fixed set of values, or contains/starts_with for free +// text) + empty shape, reused by any dataType that makes this same discrete-vs-free-text split +// (e.g. StringType via hasSourceOrValues, MarcType via isMarcIndicatorTarget). +export const getDiscreteOrTextOperators = (isDiscrete) => [ + OPERATORS.EQUAL, + OPERATORS.NOT_EQUAL, + ...(isDiscrete ? [OPERATORS.IN, OPERATORS.NOT_IN] : [OPERATORS.CONTAINS, OPERATORS.STARTS_WITH]), + OPERATORS.EMPTY, +]; \ No newline at end of file From 0cc79a178039076d6525f49970d0306a98916918 Mon Sep 17 00:00:00 2001 From: vashjs Date: Tue, 11 Aug 2026 13:04:56 +0200 Subject: [PATCH 2/5] update --- .../helpers/marcFieldOperators.js | 6 ----- .../helpers/marcFieldOperators.test.js | 26 ------------------- 2 files changed, 32 deletions(-) diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js index 560ee964..b14a01f4 100644 --- a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js +++ b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js @@ -35,9 +35,3 @@ export const getMarcOperators = (marcSelector) => { return isIndicatorTarget ? operators.filter((operator) => operator !== OPERATORS.EMPTY) : operators; }; - -// Lets selector-UI code reset a stale operator (e.g. `in` selected, then a -// subfield is added) when the shape changes underneath it. -export const isMarcOperatorValid = (operator, marcSelector) => ( - getMarcOperators(marcSelector).includes(operator) -); diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js index de7baa3f..af072835 100644 --- a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js +++ b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js @@ -1,7 +1,6 @@ import { isMarcIndicatorTarget, getMarcOperators, - isMarcOperatorValid, } from './marcFieldOperators'; import { OPERATORS, getDiscreteOrTextOperators } from '../../../constants/operators'; @@ -78,29 +77,4 @@ describe('marcFieldOperators', () => { ]); }); }); - - describe('isMarcOperatorValid', () => { - it('is true for an operator that belongs to the current shape', () => { - expect(isMarcOperatorValid(OPERATORS.IN, { indicator1: { isTarget: true, value: null } })).toBe(true); - }); - - it('is false once the shape changes underneath the operator (indicator target -> subfield added)', () => { - const marcSelector = { subfield: 'a' }; - - expect(isMarcOperatorValid(OPERATORS.IN, marcSelector)).toBe(false); - }); - - it('is true when the operator is shared by both operator sets (e.g. `==`)', () => { - expect(isMarcOperatorValid(OPERATORS.EQUAL, {})).toBe(true); - expect(isMarcOperatorValid(OPERATORS.EQUAL, { indicator1: { isTarget: true, value: null } })).toBe(true); - }); - - it('is false for empty on an indicator target, since an indicator always has a value', () => { - expect(isMarcOperatorValid(OPERATORS.EMPTY, { indicator1: { isTarget: true, value: null } })).toBe(false); - }); - - it('is true for empty on a non-indicator-target selector', () => { - expect(isMarcOperatorValid(OPERATORS.EMPTY, { subfield: 'a' })).toBe(true); - }); - }); }); From 92277585e9d23820efecfc21bdd905c3488c89ac Mon Sep 17 00:00:00 2001 From: vashjs Date: Tue, 11 Aug 2026 13:07:13 +0200 Subject: [PATCH 3/5] remove comment --- src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js index b14a01f4..a8ac19a6 100644 --- a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js +++ b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js @@ -1,10 +1,5 @@ import { OPERATORS, getDiscreteOrTextOperators } from '../../../constants/operators'; -// mod-fqm-manager always reports dataType: "marcType" for every synthetic MARC -// column (tag-only, indicator, subfield, constrained-subfield, ...), so the -// operator set has to be derived from the shape of the MARC selector itself -// (see MarcFieldFactory.parse() in lib-fqm-query-processor) rather than from dataType. - /** * True when the MARC selector's query target is an indicator (a single coded character, * e.g. marc_245_ind1) rather than a subfield or the whole tag. An indicator target behaves From 33b05392c1a7e7ea826be982307254af7ac12663 Mon Sep 17 00:00:00 2001 From: vashjs Date: Tue, 11 Aug 2026 15:01:00 +0200 Subject: [PATCH 4/5] update logic --- .../RepeatableFields/RepeatableFields.js | 1 + .../helpers/marcFieldOperators.js | 98 +++++++++++++++ .../helpers/marcFieldOperators.test.js | 115 ++++++++++++++++++ .../QueryBuilder/helpers/query.js | 1 + .../QueryBuilder/helpers/selectOptions.js | 10 +- .../helpers/selectOptions.test.js | 6 +- 6 files changed, 223 insertions(+), 8 deletions(-) diff --git a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js index 1dd3e2d1..3c3cf82b 100644 --- a/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js +++ b/src/QueryBuilder/QueryBuilder/QueryBuilderModal/RepeatableFields/RepeatableFields.js @@ -118,6 +118,7 @@ export const RepeatableFields = memo(({ source, setSource, columns, entityTypeId dataType: field.dataType, hasSourceOrValues: hasValueOptions(field), isFromNestedField: field.value.includes(REPEATABLE_FIELD_DELIMITER), + fieldName: field.value, intl, }), current: '', diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js index a8ac19a6..53aafbfc 100644 --- a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js +++ b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js @@ -1,5 +1,103 @@ import { OPERATORS, getDiscreteOrTextOperators } from '../../../constants/operators'; +// 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 +); + +const targetIndicator = (slot, targetSlot) => ( + slot === targetSlot ? { isTarget: true, value: null } : null +); + +/** + * 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} + */ +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 = core.match(MARC_CORE_PATTERNS.subfield); + + if (match && !isControlFieldTag(match[1])) { + return { subfield: match[2].toLowerCase(), indicator1: null, indicator2: null }; + } + + match = core.match(MARC_CORE_PATTERNS.dualIndicatorSubfield); + 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 = core.match(MARC_CORE_PATTERNS.constrainedSubfield); + 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 = core.match(MARC_CORE_PATTERNS.constrainedIndicatorTarget); + 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 = core.match(MARC_CORE_PATTERNS.indicatorTarget); + if (match && !isControlFieldTag(match[1])) { + const [, , targetSlot] = match; + + return { + subfield: null, + indicator1: targetIndicator('1', targetSlot), + indicator2: targetIndicator('2', targetSlot), + }; + } + + match = core.match(MARC_CORE_PATTERNS.tagOnly); + if (match) { + return { subfield: null, indicator1: null, indicator2: null }; + } + + return null; +}; + /** * True when the MARC selector's query target is an indicator (a single coded character, * e.g. marc_245_ind1) rather than a subfield or the whole tag. An indicator target behaves diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js index af072835..6757af43 100644 --- a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js +++ b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js @@ -1,10 +1,125 @@ import { + parseMarcSelector, isMarcIndicatorTarget, getMarcOperators, } from './marcFieldOperators'; import { OPERATORS, getDiscreteOrTextOperators } from '../../../constants/operators'; describe('marcFieldOperators', () => { + describe('parseMarcSelector', () => { + it('returns null for a non-string field name', () => { + expect(parseMarcSelector(undefined)).toBeNull(); + }); + + it('returns null for a non-MARC field name', () => { + expect(parseMarcSelector('title')).toBeNull(); + }); + + it('parses a tag-only field name (marc_245)', () => { + expect(parseMarcSelector('marc_245')).toEqual({ + subfield: null, + indicator1: null, + indicator2: null, + }); + }); + + it('parses a control field tag as tag-only (marc_008)', () => { + expect(parseMarcSelector('marc_008')).toEqual({ + subfield: null, + indicator1: null, + indicator2: null, + }); + }); + + it('parses a subfield field name (marc_245_a)', () => { + expect(parseMarcSelector('marc_245_a')).toEqual({ + subfield: 'a', + indicator1: null, + indicator2: null, + }); + }); + + it('parses an indicator-target field name (marc_245_ind1)', () => { + expect(parseMarcSelector('marc_245_ind1')).toEqual({ + subfield: null, + indicator1: { isTarget: true, value: null }, + indicator2: null, + }); + }); + + it('parses the other indicator-target field name (marc_245_ind2)', () => { + expect(parseMarcSelector('marc_245_ind2')).toEqual({ + subfield: null, + indicator1: null, + indicator2: { isTarget: true, value: null }, + }); + }); + + it('parses a constrained subfield with one indicator fixed (marc_245_ind1_7_a)', () => { + expect(parseMarcSelector('marc_245_ind1_7_a')).toEqual({ + subfield: 'a', + indicator1: { isTarget: false, value: '7' }, + indicator2: null, + }); + }); + + it('parses a constrained subfield with a blank indicator (marc_245_ind1_blank_a)', () => { + expect(parseMarcSelector('marc_245_ind1_blank_a')).toEqual({ + subfield: 'a', + indicator1: { isTarget: false, value: 'blank' }, + indicator2: null, + }); + }); + + it('parses a dual-indicator constrained subfield (marc_245_ind1_1_ind2_2_a)', () => { + expect(parseMarcSelector('marc_245_ind1_1_ind2_2_a')).toEqual({ + subfield: 'a', + indicator1: { isTarget: false, value: '1' }, + indicator2: { isTarget: false, value: '2' }, + }); + }); + + it('parses a constrained indicator target (marc_245_ind1_1_ind2)', () => { + expect(parseMarcSelector('marc_245_ind1_1_ind2')).toEqual({ + subfield: null, + indicator1: { isTarget: false, value: '1' }, + indicator2: { isTarget: true, value: null }, + }); + }); + + it('parses a constrained indicator target with the constraint on ind2 (marc_245_ind2_1_ind1)', () => { + expect(parseMarcSelector('marc_245_ind2_1_ind1')).toEqual({ + subfield: null, + indicator1: { isTarget: true, value: null }, + indicator2: { isTarget: false, value: '1' }, + }); + }); + + it('strips a composite source-alias prefix before parsing (marc_bib.marc_245_ind1)', () => { + expect(parseMarcSelector('marc_bib.marc_245_ind1')).toEqual({ + subfield: null, + indicator1: { isTarget: true, value: null }, + indicator2: null, + }); + }); + + it('is case-insensitive (MARC_245_IND1)', () => { + expect(parseMarcSelector('MARC_245_IND1')).toEqual({ + subfield: null, + indicator1: { isTarget: true, value: null }, + indicator2: null, + }); + }); + + it('returns null for a subfield on a control field tag (marc_008_a)', () => { + expect(parseMarcSelector('marc_008_a')).toBeNull(); + }); + + it('returns null when both indicators on a constrained-indicator-target are the same slot (marc_245_ind1_1_ind1)', () => { + expect(parseMarcSelector('marc_245_ind1_1_ind1')).toBeNull(); + }); + }); + describe('isMarcIndicatorTarget', () => { it('returns false for a tag-only selector (marc_245)', () => { expect(isMarcIndicatorTarget({})).toBe(false); diff --git a/src/QueryBuilder/QueryBuilder/helpers/query.js b/src/QueryBuilder/QueryBuilder/helpers/query.js index 1978d7a5..65a30298 100644 --- a/src/QueryBuilder/QueryBuilder/helpers/query.js +++ b/src/QueryBuilder/QueryBuilder/helpers/query.js @@ -317,6 +317,7 @@ const getFormattedSourceField = async ({ dataType, hasSourceOrValues, isFromNestedField: fieldItem.value.includes(REPEATABLE_FIELD_DELIMITER), + fieldName: fieldItem.value, intl, }), current: operator, diff --git a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js index c59a1087..0e360f36 100644 --- a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js +++ b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.js @@ -5,7 +5,7 @@ import { DATA_TYPES } from '../../../constants/dataTypes'; import { BOOLEAN_OPERATORS, OPERATORS, getDiscreteOrTextOperators } from '../../../constants/operators'; import { COLUMN_KEYS } from '../../../constants/columnKeys'; import { getOperatorLabel } from './operatorLabels'; -import { getMarcOperators } from './marcFieldOperators'; +import { getMarcOperators, parseMarcSelector } from './marcFieldOperators'; export const REPEATABLE_FIELD_DELIMITER = '[*]->'; @@ -75,15 +75,15 @@ const booleanOperators = (isFromNestedField, intl) => [ op(OPERATORS.EMPTY, intl), ]; -const marcOperators = (marcSelector, intl) => ( - getMarcOperators(marcSelector).map((operator) => op(operator, intl)) +const marcOperators = (fieldName, intl) => ( + getMarcOperators(parseMarcSelector(fieldName) || {}).map((operator) => op(operator, intl)) ); export const getOperatorOptions = ({ dataType, hasSourceOrValues, isFromNestedField, - marcSelector, + fieldName, intl, }) => { switch (dataType) { @@ -91,7 +91,7 @@ export const getOperatorOptions = ({ return getOperatorsWithPlaceholder(stringOperators(hasSourceOrValues, intl), intl); case DATA_TYPES.MarcType: - return getOperatorsWithPlaceholder(marcOperators(marcSelector, intl), intl); + return getOperatorsWithPlaceholder(marcOperators(fieldName, intl), intl); case DATA_TYPES.RangedUUIDType: case DATA_TYPES.OpenUUIDType: diff --git a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.test.js b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.test.js index 05df76e8..c87f7d5b 100644 --- a/src/QueryBuilder/QueryBuilder/helpers/selectOptions.test.js +++ b/src/QueryBuilder/QueryBuilder/helpers/selectOptions.test.js @@ -456,7 +456,7 @@ describe('select options', () => { expect(intlMock.formatMessage).not.toHaveBeenCalled(); }); - it('should return text operators with placeholder for marc type when no marcSelector is given (tag-only)', () => { + it('should return text operators with placeholder for marc type when no fieldName is given (tag-only)', () => { const options = getOperatorOptions({ dataType: DATA_TYPES.MarcType, intl: intlMock, @@ -477,7 +477,7 @@ describe('select options', () => { it('should return text operators with placeholder for marc type when a subfield is selected', () => { const options = getOperatorOptions({ dataType: DATA_TYPES.MarcType, - marcSelector: { subfield: 'a' }, + fieldName: 'marc_245_a', intl: intlMock, }); @@ -496,7 +496,7 @@ describe('select options', () => { it('should return coded operators without empty, with placeholder, for marc type when an indicator is the target', () => { const options = getOperatorOptions({ dataType: DATA_TYPES.MarcType, - marcSelector: { indicator1: { isTarget: true, value: null } }, + fieldName: 'marc_245_ind1', intl: intlMock, }); From a0942fc05c7cdb8ac29876198a75b3cd255a648d Mon Sep 17 00:00:00 2001 From: vashjs Date: Tue, 11 Aug 2026 15:08:34 +0200 Subject: [PATCH 5/5] fix sonar issues --- .../QueryBuilder/helpers/marcFieldOperators.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js index 53aafbfc..db903234 100644 --- a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js +++ b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js @@ -40,13 +40,13 @@ export const parseMarcSelector = (fieldName) => { const lastDotIndex = fieldName.lastIndexOf('.'); const core = lastDotIndex > 0 ? fieldName.slice(lastDotIndex + 1) : fieldName; - let match = core.match(MARC_CORE_PATTERNS.subfield); + let match = MARC_CORE_PATTERNS.subfield.exec(core); if (match && !isControlFieldTag(match[1])) { return { subfield: match[2].toLowerCase(), indicator1: null, indicator2: null }; } - match = core.match(MARC_CORE_PATTERNS.dualIndicatorSubfield); + match = MARC_CORE_PATTERNS.dualIndicatorSubfield.exec(core); if (match && !isControlFieldTag(match[1])) { return { subfield: match[4].toLowerCase(), @@ -55,7 +55,7 @@ export const parseMarcSelector = (fieldName) => { }; } - match = core.match(MARC_CORE_PATTERNS.constrainedSubfield); + match = MARC_CORE_PATTERNS.constrainedSubfield.exec(core); if (match && !isControlFieldTag(match[1])) { const [, , constraintSlot, constraintValue, subfield] = match; @@ -66,7 +66,7 @@ export const parseMarcSelector = (fieldName) => { }; } - match = core.match(MARC_CORE_PATTERNS.constrainedIndicatorTarget); + match = MARC_CORE_PATTERNS.constrainedIndicatorTarget.exec(core); if (match && !isControlFieldTag(match[1])) { const [, , constraintSlot, constraintValue, targetSlot] = match; @@ -79,7 +79,7 @@ export const parseMarcSelector = (fieldName) => { } } - match = core.match(MARC_CORE_PATTERNS.indicatorTarget); + match = MARC_CORE_PATTERNS.indicatorTarget.exec(core); if (match && !isControlFieldTag(match[1])) { const [, , targetSlot] = match; @@ -90,7 +90,7 @@ export const parseMarcSelector = (fieldName) => { }; } - match = core.match(MARC_CORE_PATTERNS.tagOnly); + match = MARC_CORE_PATTERNS.tagOnly.exec(core); if (match) { return { subfield: null, indicator1: null, indicator2: null }; }