Skip to content

Commit 61189d8

Browse files
Vojtěch Václav Portešvojtechportes
authored andcommitted
feat: Added validation functionality
1 parent ea12f87 commit 61189d8

20 files changed

Lines changed: 1553 additions & 110 deletions

README.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ React Query Builder is designed to be highly configurable. You can control:
107107
- field definitions
108108
- initial and controlled query data
109109
- drag and drop support
110+
- validation rules
110111
- read-only behavior
111112
- supported group types
112113
- theming
@@ -145,6 +146,7 @@ Each field can define:
145146
- `type`: field type
146147
- `operators`: allowed operators for the field
147148
- `value`: source value for `LIST`, `MULTI_LIST`, or `STATEMENT`
149+
- `validation`: optional validation rules for the field
148150

149151
#### Type
150152

@@ -195,6 +197,55 @@ Prefer `IN`, `CONTAINS`, and `NOT_CONTAINS`.
195197

196198
`IS_NULL` and `IS_NOT_NULL` are value-less operators, so the built-in UI does not render a value input for them.
197199

200+
#### Validation
201+
202+
Validation is defined per field and is type-aware.
203+
204+
```typescript
205+
const fields: IBuilderFieldProps[] = [
206+
{
207+
field: 'NAME',
208+
label: 'Name',
209+
type: 'TEXT',
210+
operators: ['EQUAL', 'CONTAINS'],
211+
validation: {
212+
required: true,
213+
minLength: 2,
214+
maxLength: 50,
215+
},
216+
},
217+
{
218+
field: 'PRICE',
219+
label: 'Price',
220+
type: 'NUMBER',
221+
operators: ['EQUAL', 'BETWEEN'],
222+
validation: {
223+
min: 0,
224+
range: {
225+
requireAscending: true,
226+
allowEqual: false,
227+
},
228+
},
229+
},
230+
];
231+
```
232+
233+
Built-in validation supports:
234+
235+
- shared rules like `required`, `oneOf`, and `custom`
236+
- text rules like `minLength`, `maxLength`, and `matches`
237+
- number rules like `min`, `max`, `integer`, `positive`, and `negative`
238+
- date rules like `minDate` and `maxDate`
239+
- multi-list rules like `minItems` and `maxItems`
240+
- range validation for `BETWEEN` and `NOT_BETWEEN`
241+
242+
If you use `range`, you can validate both the range shape and the relationship between values:
243+
244+
- `requireAscending`
245+
- `allowEqual`
246+
- `validate`
247+
- `message`
248+
198249
### Data
199250

200251
`data` is a controlled prop. Pass an array of rules and groups, and update it through `onChange`.
@@ -255,6 +306,9 @@ Important `Builder` props:
255306
- `draggable?: boolean`
256307
- `singleRootGroup?: boolean`
257308
- `groupTypes?: 'with-modifiers' | 'without-modifiers' | 'both'`
309+
- `validator?: IBuilderValidator`
310+
- `onStateChange?: (state: IBuilderStateChange) => void`
311+
- `showValidation?: boolean`
258312

259313
#### `readOnly`
260314

@@ -297,6 +351,37 @@ Controls what kinds of groups users can add:
297351

298352
When set to `'both'`, the `Add Group` action becomes a popover that lets the user choose which group type to create.
299353

354+
#### `validator`
355+
356+
`validator` lets you override the built-in validation engine with your own implementation.
357+
358+
The validator receives:
359+
360+
- the current query data
361+
- builder validation context including `fields`, `singleRootGroup`, and `groupTypes`
362+
363+
It should return an `IBuilderValidationResult` or a promise resolving to one.
364+
365+
This makes it possible to plug in optional adapters such as Yup or Joi without coupling the core builder to a specific validation library.
366+
367+
#### `onStateChange`
368+
369+
`onStateChange` emits the current builder state together with validation output:
370+
371+
```typescript
372+
{
373+
data: DenormalizedQuery;
374+
isValid: boolean;
375+
validation: IBuilderValidationResult;
376+
}
377+
```
378+
379+
`onChange` still emits only the denormalized query data.
380+
381+
#### `showValidation`
382+
383+
When `showValidation` is `true`, the built-in `Rule` component renders validation issues under the affected rule.
384+
300385
### Components
301386

302387
You can fully customize the rendered components through the `components` prop.
@@ -420,3 +505,19 @@ const strings: IStrings = {
420505
`ANY_IN`, `LIKE`, and `NOT_LIKE` are deprecated here as well and will be removed in `2.0.0`.
421506

422507
It is not required to translate every string. Any string you omit falls back to the built-in defaults.
508+
509+
Validation copy is also configurable through `strings.validation`, for example:
510+
511+
```typescript
512+
const strings: IStrings = {
513+
validation: {
514+
required: 'This field is required',
515+
min: 'Value must be at least {min}',
516+
max: 'Value must be at most {max}',
517+
minItems: 'Select at least {min} values',
518+
maxItems: 'Select at most {max} values',
519+
},
520+
};
521+
```
522+
523+
Validation strings support placeholder replacement for values such as `{field}`, `{operator}`, `{min}`, and `{max}`.

example/src/main.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ const fields: IBuilderFieldProps[] = [
132132
'LIKE',
133133
'NOT_LIKE',
134134
],
135+
validation: {
136+
required: true
137+
}
135138
},
136139
{
137140
field: 'HAS_LOW_CREDIT',
@@ -210,6 +213,8 @@ const App: React.FC = () => {
210213
draggable={draggable}
211214
groupTypes="both"
212215
singleRootGroup={singleRootGroup}
216+
showValidation
217+
213218
/>
214219
</ThemeProvider>
215220

src/builder-context.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { FC, createContext } from 'react';
22
import {
33
IBuilderComponentsProps,
44
IBuilderFieldProps,
5+
IBuilderValidationResult,
56
BuilderGroupMode,
67
defaultComponents,
78
} from './builder';
@@ -15,6 +16,8 @@ export interface IBuilderContextProps {
1516
draggable?: boolean;
1617
singleRootGroup?: boolean;
1718
groupTypes?: BuilderGroupMode;
19+
showValidation?: boolean;
20+
validation?: IBuilderValidationResult;
1821
components: IBuilderComponentsProps;
1922
strings: IStrings;
2023
setData: React.Dispatch<NormalizedQuery>;
@@ -35,6 +38,8 @@ export interface IBuilderContextProviderProps {
3538
draggable?: boolean;
3639
singleRootGroup?: boolean;
3740
groupTypes?: BuilderGroupMode;
41+
showValidation?: boolean;
42+
validation?: IBuilderValidationResult;
3843
components: IBuilderComponentsProps;
3944
strings: IStrings;
4045
setData: React.Dispatch<NormalizedQuery>;
@@ -54,6 +59,8 @@ export const BuilderContextProvider: FC<IBuilderContextProviderProps> = ({
5459
draggable,
5560
singleRootGroup,
5661
groupTypes,
62+
showValidation,
63+
validation,
5764
setData,
5865
onChange,
5966
updateData,
@@ -84,6 +91,10 @@ export const BuilderContextProvider: FC<IBuilderContextProviderProps> = ({
8491
...defaultStrings.operators,
8592
...strings.operators,
8693
},
94+
validation: {
95+
...defaultStrings.validation,
96+
...strings.validation,
97+
},
8798
};
8899

89100
return (
@@ -97,6 +108,8 @@ export const BuilderContextProvider: FC<IBuilderContextProviderProps> = ({
97108
draggable,
98109
singleRootGroup,
99110
groupTypes,
111+
showValidation,
112+
validation,
100113
setData,
101114
onChange,
102115
updateData,

src/builder.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,4 +273,45 @@ describe('#components/Builder', () => {
273273
},
274274
]);
275275
});
276+
277+
it('Emits validation state and renders validation issues when enabled', async () => {
278+
const onStateChange = jest.fn();
279+
const wrapper = mount(
280+
<Builder
281+
fields={[
282+
{
283+
field: 'REQUIRED_TEXT',
284+
label: 'Required Text',
285+
type: 'TEXT',
286+
operators: ['EQUAL'],
287+
validation: {
288+
required: true,
289+
},
290+
},
291+
]}
292+
data={[
293+
{
294+
type: 'GROUP',
295+
value: 'AND',
296+
isNegated: false,
297+
children: [{ field: 'REQUIRED_TEXT', value: '', operator: 'EQUAL' }],
298+
},
299+
]}
300+
showValidation
301+
onStateChange={onStateChange}
302+
/>
303+
);
304+
305+
await Promise.resolve();
306+
wrapper.update();
307+
308+
expect(onStateChange).toHaveBeenCalled();
309+
expect(onStateChange.mock.calls[onStateChange.mock.calls.length - 1][0]).toMatchObject({
310+
isValid: false,
311+
validation: {
312+
isValid: false,
313+
},
314+
});
315+
expect(wrapper.text()).toContain('This value is required');
316+
});
276317
});

0 commit comments

Comments
 (0)