Skip to content

Commit e7287be

Browse files
committed
feat: improve conditional fields
- replace operator with descriptive names, - add tests, add regex, includes, oneOf
1 parent 41b073a commit e7287be

File tree

9 files changed

+692
-20
lines changed

9 files changed

+692
-20
lines changed

cypress/e2e/conditional_fields_spec.js

Lines changed: 451 additions & 0 deletions
Large diffs are not rendered by default.

dev-test/config.yml

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,13 +161,20 @@ collections: # A list of collections the CMS should be able to edit
161161
multiple: true,
162162
}
163163
- { label: 'Hidden', name: 'hidden', widget: 'hidden', default: 'hidden' }
164-
- { label: 'Conditional string', name: 'conditional_string', widget: 'string', condition: { field: 'select', value: 'a', operator: '!=' } }
164+
- { label: 'Conditional string', name: 'conditional_string', widget: 'string', condition: { field: 'select', value: 'a', operator: 'notEqual' } }
165165
- label: 'Conditional object'
166166
name: 'conditional_object'
167167
widget: 'object'
168168
condition: { field: 'boolean', value: true }
169169
fields:
170170
- { label: 'Title', name: 'title', widget: 'string' }
171+
- label: 'Conditional oneOf'
172+
name: 'conditional_oneof'
173+
widget: 'string'
174+
condition: { field: 'select', value: ['a', 'b'], operator: 'oneOf' }
175+
- { label: 'Conditional regex', name: 'conditional_regex', widget: 'string', condition: { field: 'select', value: '/^(a|b)$/', operator: 'matches' } }
176+
- { label: 'Conditional regex ci', name: 'conditional_regex_ci', widget: 'string', condition: { field: 'string', value: '/foo/i', operator: 'matches' } }
177+
- { label: 'Conditional notRegex', name: 'conditional_not_regex', widget: 'string', condition: { field: 'string', value: '/^(?!\\d{3}-\\d{3}$).*$/', operator: 'matches' } }
171178
- { label: 'Color', name: 'color', widget: 'color' }
172179
- label: 'Object'
173180
name: 'object'
@@ -189,6 +196,7 @@ collections: # A list of collections the CMS should be able to edit
189196
- { label: 'Image', name: 'image', widget: 'image' }
190197
- { label: 'File', name: 'file', widget: 'file' }
191198
- { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] }
199+
- { label: 'Nested Conditional', name: 'nested_conditional', widget: 'string', condition: { field: 'object.select', value: 'a' } }
192200
- label: 'List'
193201
name: 'list'
194202
widget: 'list'
@@ -202,6 +210,7 @@ collections: # A list of collections the CMS should be able to edit
202210
- { label: 'Image', name: 'image', widget: 'image' }
203211
- { label: 'File', name: 'file', widget: 'file' }
204212
- { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] }
213+
- { label: 'List Wildcard Conditional', name: 'wildcard_cond', widget: 'string', condition: { field: 'list.*.select', value: 'b' } }
205214
- label: 'Object'
206215
name: 'object'
207216
widget: 'object'
@@ -278,6 +287,44 @@ collections: # A list of collections the CMS should be able to edit
278287
fields:
279288
- { label: 'Image', name: 'image', widget: 'image' }
280289
- { label: 'File', name: 'file', widget: 'file' }
290+
- label: 'Structure List with Wildcard Conditionals'
291+
name: 'structure'
292+
widget: 'list'
293+
types:
294+
- label: 'Image Block'
295+
name: 'image'
296+
widget: 'object'
297+
fields:
298+
- { label: 'Image', name: 'image', widget: 'image' }
299+
- { label: 'Caption', name: 'caption', widget: 'string' }
300+
- { label: 'Alt Text', name: 'alt', widget: 'string' }
301+
- label: 'Text Block'
302+
name: 'text'
303+
widget: 'object'
304+
fields:
305+
- { label: 'Content', name: 'content', widget: 'markdown' }
306+
- { label: 'Alignment', name: 'alignment', widget: 'select', options: ['left', 'center', 'right'] }
307+
- label: 'Video Block'
308+
name: 'video'
309+
widget: 'object'
310+
fields:
311+
- { label: 'Video URL', name: 'url', widget: 'string' }
312+
- { label: 'Thumbnail', name: 'thumbnail', widget: 'image' }
313+
- { label: 'Autoplay', name: 'autoplay', widget: 'boolean', default: false }
314+
- label: 'Image Options'
315+
name: 'image_options'
316+
widget: 'object'
317+
condition: { field: 'structure.*.type', value: 'image' }
318+
fields:
319+
- { label: 'Image Quality', name: 'quality', widget: 'number', min: 1, max: 100, default: 80 }
320+
- { label: 'Image Format', name: 'format', widget: 'select', options: ['jpg', 'png', 'webp'] }
321+
- label: 'Video Options'
322+
name: 'video_options'
323+
widget: 'object'
324+
condition: { field: 'structure.*.type', value: 'video' }
325+
fields:
326+
- { label: 'Video Quality', name: 'quality', widget: 'select', options: ['360p', '720p', '1080p'] }
327+
- { label: 'Captions', name: 'captions', widget: 'boolean', default: true }
281328
- name: pages # a nested collection
282329
label: Pages
283330
label_singular: 'Page'

packages/decap-cms-core/index.d.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,23 @@ declare module 'decap-cms-core' {
5353

5454
interface Condition {
5555
field: string;
56-
value: string | boolean | number;
57-
operator?: '==' | '!=' | '>' | '<' | '>=' | '<=';
56+
value:
57+
| string
58+
| boolean
59+
| number
60+
| RegExp
61+
| { regex: string; flags?: string }
62+
| (string | boolean | number)[];
63+
operator?:
64+
| 'equal'
65+
| 'notEqual'
66+
| 'greaterThan'
67+
| 'lessThan'
68+
| 'greaterThanOrEqual'
69+
| 'lessThanOrEqual'
70+
| 'oneOf'
71+
| 'includes'
72+
| 'matches';
5873
}
5974

6075
export interface CmsFieldBase {

packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js

Lines changed: 102 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -94,31 +94,103 @@ function getFieldValue({ field, entry, isTranslatable, locale }) {
9494
return entry.getIn(['data', field.get('name')]);
9595
}
9696

97-
function calculateCondition({ field, fields, entry, locale, isTranslatable }) {
97+
function calculateCondition({ field, fields, entry, locale, isTranslatable, listIndexes = [] }) {
9898
const condition = field.get('condition');
9999
if (!condition) return true;
100100

101-
const condFieldName = condition.get('field');
102-
const operator = condition.get('operator') || '==';
101+
// Get field name - supports simple names, dot-notated paths, and wildcards
102+
let condFieldName = condition.get('field');
103+
if (!condFieldName) return true;
104+
105+
// Operators are descriptive (equal, notEqual, greaterThan, etc.)
106+
const operator = condition.get('operator') || 'equal';
103107
const condValue = condition.get('value');
104108

105-
const condField = fields.find(f => f.get('name') === condFieldName);
106-
let condFieldValue = getFieldValue({ field: condField, entry, locale, isTranslatable });
109+
// Handle wildcard paths (e.g., 'structure.*.type')
110+
if (condFieldName.includes('*')) {
111+
condFieldName = condFieldName.split('*').reduce((acc, item, i) => {
112+
return `${acc}${item}${listIndexes[i] >= 0 ? listIndexes[i] : ''}`;
113+
}, '');
114+
}
115+
116+
// Get the field value - all field references are treated as potentially nested paths
117+
let condFieldValue;
118+
if (condFieldName.includes('.')) {
119+
// For dot-notated paths, traverse the entry data directly
120+
const dataPath = condFieldName.split('.');
121+
const entryData = entry.get('data');
122+
condFieldValue = entryData ? entryData.getIn(dataPath) : undefined;
123+
} else {
124+
// For simple field names, use the existing getFieldValue logic
125+
const condField = fields.find(f => f.get('name') === condFieldName);
126+
if (condField) {
127+
condFieldValue = getFieldValue({ field: condField, entry, locale, isTranslatable });
128+
}
129+
}
130+
131+
// Convert Immutable to JS if needed
107132
condFieldValue = condFieldValue?.toJS ? condFieldValue.toJS() : condFieldValue;
108133

134+
// Handle different operators
109135
switch (operator) {
110-
case '==':
136+
case 'equal':
111137
return condFieldValue == condValue;
112-
case '!=':
138+
case 'notEqual':
113139
return condFieldValue != condValue;
114-
case '<':
115-
return condFieldValue < condValue;
116-
case '>':
140+
case 'greaterThan':
117141
return condFieldValue > condValue;
118-
case '<=':
119-
return condFieldValue <= condValue;
120-
case '>=':
142+
case 'lessThan':
143+
return condFieldValue < condValue;
144+
case 'greaterThanOrEqual':
121145
return condFieldValue >= condValue;
146+
case 'lessThanOrEqual':
147+
return condFieldValue <= condValue;
148+
case 'oneOf': {
149+
const valueArray = condValue?.toJS ? condValue.toJS() : condValue;
150+
return Array.isArray(valueArray) && valueArray.includes(condFieldValue);
151+
}
152+
case 'includes': {
153+
const rawValue = condValue?.toJS ? condValue.toJS() : condValue;
154+
// If field value is array, check inclusion; if string, check substring
155+
if (Array.isArray(condFieldValue)) return condFieldValue.includes(rawValue);
156+
const target = condFieldValue == null ? '' : String(condFieldValue);
157+
return String(rawValue) !== undefined && target.includes(String(rawValue));
158+
}
159+
case 'matches': {
160+
const rawCondValue = condValue?.toJS ? condValue.toJS() : condValue;
161+
162+
// Determine regex pattern/flags from value
163+
let pattern;
164+
let flags;
165+
166+
if (rawCondValue instanceof RegExp) {
167+
pattern = rawCondValue.source;
168+
flags = rawCondValue.flags;
169+
} else if (typeof rawCondValue === 'string') {
170+
const match = rawCondValue.match(/^\/(.*)\/([gimsuy]*)$/);
171+
if (match) {
172+
pattern = match[1];
173+
flags = match[2] || undefined;
174+
} else {
175+
// if plain string, fallback to substring match semantics
176+
const target = condFieldValue == null ? '' : String(condFieldValue);
177+
return target.includes(rawCondValue);
178+
}
179+
} else if (rawCondValue && typeof rawCondValue === 'object' && rawCondValue.regex) {
180+
pattern = rawCondValue.regex;
181+
flags = rawCondValue.flags;
182+
} else {
183+
return false;
184+
}
185+
186+
try {
187+
const re = new RegExp(pattern, flags);
188+
const target = condFieldValue == null ? '' : String(condFieldValue);
189+
return re.test(target);
190+
} catch (e) {
191+
return false;
192+
}
193+
}
122194
default:
123195
return condFieldValue == condValue;
124196
}
@@ -141,6 +213,21 @@ export default class ControlPane extends React.Component {
141213
this.controlRef(field, wrappedControl);
142214
};
143215

216+
fieldCondition = (field, listIndexes = []) => {
217+
const { entry, collection, fields } = this.props;
218+
const locale = this.state.selectedLocale;
219+
const isTranslatable = hasI18n(collection);
220+
221+
return calculateCondition({
222+
field,
223+
fields,
224+
entry,
225+
locale,
226+
isTranslatable,
227+
listIndexes,
228+
});
229+
};
230+
144231
handleLocaleChange = val => {
145232
this.setState({ selectedLocale: val });
146233
this.props.onLocaleChange(val);
@@ -296,6 +383,8 @@ export default class ControlPane extends React.Component {
296383
isFieldDuplicate={field => isFieldDuplicate(field, locale, defaultLocale)}
297384
isFieldHidden={field => isFieldHidden(field, locale, defaultLocale)}
298385
locale={locale}
386+
listIndexes={[]}
387+
fieldCondition={this.fieldCondition}
299388
/>
300389
);
301390
})}

packages/decap-cms-core/src/components/Editor/EditorControlPane/Widget.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,8 @@ export default class Widget extends Component {
326326
isFieldHidden,
327327
locale,
328328
isParentListCollapsed,
329+
listIndexes,
330+
fieldCondition,
329331
} = this.props;
330332

331333
return React.createElement(controlComponent, {
@@ -379,6 +381,8 @@ export default class Widget extends Component {
379381
isFieldHidden,
380382
locale,
381383
isParentListCollapsed,
384+
listIndexes,
385+
fieldCondition,
382386
});
383387
}
384388
}

packages/decap-cms-core/src/constants/configSchema.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,42 @@ function fieldsConfig() {
7373
type: 'object',
7474
properties: {
7575
field: { type: 'string' },
76-
value: { types: ['string', 'boolean'] },
77-
operator: { type: 'string', enum: ['==', '!=', '>', '<', '>=', '<='] },
76+
value: {
77+
oneOf: [
78+
{ type: 'string' },
79+
// Allow regex as a string like '/pattern/flags' or as an object { regex, flags }
80+
{
81+
type: 'object',
82+
properties: { regex: { type: 'string' }, flags: { type: 'string' } },
83+
required: ['regex'],
84+
additionalProperties: false,
85+
},
86+
{ type: 'boolean' },
87+
{ type: 'number' },
88+
{
89+
type: 'array',
90+
items: {
91+
oneOf: [{ type: 'string' }, { type: 'boolean' }, { type: 'number' }],
92+
},
93+
},
94+
],
95+
},
96+
operator: {
97+
type: 'string',
98+
enum: [
99+
'equal',
100+
'notEqual',
101+
'greaterThan',
102+
'lessThan',
103+
'greaterThanOrEqual',
104+
'lessThanOrEqual',
105+
'oneOf',
106+
'includes',
107+
'matches',
108+
],
109+
},
78110
},
111+
required: ['field'],
79112
},
80113
},
81114
select: { $data: '0/widget' },

packages/decap-cms-core/src/types/redux.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,23 @@ export interface CmsI18nConfig {
6969

7070
interface Condition {
7171
field: string;
72-
value: string | boolean | number;
73-
operator?: '==' | '!=' | '>' | '<' | '>=' | '<=';
72+
value:
73+
| string
74+
| boolean
75+
| number
76+
| RegExp
77+
| { regex: string; flags?: string }
78+
| (string | boolean | number)[];
79+
operator?:
80+
| 'equal'
81+
| 'notEqual'
82+
| 'greaterThan'
83+
| 'lessThan'
84+
| 'greaterThanOrEqual'
85+
| 'lessThanOrEqual'
86+
| 'oneOf'
87+
| 'includes'
88+
| 'matches';
7489
}
7590

7691
export interface CmsFieldBase {

packages/decap-cms-widget-list/src/ListControl.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,13 +644,16 @@ export default class ListControl extends React.Component {
644644
parentIds,
645645
forID,
646646
t,
647+
listIndexes: indexes,
648+
fieldCondition,
647649
} = this.props;
648650

649651
const { itemsCollapsed, keys } = this.state;
650652
const collapsed = itemsCollapsed[index];
651653
const key = keys[index];
652654
let field = this.props.field;
653655
const hasError = this.hasError(index);
656+
const listIndexes = indexes?.length ? indexes.concat(index) : [index];
654657
const isVariableTypesList = this.getValueType() === valueTypes.MIXED;
655658
if (isVariableTypesList) {
656659
field = getTypedFieldForValue(field, item);
@@ -715,6 +718,8 @@ export default class ListControl extends React.Component {
715718
data-testid={`object-control-${key}`}
716719
hasError={hasError}
717720
parentIds={[...parentIds, forID, key]}
721+
listIndexes={listIndexes}
722+
fieldCondition={fieldCondition}
718723
/>
719724
)}
720725
</ClassNames>

0 commit comments

Comments
 (0)