Skip to content

Commit 37c3381

Browse files
Vojtěch Václav Portešvojtechportes
authored andcommitted
feat: Added optional usageLimit property to field entity
- Added optional usageLimit property to field entity - Updated API, Documentation and Demo sections on the library website - Updated README
1 parent 7e5bbb6 commit 37c3381

20 files changed

Lines changed: 602 additions & 18 deletions

File tree

README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,60 @@ export const MyBuilder = () => {
100100
};
101101
```
102102

103+
## Validation and Usage Limits
104+
105+
Field metadata can define both value validation and structural usage limits.
106+
Use `validation` for rule-local checks like required values or numeric bounds,
107+
and use `usageLimit` when a field or shared field bucket should only appear a
108+
limited number of times globally or within the same parent group.
109+
110+
```tsx
111+
const fields: IBuilderFieldProps[] = [
112+
{
113+
field: 'PRIMARY_EMAIL',
114+
label: 'Primary email',
115+
type: 'TEXT',
116+
operators: ['EQUAL', 'CONTAINS'],
117+
usageLimit: {
118+
max: 1,
119+
scope: 'global',
120+
},
121+
validation: {
122+
required: true,
123+
},
124+
},
125+
{
126+
field: 'BILLING_CONTACT',
127+
label: 'Billing contact',
128+
type: 'TEXT',
129+
operators: ['EQUAL'],
130+
usageLimit: {
131+
key: 'contact-field',
132+
max: 1,
133+
scope: 'parent',
134+
},
135+
},
136+
{
137+
field: 'SHIPPING_CONTACT',
138+
label: 'Shipping contact',
139+
type: 'TEXT',
140+
operators: ['EQUAL'],
141+
usageLimit: {
142+
key: 'contact-field',
143+
max: 1,
144+
scope: 'parent',
145+
},
146+
},
147+
];
148+
```
149+
150+
When a usage limit is exhausted, the field becomes disabled in the selector,
151+
and the built-in Add Rule action is disabled when no selectable fields remain
152+
in the current scope.
153+
154+
- <a href="https://vojtechportes.github.io/react-query-builder/documentation/validation" target="_blank" rel="noopener noreferrer">Documentation: Validation</a>
155+
- <a href="https://vojtechportes.github.io/react-query-builder/api/fields" target="_blank" rel="noopener noreferrer">API: Fields</a>
156+
103157
## Dynamic Field Options
104158

105159
When list options depend on other rules or on external application state, keep

example/src/constants/demo-data.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,26 @@ export const demoFields: IBuilderFieldProps[] = [
122122
type: 'STATEMENT',
123123
value: 'HAS_DEBT() AND LAST_PAYMENT_DAYS_AGO() > 30',
124124
},
125+
{
126+
field: 'FIRST_NAME',
127+
label: 'First name',
128+
type: 'TEXT',
129+
operators: [
130+
'EQUAL',
131+
'NOT_EQUAL',
132+
'CONTAINS',
133+
'NOT_CONTAINS',
134+
'STARTS_WITH',
135+
'ENDS_WITH',
136+
],
137+
validation: {
138+
required: true,
139+
},
140+
usageLimit: {
141+
max: 1,
142+
scope: 'global'
143+
}
144+
},
125145
];
126146

127147
export const initialQueryTree: DenormalizedQuery = [

example/src/pages/api-page/pages/api-content.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,9 +224,19 @@ const fieldBaseSignature = `interface IBuilderFieldBase<TType, TValue, TValidati
224224
value?: TValue;
225225
type: TType;
226226
operators?: BuilderFieldOperator[];
227+
usageLimit?: IBuilderFieldUsageLimit;
227228
validation?: TValidation;
228229
}`;
229230

231+
const fieldUsageLimitSignature = `export type BuilderFieldUsageLimitScope = 'global' | 'parent';
232+
233+
export interface IBuilderFieldUsageLimit {
234+
key?: string;
235+
max: number;
236+
scope?: BuilderFieldUsageLimitScope;
237+
message?: BuilderValidationMessage;
238+
}`;
239+
230240
const fieldOptionTypesSignature = `export type BuilderFieldOption = {
231241
value: string | number;
232242
label: string;
@@ -1004,13 +1014,18 @@ export const apiPages: IApiPage[] = [
10041014
sectionTitle: 'Core API',
10051015
summary: '',
10061016
description:
1007-
'Field definition API reference for IBuilderFieldProps, field types, operators, and validation metadata.',
1017+
'Field definition API reference for IBuilderFieldProps, field types, operators, usageLimit, and validation metadata.',
10081018
searchText:
1009-
'IBuilderFieldProps field label type value operators validation BOOLEAN TEXT DATE NUMBER STATEMENT LIST MULTI_LIST GROUP BuilderFieldOption BuilderFieldOptionsStatus IBuilderFieldOptionState INearestFieldMatch IBuilderFieldChange dynamic field options',
1019+
'IBuilderFieldProps field label type value operators usageLimit validation BOOLEAN TEXT DATE NUMBER STATEMENT LIST MULTI_LIST GROUP BuilderFieldOption BuilderFieldOptionsStatus IBuilderFieldOptionState INearestFieldMatch IBuilderFieldChange dynamic field options',
10101020
content: (
10111021
<>
10121022
<CodeBlock code={fieldTypesSignature} language="ts" label="Field unions" />
10131023
<CodeBlock code={fieldBaseSignature} language="ts" label="Shared field shape" />
1024+
<CodeBlock
1025+
code={fieldUsageLimitSignature}
1026+
language="ts"
1027+
label="Field usage limits"
1028+
/>
10141029
<CodeBlock
10151030
code={fieldOptionTypesSignature}
10161031
language="ts"
@@ -1023,8 +1038,16 @@ export const apiPages: IApiPage[] = [
10231038
<li><ItemTitle><InlineCode>type</InlineCode>:</ItemTitle> Required field type. This controls which widget and value semantics are used.</li>
10241039
<li><ItemTitle><InlineCode>value</InlineCode>:</ItemTitle> Optional default or backing field value metadata. For <InlineCode>LIST</InlineCode> and <InlineCode>MULTI_LIST</InlineCode>, this is the initial static option set.</li>
10251040
<li><ItemTitle><InlineCode>operators</InlineCode>:</ItemTitle> Optional operator whitelist. When omitted, the builder falls back to the default operators for the field type.</li>
1041+
<li><ItemTitle><InlineCode>usageLimit</InlineCode>:</ItemTitle> Optional structural constraint that limits how many rules may use this field or its shared usage bucket.</li>
10261042
<li><ItemTitle><InlineCode>validation</InlineCode>:</ItemTitle> Optional validation config. The shape depends on field type.</li>
10271043
</List>
1044+
<SectionTitle>usageLimit</SectionTitle>
1045+
<List>
1046+
<li><ItemTitle><InlineCode>max</InlineCode>:</ItemTitle> Required maximum number of matching rules allowed in the selected scope.</li>
1047+
<li><ItemTitle><InlineCode>scope</InlineCode>:</ItemTitle> Optional. Defaults to <InlineCode>global</InlineCode>. Use <InlineCode>parent</InlineCode> to limit usage only within the same immediate parent group.</li>
1048+
<li><ItemTitle><InlineCode>key</InlineCode>:</ItemTitle> Optional shared bucket identifier. When omitted, the builder uses the field&apos;s own <InlineCode>field</InlineCode> value.</li>
1049+
<li><ItemTitle><InlineCode>message</InlineCode>:</ItemTitle> Optional custom validation message used when persisted or imported data exceeds the allowed limit.</li>
1050+
</List>
10281051
<SectionTitle>Type notes</SectionTitle>
10291052
<List>
10301053
<li><ItemTitle><InlineCode>BOOLEAN</InlineCode> / <InlineCode>TEXT</InlineCode> / <InlineCode>DATE</InlineCode> / <InlineCode>NUMBER</InlineCode>:</ItemTitle> The standard scalar field families with built-in widget and validation behavior.</li>
@@ -1034,6 +1057,7 @@ export const apiPages: IApiPage[] = [
10341057
</List>
10351058
<AlertBox title="Documentation" variant="info">
10361059
<TextLink to="/documentation/usage">Usage</TextLink>,{' '}
1060+
<TextLink to="/documentation/validation">Validation</TextLink>,{' '}
10371061
<TextLink to="/documentation/dynamic-field-options">
10381062
Dynamic Field Options
10391063
</TextLink>, and <TextLink to="/documentation/localization">Localization</TextLink>.

example/src/pages/documentation-page/pages/documentation-content.tsx

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,48 @@ const validationSnippet = `const fields: IBuilderFieldProps[] = [
526526
onChange={setData}
527527
/>;`;
528528

529+
const usageLimitSnippet = `const fields: IBuilderFieldProps[] = [
530+
{
531+
field: 'PRIMARY_EMAIL',
532+
label: 'Primary email',
533+
type: 'TEXT',
534+
operators: ['EQUAL', 'CONTAINS'],
535+
usageLimit: {
536+
max: 1,
537+
scope: 'global',
538+
},
539+
},
540+
{
541+
field: 'BILLING_CONTACT',
542+
label: 'Billing contact',
543+
type: 'TEXT',
544+
operators: ['EQUAL'],
545+
usageLimit: {
546+
key: 'contact-field',
547+
max: 1,
548+
scope: 'parent',
549+
},
550+
},
551+
{
552+
field: 'SHIPPING_CONTACT',
553+
label: 'Shipping contact',
554+
type: 'TEXT',
555+
operators: ['EQUAL'],
556+
usageLimit: {
557+
key: 'contact-field',
558+
max: 1,
559+
scope: 'parent',
560+
},
561+
},
562+
];
563+
564+
<Builder
565+
fields={fields}
566+
data={data}
567+
showValidation
568+
onChange={setData}
569+
/>;`;
570+
529571
const builderBehaviorSnippet = `<Builder
530572
fields={fields}
531573
data={data}
@@ -1666,9 +1708,9 @@ export const documentationPages: IDocumentationPage[] = [
16661708
sectionTitle: 'Getting Started',
16671709
summary: '',
16681710
description:
1669-
'Built-in validation for fields and rules, validation rendering with showValidation, and custom validator integration.',
1711+
'Built-in validation for fields and rules, structural usageLimit constraints, validation rendering with showValidation, and custom validator integration.',
16701712
searchText:
1671-
'Validation built-in validation validator showValidation onStateChange required minLength maxLength minItems maxItems range validation rules fields builder',
1713+
'Validation built-in validation validator usageLimit showValidation onStateChange required minLength maxLength minItems maxItems range validation rules fields builder',
16721714
content: (
16731715
<>
16741716
<p>
@@ -1689,10 +1731,26 @@ export const documentationPages: IDocumentationPage[] = [
16891731
<li>List and multi-list fields support item-count constraints such as <InlineCode>minItems</InlineCode> and <InlineCode>maxItems</InlineCode>.</li>
16901732
<li>Range operators such as <InlineCode>BETWEEN</InlineCode> can use <InlineCode>range</InlineCode> validation to validate both values together.</li>
16911733
</List>
1734+
<SectionTitle>Structural usage limits</SectionTitle>
1735+
<p>
1736+
Use <InlineCode>usageLimit</InlineCode> when a constraint depends on how
1737+
many rules already use a field or a shared usage bucket. This is separate
1738+
from value validation because it governs query structure rather than the
1739+
validity of a single rule value.
1740+
</p>
1741+
<CodeBlock code={usageLimitSnippet} language="tsx" label="Field usage limits" />
1742+
<List>
1743+
<li><InlineCode>max</InlineCode> defines how many matching rules are allowed inside the selected scope.</li>
1744+
<li><InlineCode>scope=&quot;global&quot;</InlineCode> limits usage across the whole query tree.</li>
1745+
<li><InlineCode>scope=&quot;parent&quot;</InlineCode> limits usage only among sibling rules in the same immediate parent group.</li>
1746+
<li><InlineCode>key</InlineCode> lets multiple different fields share the same quota bucket.</li>
1747+
<li>Exhausted fields are disabled in the field selector, and the Add Rule button is disabled when no selectable fields remain in the current scope.</li>
1748+
<li><InlineCode>showValidation</InlineCode> still surfaces an issue when data arrives in an already invalid state, such as external input or text mode edits.</li>
1749+
</List>
16921750
<AlertBox title="Custom validator" variant="info">
16931751
Use <InlineCode>validator</InlineCode> when validation depends on
16941752
multiple rules, external state, or rules that are not expressible in
1695-
field-level validation config.
1753+
field-level validation config or <InlineCode>usageLimit</InlineCode>.
16961754
</AlertBox>
16971755
<AlertBox title="API reference" variant="info">
16981756
<TextLink to="/api/builder">Builder</TextLink> and{' '}

src/builder/builder.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,28 @@ describe('#components/Builder', () => {
245245
expect(queryByDataTest(container, 'Redo')).toBeNull();
246246
});
247247

248+
it('Disables root add rule when all root-scope fields are exhausted', () => {
249+
const limitedFields: IBuilderFieldProps[] = [
250+
{
251+
field: 'MOCK_FIELD',
252+
label: 'Mock Field',
253+
type: 'TEXT',
254+
operators: ['EQUAL'],
255+
usageLimit: { max: 1, scope: 'parent' },
256+
},
257+
];
258+
const { container } = render(
259+
<Builder
260+
fields={limitedFields}
261+
data={[{ field: 'MOCK_FIELD', value: 'alpha', operator: 'EQUAL' }]}
262+
singleRootGroup={false}
263+
onChange={jest.fn()}
264+
/>
265+
);
266+
267+
expect(getByDataTest(container, 'AddRootRule')).toBeDisabled();
268+
});
269+
248270
it('Switches the builder into SQL text mode', () => {
249271
const { container } = render(
250272
<Builder

src/builder/builder.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { isNodeDeletionProtected } from '../utils/is-node-deletion-protected.uti
6262
import { createBuilderFieldOptionsStore } from './utils/create-builder-field-options-store.util';
6363
import { getNearestFieldMatch } from './utils/get-nearest-field-match.util';
6464
import { isBuilderFieldInUse } from './utils/is-builder-field-in-use.util';
65+
import { canAddRuleForParent } from './utils/resolve-builder-field-usage.util';
6566
import { resolveBuilderFieldOptionState } from './utils/resolve-builder-field-option-state.util';
6667
import { resolveReconciledBuilderRuleValue } from './utils/resolve-reconciled-builder-rule-value.util';
6768

@@ -142,6 +143,7 @@ export const Builder = forwardRef<IBuilderRef, IBuilderProps>(({
142143
fieldOptionsStoreRef.current = createBuilderFieldOptionsStore();
143144
}
144145
const filteredData = data.filter((item) => !item.parent);
146+
const canAddRootRule = canAddRuleForParent(data, fields);
145147
const AddComponent = components.Add || Button;
146148
const AlertComponent = components.Alert || defaultComponents.Alert || DefaultAlert;
147149
const OutlinedButtonComponent =
@@ -845,6 +847,7 @@ export const Builder = forwardRef<IBuilderRef, IBuilderProps>(({
845847
onSwitchToBuilderMode={handleSwitchToBuilderMode}
846848
onSwitchToTextMode={handleSwitchToTextMode}
847849
onAddRootRule={handleAddRootRule}
850+
disableAddRootRule={!canAddRootRule}
848851
onAddRootGroup={handleAddRootGroup}
849852
onAddRootGroupWithoutModifiers={handleAddRootGroupWithoutModifiers}
850853
AddComponent={AddComponent}

src/builder/components/builder-root-actions.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface IBuilderRootActionsProps {
2323
onSwitchToBuilderMode?: () => void;
2424
onSwitchToTextMode?: () => void;
2525
onAddRootRule: () => void;
26+
disableAddRootRule?: boolean;
2627
onAddRootGroup: () => void;
2728
onAddRootGroupWithoutModifiers: () => void;
2829
AddComponent: React.ComponentType<IButtonProps>;
@@ -49,6 +50,7 @@ export const BuilderRootActions: FC<IBuilderRootActionsProps> = ({
4950
onSwitchToBuilderMode,
5051
onSwitchToTextMode,
5152
onAddRootRule,
53+
disableAddRootRule = false,
5254
onAddRootGroup,
5355
onAddRootGroupWithoutModifiers,
5456
AddComponent,
@@ -124,7 +126,11 @@ export const BuilderRootActions: FC<IBuilderRootActionsProps> = ({
124126
) : null}
125127
{!readOnly && strings.group && !singleRootGroup ? (
126128
<>
127-
<AddComponent onClick={onAddRootRule} data-test="AddRootRule">
129+
<AddComponent
130+
onClick={onAddRootRule}
131+
disabled={disableAddRootRule}
132+
data-test="AddRootRule"
133+
>
128134
{strings.group.addRule}
129135
</AddComponent>
130136
{groupTypes === 'both' ? (

src/builder/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export type {
2929
BuilderFieldOptionsStatus,
3030
BuilderRuleValueReconciliationStrategy,
3131
BuilderFieldType,
32+
BuilderFieldUsageLimitScope,
3233
BuilderFieldValue,
3334
BuilderGroupMode,
3435
BuilderNewNodePlacement,
@@ -47,6 +48,7 @@ export type {
4748
IBuilderFieldOptionState,
4849
IBuilderRuleValueReconciliationConfig,
4950
IBuilderFieldValidationBase,
51+
IBuilderFieldUsageLimit,
5052
IBuilderOperatorValidationRule,
5153
IBuilderStateChange,
5254
IBuilderValidationConfig,

src/builder/types/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,22 @@ export type BuilderFieldValue =
8181
| boolean
8282
| Array<{ value: string | number; label: string }>;
8383

84+
export type BuilderFieldUsageLimitScope = 'global' | 'parent';
85+
86+
export interface IBuilderFieldUsageLimit {
87+
key?: string;
88+
max: number;
89+
scope?: BuilderFieldUsageLimitScope;
90+
message?: BuilderValidationMessage;
91+
}
92+
8493
export interface IBuilderValidationMessageContext {
8594
field: IBuilderFieldProps;
8695
operator?: BuilderFieldOperator;
8796
value?: BuilderFieldValue;
8897
ruleId?: string;
8998
rangeBoundary?: 'start' | 'end';
99+
usageLimit?: IBuilderFieldUsageLimit;
90100
}
91101

92102
export type BuilderValidationMessage =
@@ -223,6 +233,7 @@ interface IBuilderFieldBase<
223233
type: TType;
224234
operators?: BuilderFieldOperator[];
225235
validation?: TValidation;
236+
usageLimit?: IBuilderFieldUsageLimit;
226237
}
227238

228239
export type IBooleanFieldProps = IBuilderFieldBase<

0 commit comments

Comments
 (0)