diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a02cd8..1e540f91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * [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-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 ## [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/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 new file mode 100644 index 00000000..db903234 --- /dev/null +++ b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js @@ -0,0 +1,130 @@ +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 = MARC_CORE_PATTERNS.subfield.exec(core); + + if (match && !isControlFieldTag(match[1])) { + return { subfield: match[2].toLowerCase(), indicator1: null, indicator2: null }; + } + + 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), + }; + } + + match = MARC_CORE_PATTERNS.tagOnly.exec(core); + 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 + * 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; +}; diff --git a/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js new file mode 100644 index 00000000..6757af43 --- /dev/null +++ b/src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js @@ -0,0 +1,195 @@ +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); + }); + + 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, + ]); + }); + }); +}); 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 676e54d1..0e360f36 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, parseMarcSelector } 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 = (fieldName, intl) => ( + getMarcOperators(parseMarcSelector(fieldName) || {}).map((operator) => op(operator, intl)) +); + export const getOperatorOptions = ({ dataType, hasSourceOrValues, isFromNestedField, + fieldName, intl, }) => { switch (dataType) { case DATA_TYPES.StringType: return getOperatorsWithPlaceholder(stringOperators(hasSourceOrValues, intl), intl); + case DATA_TYPES.MarcType: + return getOperatorsWithPlaceholder(marcOperators(fieldName, 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..c87f7d5b 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 fieldName 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, + fieldName: 'marc_245_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, + fieldName: 'marc_245_ind1', + 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