Skip to content

Commit 9fecacd

Browse files
Vojtěch Václav Portešvojtechportes
authored andcommitted
feat: Added newNodePlacement property allowing to place new node either to the start or to the end of parent node
1 parent 240ffc6 commit 9fecacd

13 files changed

Lines changed: 261 additions & 15 deletions

File tree

example/src/components/demo-playground.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,37 @@ const Toggle = styled.input<{ $disabled?: boolean }>`
7777
}
7878
`;
7979

80+
const SelectField = styled.label`
81+
display: grid;
82+
gap: 0.35rem;
83+
`;
84+
85+
const SelectFieldLabel = styled.span`
86+
font-size: 0.95rem;
87+
font-weight: 400;
88+
color: #334155;
89+
`;
90+
91+
const SelectControl = styled.select`
92+
width: 100%;
93+
padding: 0.68rem 2.2rem 0.68rem 0.85rem;
94+
border: 1px solid #dbe4f0;
95+
border-radius: 10px;
96+
background: #fff;
97+
color: #0f172a;
98+
font-size: 0.92rem;
99+
appearance: none;
100+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M2.5 4.5L6 8L9.5 4.5' stroke='%230f172a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
101+
background-repeat: no-repeat;
102+
background-position: right 0.85rem center;
103+
104+
&:focus {
105+
outline: none;
106+
border-color: ${siteTheme.primaryLight};
107+
box-shadow: 0 0 0 3px ${siteTheme.primaryGlow};
108+
}
109+
`;
110+
80111
const ChoiceGroup = styled.div`
81112
display: grid;
82113
gap: 0.7rem;
@@ -161,6 +192,9 @@ export const DemoPlayground: React.FC<IDemoPlaygroundProps> = ({
161192
const [lockable, setLockable] = React.useState(false);
162193
const [cloneable, setCloneable] = React.useState(false);
163194
const [draggable, setDraggable] = React.useState(false);
195+
const [newNodePlacement, setNewNodePlacement] = React.useState<
196+
'append' | 'prepend'
197+
>('append');
164198
const [history, setHistory] = React.useState(false);
165199
const [textMode, setTextMode] = React.useState(false);
166200
const [defaultMode, setDefaultMode] = React.useState<'builder' | 'text'>('builder');
@@ -206,6 +240,7 @@ export const DemoPlayground: React.FC<IDemoPlaygroundProps> = ({
206240
cloneable,
207241
onChange: setData,
208242
draggable,
243+
newNodePlacement,
209244
history,
210245
textMode,
211246
defaultMode,
@@ -230,6 +265,7 @@ export const DemoPlayground: React.FC<IDemoPlaygroundProps> = ({
230265
lockable,
231266
cloneable,
232267
draggable,
268+
newNodePlacement,
233269
history,
234270
textMode,
235271
defaultMode,
@@ -245,6 +281,7 @@ export const DemoPlayground: React.FC<IDemoPlaygroundProps> = ({
245281
lockable,
246282
cloneable,
247283
draggable,
284+
newNodePlacement,
248285
history,
249286
textMode,
250287
defaultMode,
@@ -368,6 +405,19 @@ export const DemoPlayground: React.FC<IDemoPlaygroundProps> = ({
368405
/>
369406
<span>Show validation errors</span>
370407
</ToggleRow>
408+
409+
<SelectField>
410+
<SelectFieldLabel>New node placement</SelectFieldLabel>
411+
<SelectControl
412+
value={newNodePlacement}
413+
onChange={event =>
414+
setNewNodePlacement(event.target.value as 'append' | 'prepend')
415+
}
416+
>
417+
<option value="append">Append to end</option>
418+
<option value="prepend">Prepend to start</option>
419+
</SelectControl>
420+
</SelectField>
371421
</Panel>
372422

373423
<Panel>

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const builderSignature = `export interface IBuilderProps {
2222
draggable?: boolean;
2323
singleRootGroup?: boolean;
2424
groupTypes?: 'with-modifiers' | 'without-modifiers' | 'both';
25+
newNodePlacement?: 'append' | 'prepend';
2526
validator?: IBuilderValidator;
2627
onStateChange?: (state: IBuilderStateChange) => void;
2728
showValidation?: boolean;
@@ -481,7 +482,7 @@ export const apiPages: IApiPage[] = [
481482
description:
482483
'Builder component API reference for IBuilderProps, controlled data flow, validation, and editing options.',
483484
searchText:
484-
'Builder component IBuilderProps onChange controlled component defaults strings components validator history state change undo redo canUndo canRedo',
485+
'Builder component IBuilderProps onChange controlled component defaults strings components validator history state change undo redo canUndo canRedo newNodePlacement append prepend',
485486
content: (
486487
<>
487488
<CodeBlock code={builderSignature} language="ts" label="IBuilderProps" />
@@ -501,6 +502,7 @@ export const apiPages: IApiPage[] = [
501502
<li><ItemTitle><InlineCode>draggable</InlineCode>:</ItemTitle> Defaults to <InlineCode>false</InlineCode>. Enables drag-and-drop reordering and movement of query nodes.</li>
502503
<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>
503504
<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>
505+
<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>
504506
<li><ItemTitle><InlineCode>validator</InlineCode>:</ItemTitle> Optional function that receives the denormalized query plus validation context and returns a validation result synchronously or asynchronously.</li>
505507
<li><ItemTitle><InlineCode>onStateChange</InlineCode>:</ItemTitle> Optional callback fired with <InlineCode>data</InlineCode>, <InlineCode>isValid</InlineCode>, the full validation object, and history state flags such as <InlineCode>canUndo</InlineCode> and <InlineCode>canRedo</InlineCode>.</li>
506508
<li><ItemTitle><InlineCode>showValidation</InlineCode>:</ItemTitle> Defaults to <InlineCode>false</InlineCode>. Controls whether validation issues are rendered in the built-in UI.</li>

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ const builderBehaviorSnippet = `<Builder
309309
lockable
310310
cloneable
311311
draggable
312+
newNodePlacement="prepend"
312313
singleRootGroup={false}
313314
groupTypes="both"
314315
onChange={setData}
@@ -325,6 +326,10 @@ const builderBehaviorSnippet = `<Builder
325326
// draggable:
326327
// Enables drag-and-drop for editable rules and groups.
327328
//
329+
// newNodePlacement="prepend":
330+
// Inserts newly added rules and groups at the beginning of their parent instead
331+
// of appending them to the end. The default is "append".
332+
//
328333
// singleRootGroup={false}:
329334
// Allows multiple root-level nodes instead of wrapping everything into one root group.
330335
//
@@ -1047,9 +1052,9 @@ export const documentationPages: IDocumentationPage[] = [
10471052
sectionTitle: 'Getting Started',
10481053
summary: '',
10491054
description:
1050-
'Documentation for clone controls, drag-and-drop, root-group behavior, and group mode configuration.',
1055+
'Documentation for clone controls, drag-and-drop, insertion placement, root-group behavior, and group mode configuration.',
10511056
searchText:
1052-
'builder behavior cloneable clone controls draggable drag and drop singleRootGroup groupTypes with modifiers without modifiers both root group',
1057+
'builder behavior cloneable clone controls draggable drag and drop newNodePlacement append prepend singleRootGroup groupTypes with modifiers without modifiers both root group',
10531058
content: (
10541059
<>
10551060
<p>
@@ -1089,6 +1094,26 @@ export const documentationPages: IDocumentationPage[] = [
10891094
wants multiple top-level nodes instead of one wrapped root group.
10901095
</li>
10911096
</List>
1097+
<SectionTitle>newNodePlacement</SectionTitle>
1098+
<List>
1099+
<li>
1100+
Defaults to <InlineCode>'append'</InlineCode>, which inserts newly
1101+
added rules and groups at the end of their parent.
1102+
</li>
1103+
<li>
1104+
Set it to <InlineCode>'prepend'</InlineCode> to insert new rules
1105+
and groups at the beginning of their parent instead.
1106+
</li>
1107+
<li>
1108+
This affects built-in Add Rule and Add Group controls, root-level
1109+
add controls, and imperative ref methods when no explicit index is
1110+
provided.
1111+
</li>
1112+
<li>
1113+
It does not change cloning or drag-and-drop behavior, since those
1114+
actions already use explicit target positions.
1115+
</li>
1116+
</List>
10921117
<SectionTitle>groupTypes</SectionTitle>
10931118
<List>
10941119
<li>

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const formatBuilderSource = ({
88
lockable,
99
cloneable,
1010
draggable,
11+
newNodePlacement,
1112
history,
1213
textMode,
1314
defaultMode,
@@ -59,6 +60,9 @@ export const formatBuilderSource = ({
5960
lockable ? 'lockable' : null,
6061
cloneable ? 'cloneable' : null,
6162
draggable ? 'draggable' : null,
63+
newNodePlacement === 'prepend'
64+
? 'newNodePlacement="prepend"'
65+
: null,
6266
history ? 'history' : null,
6367
textMode ? 'textMode' : null,
6468
textMode && defaultMode === 'text' ? 'defaultMode="text"' : null,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface IBuilderSourceOptions {
77
lockable: boolean;
88
cloneable: boolean;
99
draggable: boolean;
10+
newNodePlacement: 'append' | 'prepend';
1011
history: boolean;
1112
textMode: boolean;
1213
defaultMode: 'builder' | 'text';

src/builder-context.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { FC, createContext } from 'react';
22
import {
33
BuilderGroupMode,
4+
BuilderNewNodePlacement,
45
IBuilderComponentsProps,
56
IBuilderFieldProps,
67
IBuilderValidationResult,
@@ -22,6 +23,7 @@ export interface IBuilderContextProps {
2223
draggable?: boolean;
2324
singleRootGroup?: boolean;
2425
groupTypes?: BuilderGroupMode;
26+
newNodePlacement?: BuilderNewNodePlacement;
2527
showValidation?: boolean;
2628
validation?: IBuilderValidationResult;
2729
components: IBuilderComponentsProps;
@@ -53,6 +55,7 @@ export interface IBuilderContextProviderProps {
5355
draggable?: boolean;
5456
singleRootGroup?: boolean;
5557
groupTypes?: BuilderGroupMode;
58+
newNodePlacement?: BuilderNewNodePlacement;
5659
showValidation?: boolean;
5760
validation?: IBuilderValidationResult;
5861
components: IBuilderComponentsProps;
@@ -78,6 +81,7 @@ export const BuilderContextProvider: FC<IBuilderContextProviderProps> = ({
7881
draggable,
7982
singleRootGroup,
8083
groupTypes,
84+
newNodePlacement,
8185
showValidation,
8286
validation,
8387
setData,
@@ -139,6 +143,7 @@ export const BuilderContextProvider: FC<IBuilderContextProviderProps> = ({
139143
draggable,
140144
singleRootGroup,
141145
groupTypes,
146+
newNodePlacement,
142147
showValidation,
143148
validation,
144149
setData,

src/builder/builder.test.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,39 @@ describe('#components/Builder', () => {
10541054
expect(getByDataTest(container, 'Redo')).toBeDisabled();
10551055
});
10561056

1057+
it('Prepends new root nodes when newNodePlacement is set to prepend', () => {
1058+
const onChange = jest.fn();
1059+
const { container } = render(
1060+
<Builder
1061+
fields={fields}
1062+
data={[
1063+
{ field: 'MOCK_FIELD', value: 'alpha', operator: 'EQUAL' },
1064+
{ field: 'MOCK_NUMBER', value: 5, operator: 'NOT_EQUAL' },
1065+
]}
1066+
singleRootGroup={false}
1067+
newNodePlacement="prepend"
1068+
onChange={onChange}
1069+
/>
1070+
);
1071+
1072+
fireEvent.click(getByDataTest(container, 'AddRootRule'));
1073+
1074+
expect(onChange).toHaveBeenLastCalledWith([
1075+
expect.objectContaining({ field: '' }),
1076+
expect.objectContaining({ field: 'MOCK_FIELD', value: 'alpha' }),
1077+
expect.objectContaining({ field: 'MOCK_NUMBER', value: 5 }),
1078+
]);
1079+
1080+
fireEvent.click(getByDataTest(container, 'AddRootGroup'));
1081+
1082+
expect(onChange).toHaveBeenLastCalledWith([
1083+
expect.objectContaining({ type: 'GROUP' }),
1084+
expect.objectContaining({ field: '' }),
1085+
expect.objectContaining({ field: 'MOCK_FIELD', value: 'alpha' }),
1086+
expect.objectContaining({ field: 'MOCK_NUMBER', value: 5 }),
1087+
]);
1088+
});
1089+
10571090
it('Undoes and redoes rule value edits when history is enabled', () => {
10581091
const onChange = jest.fn();
10591092
const { container } = render(
@@ -2236,4 +2269,67 @@ describe('#components/Builder', () => {
22362269
},
22372270
]);
22382271
});
2272+
2273+
it('Uses newNodePlacement for imperative add methods when index is omitted', () => {
2274+
const onChange = jest.fn();
2275+
let builderRefObject: React.MutableRefObject<IBuilderRef | null> | null = null;
2276+
2277+
const TestComponent = () => {
2278+
const builderRef = useBuilderRef();
2279+
builderRefObject = builderRef;
2280+
2281+
return (
2282+
<Builder
2283+
ref={builderRef}
2284+
fields={fields}
2285+
data={[
2286+
{
2287+
type: 'GROUP',
2288+
value: 'AND',
2289+
isNegated: false,
2290+
children: [
2291+
{ field: 'MOCK_FIELD', value: 'alpha', operator: 'EQUAL' },
2292+
{ field: 'MOCK_NUMBER', value: 5, operator: 'NOT_EQUAL' },
2293+
],
2294+
},
2295+
]}
2296+
newNodePlacement="prepend"
2297+
onChange={onChange}
2298+
/>
2299+
);
2300+
};
2301+
2302+
render(<TestComponent />);
2303+
2304+
const getBuilderRef = () => {
2305+
const currentBuilderRef = builderRefObject?.current;
2306+
expect(currentBuilderRef).toBeDefined();
2307+
return currentBuilderRef as IBuilderRef;
2308+
};
2309+
const rootGroupId = getBuilderRef()
2310+
.getNodes()
2311+
.find((node) => 'type' in node)?.id as string;
2312+
2313+
act(() => {
2314+
expect(
2315+
getBuilderRef().addRule(
2316+
{ field: 'MOCK_FIELD', value: 'beta', operator: 'EQUAL' },
2317+
rootGroupId
2318+
)
2319+
).toBe(true);
2320+
});
2321+
2322+
expect(onChange).toHaveBeenLastCalledWith([
2323+
{
2324+
type: 'GROUP',
2325+
value: 'AND',
2326+
isNegated: false,
2327+
children: [
2328+
{ field: 'MOCK_FIELD', value: 'beta', operator: 'EQUAL' },
2329+
{ field: 'MOCK_FIELD', value: 'alpha', operator: 'EQUAL' },
2330+
{ field: 'MOCK_NUMBER', value: 5, operator: 'NOT_EQUAL' },
2331+
],
2332+
},
2333+
]);
2334+
});
22392335
});

0 commit comments

Comments
 (0)