Skip to content
Draft
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 @@ -7,6 +7,7 @@
* [UIPQB-210](https://folio-org.atlassian.net/browse/UIPQB-210) Localize operators, boolean operators, and boolean values in the query builder and user-friendly query
* [UIPQB-296](https://folio-org.atlassian.net/browse/UIPQB-296) Adjust color contrast of queryArea to comply with WCAG AA standard (4.5:1).
* [UIPQB-277](https://folio-org.atlassian.net/browse/UIPQB-277) Define supported operators for marcDataType
* [UIPQB-287](https://folio-org.atlassian.net/browse/UIPQB-287) Support querying dynamic MARC fields

## [3.0.2](https://github.com/folio-org/ui-plugin-query-builder/tree/v3.0.2) (2026-06-03)
* [UIPQB-279](https://folio-org.atlassian.net/browse/UIPQB-279) Add support for fields containing a valueSourceApi property without a source property
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, useIntl } from 'react-intl';
import { Select, TextField } from '@folio/stripes/components';

import { assembleMarcFieldName, parseMarcFieldName, isControlFieldTag, MARC_TARGETS } from '../../helpers/marcFields';

// Field-cell control for a MARC condition. The user picks a tag and what to query ("search on": a subfield, an
// indicator, or the whole tag) — that's the target, whose value goes in the row's value box. Any remaining
// indicator can be pinned to a single fixed value (a filter). The control owns its draft state and emits the
// canonical field name (or '' while incomplete) plus the chosen target, so the row can attach the right operator
// set.
const toDraft = (fieldName) => {
const parsed = parseMarcFieldName(fieldName);

if (parsed) {
return {
tag: parsed.tag,
target: parsed.target,
subfield: parsed.subfield ?? '',
ind1: parsed.ind1 ?? '',
ind2: parsed.ind2 ?? '',
};
}

return { tag: '', target: MARC_TARGETS.SUBFIELD, subfield: '', ind1: '', ind2: '' };
};

export const MarcFieldControl = ({ sourcePrefix, value, onFieldChange, index }) => {
const intl = useIntl();
const [draft, setDraft] = useState(() => toDraft(value));

const update = (patch) => {
const next = { ...draft, ...patch };

// Control fields (00X) have no indicators or subfields — only the whole tag can be queried. Force the target
// so the user can't build an invalid field like marc_008_a.
if (isControlFieldTag(next.tag)) {
next.target = MARC_TARGETS.TAG;
}

setDraft(next);
onFieldChange(
assembleMarcFieldName({
sourcePrefix,
tag: next.tag,
target: next.target,
subfield: next.subfield,
ind1: next.ind1,
ind2: next.ind2,
}) ?? '',
next.target,
);
};

const wholeTagOption = {
value: MARC_TARGETS.TAG,
label: intl.formatMessage({ id: 'ui-plugin-query-builder.marc.target.wholeTag' }),
};
// Control fields can only be queried as a whole; data fields (010+) offer subfield/indicator targets too.
const targetOptions = isControlFieldTag(draft.tag)
? [wholeTagOption]
: [
{ value: MARC_TARGETS.SUBFIELD, label: intl.formatMessage({ id: 'ui-plugin-query-builder.marc.target.subfield' }) },
{ value: MARC_TARGETS.IND1, label: intl.formatMessage({ id: 'ui-plugin-query-builder.marc.target.ind1' }) },
{ value: MARC_TARGETS.IND2, label: intl.formatMessage({ id: 'ui-plugin-query-builder.marc.target.ind2' }) },
wholeTagOption,
];

// Indicator constraint inputs appear for any indicator that is neither the target nor irrelevant (whole tag).
const showInd1Filter = draft.target === MARC_TARGETS.SUBFIELD || draft.target === MARC_TARGETS.IND2;
const showInd2Filter = draft.target === MARC_TARGETS.SUBFIELD || draft.target === MARC_TARGETS.IND1;

return (
<div className="marc-field-control" data-testid={`marc-field-${index}`}>
<TextField
label={<FormattedMessage id="ui-plugin-query-builder.marc.tag" />}
value={draft.tag}
onChange={(e) => update({ tag: e.target.value.trim() })}
maxLength={3}
data-testid={`marc-tag-${index}`}
/>
<Select
label={<FormattedMessage id="ui-plugin-query-builder.marc.searchOn" />}
dataOptions={targetOptions}
value={draft.target}
onChange={(e) => update({ target: e.target.value })}
data-testid={`marc-target-${index}`}
/>

{draft.target === MARC_TARGETS.SUBFIELD && (
<TextField
label={<FormattedMessage id="ui-plugin-query-builder.marc.subfield" />}
value={draft.subfield}
onChange={(e) => update({ subfield: e.target.value.trim() })}
maxLength={1}
data-testid={`marc-subfield-${index}`}
/>
)}

{showInd1Filter && (
<TextField
label={<FormattedMessage id="ui-plugin-query-builder.marc.ind1Filter" />}
value={draft.ind1}
onChange={(e) => update({ ind1: e.target.value.trim() })}
maxLength={5}
data-testid={`marc-ind1-${index}`}
/>
)}
{showInd2Filter && (
<TextField
label={<FormattedMessage id="ui-plugin-query-builder.marc.ind2Filter" />}
value={draft.ind2}
onChange={(e) => update({ ind2: e.target.value.trim() })}
maxLength={5}
data-testid={`marc-ind2-${index}`}
/>
)}
</div>
);
};

MarcFieldControl.propTypes = {
sourcePrefix: PropTypes.string,
value: PropTypes.string,
onFieldChange: PropTypes.func.isRequired,
index: PropTypes.number,
};

MarcFieldControl.defaultProps = {
sourcePrefix: '',
value: '',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { render, fireEvent, cleanup } from '@testing-library/react';
import Intl from '../../../../../test/jest/__mock__/intlProvider.mock';
import { MarcFieldControl } from './MarcFieldControl';

const setup = (props = {}) => {
const onFieldChange = jest.fn();
const utils = render(
<Intl>
<MarcFieldControl index={0} onFieldChange={onFieldChange} {...props} />
</Intl>,
);

return { onFieldChange, ...utils };
};

const change = (el, value) => fireEvent.change(el, { target: { value } });

afterEach(cleanup);

describe('MarcFieldControl', () => {
it('defaults to subfield mode and shows tag, target, subfield, and both indicator filters', () => {
const { getByTestId } = setup();

expect(getByTestId('marc-tag-0')).toBeInTheDocument();
expect(getByTestId('marc-target-0')).toBeInTheDocument();
expect(getByTestId('marc-subfield-0')).toBeInTheDocument();
expect(getByTestId('marc-ind1-0')).toBeInTheDocument();
expect(getByTestId('marc-ind2-0')).toBeInTheDocument();
});

it('assembles a subfield field name, adding indicator filters as they are entered', () => {
const { getByTestId, onFieldChange } = setup();

change(getByTestId('marc-tag-0'), '245');
change(getByTestId('marc-subfield-0'), 'a');
expect(onFieldChange).toHaveBeenLastCalledWith('marc_245_a', 'subfield');

change(getByTestId('marc-ind1-0'), '1');
change(getByTestId('marc-ind2-0'), '2');
expect(onFieldChange).toHaveBeenLastCalledWith('marc_245_ind1_1_ind2_2_a', 'subfield');
});

it('prepends the composite source prefix', () => {
const { getByTestId, onFieldChange } = setup({ sourcePrefix: 'marc_bib.' });

change(getByTestId('marc-tag-0'), '245');
change(getByTestId('marc-subfield-0'), 'a');

expect(onFieldChange).toHaveBeenLastCalledWith('marc_bib.marc_245_a', 'subfield');
});

it('targets an indicator: hides the subfield and the target indicator, keeps the other as a filter', () => {
const { getByTestId, queryByTestId, onFieldChange } = setup();

change(getByTestId('marc-tag-0'), '245');
change(getByTestId('marc-target-0'), 'ind1');

expect(queryByTestId('marc-subfield-0')).not.toBeInTheDocument();
expect(queryByTestId('marc-ind1-0')).not.toBeInTheDocument();
expect(getByTestId('marc-ind2-0')).toBeInTheDocument();
expect(onFieldChange).toHaveBeenLastCalledWith('marc_245_ind1', 'ind1');

change(getByTestId('marc-ind2-0'), '0');
expect(onFieldChange).toHaveBeenLastCalledWith('marc_245_ind2_0_ind1', 'ind1');
});

it('restricts control-field tags (00X) to the whole tag', () => {
const { getByTestId, queryByTestId, onFieldChange } = setup();

change(getByTestId('marc-tag-0'), '008');

expect(queryByTestId('marc-subfield-0')).not.toBeInTheDocument();
expect(queryByTestId('marc-ind1-0')).not.toBeInTheDocument();
expect(queryByTestId('marc-ind2-0')).not.toBeInTheDocument();
expect(getByTestId('marc-target-0').querySelectorAll('option')).toHaveLength(1);
expect(onFieldChange).toHaveBeenLastCalledWith('marc_008', 'tag');
});

it('round-trips a saved subfield field name into its inputs', () => {
const { getByTestId } = setup({ value: 'marc_245_ind1_1_ind2_2_a' });

expect(getByTestId('marc-tag-0').value).toBe('245');
expect(getByTestId('marc-target-0').value).toBe('subfield');
expect(getByTestId('marc-subfield-0').value).toBe('a');
expect(getByTestId('marc-ind1-0').value).toBe('1');
expect(getByTestId('marc-ind2-0').value).toBe('2');
});

it('round-trips a saved indicator-target field name', () => {
const { getByTestId, queryByTestId } = setup({ value: 'marc_245_ind2_0_ind1' });

expect(getByTestId('marc-target-0').value).toBe('ind1');
expect(getByTestId('marc-ind2-0').value).toBe('0');
expect(queryByTestId('marc-ind1-0')).not.toBeInTheDocument();
expect(queryByTestId('marc-subfield-0')).not.toBeInTheDocument();
});

it('round-trips a subfield field name with no indicator constraints (empty indicator filters)', () => {
const { getByTestId } = setup({ value: 'marc_245_a' });

expect(getByTestId('marc-tag-0').value).toBe('245');
expect(getByTestId('marc-target-0').value).toBe('subfield');
expect(getByTestId('marc-subfield-0').value).toBe('a');
expect(getByTestId('marc-ind1-0').value).toBe('');
expect(getByTestId('marc-ind2-0').value).toBe('');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { MarcFieldControl } from './MarcFieldControl';
Loading