From 1409cc2eb5ebac18b7f7b1f006b417591e014d3d Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Tue, 11 Mar 2025 15:36:08 +0100 Subject: [PATCH 1/7] feat: conditional fields --- packages/decap-cms-core/index.d.ts | 7 ++ .../Editor/EditorControlPane/EditorControl.js | 3 + .../EditorControlPane/EditorControlPane.js | 100 ++++++++++++------ .../src/constants/configSchema.js | 8 ++ packages/decap-cms-core/src/types/redux.ts | 7 ++ 5 files changed, 92 insertions(+), 33 deletions(-) diff --git a/packages/decap-cms-core/index.d.ts b/packages/decap-cms-core/index.d.ts index d5efb55dd7dd..76df79d1ac54 100644 --- a/packages/decap-cms-core/index.d.ts +++ b/packages/decap-cms-core/index.d.ts @@ -51,6 +51,12 @@ declare module 'decap-cms-core' { default_locale?: string; } + interface Condition { + field: string; + value: string | boolean | number; + operator?: '==' | '!=' | '>' | '<' | '>=' | '<='; + } + export interface CmsFieldBase { name: string; label?: string; @@ -61,6 +67,7 @@ declare module 'decap-cms-core' { media_folder?: string; public_folder?: string; comment?: string; + condition?: Condition; } export interface CmsFieldBoolean { diff --git a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControl.js b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControl.js index a5467c121565..726517ef7bbf 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControl.js +++ b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControl.js @@ -154,6 +154,7 @@ class EditorControl extends React.Component { collection: ImmutablePropTypes.map.isRequired, isDisabled: PropTypes.bool, isHidden: PropTypes.bool, + isConditionMet: PropTypes.bool, isFieldDuplicate: PropTypes.func, isFieldHidden: PropTypes.func, locale: PropTypes.string, @@ -217,6 +218,7 @@ class EditorControl extends React.Component { isLoadingAsset, isDisabled, isHidden, + isConditionMet, isFieldDuplicate, isFieldHidden, locale, @@ -241,6 +243,7 @@ class EditorControl extends React.Component { className={className} css={css` ${isHidden && styleStrings.hidden}; + ${isConditionMet === false && 'display: none;'} `} > diff --git a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js index dae2d3d3a241..cf75fdfb4db5 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js +++ b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js @@ -94,6 +94,34 @@ function getFieldValue({ field, entry, isTranslatable, locale }) { return entry.getIn(['data', field.get('name')]); } +function calculateCondition({field, fields, entry, locale, isTranslatable}) { + const condition = field.get('condition') + if (!condition) return true; + + const condFieldName = condition.get('field'); + const condValue = condition.get('value'); + const operator = condition.get('operator') || '=='; + const condField = fields.find(f => f.get('name') === condFieldName); + const condFieldValue = getFieldValue({ field: condField, entry, locale, isTranslatable, }); + + switch (operator) { + case '==': + return condFieldValue == condValue; + case '!=': + return condFieldValue != condValue; + case '<': + return condFieldValue < condValue; + case '>': + return condFieldValue > condValue; + case '<=': + return condFieldValue <= condValue; + case '>=': + return condFieldValue >= condValue; + default: + return condFieldValue == condValue; + } +} + export default class ControlPane extends React.Component { state = { selectedLocale: this.props.locale, @@ -116,41 +144,39 @@ export default class ControlPane extends React.Component { this.props.onLocaleChange(val); }; - copyFromOtherLocale = - ({ targetLocale, t }) => - sourceLocale => { - if ( - !window.confirm( - t('editor.editorControlPane.i18n.copyFromLocaleConfirm', { - locale: sourceLocale.toUpperCase(), - }), - ) - ) { - return; - } - const { entry, collection } = this.props; - const { locales, defaultLocale } = getI18nInfo(collection); - - const locale = this.state.selectedLocale; - const i18n = locales && { - currentLocale: locale, - locales, - defaultLocale, - }; - - this.props.fields.forEach(field => { - if (isFieldTranslatable(field, targetLocale, sourceLocale)) { - const copyValue = getFieldValue({ - field, - entry, - locale: sourceLocale, - isTranslatable: sourceLocale !== defaultLocale, - }); - if (copyValue) this.props.onChange(field, copyValue, undefined, i18n); - } - }); + copyFromOtherLocale = ({ targetLocale, t }) => sourceLocale => { + if ( + !window.confirm( + t('editor.editorControlPane.i18n.copyFromLocaleConfirm', { + locale: sourceLocale.toUpperCase(), + }), + ) + ) { + return; + } + const { entry, collection } = this.props; + const { locales, defaultLocale } = getI18nInfo(collection); + + const locale = this.state.selectedLocale; + const i18n = locales && { + currentLocale: locale, + locales, + defaultLocale, }; + this.props.fields.forEach(field => { + if (isFieldTranslatable(field, targetLocale, sourceLocale)) { + const copyValue = getFieldValue({ + field, + entry, + locale: sourceLocale, + isTranslatable: sourceLocale !== defaultLocale, + }); + if (copyValue) this.props.onChange(field, copyValue, undefined, i18n); + } + }); + }; + validate = async () => { this.props.fields.forEach(field => { if (field.get('widget') === 'hidden') return; @@ -224,6 +250,13 @@ export default class ControlPane extends React.Component { const isDuplicate = isFieldDuplicate(field, locale, defaultLocale); const isHidden = isFieldHidden(field, locale, defaultLocale); const key = i18n ? `${locale}_${i}` : i; + const isConditionMet = calculateCondition({ + field, + fields, + entry, + locale, + isTranslatable, + }); return ( isFieldDuplicate(field, locale, defaultLocale)} isFieldHidden={field => isFieldHidden(field, locale, defaultLocale)} locale={locale} + isConditionMet={isConditionMet} /> ); })} diff --git a/packages/decap-cms-core/src/constants/configSchema.js b/packages/decap-cms-core/src/constants/configSchema.js index 369a4f09341f..ef1aef41502a 100644 --- a/packages/decap-cms-core/src/constants/configSchema.js +++ b/packages/decap-cms-core/src/constants/configSchema.js @@ -69,6 +69,14 @@ function fieldsConfig() { field: { $ref: `field_${id}` }, fields: { $ref: `fields_${id}` }, types: { $ref: `fields_${id}` }, + condition: { + type: 'object', + properties: { + field: { type: 'string' }, + value: { types: ['string', 'boolean'] }, + operator: { type: 'string', enum: ['==', '!=', '>', '<', '>=', '<='] }, + }, + }, }, select: { $data: '0/widget' }, selectCases: { diff --git a/packages/decap-cms-core/src/types/redux.ts b/packages/decap-cms-core/src/types/redux.ts index f88f5becdc76..3aa4a35b3891 100644 --- a/packages/decap-cms-core/src/types/redux.ts +++ b/packages/decap-cms-core/src/types/redux.ts @@ -67,6 +67,12 @@ export interface CmsI18nConfig { default_locale?: string; } +interface Condition { + field: string; + value: string | boolean | number; + operator?: '==' | '!=' | '>' | '<' | '>=' | '<='; +} + export interface CmsFieldBase { name: string; label?: string; @@ -77,6 +83,7 @@ export interface CmsFieldBase { media_folder?: string; public_folder?: string; comment?: string; + condition?: Condition; } export interface CmsFieldBoolean { From c8966b307458917750e74a2475b9a17fd6685002 Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Thu, 13 Mar 2025 09:20:56 +0100 Subject: [PATCH 2/7] feat: add conditional to kitchen sink calculate condition on validation --- dev-test/config.yml | 7 +++++ .../Editor/EditorControlPane/EditorControl.js | 3 --- .../EditorControlPane/EditorControlPane.js | 27 +++++++++++++------ 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/dev-test/config.yml b/dev-test/config.yml index 6eac43f98db9..32f9d303868e 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -161,6 +161,13 @@ collections: # A list of collections the CMS should be able to edit multiple: true, } - { label: 'Hidden', name: 'hidden', widget: 'hidden', default: 'hidden' } + - { label: 'Conditional string', name: 'conditional_string', widget: 'string', condition: { field: 'select', value: 'a', operator: '!=' } } + - label: 'Conditional object' + name: 'conditional_object' + widget: 'object' + condition: { field: 'boolean', value: true } + fields: + - { label: 'Title', name: 'title', widget: 'string' } - { label: 'Color', name: 'color', widget: 'color' } - label: 'Object' name: 'object' diff --git a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControl.js b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControl.js index 726517ef7bbf..a5467c121565 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControl.js +++ b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControl.js @@ -154,7 +154,6 @@ class EditorControl extends React.Component { collection: ImmutablePropTypes.map.isRequired, isDisabled: PropTypes.bool, isHidden: PropTypes.bool, - isConditionMet: PropTypes.bool, isFieldDuplicate: PropTypes.func, isFieldHidden: PropTypes.func, locale: PropTypes.string, @@ -218,7 +217,6 @@ class EditorControl extends React.Component { isLoadingAsset, isDisabled, isHidden, - isConditionMet, isFieldDuplicate, isFieldHidden, locale, @@ -243,7 +241,6 @@ class EditorControl extends React.Component { className={className} css={css` ${isHidden && styleStrings.hidden}; - ${isConditionMet === false && 'display: none;'} `} > diff --git a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js index cf75fdfb4db5..bf95ef83b3a1 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js +++ b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js @@ -99,10 +99,12 @@ function calculateCondition({field, fields, entry, locale, isTranslatable}) { if (!condition) return true; const condFieldName = condition.get('field'); - const condValue = condition.get('value'); const operator = condition.get('operator') || '=='; + const condValue = condition.get('value'); + const condField = fields.find(f => f.get('name') === condFieldName); - const condFieldValue = getFieldValue({ field: condField, entry, locale, isTranslatable, }); + let condFieldValue = getFieldValue({ field: condField, entry, locale, isTranslatable, }); + condFieldValue = condFieldValue?.toJS ? condFieldValue.toJS() : condFieldValue; switch (operator) { case '==': @@ -178,13 +180,22 @@ export default class ControlPane extends React.Component { }; validate = async () => { - this.props.fields.forEach(field => { - if (field.get('widget') === 'hidden') return; + const { fields, entry, collection } = this.props; + + fields.forEach(field => { + const isConditionMet = calculateCondition({ + field, + fields, + entry, + locale: this.state.selectedLocale, + isTranslatable: hasI18n(collection), + }); + + if (field.get('widget') === 'hidden' || !isConditionMet) return; + const control = this.childRefs[field.get('name')]; const validateFn = control?.innerWrappedControl?.validate ?? control?.validate; - if (validateFn) { - validateFn(); - } + if (validateFn) validateFn(); }); }; @@ -257,6 +268,7 @@ export default class ControlPane extends React.Component { locale, isTranslatable, }); + if (!isConditionMet) return return ( isFieldDuplicate(field, locale, defaultLocale)} isFieldHidden={field => isFieldHidden(field, locale, defaultLocale)} locale={locale} - isConditionMet={isConditionMet} /> ); })} From 41b073a696f0f06f7b89d5b77e06ace0daa13120 Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Tue, 11 Nov 2025 14:39:07 +0100 Subject: [PATCH 3/7] fix: format --- .../EditorControlPane/EditorControlPane.js | 72 ++++++++++--------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js index bf95ef83b3a1..956d65adab18 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js +++ b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js @@ -94,8 +94,8 @@ function getFieldValue({ field, entry, isTranslatable, locale }) { return entry.getIn(['data', field.get('name')]); } -function calculateCondition({field, fields, entry, locale, isTranslatable}) { - const condition = field.get('condition') +function calculateCondition({ field, fields, entry, locale, isTranslatable }) { + const condition = field.get('condition'); if (!condition) return true; const condFieldName = condition.get('field'); @@ -103,7 +103,7 @@ function calculateCondition({field, fields, entry, locale, isTranslatable}) { const condValue = condition.get('value'); const condField = fields.find(f => f.get('name') === condFieldName); - let condFieldValue = getFieldValue({ field: condField, entry, locale, isTranslatable, }); + let condFieldValue = getFieldValue({ field: condField, entry, locale, isTranslatable }); condFieldValue = condFieldValue?.toJS ? condFieldValue.toJS() : condFieldValue; switch (operator) { @@ -146,38 +146,40 @@ export default class ControlPane extends React.Component { this.props.onLocaleChange(val); }; - copyFromOtherLocale = ({ targetLocale, t }) => sourceLocale => { - if ( - !window.confirm( - t('editor.editorControlPane.i18n.copyFromLocaleConfirm', { - locale: sourceLocale.toUpperCase(), - }), - ) - ) { - return; - } - const { entry, collection } = this.props; - const { locales, defaultLocale } = getI18nInfo(collection); - - const locale = this.state.selectedLocale; - const i18n = locales && { - currentLocale: locale, - locales, - defaultLocale, - }; - - this.props.fields.forEach(field => { - if (isFieldTranslatable(field, targetLocale, sourceLocale)) { - const copyValue = getFieldValue({ - field, - entry, - locale: sourceLocale, - isTranslatable: sourceLocale !== defaultLocale, - }); - if (copyValue) this.props.onChange(field, copyValue, undefined, i18n); + copyFromOtherLocale = + ({ targetLocale, t }) => + sourceLocale => { + if ( + !window.confirm( + t('editor.editorControlPane.i18n.copyFromLocaleConfirm', { + locale: sourceLocale.toUpperCase(), + }), + ) + ) { + return; } - }); - }; + const { entry, collection } = this.props; + const { locales, defaultLocale } = getI18nInfo(collection); + + const locale = this.state.selectedLocale; + const i18n = locales && { + currentLocale: locale, + locales, + defaultLocale, + }; + + this.props.fields.forEach(field => { + if (isFieldTranslatable(field, targetLocale, sourceLocale)) { + const copyValue = getFieldValue({ + field, + entry, + locale: sourceLocale, + isTranslatable: sourceLocale !== defaultLocale, + }); + if (copyValue) this.props.onChange(field, copyValue, undefined, i18n); + } + }); + }; validate = async () => { const { fields, entry, collection } = this.props; @@ -268,7 +270,7 @@ export default class ControlPane extends React.Component { locale, isTranslatable, }); - if (!isConditionMet) return + if (!isConditionMet) return; return ( Date: Thu, 13 Nov 2025 12:50:12 +0100 Subject: [PATCH 4/7] feat: improve conditional fields - replace operator with descriptive names, - add tests, add regex, includes, oneOf --- cypress/e2e/conditional_fields_spec.js | 451 ++++++++++++++++++ dev-test/config.yml | 49 +- packages/decap-cms-core/index.d.ts | 19 +- .../EditorControlPane/EditorControlPane.js | 115 ++++- .../Editor/EditorControlPane/Widget.js | 4 + .../src/constants/configSchema.js | 37 +- packages/decap-cms-core/src/types/redux.ts | 19 +- .../decap-cms-widget-list/src/ListControl.js | 5 + .../src/ObjectControl.js | 13 + 9 files changed, 692 insertions(+), 20 deletions(-) create mode 100644 cypress/e2e/conditional_fields_spec.js diff --git a/cypress/e2e/conditional_fields_spec.js b/cypress/e2e/conditional_fields_spec.js new file mode 100644 index 000000000000..1c99717c01fa --- /dev/null +++ b/cypress/e2e/conditional_fields_spec.js @@ -0,0 +1,451 @@ +import '../utils/dismiss-local-backup'; +import { login } from '../utils/steps'; + +describe('Conditional Fields', () => { + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + beforeEach(() => { + login(); + }); + + it('should show/hide string field based on select value using notEqual', () => { + cy.contains('a', 'Kitchen Sink').click(); + cy.contains('a', 'New Kitchen Sink Post').click(); + + // Initially, select field should have no value, so conditional field should be visible (condition: select != 'a') + cy.contains('label', 'Conditional string').should('be.visible'); + + // Select value 'a' - conditional field should be hidden + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('a'); + + // Conditional string field should now be hidden + cy.contains('label', 'Conditional string').should('not.exist'); + + // Change select to 'b' - conditional field should reappear + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('b'); + + cy.contains('label', 'Conditional string').should('be.visible'); + + // Change select to 'c' - conditional field should still be visible + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('c'); + + cy.contains('label', 'Conditional string').should('be.visible'); + }); + + it('should show/hide object field based on boolean value using equal', () => { + cy.contains('a', 'Kitchen Sink').click(); + cy.contains('a', 'New Kitchen Sink Post').click(); + + // Initially, boolean field should be false, so conditional object should be hidden + cy.contains('label', 'Conditional object').should('not.exist'); + + // Toggle boolean to true + cy.contains('label', 'Boolean') + .parent() + .next() + .find('input[type="checkbox"]') + .click(); + + // Conditional object should now be visible + cy.contains('label', 'Conditional object').should('be.visible'); + + // Verify the nested field inside the conditional object is accessible + cy.contains('label', 'Conditional object') + .parent() + .parent() + .within(() => { + cy.contains('label', 'Title').should('be.visible'); + }); + + // Toggle boolean back to false + cy.contains('label', 'Boolean') + .parent() + .next() + .find('input[type="checkbox"]') + .click(); + + // Conditional object should be hidden again + cy.contains('label', 'Conditional object').should('not.exist'); + }); + + it('should not validate hidden conditional fields on save', () => { + cy.contains('a', 'Kitchen Sink').click(); + cy.contains('a', 'New Kitchen Sink Post').click(); + + // Fill in required fields + cy.get('input[type="text"]').first().clear(); + cy.get('input[type="text"]').first().type('Test Conditional Post'); + + // Ensure boolean is false so conditional object is hidden + cy.contains('label', 'Boolean') + .parent() + .next() + .find('input[type="checkbox"]') + .then($checkbox => { + if ($checkbox.is(':checked')) { + $checkbox.click(); + } + }); + + // Conditional object should be hidden + cy.contains('label', 'Conditional object').should('not.exist'); + + // Try to save - should succeed even though conditional object has required nested fields + cy.contains('button', 'Publish').click(); + cy.contains('button', 'Publish now').click(); + + // Should show success notification (or at least not show validation error for hidden field) + cy.get('.notif__container', { timeout: 10000 }).should('be.visible'); + }); + + it('should validate visible conditional fields on save', () => { + cy.contains('a', 'Kitchen Sink').click(); + cy.contains('a', 'New Kitchen Sink Post').click(); + + // Fill in required fields + cy.get('input[type="text"]').first().clear(); + cy.get('input[type="text"]').first().type('Test Conditional Post 2'); + + // Make boolean true so conditional object appears + cy.contains('label', 'Boolean') + .parent() + .next() + .find('input[type="checkbox"]') + .click(); + + // Conditional object should be visible + cy.contains('label', 'Conditional object').should('be.visible'); + + // Leave the nested field empty (it should be required) + // Try to save - should fail if nested field has validation + cy.contains('button', 'Publish').click(); + + // Note: This test assumes the nested 'Title' field would have validation + // If it doesn't, we should still verify the field is interactable + cy.contains('label', 'Conditional object') + .parent() + .parent() + .within(() => { + cy.contains('label', 'Title') + .parent() + .next() + .find('input') + .type('Nested Title'); + }); + }); + + it('should persist field values when field visibility toggles', () => { + cy.contains('a', 'Kitchen Sink').click(); + cy.contains('a', 'New Kitchen Sink Post').click(); + + // Set select to 'b' to make conditional string visible + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('b'); + + // Enter a value in conditional string + cy.contains('label', 'Conditional string') + .parent() + .next() + .find('input') + .type('This value should persist'); + + // Hide the field by changing select to 'a' + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('a'); + + // Field should be hidden + cy.contains('label', 'Conditional string').should('not.exist'); + + // Show the field again by changing select to 'c' + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('c'); + + // Verify the value persisted + cy.contains('label', 'Conditional string') + .parent() + .next() + .find('input') + .should('have.value', 'This value should persist'); + }); + + it('should work with comparison operators', () => { + // This test would require adding test fields with numeric comparisons + // For now, we're testing with the existing notEqual and equal behaviors + // Future enhancement: Add fields with >, <, >=, <= operators to dev-test config + cy.contains('a', 'Kitchen Sink').click(); + cy.contains('a', 'New Kitchen Sink Post').click(); + + // Test != operator (already done above) + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('a'); + cy.contains('label', 'Conditional string').should('not.exist'); + + // Test == operator (with boolean) + cy.contains('label', 'Boolean') + .parent() + .next() + .find('input[type="checkbox"]') + .click(); + cy.contains('label', 'Conditional object').should('be.visible'); + }); + + it('should show/hide field with oneOf operator matching array values', () => { + cy.contains('a', 'Kitchen Sink').click(); + cy.contains('a', 'New Kitchen Sink Post').click(); + + // The oneOf conditional field should be hidden when select is not 'a' or 'b' + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('c'); + + cy.contains('label', 'Conditional oneOf').should('not.exist'); + + // Select value 'a' - conditional field should appear (a is in ['a', 'b']) + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('a'); + + cy.contains('label', 'Conditional oneOf').should('be.visible'); + + // Select value 'b' - conditional field should still be visible (b is in ['a', 'b']) + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('b'); + + cy.contains('label', 'Conditional oneOf').should('be.visible'); + + // Change back to 'c' - conditional field should be hidden again + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('c'); + + cy.contains('label', 'Conditional oneOf').should('not.exist'); + }); + + it('should handle nested field conditions with dot-notated field paths', () => { + cy.contains('a', 'Kitchen Sink').click(); + cy.contains('a', 'New Kitchen Sink Post').click(); + + // Expand the Object field to access nested select + cy.contains('label', 'Object') + .parent() + .parent() + .within(() => { + // Check if object is collapsed and expand it + cy.get('button').first().then($btn => { + if ($btn.attr('aria-expanded') === 'false') { + $btn.click(); + } + }); + + // Change nested select to 'a' + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('a'); + }); + + // Nested conditional field should now be visible + cy.contains('label', 'Nested Conditional').should('be.visible'); + + // Change nested select to 'b' + cy.contains('label', 'Object') + .parent() + .parent() + .within(() => { + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('b'); + }); + + // Nested conditional field should be hidden + cy.contains('label', 'Nested Conditional').should('not.exist'); + }); + + it('should handle wildcard field paths in list items', () => { + cy.contains('a', 'Kitchen Sink').click(); + cy.contains('a', 'New Kitchen Sink Post').click(); + + // Expand the List field + cy.contains('label', 'List') + .parent() + .parent() + .within(() => { + // Add a list item + cy.contains('button', 'Add').click(); + + // The wildcard conditional field should be hidden initially + cy.contains('label', 'List Wildcard Conditional').should('not.exist'); + + // Change select in the list item to 'b' + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('b'); + }); + + // Now the wildcard conditional should be visible + cy.contains('label', 'List Wildcard Conditional').should('be.visible'); + + // Change select to a different value + cy.contains('label', 'List') + .parent() + .parent() + .within(() => { + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('a'); + }); + + // Wildcard conditional should be hidden again + cy.contains('label', 'List Wildcard Conditional').should('not.exist'); + }); + + it('should handle wildcard conditions in typed lists with structure.*.type pattern', () => { + cy.contains('a', 'Kitchen Sink').click(); + cy.contains('a', 'New Kitchen Sink Post').click(); + + // Image/Video Options should be hidden initially + cy.contains('label', 'Image Options').should('not.exist'); + cy.contains('label', 'Video Options').should('not.exist'); + + // Expand Structure List with Wildcard Conditionals + cy.contains('label', 'Structure List with Wildcard Conditionals') + .parent() + .parent() + .within(() => { + // Add an image block + cy.contains('button', 'Add').click(); + }); + + // Select 'Image Block' type from the type chooser + cy.contains('button', 'Image Block').click(); + + // Image Options should now be visible + cy.contains('label', 'Image Options').should('be.visible'); + cy.contains('label', 'Video Options').should('not.exist'); + + // Add another item - this time a video block + cy.contains('label', 'Structure List with Wildcard Conditionals') + .parent() + .parent() + .within(() => { + cy.contains('button', 'Add').click(); + }); + + cy.contains('button', 'Video Block').click(); + + // Video Options should now be visible + cy.contains('label', 'Video Options').should('be.visible'); + + // Image Options should still be visible (because there's still an image block) + cy.contains('label', 'Image Options').should('be.visible'); + }); + + it('should show/hide field using regex match operator (matches)', () => { + cy.contains('a', 'Kitchen Sink').click(); + cy.contains('a', 'New Kitchen Sink Post').click(); + + // Ensure Select is 'c' so a regex that matches /^a|b$/ won't match + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('c'); + + // Field using regex should be hidden + cy.contains('label', 'Conditional regex').should('not.exist'); + + // Select value 'a' - matches regex '/^(a|b)$/' + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('a'); + + cy.contains('label', 'Conditional regex').should('be.visible'); + + // Select value 'b' - should also match + cy.contains('label', 'Select') + .parent() + .next() + .find('select') + .select('b'); + + cy.contains('label', 'Conditional regex').should('be.visible'); + }); + + it('should respect regex flags and negated regex via matches operator', () => { + cy.contains('a', 'Kitchen Sink').click(); + cy.contains('a', 'New Kitchen Sink Post').click(); + + // Case-insensitive match: conditional should appear when input contains 'FOO' with /foo/i + cy.contains('label', 'String') + .parent() + .next() + .find('input') + .as('ciInput'); + + cy.get('@ciInput').clear(); + cy.get('@ciInput').type('FOO'); + + cy.contains('label', 'Conditional regex ci').should('be.visible'); + + // Negated regex: using a matches operator with a negative lookahead, field should be hidden when the value matches the original pattern + cy.contains('label', 'String') + .parent() + .next() + .find('input') + .as('notRegexInput'); + + cy.get('@notRegexInput').clear(); + cy.get('@notRegexInput').type('123-456'); + + cy.contains('label', 'Conditional notRegex').should('not.exist'); + }); +}); diff --git a/dev-test/config.yml b/dev-test/config.yml index 32f9d303868e..bf8d43d9725e 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -161,13 +161,20 @@ collections: # A list of collections the CMS should be able to edit multiple: true, } - { label: 'Hidden', name: 'hidden', widget: 'hidden', default: 'hidden' } - - { label: 'Conditional string', name: 'conditional_string', widget: 'string', condition: { field: 'select', value: 'a', operator: '!=' } } + - { label: 'Conditional string', name: 'conditional_string', widget: 'string', condition: { field: 'select', value: 'a', operator: 'notEqual' } } - label: 'Conditional object' name: 'conditional_object' widget: 'object' condition: { field: 'boolean', value: true } fields: - { label: 'Title', name: 'title', widget: 'string' } + - label: 'Conditional oneOf' + name: 'conditional_oneof' + widget: 'string' + condition: { field: 'select', value: ['a', 'b'], operator: 'oneOf' } + - { label: 'Conditional regex', name: 'conditional_regex', widget: 'string', condition: { field: 'select', value: '/^(a|b)$/', operator: 'matches' } } + - { label: 'Conditional regex ci', name: 'conditional_regex_ci', widget: 'string', condition: { field: 'string', value: '/foo/i', operator: 'matches' } } + - { label: 'Conditional notRegex', name: 'conditional_not_regex', widget: 'string', condition: { field: 'string', value: '/^(?!\\d{3}-\\d{3}$).*$/', operator: 'matches' } } - { label: 'Color', name: 'color', widget: 'color' } - label: 'Object' name: 'object' @@ -189,6 +196,7 @@ collections: # A list of collections the CMS should be able to edit - { label: 'Image', name: 'image', widget: 'image' } - { label: 'File', name: 'file', widget: 'file' } - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } + - { label: 'Nested Conditional', name: 'nested_conditional', widget: 'string', condition: { field: 'object.select', value: 'a' } } - label: 'List' name: 'list' widget: 'list' @@ -202,6 +210,7 @@ collections: # A list of collections the CMS should be able to edit - { label: 'Image', name: 'image', widget: 'image' } - { label: 'File', name: 'file', widget: 'file' } - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } + - { label: 'List Wildcard Conditional', name: 'wildcard_cond', widget: 'string', condition: { field: 'list.*.select', value: 'b' } } - label: 'Object' name: 'object' widget: 'object' @@ -278,6 +287,44 @@ collections: # A list of collections the CMS should be able to edit fields: - { label: 'Image', name: 'image', widget: 'image' } - { label: 'File', name: 'file', widget: 'file' } + - label: 'Structure List with Wildcard Conditionals' + name: 'structure' + widget: 'list' + types: + - label: 'Image Block' + name: 'image' + widget: 'object' + fields: + - { label: 'Image', name: 'image', widget: 'image' } + - { label: 'Caption', name: 'caption', widget: 'string' } + - { label: 'Alt Text', name: 'alt', widget: 'string' } + - label: 'Text Block' + name: 'text' + widget: 'object' + fields: + - { label: 'Content', name: 'content', widget: 'markdown' } + - { label: 'Alignment', name: 'alignment', widget: 'select', options: ['left', 'center', 'right'] } + - label: 'Video Block' + name: 'video' + widget: 'object' + fields: + - { label: 'Video URL', name: 'url', widget: 'string' } + - { label: 'Thumbnail', name: 'thumbnail', widget: 'image' } + - { label: 'Autoplay', name: 'autoplay', widget: 'boolean', default: false } + - label: 'Image Options' + name: 'image_options' + widget: 'object' + condition: { field: 'structure.*.type', value: 'image' } + fields: + - { label: 'Image Quality', name: 'quality', widget: 'number', min: 1, max: 100, default: 80 } + - { label: 'Image Format', name: 'format', widget: 'select', options: ['jpg', 'png', 'webp'] } + - label: 'Video Options' + name: 'video_options' + widget: 'object' + condition: { field: 'structure.*.type', value: 'video' } + fields: + - { label: 'Video Quality', name: 'quality', widget: 'select', options: ['360p', '720p', '1080p'] } + - { label: 'Captions', name: 'captions', widget: 'boolean', default: true } - name: pages # a nested collection label: Pages label_singular: 'Page' diff --git a/packages/decap-cms-core/index.d.ts b/packages/decap-cms-core/index.d.ts index 6520725af326..452b75ab46a2 100644 --- a/packages/decap-cms-core/index.d.ts +++ b/packages/decap-cms-core/index.d.ts @@ -53,8 +53,23 @@ declare module 'decap-cms-core' { interface Condition { field: string; - value: string | boolean | number; - operator?: '==' | '!=' | '>' | '<' | '>=' | '<='; + value: + | string + | boolean + | number + | RegExp + | { regex: string; flags?: string } + | (string | boolean | number)[]; + operator?: + | 'equal' + | 'notEqual' + | 'greaterThan' + | 'lessThan' + | 'greaterThanOrEqual' + | 'lessThanOrEqual' + | 'oneOf' + | 'includes' + | 'matches'; } export interface CmsFieldBase { diff --git a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js index 956d65adab18..7d78060daba9 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js +++ b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js @@ -94,31 +94,103 @@ function getFieldValue({ field, entry, isTranslatable, locale }) { return entry.getIn(['data', field.get('name')]); } -function calculateCondition({ field, fields, entry, locale, isTranslatable }) { +function calculateCondition({ field, fields, entry, locale, isTranslatable, listIndexes = [] }) { const condition = field.get('condition'); if (!condition) return true; - const condFieldName = condition.get('field'); - const operator = condition.get('operator') || '=='; + // Get field name - supports simple names, dot-notated paths, and wildcards + let condFieldName = condition.get('field'); + if (!condFieldName) return true; + + // Operators are descriptive (equal, notEqual, greaterThan, etc.) + const operator = condition.get('operator') || 'equal'; const condValue = condition.get('value'); - const condField = fields.find(f => f.get('name') === condFieldName); - let condFieldValue = getFieldValue({ field: condField, entry, locale, isTranslatable }); + // Handle wildcard paths (e.g., 'structure.*.type') + if (condFieldName.includes('*')) { + condFieldName = condFieldName.split('*').reduce((acc, item, i) => { + return `${acc}${item}${listIndexes[i] >= 0 ? listIndexes[i] : ''}`; + }, ''); + } + + // Get the field value - all field references are treated as potentially nested paths + let condFieldValue; + if (condFieldName.includes('.')) { + // For dot-notated paths, traverse the entry data directly + const dataPath = condFieldName.split('.'); + const entryData = entry.get('data'); + condFieldValue = entryData ? entryData.getIn(dataPath) : undefined; + } else { + // For simple field names, use the existing getFieldValue logic + const condField = fields.find(f => f.get('name') === condFieldName); + if (condField) { + condFieldValue = getFieldValue({ field: condField, entry, locale, isTranslatable }); + } + } + + // Convert Immutable to JS if needed condFieldValue = condFieldValue?.toJS ? condFieldValue.toJS() : condFieldValue; + // Handle different operators switch (operator) { - case '==': + case 'equal': return condFieldValue == condValue; - case '!=': + case 'notEqual': return condFieldValue != condValue; - case '<': - return condFieldValue < condValue; - case '>': + case 'greaterThan': return condFieldValue > condValue; - case '<=': - return condFieldValue <= condValue; - case '>=': + case 'lessThan': + return condFieldValue < condValue; + case 'greaterThanOrEqual': return condFieldValue >= condValue; + case 'lessThanOrEqual': + return condFieldValue <= condValue; + case 'oneOf': { + const valueArray = condValue?.toJS ? condValue.toJS() : condValue; + return Array.isArray(valueArray) && valueArray.includes(condFieldValue); + } + case 'includes': { + const rawValue = condValue?.toJS ? condValue.toJS() : condValue; + // If field value is array, check inclusion; if string, check substring + if (Array.isArray(condFieldValue)) return condFieldValue.includes(rawValue); + const target = condFieldValue == null ? '' : String(condFieldValue); + return String(rawValue) !== undefined && target.includes(String(rawValue)); + } + case 'matches': { + const rawCondValue = condValue?.toJS ? condValue.toJS() : condValue; + + // Determine regex pattern/flags from value + let pattern; + let flags; + + if (rawCondValue instanceof RegExp) { + pattern = rawCondValue.source; + flags = rawCondValue.flags; + } else if (typeof rawCondValue === 'string') { + const match = rawCondValue.match(/^\/(.*)\/([gimsuy]*)$/); + if (match) { + pattern = match[1]; + flags = match[2] || undefined; + } else { + // if plain string, fallback to substring match semantics + const target = condFieldValue == null ? '' : String(condFieldValue); + return target.includes(rawCondValue); + } + } else if (rawCondValue && typeof rawCondValue === 'object' && rawCondValue.regex) { + pattern = rawCondValue.regex; + flags = rawCondValue.flags; + } else { + return false; + } + + try { + const re = new RegExp(pattern, flags); + const target = condFieldValue == null ? '' : String(condFieldValue); + return re.test(target); + } catch (e) { + return false; + } + } default: return condFieldValue == condValue; } @@ -141,6 +213,21 @@ export default class ControlPane extends React.Component { this.controlRef(field, wrappedControl); }; + fieldCondition = (field, listIndexes = []) => { + const { entry, collection, fields } = this.props; + const locale = this.state.selectedLocale; + const isTranslatable = hasI18n(collection); + + return calculateCondition({ + field, + fields, + entry, + locale, + isTranslatable, + listIndexes, + }); + }; + handleLocaleChange = val => { this.setState({ selectedLocale: val }); this.props.onLocaleChange(val); @@ -296,6 +383,8 @@ export default class ControlPane extends React.Component { isFieldDuplicate={field => isFieldDuplicate(field, locale, defaultLocale)} isFieldHidden={field => isFieldHidden(field, locale, defaultLocale)} locale={locale} + listIndexes={[]} + fieldCondition={this.fieldCondition} /> ); })} diff --git a/packages/decap-cms-core/src/components/Editor/EditorControlPane/Widget.js b/packages/decap-cms-core/src/components/Editor/EditorControlPane/Widget.js index c6d165e0bca7..eac023b809e1 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorControlPane/Widget.js +++ b/packages/decap-cms-core/src/components/Editor/EditorControlPane/Widget.js @@ -326,6 +326,8 @@ export default class Widget extends Component { isFieldHidden, locale, isParentListCollapsed, + listIndexes, + fieldCondition, } = this.props; return React.createElement(controlComponent, { @@ -379,6 +381,8 @@ export default class Widget extends Component { isFieldHidden, locale, isParentListCollapsed, + listIndexes, + fieldCondition, }); } } diff --git a/packages/decap-cms-core/src/constants/configSchema.js b/packages/decap-cms-core/src/constants/configSchema.js index a4c2e303a954..435db3f1b36b 100644 --- a/packages/decap-cms-core/src/constants/configSchema.js +++ b/packages/decap-cms-core/src/constants/configSchema.js @@ -73,9 +73,42 @@ function fieldsConfig() { type: 'object', properties: { field: { type: 'string' }, - value: { types: ['string', 'boolean'] }, - operator: { type: 'string', enum: ['==', '!=', '>', '<', '>=', '<='] }, + value: { + oneOf: [ + { type: 'string' }, + // Allow regex as a string like '/pattern/flags' or as an object { regex, flags } + { + type: 'object', + properties: { regex: { type: 'string' }, flags: { type: 'string' } }, + required: ['regex'], + additionalProperties: false, + }, + { type: 'boolean' }, + { type: 'number' }, + { + type: 'array', + items: { + oneOf: [{ type: 'string' }, { type: 'boolean' }, { type: 'number' }], + }, + }, + ], + }, + operator: { + type: 'string', + enum: [ + 'equal', + 'notEqual', + 'greaterThan', + 'lessThan', + 'greaterThanOrEqual', + 'lessThanOrEqual', + 'oneOf', + 'includes', + 'matches', + ], + }, }, + required: ['field'], }, }, select: { $data: '0/widget' }, diff --git a/packages/decap-cms-core/src/types/redux.ts b/packages/decap-cms-core/src/types/redux.ts index 9f7e8a9c1912..58751e970e7b 100644 --- a/packages/decap-cms-core/src/types/redux.ts +++ b/packages/decap-cms-core/src/types/redux.ts @@ -69,8 +69,23 @@ export interface CmsI18nConfig { interface Condition { field: string; - value: string | boolean | number; - operator?: '==' | '!=' | '>' | '<' | '>=' | '<='; + value: + | string + | boolean + | number + | RegExp + | { regex: string; flags?: string } + | (string | boolean | number)[]; + operator?: + | 'equal' + | 'notEqual' + | 'greaterThan' + | 'lessThan' + | 'greaterThanOrEqual' + | 'lessThanOrEqual' + | 'oneOf' + | 'includes' + | 'matches'; } export interface CmsFieldBase { diff --git a/packages/decap-cms-widget-list/src/ListControl.js b/packages/decap-cms-widget-list/src/ListControl.js index 97f74e9da16c..1a60a392dd6f 100644 --- a/packages/decap-cms-widget-list/src/ListControl.js +++ b/packages/decap-cms-widget-list/src/ListControl.js @@ -644,6 +644,8 @@ export default class ListControl extends React.Component { parentIds, forID, t, + listIndexes: indexes, + fieldCondition, } = this.props; const { itemsCollapsed, keys } = this.state; @@ -651,6 +653,7 @@ export default class ListControl extends React.Component { const key = keys[index]; let field = this.props.field; const hasError = this.hasError(index); + const listIndexes = indexes?.length ? indexes.concat(index) : [index]; const isVariableTypesList = this.getValueType() === valueTypes.MIXED; if (isVariableTypesList) { field = getTypedFieldForValue(field, item); @@ -715,6 +718,8 @@ export default class ListControl extends React.Component { data-testid={`object-control-${key}`} hasError={hasError} parentIds={[...parentIds, forID, key]} + listIndexes={listIndexes} + fieldCondition={fieldCondition} /> )} diff --git a/packages/decap-cms-widget-object/src/ObjectControl.js b/packages/decap-cms-widget-object/src/ObjectControl.js index 3ecadfcd434a..c903f152eb27 100644 --- a/packages/decap-cms-widget-object/src/ObjectControl.js +++ b/packages/decap-cms-widget-object/src/ObjectControl.js @@ -108,6 +108,8 @@ export default class ObjectControl extends React.Component { locale, collapsed, forID, + listIndexes, + fieldCondition, } = this.props; if (field.get('widget') === 'hidden') { @@ -119,6 +121,15 @@ export default class ObjectControl extends React.Component { const isDuplicate = isFieldDuplicate && isFieldDuplicate(field); const isHidden = isFieldHidden && isFieldHidden(field); + // Check if field should be hidden based on conditions + const hideField = + field.get('condition') && fieldCondition && !fieldCondition(field, listIndexes); + + // Skip rendering if field should be hidden + if (hideField) { + return null; + } + return ( ); } From bca3f465e6d1bb0429ff911c666baa3869ee03a9 Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Thu, 13 Nov 2025 13:28:07 +0100 Subject: [PATCH 5/7] fix: config --- dev-test/config.yml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/dev-test/config.yml b/dev-test/config.yml index bf8d43d9725e..bd5e0bd8a7ab 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -144,15 +144,13 @@ collections: # A list of collections the CMS should be able to edit search_fields: ['title', 'body'] value_field: 'title' - { label: 'Title', name: 'title', widget: 'string' } - - { label: 'Boolean', name: 'boolean', widget: 'boolean', default: true } - { label: 'Map', name: 'map', widget: 'map' } - { label: 'Text', name: 'text', widget: 'text', hint: 'Plain text, not markdown' } - { label: 'Number', name: 'number', widget: 'number', hint: 'To infinity and beyond!' } - - { label: 'Markdown', name: 'markdown', widget: 'markdown' } - { label: 'Datetime', name: 'datetime', widget: 'datetime' } - { label: 'Image', name: 'image', widget: 'image' } - { label: 'File', name: 'file', widget: 'file' } - - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } + - { label: 'Color', name: 'color', widget: 'color' } - { label: 'Select multiple', name: 'select_multiple', @@ -161,21 +159,25 @@ collections: # A list of collections the CMS should be able to edit multiple: true, } - { label: 'Hidden', name: 'hidden', widget: 'hidden', default: 'hidden' } - - { label: 'Conditional string', name: 'conditional_string', widget: 'string', condition: { field: 'select', value: 'a', operator: 'notEqual' } } - - label: 'Conditional object' + - { label: 'Boolean', name: 'boolean', widget: 'boolean', default: true, hint: 'Controls visibility of "Conditional Object" below' } + - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'], hint: 'Controls multiple conditional fields below (try different values)' } + - { label: 'String', name: 'string', widget: 'string', hint: 'Type "foo" or "123-456" to test regex conditionals below' } + - { label: 'Conditional String (notEqual)', name: 'conditional_string', widget: 'string', hint: 'Visible when Select ≠ "a"', condition: { field: 'select', value: 'a', operator: 'notEqual' } } + - label: 'Conditional Object (equal)' name: 'conditional_object' widget: 'object' + hint: 'Visible when Boolean = true' condition: { field: 'boolean', value: true } fields: - { label: 'Title', name: 'title', widget: 'string' } - - label: 'Conditional oneOf' + - label: 'Conditional OneOf' name: 'conditional_oneof' widget: 'string' + hint: 'Visible when Select is "a" OR "b"' condition: { field: 'select', value: ['a', 'b'], operator: 'oneOf' } - - { label: 'Conditional regex', name: 'conditional_regex', widget: 'string', condition: { field: 'select', value: '/^(a|b)$/', operator: 'matches' } } - - { label: 'Conditional regex ci', name: 'conditional_regex_ci', widget: 'string', condition: { field: 'string', value: '/foo/i', operator: 'matches' } } - - { label: 'Conditional notRegex', name: 'conditional_not_regex', widget: 'string', condition: { field: 'string', value: '/^(?!\\d{3}-\\d{3}$).*$/', operator: 'matches' } } - - { label: 'Color', name: 'color', widget: 'color' } + - { label: 'Conditional Regex (matches)', name: 'conditional_regex', widget: 'string', hint: 'Visible when Select matches /^(a|b)$/', condition: { field: 'select', value: '/^(a|b)$/', operator: 'matches' } } + - { label: 'Conditional Regex CI', name: 'conditional_regex_ci', widget: 'string', hint: 'Visible when String contains "foo" (case-insensitive)', condition: { field: 'string', value: '/foo/i', operator: 'matches' } } + - { label: 'Conditional Negated Regex', name: 'conditional_not_regex', widget: 'string', hint: 'Visible when String does NOT match pattern ###-### (e.g., 123-456)', condition: { field: 'string', value: '/^(?!\\d{3}-\\d{3}$).*$/', operator: 'matches' } } - label: 'Object' name: 'object' widget: 'object' @@ -325,6 +327,7 @@ collections: # A list of collections the CMS should be able to edit fields: - { label: 'Video Quality', name: 'quality', widget: 'select', options: ['360p', '720p', '1080p'] } - { label: 'Captions', name: 'captions', widget: 'boolean', default: true } + - { label: 'Markdown', name: 'markdown', widget: 'markdown' } - name: pages # a nested collection label: Pages label_singular: 'Page' From f670fee2238d1c53922ddeefb94fbce1b4990e32 Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Thu, 13 Nov 2025 13:39:28 +0100 Subject: [PATCH 6/7] feat: better conditional performance --- .../EditorControlPane/EditorControlPane.js | 184 +++++++++++++++++- 1 file changed, 174 insertions(+), 10 deletions(-) diff --git a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js index 7d78060daba9..922f98e4c0db 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js +++ b/packages/decap-cms-core/src/components/Editor/EditorControlPane/EditorControlPane.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; +import memoize from 'lodash/memoize'; import { buttons, colors, @@ -196,12 +197,170 @@ function calculateCondition({ field, fields, entry, locale, isTranslatable, list } } +/** + * Memoized version of calculateCondition to improve performance. + * + * PERFORMANCE OPTIMIZATION: + * Without memoization, calculateCondition is called on every render for every field, + * creating an O(n²) performance problem. When a user types in a field, the entry + * object changes, causing all fields to re-render and recalculate their conditions. + * + * This memoization caches results based on: + * 1. The field name being checked + * 2. The actual VALUE of the condition field (not the entire entry object) + * 3. List indexes for wildcard paths + * + * This means: + * - Typing in field A only recalculates conditions that depend on field A + * - Typing in field B doesn't trigger recalculation for fields that depend on A + * - Cache is automatically cleared when switching entries (see componentDidUpdate) + * + * This reduces O(n²) to O(n) complexity for conditional field evaluation. + */ +const memoizedCalculateCondition = memoize( + ({ field, fields, entry, locale, isTranslatable, listIndexes = [] }) => { + return calculateCondition({ field, fields, entry, locale, isTranslatable, listIndexes }); + }, + // Custom resolver: create cache key from field name, condition field value, and listIndexes + ({ field, entry, listIndexes = [] }) => { + const condition = field.get('condition'); + if (!condition) return `${field.get('name')}-no-condition`; + + let condFieldName = condition.get('field'); + if (!condFieldName) return `${field.get('name')}-no-field`; + + // Handle wildcard paths + if (condFieldName.includes('*')) { + condFieldName = condFieldName.split('*').reduce((acc, item, i) => { + return `${acc}${item}${listIndexes[i] >= 0 ? listIndexes[i] : ''}`; + }, ''); + } + + // Get the actual value of the condition field to use in cache key + let condFieldValue; + if (condFieldName.includes('.')) { + const dataPath = condFieldName.split('.'); + const entryData = entry.get('data'); + condFieldValue = entryData ? entryData.getIn(dataPath) : undefined; + } else { + const entryData = entry.get('data'); + condFieldValue = entryData ? entryData.get(condFieldName) : undefined; + } + + // Create cache key from field name, condition field name, condition field value, and list indexes + // This ensures we only recalculate when the condition field's value actually changes + const valueKey = condFieldValue?.toJS + ? JSON.stringify(condFieldValue.toJS()) + : String(condFieldValue); + return `${field.get('name')}-${condFieldName}-${valueKey}-${listIndexes.join(',')}`; + }, +); + export default class ControlPane extends React.Component { state = { selectedLocale: this.props.locale, + // Track which fields are currently visible based on conditions + // This allows us to update visibility asynchronously without blocking input + fieldVisibility: {}, }; childRefs = {}; + visibilityUpdateTimer = null; + + componentDidMount() { + // Calculate initial visibility + this.scheduleVisibilityUpdate(); + } + + componentDidUpdate(prevProps, prevState) { + // Clear memoization cache when switching to a different entry + // This ensures we don't use stale cached condition calculations + if (prevProps.entry !== this.props.entry) { + memoizedCalculateCondition.cache.clear(); + // Force immediate recalculation for new entry + this.setState({ fieldVisibility: {} }); + this.scheduleVisibilityUpdate(true); + } else if ( + prevProps.entry !== this.props.entry || + prevState.selectedLocale !== this.state.selectedLocale + ) { + // Schedule async visibility update when data changes + this.scheduleVisibilityUpdate(); + } + } + + componentWillUnmount() { + // Clean up any pending visibility updates + if (this.visibilityUpdateTimer) { + clearTimeout(this.visibilityUpdateTimer); + } + } + + /** + * Schedule an asynchronous update of field visibility. + * This allows user input to remain smooth while conditional field + * visibility is recalculated in the background. + * + * @param {boolean} immediate - If true, update immediately without debouncing + */ + scheduleVisibilityUpdate = (immediate = false) => { + // Clear any pending update + if (this.visibilityUpdateTimer) { + clearTimeout(this.visibilityUpdateTimer); + } + + const updateVisibility = () => { + const { entry, collection, fields } = this.props; + const locale = this.state.selectedLocale; + const isTranslatable = hasI18n(collection); + + // Calculate visibility for all fields with conditions + const newVisibility = {}; + fields.forEach(field => { + const fieldName = field.get('name'); + const condition = field.get('condition'); + + if (condition) { + // Use memoized calculation + newVisibility[fieldName] = memoizedCalculateCondition({ + field, + fields, + entry, + locale, + isTranslatable, + listIndexes: [], + }); + } else { + // Fields without conditions are always visible + newVisibility[fieldName] = true; + } + }); + + this.setState({ fieldVisibility: newVisibility }); + }; + + if (immediate) { + // Update immediately (e.g., when switching entries) + updateVisibility(); + } else { + // Debounce updates to avoid excessive recalculation during rapid typing + // Use requestIdleCallback if available, otherwise setTimeout + if (typeof requestIdleCallback !== 'undefined') { + this.visibilityUpdateTimer = requestIdleCallback( + () => { + updateVisibility(); + this.visibilityUpdateTimer = null; + }, + { timeout: 150 }, // Max delay of 150ms + ); + } else { + this.visibilityUpdateTimer = setTimeout(() => { + updateVisibility(); + this.visibilityUpdateTimer = null; + }, 50); // Small delay to batch updates + } + } + }; controlRef = (field, wrappedControl) => { if (!wrappedControl) return; @@ -218,7 +377,7 @@ export default class ControlPane extends React.Component { const locale = this.state.selectedLocale; const isTranslatable = hasI18n(collection); - return calculateCondition({ + return memoizedCalculateCondition({ field, fields, entry, @@ -272,7 +431,7 @@ export default class ControlPane extends React.Component { const { fields, entry, collection } = this.props; fields.forEach(field => { - const isConditionMet = calculateCondition({ + const isConditionMet = memoizedCalculateCondition({ field, fields, entry, @@ -346,18 +505,20 @@ export default class ControlPane extends React.Component { {fields .filter(f => f.get('widget') !== 'hidden') .map((field, i) => { + const fieldName = field.get('name'); const isTranslatable = isFieldTranslatable(field, locale, defaultLocale); const isDuplicate = isFieldDuplicate(field, locale, defaultLocale); const isHidden = isFieldHidden(field, locale, defaultLocale); const key = i18n ? `${locale}_${i}` : i; - const isConditionMet = calculateCondition({ - field, - fields, - entry, - locale, - isTranslatable, - }); - if (!isConditionMet) return; + + // Use cached visibility state for smooth performance + // If visibility hasn't been calculated yet, default to visible to avoid hiding fields + const hasCondition = !!field.get('condition'); + const isConditionMet = hasCondition + ? this.state.fieldVisibility[fieldName] !== false // Default to true if not yet calculated + : true; + + if (!isConditionMet) return null; return ( { + // Call parent onChange onChange(field, newValue, newMetadata, i18n); + // Schedule async visibility update to reflect condition changes + this.scheduleVisibilityUpdate(); }} onValidate={onValidate} controlRef={this.getControlRef(field)} From 376117153ed339b9a80d4168aa6e45f65a98a2c6 Mon Sep 17 00:00:00 2001 From: Martin Jagodic Date: Thu, 13 Nov 2025 15:17:14 +0100 Subject: [PATCH 7/7] chore: update snapshots --- .../__snapshots__/ListControl.spec.js.snap | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/decap-cms-widget-list/src/__tests__/__snapshots__/ListControl.spec.js.snap b/packages/decap-cms-widget-list/src/__tests__/__snapshots__/ListControl.spec.js.snap index fe50682ab40d..764d659cbe3e 100644 --- a/packages/decap-cms-widget-list/src/__tests__/__snapshots__/ListControl.spec.js.snap +++ b/packages/decap-cms-widget-list/src/__tests__/__snapshots__/ListControl.spec.js.snap @@ -225,6 +225,7 @@ exports[`ListControl should add to list when add button is clicked 1`] = ` field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" fieldserrors="Map {}" forlist="" + listindexes="0" parentids="forID,0" validationkey="0" value="Map {}" @@ -477,6 +478,7 @@ exports[`ListControl should remove from list when remove button is clicked 1`] = field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"minimize_collapsed\\": true, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" fieldserrors="Map {}" forlist="" + listindexes="0" parentids="forID,0" validationkey="0" value="Map { \\"string\\": \\"item 1\\" }" @@ -507,6 +509,7 @@ exports[`ListControl should remove from list when remove button is clicked 1`] = field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"minimize_collapsed\\": true, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" fieldserrors="Map {}" forlist="" + listindexes="1" parentids="forID,1" validationkey="1" value="Map { \\"string\\": \\"item 2\\" }" @@ -761,6 +764,7 @@ exports[`ListControl should remove from list when remove button is clicked 2`] = field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"minimize_collapsed\\": true, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" fieldserrors="Map {}" forlist="" + listindexes="0" parentids="forID,1" validationkey="1" value="Map { \\"string\\": \\"item 2\\" }" @@ -1024,6 +1028,7 @@ exports[`ListControl should render list with fields with collapse = "false" and field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" fieldserrors="Map {}" forlist="" + listindexes="0" parentids="forID,0" validationkey="0" value="Map { \\"string\\": \\"item 1\\" }" @@ -1054,6 +1059,7 @@ exports[`ListControl should render list with fields with collapse = "false" and field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" fieldserrors="Map {}" forlist="" + listindexes="1" parentids="forID,1" validationkey="1" value="Map { \\"string\\": \\"item 2\\" }" @@ -1306,6 +1312,7 @@ exports[`ListControl should render list with fields with collapse = "false" and field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"minimize_collapsed\\": true, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" fieldserrors="Map {}" forlist="" + listindexes="0" parentids="forID,0" validationkey="0" value="Map { \\"string\\": \\"item 1\\" }" @@ -1336,6 +1343,7 @@ exports[`ListControl should render list with fields with collapse = "false" and field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"minimize_collapsed\\": true, \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" fieldserrors="Map {}" forlist="" + listindexes="1" parentids="forID,1" validationkey="1" value="Map { \\"string\\": \\"item 2\\" }" @@ -1590,6 +1598,7 @@ exports[`ListControl should render list with fields with default collapse ("true field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" fieldserrors="Map {}" forlist="" + listindexes="0" parentids="forID,0" validationkey="0" value="Map { \\"string\\": \\"item 1\\" }" @@ -1622,6 +1631,7 @@ exports[`ListControl should render list with fields with default collapse ("true field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"fields\\": List [ Map { \\"label\\": \\"String\\", \\"name\\": \\"string\\", \\"widget\\": \\"string\\" } ] }" fieldserrors="Map {}" forlist="" + listindexes="1" parentids="forID,1" validationkey="1" value="Map { \\"string\\": \\"item 2\\" }" @@ -2058,6 +2068,7 @@ exports[`ListControl should render list with nested object 1`] = ` field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"field\\": Map { \\"name\\": \\"object\\", \\"widget\\": \\"object\\", \\"label\\": \\"Object\\", \\"fields\\": List [ Map { \\"name\\": \\"title\\", \\"widget\\": \\"string\\", \\"label\\": \\"Title\\" } ] } }" fieldserrors="Map {}" forlist="" + listindexes="0" parentids="forID,0" validationkey="0" value="Map { \\"object\\": Map { \\"title\\": \\"item 1\\" } }" @@ -2090,6 +2101,7 @@ exports[`ListControl should render list with nested object 1`] = ` field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"field\\": Map { \\"name\\": \\"object\\", \\"widget\\": \\"object\\", \\"label\\": \\"Object\\", \\"fields\\": List [ Map { \\"name\\": \\"title\\", \\"widget\\": \\"string\\", \\"label\\": \\"Title\\" } ] } }" fieldserrors="Map {}" forlist="" + listindexes="1" parentids="forID,1" validationkey="1" value="Map { \\"object\\": Map { \\"title\\": \\"item 2\\" } }" @@ -2342,6 +2354,7 @@ exports[`ListControl should render list with nested object with collapse = false field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"field\\": Map { \\"name\\": \\"object\\", \\"widget\\": \\"object\\", \\"label\\": \\"Object\\", \\"fields\\": List [ Map { \\"name\\": \\"title\\", \\"widget\\": \\"string\\", \\"label\\": \\"Title\\" } ] } }" fieldserrors="Map {}" forlist="" + listindexes="0" parentids="forID,0" validationkey="0" value="Map { \\"object\\": Map { \\"title\\": \\"item 1\\" } }" @@ -2372,6 +2385,7 @@ exports[`ListControl should render list with nested object with collapse = false field="Map { \\"name\\": \\"list\\", \\"label\\": \\"List\\", \\"collapsed\\": false, \\"field\\": Map { \\"name\\": \\"object\\", \\"widget\\": \\"object\\", \\"label\\": \\"Object\\", \\"fields\\": List [ Map { \\"name\\": \\"title\\", \\"widget\\": \\"string\\", \\"label\\": \\"Title\\" } ] } }" fieldserrors="Map {}" forlist="" + listindexes="1" parentids="forID,1" validationkey="1" value="Map { \\"object\\": Map { \\"title\\": \\"item 2\\" } }"