Skip to content

Commit 4bf3a5f

Browse files
Vojtěch Václav Portešvojtechportes
authored andcommitted
feat: Added dynamic field and rule option APIs
- Added imperative APIs for dynamic field-level and rule-level options - Added dependency-aware rule option binding, nearest-field lookup, and rule dependency subscriptions - Added explicit rule value reconciliation for strict select semantics - Support numeric option values across the imperative option APIs - Updated the documentation, API reference, live examples, and README - Added regression tests for hydration, reloads, dependencies, and reconciliation edge cases
1 parent 4d228a6 commit 4bf3a5f

36 files changed

Lines changed: 3124 additions & 459 deletions

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ API instead of replacing the full field definition.
109109
This is especially useful for dependent selects, async option loading, and
110110
integrations with tools like TanStack React Query.
111111

112-
The imperative API includes methods such as:
112+
Use field-level methods when every rule of the same field should share one
113+
runtime option set:
113114

114115
- `isFieldInUse(field)`
115116
- `setFieldOptions(field, options)`
@@ -118,6 +119,16 @@ The imperative API includes methods such as:
118119
- `reloadFieldOptions(field)`
119120
- `clearFieldOptions(field)`
120121

122+
Use rule-level methods when options depend on other rules or on surrounding app
123+
state:
124+
125+
- `bindRuleOptions(field, config)`
126+
- `useBuilderRuleDependencies(builderRef, field, dependencyFields)`
127+
- `subscribeToRuleDependencies(field, dependencyFields, listener)`
128+
- `setRuleOptions(ruleId, options)`
129+
- `setRuleOptionsStatus(ruleId, status)`
130+
- `reconcileRuleValueWithOptions(ruleId, { strategy: 'clear-if-missing' })`
131+
121132
See the Dynamic Field Options documentation for the full concept, live example,
122133
and React Query integration example:
123134

example/src/components/imperative-field-options-demo.tsx

Lines changed: 67 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type DenormalizedQuery,
77
type IBuilderFieldProps,
88
} from '@vojtechportes/react-query-builder';
9+
import { waitForTimeout } from './utils/wait-for-timeout.util';
910

1011
const DemoCard = styled.div`
1112
display: grid;
@@ -16,36 +17,6 @@ const DemoCard = styled.div`
1617
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
1718
`;
1819

19-
const Toolbar = styled.div`
20-
display: flex;
21-
flex-wrap: wrap;
22-
gap: 0.75rem;
23-
align-items: center;
24-
justify-content: space-between;
25-
`;
26-
27-
const StatusRow = styled.div`
28-
display: flex;
29-
flex-wrap: wrap;
30-
gap: 0.75rem;
31-
font-size: 0.92rem;
32-
color: #475569;
33-
`;
34-
35-
const SmallButton = styled.button`
36-
padding: 0.6rem 0.9rem;
37-
border: 1px solid #cbd5e1;
38-
border-radius: 999px;
39-
background: #fff;
40-
color: #0f172a;
41-
cursor: pointer;
42-
43-
&:hover {
44-
border-color: #93c5fd;
45-
background: #eff6ff;
46-
}
47-
`;
48-
4920
const fields: IBuilderFieldProps[] = [
5021
{
5122
field: 'COUNTRY',
@@ -74,153 +45,88 @@ const initialData: DenormalizedQuery = [
7445
isNegated: false,
7546
children: [
7647
{
77-
field: 'COUNTRY',
78-
operator: 'EQUAL',
79-
value: 'CZ',
48+
type: 'GROUP',
49+
value: 'AND',
50+
isNegated: false,
51+
children: [
52+
{
53+
field: 'COUNTRY',
54+
operator: 'EQUAL',
55+
value: 'CZ',
56+
},
57+
{
58+
field: 'CITY',
59+
operator: 'EQUAL',
60+
value: 'PRG',
61+
},
62+
],
8063
},
8164
{
82-
field: 'CITY',
83-
operator: 'EQUAL',
84-
value: '',
65+
type: 'GROUP',
66+
value: 'AND',
67+
isNegated: false,
68+
children: [
69+
{
70+
field: 'COUNTRY',
71+
operator: 'EQUAL',
72+
value: 'SK',
73+
},
74+
{
75+
field: 'CITY',
76+
operator: 'EQUAL',
77+
value: 'BTS',
78+
},
79+
],
8580
},
8681
],
8782
},
8883
];
8984

85+
const cityOptionsByCountry = {
86+
CZ: [
87+
{ value: 'PRG', label: 'Prague' },
88+
{ value: 'BRN', label: 'Brno' },
89+
{ value: 'OSR', label: 'Ostrava' },
90+
],
91+
SK: [
92+
{ value: 'BTS', label: 'Bratislava' },
93+
{ value: 'KSC', label: 'Kosice' },
94+
{ value: 'ZIL', label: 'Zilina' },
95+
],
96+
DE: [
97+
{ value: 'BER', label: 'Berlin' },
98+
{ value: 'MUC', label: 'Munich' },
99+
{ value: 'HAM', label: 'Hamburg' },
100+
],
101+
} as const;
102+
90103
export const ImperativeFieldOptionsDemo: React.FC = () => {
91104
const [data, setData] = React.useState<DenormalizedQuery>(initialData);
92-
const [cityOptionsStatus, setCityOptionsStatus] = React.useState<
93-
'idle' | 'loading' | 'success' | 'error'
94-
>('idle');
95-
const [cityReloadToken, setCityReloadToken] = React.useState(0);
96105
const builderRef = useBuilderRef();
97-
const cityRuleInUse = React.useMemo(
98-
() =>
99-
data.some(
100-
node =>
101-
'children' in node &&
102-
node.children.some(
103-
child => 'field' in child && child.field === 'CITY'
104-
)
105-
),
106-
[data]
107-
);
108-
const selectedCountry = React.useMemo(() => {
109-
const rootGroup = data[0];
110-
const countryRule =
111-
rootGroup && 'children' in rootGroup
112-
? rootGroup.children.find(
113-
child => 'field' in child && child.field === 'COUNTRY'
114-
)
115-
: undefined;
116-
117-
return countryRule && 'value' in countryRule && typeof countryRule.value === 'string'
118-
? countryRule.value
119-
: '';
120-
}, [data]);
121-
122-
const handleReloadCityOptions = React.useCallback(() => {
123-
setCityReloadToken(token => token + 1);
124-
}, []);
125-
126106
React.useEffect(() => {
127-
if (!cityRuleInUse) {
128-
setCityOptionsStatus('idle');
129-
builderRef.current?.clearFieldOptions('CITY');
130-
return;
131-
}
132-
133-
builderRef.current?.invalidateFieldOptions('CITY');
134-
builderRef.current?.setFieldOptionsStatus('CITY', 'loading');
135-
setCityOptionsStatus('loading');
136-
137-
const timeoutId = window.setTimeout(() => {
138-
if (selectedCountry === 'CZ') {
139-
builderRef.current?.setFieldOptions('CITY', [
140-
{ value: 'PRG', label: 'Prague' },
141-
{ value: 'BRN', label: 'Brno' },
142-
{ value: 'OSR', label: 'Ostrava' },
143-
]);
144-
} else if (selectedCountry === 'SK') {
145-
builderRef.current?.setFieldOptions('CITY', [
146-
{ value: 'BTS', label: 'Bratislava' },
147-
{ value: 'KSC', label: 'Kosice' },
148-
{ value: 'ZIL', label: 'Zilina' },
149-
]);
150-
} else if (selectedCountry === 'DE') {
151-
builderRef.current?.setFieldOptions('CITY', [
152-
{ value: 'BER', label: 'Berlin' },
153-
{ value: 'MUC', label: 'Munich' },
154-
{ value: 'HAM', label: 'Hamburg' },
155-
]);
156-
} else {
157-
builderRef.current?.setFieldOptions('CITY', []);
158-
}
107+
return builderRef.bindRuleOptions('CITY', {
108+
dependencies: ['COUNTRY'],
109+
resolve: async ({ dependencies, signal }) => {
110+
const countryValue = dependencies.COUNTRY?.value;
159111

160-
setCityOptionsStatus('success');
161-
}, 650);
112+
if (typeof countryValue !== 'string') {
113+
return [];
114+
}
162115

163-
return () => window.clearTimeout(timeoutId);
164-
}, [builderRef, cityRuleInUse, cityReloadToken, selectedCountry]);
116+
await waitForTimeout(550, signal);
117+
return cityOptionsByCountry[countryValue] ?? [];
118+
},
119+
onOptionsResolved: ({ ruleId }) => {
120+
builderRef.reconcileRuleValueWithOptions(ruleId, {
121+
strategy: 'clear-if-missing',
122+
});
123+
},
124+
});
125+
}, [builderRef]);
165126

166127
return (
167128
<DemoCard>
168-
<Toolbar>
169-
<SmallButton
170-
type="button"
171-
onClick={() => {
172-
const rootGroupId = builderRef.current
173-
?.getNodes()
174-
.find((node) => 'type' in node)?.id;
175-
const cityRuleId = builderRef.current
176-
?.getNodes()
177-
.find((node) => 'field' in node && node.field === 'CITY')?.id;
178-
179-
if (cityRuleId) {
180-
builderRef.current?.deleteNode(cityRuleId);
181-
return;
182-
}
183-
184-
if (rootGroupId) {
185-
builderRef.current?.addRule(
186-
{
187-
field: 'CITY',
188-
operator: 'EQUAL',
189-
value: '',
190-
},
191-
rootGroupId
192-
);
193-
}
194-
}}
195-
>
196-
{cityRuleInUse ? 'Remove city rule' : 'Add city rule'}
197-
</SmallButton>
198-
<SmallButton
199-
type="button"
200-
onClick={() => builderRef.current?.reloadFieldOptions('CITY')}
201-
>
202-
Reload city options
203-
</SmallButton>
204-
</Toolbar>
205-
<StatusRow>
206-
<span>City rule in scope: {cityRuleInUse ? 'yes' : 'no'}</span>
207-
<span>City options status: {cityOptionsStatus}</span>
208-
<span>
209-
Runtime options:{' '}
210-
{builderRef.current?.getFieldOptionState('CITY').options.length ?? 0}
211-
</span>
212-
</StatusRow>
213-
<Builder
214-
ref={builderRef}
215-
fields={fields}
216-
data={data}
217-
onFieldOptionsReload={(field) => {
218-
if (field === 'CITY') {
219-
handleReloadCityOptions();
220-
}
221-
}}
222-
onChange={setData}
223-
/>
129+
<Builder ref={builderRef} fields={fields} data={data} onChange={setData} />
224130
</DemoCard>
225131
);
226132
};

0 commit comments

Comments
 (0)