Skip to content

Commit 3ae2b75

Browse files
Vojtěch Václav Portešvojtechportes
authored andcommitted
feat: Added a possibility to disable negated groups by not rendering a "NOT" toggle and adjusting text mode validation
- Added allowGroupNegation property on Builder, defaults to true - Updated text mode validation - Updated library website and readme
1 parent 3c7f652 commit 3ae2b75

42 files changed

Lines changed: 924 additions & 143 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.codex/AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Long-running tooling (tests, docker compose, migrations, etc.) must always be invoked with sensible timeouts or in non-interactive batch mode. Never leave a shell command waiting indefinitely—prefer explicit timeouts, scripted runs, or log polling after the command exits.
2+
3+
Prefer arrow functions over function statements. Make sure each file contains exactly one function, one react component or set of constants that are related.
4+
5+
If pattern is react component, function is tied to one or two interfaces or one or two styled components, they can be in one file. Once the file becomes too complex, prefer splitting types into ./types folder relative to the component or move styled components to ./components folder relative to the component.
6+
7+
Utility functions should be always suffixed with .util.(ts|tsx).

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ node_modules
99
.rts2_cache_umd
1010
*.tsbuildinfo
1111
dist
12+
.tmp
1213

1314
/coverage
1415
example/dist

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,9 @@ For advanced text editing, the package also exposes
380380
`@vojtechportes/react-query-builder/monaco`. The built-in editor is lightweight
381381
and works without extra dependencies, while the Monaco integration is the
382382
recommended path when locked query segments must stay protected in text mode.
383+
Set `allowGroupNegation={false}` to remove the group-level `NOT` toggle and
384+
reject `NOT (...)` groups in text mode, while still allowing operator-level
385+
negation such as `NOT IN` or `IS NOT NULL`.
383386

384387
- <a href="https://vojtechportes.github.io/react-query-builder/documentation/text-mode" target="_blank" rel="noopener noreferrer">Documentation: Text Mode</a>
385388
- <a href="https://vojtechportes.github.io/react-query-builder/api/builder" target="_blank" rel="noopener noreferrer">API: Builder</a>

example/src/components/demo-playground.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ export const DemoPlayground: React.FC<IDemoPlaygroundProps> = ({
264264
const [lockable, setLockable] = React.useState(false);
265265
const [cloneable, setCloneable] = React.useState(false);
266266
const [draggable, setDraggable] = React.useState(false);
267+
const [allowGroupNegation, setAllowGroupNegation] = React.useState(true);
267268
const [newNodePlacement, setNewNodePlacement] = React.useState<
268269
'append' | 'prepend'
269270
>('append');
@@ -332,6 +333,7 @@ export const DemoPlayground: React.FC<IDemoPlaygroundProps> = ({
332333
cloneable,
333334
onChange: setData,
334335
draggable,
336+
allowGroupNegation,
335337
newNodePlacement,
336338
history,
337339
textMode,
@@ -358,6 +360,7 @@ export const DemoPlayground: React.FC<IDemoPlaygroundProps> = ({
358360
lockable,
359361
cloneable,
360362
draggable,
363+
allowGroupNegation,
361364
newNodePlacement,
362365
history,
363366
textMode,
@@ -375,6 +378,7 @@ export const DemoPlayground: React.FC<IDemoPlaygroundProps> = ({
375378
lockable,
376379
cloneable,
377380
draggable,
381+
allowGroupNegation,
378382
newNodePlacement,
379383
history,
380384
textMode,
@@ -440,6 +444,15 @@ export const DemoPlayground: React.FC<IDemoPlaygroundProps> = ({
440444
<span>Draggable nodes</span>
441445
</ToggleRow>
442446

447+
<ToggleRow>
448+
<Toggle
449+
type="checkbox"
450+
checked={allowGroupNegation}
451+
onChange={event => setAllowGroupNegation(event.target.checked)}
452+
/>
453+
<span>Allow group negation</span>
454+
</ToggleRow>
455+
443456
<ToggleRow>
444457
<Toggle
445458
type="checkbox"

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const builderSignature = `export interface IBuilderProps {
2121
lockable?: boolean;
2222
cloneable?: boolean;
2323
draggable?: boolean;
24+
allowGroupNegation?: boolean;
2425
singleRootGroup?: boolean;
2526
groupTypes?: 'with-modifiers' | 'without-modifiers' | 'both';
2627
newNodePlacement?: 'append' | 'prepend';
@@ -866,6 +867,7 @@ export const apiPages: IApiPage[] = [
866867
<li><ItemTitle><InlineCode>lockable</InlineCode>:</ItemTitle> Defaults to <InlineCode>false</InlineCode>. Renders lock controls for rules and groups and writes the resulting lock state back into emitted query data without discarding existing targeted <InlineCode>readOnly.targets</InlineCode> configurations.</li>
867868
<li><ItemTitle><InlineCode>cloneable</InlineCode>:</ItemTitle> Defaults to <InlineCode>false</InlineCode>. Renders clone controls for rules and groups and inserts the cloned node directly below the original.</li>
868869
<li><ItemTitle><InlineCode>draggable</InlineCode>:</ItemTitle> Defaults to <InlineCode>false</InlineCode>. Enables drag-and-drop reordering and movement of query nodes.</li>
870+
<li><ItemTitle><InlineCode>allowGroupNegation</InlineCode>:</ItemTitle> Defaults to <InlineCode>true</InlineCode>. When set to <InlineCode>false</InlineCode>, group negation is disabled across the builder: the group-level <InlineCode>NOT</InlineCode> control is hidden, emitted groups are normalized to non-negated form, and SQL text mode rejects group-level <InlineCode>NOT (...)</InlineCode> expressions. Operator-level negation such as <InlineCode>NOT IN</InlineCode>, <InlineCode>NOT LIKE</InlineCode>, <InlineCode>IS NOT NULL</InlineCode>, and <InlineCode>NOT BETWEEN</InlineCode> remains supported.</li>
869871
<li><ItemTitle><InlineCode>singleRootGroup</InlineCode>:</ItemTitle> Defaults to <InlineCode>true</InlineCode>. Wraps root-level items into a single root group and prevents deleting that root group. Text mode requires this to stay enabled.</li>
870872
<li><ItemTitle><InlineCode>groupTypes</InlineCode>:</ItemTitle> Defaults to <InlineCode>'with-modifiers'</InlineCode>. Controls whether groups use combinator/negation controls, modifierless groups, or both. When text mode is active, builder-compatible SQL round-tripping uses groups with modifiers.</li>
871873
<li><ItemTitle><InlineCode>newNodePlacement</InlineCode>:</ItemTitle> Defaults to <InlineCode>'append'</InlineCode>. Controls whether newly added rules and groups are inserted at the end or the beginning of their parent when built-in add actions or imperative add methods omit an explicit index.</li>

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,7 @@ const builderBehaviorSnippet = `<Builder
575575
readOnlyProtectsDelete
576576
cloneable
577577
draggable
578+
allowGroupNegation={false}
578579
newNodePlacement="prepend"
579580
singleRootGroup={false}
580581
groupTypes="both"
@@ -596,6 +597,10 @@ const builderBehaviorSnippet = `<Builder
596597
// draggable:
597598
// Enables drag-and-drop for editable rules and groups.
598599
//
600+
// allowGroupNegation={false}:
601+
// Hides the group-level NOT toggle and rejects NOT (...) groups in text mode
602+
// while still allowing operator-level negation such as NOT IN or IS NOT NULL.
603+
//
599604
// newNodePlacement="prepend":
600605
// Inserts newly added rules and groups at the beginning of their parent instead
601606
// of appending them to the end. The default is "append".
@@ -1833,7 +1838,7 @@ export const documentationPages: IDocumentationPage[] = [
18331838
description:
18341839
'Documentation for clone controls, drag-and-drop, insertion placement, root-group behavior, and group mode configuration.',
18351840
searchText:
1836-
'builder behavior cloneable clone controls draggable drag and drop readOnlyProtectsDelete newNodePlacement append prepend singleRootGroup groupTypes with modifiers without modifiers both root group',
1841+
'builder behavior cloneable clone controls draggable drag and drop allowGroupNegation group negation not groups readOnlyProtectsDelete newNodePlacement append prepend singleRootGroup groupTypes with modifiers without modifiers both root group',
18371842
content: (
18381843
<>
18391844
<p>
@@ -1914,6 +1919,30 @@ export const documentationPages: IDocumentationPage[] = [
19141919
insert when adding a new group.
19151920
</li>
19161921
</List>
1922+
<SectionTitle>allowGroupNegation</SectionTitle>
1923+
<List>
1924+
<li>
1925+
Set <InlineCode>allowGroupNegation={false}</InlineCode> when you want
1926+
groups to keep combinators like <InlineCode>AND</InlineCode> and{' '}
1927+
<InlineCode>OR</InlineCode> but remove the group-level{' '}
1928+
<InlineCode>NOT</InlineCode> option.
1929+
</li>
1930+
<li>
1931+
This is narrower than <InlineCode>groupTypes</InlineCode>: it only
1932+
disables negating whole groups and does not affect whether groups
1933+
render combinator controls.
1934+
</li>
1935+
<li>
1936+
Operator-level negation still works normally, including{' '}
1937+
<InlineCode>NOT IN</InlineCode>, <InlineCode>NOT LIKE</InlineCode>,{' '}
1938+
<InlineCode>IS NOT NULL</InlineCode>, and{' '}
1939+
<InlineCode>NOT BETWEEN</InlineCode>.
1940+
</li>
1941+
<li>
1942+
When disabled, incoming negated groups are normalized to non-negated
1943+
form so the builder output stays consistent with the visible UI.
1944+
</li>
1945+
</List>
19171946
<AlertBox title="Related docs" variant="info">
19181947
<TextLink to="/documentation/history">Undo and Redo</TextLink>,{' '}
19191948
<TextLink to="/documentation/builder-ref">Builder Ref</TextLink>,{' '}
@@ -2264,6 +2293,14 @@ export const documentationPages: IDocumentationPage[] = [
22642293
operator, and value segments can be protected inline, while read-only
22652294
negation is additionally enforced semantically and reported below the editor when changed.
22662295
</li>
2296+
<li>
2297+
<ItemTitle>Group negation validation:</ItemTitle> When{' '}
2298+
<InlineCode>allowGroupNegation={false}</InlineCode>, the text editor
2299+
rejects group-level <InlineCode>NOT (...)</InlineCode> expressions
2300+
and highlights the offending <InlineCode>NOT</InlineCode> token, while
2301+
still allowing operator-level negation such as{' '}
2302+
<InlineCode>NOT IN</InlineCode> or <InlineCode>IS NOT NULL</InlineCode>.
2303+
</li>
22672304
<li>
22682305
<ItemTitle>Monaco packaging:</ItemTitle>{' '}
22692306
<InlineCode>monaco-editor</InlineCode> is an optional peer dependency.

example/src/utils/builder-source/format-builder-source.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const formatBuilderSource = ({
1515
lockable,
1616
cloneable,
1717
draggable,
18+
allowGroupNegation,
1819
newNodePlacement,
1920
history,
2021
textMode,
@@ -91,6 +92,7 @@ import { components as radixComponents } from '@vojtechportes/react-query-builde
9192
lockable ? 'lockable' : null,
9293
cloneable ? 'cloneable' : null,
9394
draggable ? 'draggable' : null,
95+
allowGroupNegation ? null : 'allowGroupNegation={false}',
9496
newNodePlacement === 'prepend'
9597
? 'newNodePlacement="prepend"'
9698
: null,

example/src/utils/builder-source/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface IBuilderSourceOptions {
1515
lockable: boolean;
1616
cloneable: boolean;
1717
draggable: boolean;
18+
allowGroupNegation: boolean;
1819
newNodePlacement: 'append' | 'prepend';
1920
history: boolean;
2021
textMode: boolean;

src/builder-context.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface IBuilderContextProps {
2424
lockable?: boolean;
2525
cloneable?: boolean;
2626
draggable?: boolean;
27+
allowGroupNegation?: boolean;
2728
singleRootGroup?: boolean;
2829
groupTypes?: BuilderGroupMode;
2930
newNodePlacement?: BuilderNewNodePlacement;
@@ -61,6 +62,7 @@ export interface IBuilderContextProviderProps {
6162
lockable?: boolean;
6263
cloneable?: boolean;
6364
draggable?: boolean;
65+
allowGroupNegation?: boolean;
6466
singleRootGroup?: boolean;
6567
groupTypes?: BuilderGroupMode;
6668
newNodePlacement?: BuilderNewNodePlacement;
@@ -96,6 +98,7 @@ export const BuilderContextProvider: FC<IBuilderContextProviderProps> = ({
9698
lockable,
9799
cloneable,
98100
draggable,
101+
allowGroupNegation,
99102
singleRootGroup,
100103
groupTypes,
101104
newNodePlacement,
@@ -163,6 +166,7 @@ export const BuilderContextProvider: FC<IBuilderContextProviderProps> = ({
163166
lockable,
164167
cloneable,
165168
draggable,
169+
allowGroupNegation,
166170
singleRootGroup,
167171
groupTypes,
168172
newNodePlacement,

src/builder/builder.test.tsx

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,29 @@ describe('#components/Builder', () => {
832832
).toContain('Negation is read-only');
833833
});
834834

835+
it('Rejects negation in text mode when allowGroupNegation is false', () => {
836+
const onChange = jest.fn();
837+
const { container } = render(
838+
<Builder
839+
fields={fields}
840+
data={[{ field: 'MOCK_FIELD', value: 'alpha', operator: 'EQUAL' }]}
841+
textMode
842+
defaultMode="text"
843+
allowGroupNegation={false}
844+
onChange={onChange}
845+
/>
846+
);
847+
848+
fireEvent.change(getByDataTest(container, 'TextModeEditor'), {
849+
target: { value: "NOT (MOCK_FIELD = 'alpha')" },
850+
});
851+
852+
expect(onChange).not.toHaveBeenCalled();
853+
expect(getByDataTest(container, 'TextModeError').textContent).toContain(
854+
'Negation is not allowed'
855+
);
856+
});
857+
835858
it('Preserves protected ranges after editing an unlocked value in text mode', async () => {
836859
const onChange = jest.fn();
837860
const { container } = render(
@@ -2312,6 +2335,82 @@ describe('#components/Builder', () => {
23122335
expect(queryByDataTest(container, 'Option[or]')).toBeNull();
23132336
});
23142337

2338+
it('Hides the negation control when allowGroupNegation is false', () => {
2339+
const { container } = render(
2340+
<Builder
2341+
fields={fields}
2342+
data={[
2343+
{
2344+
type: 'GROUP',
2345+
value: 'AND',
2346+
isNegated: false,
2347+
children: [{ field: 'MOCK_FIELD', value: '', operator: 'EQUAL' }],
2348+
},
2349+
]}
2350+
allowGroupNegation={false}
2351+
onChange={jest.fn()}
2352+
/>
2353+
);
2354+
2355+
expect(queryByDataTest(container, 'Option[not]')).toBeNull();
2356+
});
2357+
2358+
it('Removes negation from emitted group data when allowGroupNegation is false', () => {
2359+
const { container } = render(
2360+
<Builder
2361+
fields={fields}
2362+
data={[
2363+
{
2364+
type: 'GROUP',
2365+
value: 'AND',
2366+
isNegated: true,
2367+
children: [{ field: 'MOCK_FIELD', value: 'alpha', operator: 'EQUAL' }],
2368+
},
2369+
]}
2370+
textMode
2371+
defaultMode="text"
2372+
allowGroupNegation={false}
2373+
onChange={jest.fn()}
2374+
/>
2375+
);
2376+
2377+
expect(
2378+
(getByDataTest(container, 'TextModeEditor') as HTMLTextAreaElement).value
2379+
).toBe("(MOCK_FIELD = 'alpha')");
2380+
});
2381+
2382+
it('Stays stable when rerendered with negated incoming data and allowGroupNegation is false', async () => {
2383+
const onStateChange = jest.fn();
2384+
const props: IBuilderProps = {
2385+
fields,
2386+
data: [
2387+
{
2388+
type: 'GROUP',
2389+
value: 'AND',
2390+
isNegated: true,
2391+
children: [{ field: 'MOCK_FIELD', value: 'alpha', operator: 'EQUAL' }],
2392+
},
2393+
],
2394+
allowGroupNegation: false,
2395+
onStateChange,
2396+
onChange: jest.fn(),
2397+
};
2398+
const { rerender } = render(<Builder {...props} />);
2399+
2400+
await waitFor(() =>
2401+
expect(onStateChange.mock.calls.length).toBeGreaterThanOrEqual(1)
2402+
);
2403+
const initialCallCount = onStateChange.mock.calls.length;
2404+
2405+
rerender(<Builder {...props} />);
2406+
2407+
await act(async () => {
2408+
await new Promise((resolve) => setTimeout(resolve, 50));
2409+
});
2410+
2411+
expect(onStateChange).toHaveBeenCalledTimes(initialCallCount);
2412+
});
2413+
23152414
it('Locks the builder to a single undeletable root group', () => {
23162415
const { container } = render(
23172416
<Builder

0 commit comments

Comments
 (0)