Skip to content

Commit 0b64ec5

Browse files
Vojtěch Václav Portešvojtechportes
authored andcommitted
feat: Added lockable groups and rules
1 parent a13c0ec commit 0b64ec5

27 files changed

Lines changed: 480 additions & 199 deletions

README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
- [Data](#data)
2727
- [Group With Modifiers](#group-with-modifiers)
2828
- [Group Without Modifiers](#group-without-modifiers)
29+
- [Node-level Read-only](#node-level-read-only)
2930
- [Number Values](#number-values)
3031
- [Builder Props](#builder-props)
3132
- [`readOnly`](#readonly)
@@ -342,6 +343,45 @@ const data = [
342343

343344
Groups without modifiers do not render `Not`, `And`, or `Or`, and they do not output `value` or `isNegated`.
344345

346+
#### Node-level read-only
347+
348+
Read-only can be set globally on `Builder`, but it can also be set directly in the query data on groups and rules.
349+
350+
```typescript
351+
const data = [
352+
{
353+
type: 'GROUP',
354+
value: 'AND',
355+
isNegated: false,
356+
readOnly: {
357+
enabled: true,
358+
inheritToChildren: true,
359+
},
360+
children: [
361+
{
362+
field: 'IS_IN_EU',
363+
operator: 'EQUAL',
364+
value: false,
365+
},
366+
{
367+
field: 'CUSTOMER_COUNTRY',
368+
operator: 'EQUAL',
369+
value: 'CZ',
370+
readOnly: true,
371+
},
372+
],
373+
},
374+
];
375+
```
376+
377+
Supported shapes:
378+
379+
- rule: `readOnly?: boolean`
380+
- group: `readOnly?: boolean | { enabled: boolean; inheritToChildren?: boolean }`
381+
382+
When a group or rule is read-only, it cannot be edited, deleted, or dragged.
383+
If a group uses `inheritToChildren: true`, the read-only state is passed down to all descendants.
384+
345385
#### Number values
346386

347387
`NUMBER` fields emit numeric values in the builder output.

example/src/main.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ const initialQueryTree: DenormalizedQuery = [
9393
type: 'GROUP',
9494
value: 'AND',
9595
isNegated: false,
96+
readOnly: {
97+
enabled: true,
98+
inheritToChildren: true,
99+
},
96100
children: [
97101
{
98102
field: 'IS_VAT_PAYER',
@@ -104,6 +108,23 @@ const initialQueryTree: DenormalizedQuery = [
104108
operator: 'ALL_IN',
105109
value: ['B2B', 'Priority'],
106110
},
111+
{
112+
type: 'GROUP',
113+
value: 'OR',
114+
isNegated: false,
115+
children: [
116+
{
117+
field: 'COMPANY_NAME',
118+
operator: 'CONTAINS',
119+
value: 'Prague',
120+
},
121+
{
122+
field: 'ORDER_CREATED_AT',
123+
operator: 'LARGER_EQUAL',
124+
value: '2025-01-01',
125+
},
126+
],
127+
},
107128
],
108129
},
109130
],

src/builder.test.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,4 +358,113 @@ describe('#components/Builder', () => {
358358

359359
expect(wrapper.text()).not.toContain('is not defined');
360360
});
361+
362+
it('Disables editing and dragging for locally read-only rules', () => {
363+
const wrapper = mount(
364+
<Builder
365+
fields={fields}
366+
draggable
367+
data={[
368+
{
369+
type: 'GROUP',
370+
value: 'AND',
371+
isNegated: false,
372+
children: [
373+
{
374+
field: 'MOCK_FIELD',
375+
value: '',
376+
operator: 'EQUAL',
377+
readOnly: true,
378+
},
379+
],
380+
},
381+
]}
382+
/>
383+
);
384+
385+
expect(
386+
wrapper.find('[data-test="IteratorRule"]').find('[data-test="DragHandle"]').length
387+
).toEqual(0);
388+
expect(
389+
wrapper.find('[data-test="IteratorRule"]').find('button').filterWhere((node) => node.text() === 'Delete').length
390+
).toEqual(0);
391+
});
392+
393+
it('Locks group controls without inheriting read-only to descendants by default', () => {
394+
const wrapper = mount(
395+
<Builder
396+
fields={fields}
397+
draggable
398+
data={[
399+
{
400+
type: 'GROUP',
401+
value: 'AND',
402+
isNegated: false,
403+
children: [
404+
{
405+
type: 'GROUP',
406+
value: 'AND',
407+
isNegated: false,
408+
readOnly: true,
409+
children: [
410+
{
411+
field: 'MOCK_FIELD',
412+
value: '',
413+
operator: 'EQUAL',
414+
},
415+
],
416+
},
417+
],
418+
},
419+
]}
420+
/>
421+
);
422+
423+
expect(wrapper.find('[data-test="AddRule"]').hostNodes().length).toEqual(1);
424+
expect(
425+
wrapper.find('button').filterWhere((node) => node.text() === 'Delete').hostNodes()
426+
.length
427+
).toEqual(1);
428+
expect(wrapper.find('[data-test="DragHandle"]').hostNodes().length).toEqual(0);
429+
});
430+
431+
it('Inherits read-only state to descendants when configured on a group', () => {
432+
const wrapper = mount(
433+
<Builder
434+
fields={fields}
435+
draggable
436+
data={[
437+
{
438+
type: 'GROUP',
439+
value: 'AND',
440+
isNegated: false,
441+
children: [
442+
{
443+
type: 'GROUP',
444+
value: 'AND',
445+
isNegated: false,
446+
readOnly: {
447+
enabled: true,
448+
inheritToChildren: true,
449+
},
450+
children: [
451+
{
452+
field: 'MOCK_FIELD',
453+
value: '',
454+
operator: 'EQUAL',
455+
},
456+
],
457+
},
458+
],
459+
},
460+
]}
461+
/>
462+
);
463+
464+
expect(
465+
wrapper.find('button').filterWhere((node) => node.text() === 'Delete').hostNodes()
466+
.length
467+
).toEqual(0);
468+
expect(wrapper.find('[data-test="DragHandle"]').hostNodes().length).toEqual(0);
469+
});
361470
});

src/form/select-multi.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,21 @@ describe('#components/SelectMulti', () => {
4545
expect(onChange).toBeCalledTimes(0);
4646
expect(onDelete).toBeCalledTimes(2);
4747
});
48+
49+
it('Does not render remove buttons when disabled', () => {
50+
const wrapper = mount(
51+
<SelectMulti
52+
disabled
53+
onChange={jest.fn()}
54+
onDelete={jest.fn()}
55+
selectedValue={['test']}
56+
values={mockValues}
57+
/>
58+
);
59+
60+
const tags = wrapper.find('[data-test="SelectMultiTag"]').hostNodes();
61+
62+
expect(tags).toHaveLength(1);
63+
expect(tags.find('[data-test="Delete"]')).toHaveLength(0);
64+
});
4865
});

src/group/group.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,28 @@ describe('#components/Group', () => {
135135
addGroup.simulate('click');
136136
});
137137

138+
it('Hides group controls when the group is locally read-only', () => {
139+
const wrapper = mount(
140+
<BuilderContext.Provider
141+
value={{
142+
components,
143+
fields,
144+
data,
145+
strings,
146+
setData,
147+
onChange,
148+
readOnly: false,
149+
}}
150+
>
151+
<Group id="test-2" isRoot={false} value="AND" isNegated={false} readOnly />
152+
</BuilderContext.Provider>
153+
);
154+
155+
expect(wrapper.find('[data-test="AddRule"]').length).toEqual(0);
156+
expect(wrapper.find('[data-test="AddGroup"]').length).toEqual(0);
157+
expect(wrapper.find('[data-test="Remove"]').length).toEqual(0);
158+
});
159+
138160
it('Tests no srtrings scenario', () => {
139161
const wrapper = mount(
140162
<BuilderContext.Provider

src/group/group.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface IGroupProps {
2424
contentOverlay?: React.ReactNode;
2525
id: string;
2626
isRoot: boolean;
27+
readOnly?: boolean;
2728
}
2829

2930
export const Group: FC<IGroupProps> = ({
@@ -34,6 +35,7 @@ export const Group: FC<IGroupProps> = ({
3435
contentOverlay,
3536
id,
3637
isRoot,
38+
readOnly: localReadOnly = false,
3739
}) => {
3840
const {
3941
components,
@@ -46,14 +48,15 @@ export const Group: FC<IGroupProps> = ({
4648
groupTypes,
4749
singleRootGroup,
4850
} = useContext(BuilderContext);
51+
const isReadOnly = readOnly || localReadOnly;
4952
const Add = components.Add || Button;
5053
const GroupContainer = components.Group || DefaultGroupContainer;
5154
const Option = components.GroupHeaderOption || DefaultOption;
5255
const Popover = components.Popover || DefaultPopover;
5356
const PopoverItem = components.PopoverItem || DefaultPopoverItem;
5457
const Remove = components.Remove || SecondaryButton;
5558
const resolvedGroupTypes = groupTypes || 'with-modifiers';
56-
const canDeleteGroup = !(singleRootGroup && isRoot);
59+
const canDeleteGroup = !(singleRootGroup && isRoot) && !isReadOnly;
5760

5861
const addItem = (payload: NormalizedNode) => {
5962
applyDataUpdate(
@@ -170,7 +173,7 @@ export const Group: FC<IGroupProps> = ({
170173
<Option
171174
isSelected={Boolean(isNegated)}
172175
value={!isNegated}
173-
disabled={readOnly}
176+
disabled={isReadOnly}
174177
onClick={handleToggleNegateGroup}
175178
data-test="Option[not]"
176179
>
@@ -179,7 +182,7 @@ export const Group: FC<IGroupProps> = ({
179182
<Option
180183
isSelected={value === 'AND'}
181184
value="AND"
182-
disabled={readOnly}
185+
disabled={isReadOnly}
183186
onClick={handleChangeGroupType}
184187
data-test="Option[and]"
185188
>
@@ -188,7 +191,7 @@ export const Group: FC<IGroupProps> = ({
188191
<Option
189192
isSelected={value === 'OR'}
190193
value="OR"
191-
disabled={readOnly}
194+
disabled={isReadOnly}
192195
onClick={handleChangeGroupType}
193196
data-test="Option[or]"
194197
>
@@ -198,7 +201,7 @@ export const Group: FC<IGroupProps> = ({
198201
) : null
199202
}
200203
controlsRight={
201-
!readOnly && (
204+
!isReadOnly && (
202205
<>
203206
<Add onClick={handleAddRule} data-test="AddRule">
204207
{strings.group.addRule}

src/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,9 @@ export { queryOperators } from './utils/query-operators';
9797
export type {
9898
DenormalizedNode,
9999
DenormalizedQuery,
100+
GroupReadOnly,
100101
IDenormalizedGroupNodeBase,
102+
IGroupReadOnlyConfig,
101103
IDenormalizedGroupNodeWithModifiers,
102104
IDenormalizedGroupNodeWithoutModifiers,
103105
IDenormalizedRuleNode,

0 commit comments

Comments
 (0)