Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand Down
130 changes: 130 additions & 0 deletions src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.js
Original file line number Diff line number Diff line change
@@ -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;
};
195 changes: 195 additions & 0 deletions src/QueryBuilder/QueryBuilder/helpers/marcFieldOperators.test.js
Original file line number Diff line number Diff line change
@@ -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,
]);
});
});
});
1 change: 1 addition & 0 deletions src/QueryBuilder/QueryBuilder/helpers/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ const getFormattedSourceField = async ({
dataType,
hasSourceOrValues,
isFromNestedField: fieldItem.value.includes(REPEATABLE_FIELD_DELIMITER),
fieldName: fieldItem.value,
intl,
}),
current: operator,
Expand Down
Loading