diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bb182bf7..87572205e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +**Developers** + +- Improved Call to Action field schema support for add-ons. + ## v1.25.0 - 2026-09-10 **Security** diff --git a/packages/components/src/lib/smart-token-control/editor.scss b/packages/components/src/lib/smart-token-control/editor.scss index 6a9d37956..fd98a33c2 100644 --- a/packages/components/src/lib/smart-token-control/editor.scss +++ b/packages/components/src/lib/smart-token-control/editor.scss @@ -2,11 +2,14 @@ width: 100%; .component-smart-token-control__input { - padding: 5px; + align-items: center; + box-sizing: border-box; + min-height: 40px; + padding: 4px 8px; box-shadow: 0 0 0 transparent; - border-radius: 4px; + border-radius: 2px; border: 1px solid #8c8f94; - margin: 0 1px; + margin: 0; display: flex; flex-wrap: wrap; justify-content: space-between; @@ -58,8 +61,8 @@ padding: 0; margin: 0; width: 100%; - line-height: 1.2; - min-height: auto; + line-height: 1.4; + min-height: 30px; cursor: pointer; } @@ -76,6 +79,12 @@ margin: 0; } } + + &.is-focused .component-smart-token-control__input { + border-color: var(--wp-admin-theme-color); + box-shadow: 0 0 0 1px var(--wp-admin-theme-color); + outline: 2px solid transparent; + } } .component-smart-token-control__suggestions { diff --git a/packages/core-data/src/call-to-actions/actions.ts b/packages/core-data/src/call-to-actions/actions.ts index cc5d42e2f..725b38b97 100644 --- a/packages/core-data/src/call-to-actions/actions.ts +++ b/packages/core-data/src/call-to-actions/actions.ts @@ -4,6 +4,7 @@ import { compare as jsonpatchCompare } from 'fast-json-patch'; import { __, sprintf } from '@popup-maker/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { decodeEntities } from '@wordpress/html-entities'; +import { applyFilters } from '@wordpress/hooks'; import { ACTION_TYPES, NOTICE_CONTEXT } from './constants'; @@ -35,6 +36,12 @@ const { INVALIDATE_RESOLUTION, } = ACTION_TYPES; +/** Apply editor-provided normalization immediately before validation and I/O. */ +const prepareCallToActionForSave = < T extends Partial< EditableCta > >( + callToAction: T +): T => + applyFilters( 'popupMaker.callToAction.prepareForSave', callToAction ) as T; + /** * Helper function to handle field-specific validation errors using WordPress notices * @@ -134,9 +141,9 @@ const entityActions = { /** * Create a new entity record. Values sent to the server immediately. * - * @param {Editable} callToAction The entity to create. - * @param {boolean} validate An optional validation function. - * @param {boolean} withNotices Whether to show notices. + * @param {Partial} callToAction The entity to create. + * @param {boolean} validate An optional validation function. + * @param {boolean} withNotices Whether to show notices. * @return {Promise | false>} The created entity or false if validation fails. */ createCallToAction: @@ -157,7 +164,8 @@ const entityActions = { }, } ); - const { id, ...newCta } = callToAction; + const { id, ...rawCta } = callToAction; + const newCta = prepareCallToActionForSave( rawCta ); if ( validate ) { const validation = validateCallToAction( newCta ); @@ -281,18 +289,24 @@ const entityActions = { * @param {PartialEditableCta} callToAction The entity to update. * @param {boolean} validate An optional validation function. * @param {boolean} withNotices Whether to show notices. - * @return {Promise} The updated entity or false if validation fails. + * @param {boolean} prepare Whether to run save preparation. + * @return {Promise | false>} The updated entity or false if validation fails. */ updateCallToAction: ( callToAction: PartialEditableCta, validate: boolean = true, - withNotices: boolean = true + withNotices: boolean = true, + prepare: boolean = true ): ThunkAction< CallToAction< 'edit' > | false > => async ( { select, dispatch, registry } ) => { const action = 'updateCallToAction'; try { + const preparedCallToAction = prepare + ? prepareCallToActionForSave( callToAction ) + : callToAction; + dispatch( { type: CHANGE_ACTION_STATUS, payload: { @@ -302,7 +316,8 @@ const entityActions = { } ); if ( validate ) { - const validation = validateCallToAction( callToAction ); + const validation = + validateCallToAction( preparedCallToAction ); if ( true !== validation ) { dispatch( { @@ -328,7 +343,7 @@ const entityActions = { } const canonicalCallToAction = await select.getCallToAction( - callToAction.id + preparedCallToAction.id ); if ( ! canonicalCallToAction ) { @@ -366,7 +381,7 @@ const entityActions = { `ctas/${ canonicalCallToAction.id }`, { method: 'POST', - data: callToAction, + data: preparedCallToAction, } ); @@ -726,10 +741,12 @@ const editorActions = { return false; } + const preparedCallToAction = + prepareCallToActionForSave( editedCallToAction ); - if ( editedCallToAction && validate ) { + if ( preparedCallToAction && validate ) { const validation = - validateCallToAction( editedCallToAction ); + validateCallToAction( preparedCallToAction ); if ( true !== validation ) { registry.batch( async () => { @@ -757,9 +774,10 @@ const editorActions = { } const result = await dispatch.updateCallToAction( - editedCallToAction, + preparedCallToAction, false, - withNotices + withNotices, + false ); if ( result ) { @@ -1023,7 +1041,7 @@ const editorActions = { * Opens the editor immediately. If the entity isn't in the store yet it is * resolved in the background while consumers render a loading state. * - * @param {EditorId} editorId The editor ID. + * @param {CtaEditorId} editorId The editor ID. * @return {Promise} */ changeEditorId: @@ -1284,9 +1302,9 @@ const resolutionActions = { /** * Change status of a dispatch action request. * - * @param {CallToActionsStore[ 'ActionNames' ]} actionName Action name to change status of. - * @param {Statuses} status New status. - * @param {string|undefined} message Optional error message. + * @param {string} actionName Action name to change status of. + * @param {DispatchStatus} status New status. + * @param {string|undefined} message Optional error message. * @return {Object} Action object. */ changeActionStatus: diff --git a/packages/cta-editor/src/components/field-with-error.tsx b/packages/cta-editor/src/components/field-with-error.tsx index 7202f719b..bd07ac663 100644 --- a/packages/cta-editor/src/components/field-with-error.tsx +++ b/packages/cta-editor/src/components/field-with-error.tsx @@ -3,12 +3,16 @@ import { Field } from '@popup-maker/fields'; import { URLControl } from '@popup-maker/components'; import { FieldWrapper } from './field-wrapper'; import { useFieldError } from '../hooks'; +import { + getFieldDefault, + normalizeFieldDefault, +} from '../registries/fields/field-visibility'; -import type { FieldProps } from '@popup-maker/fields'; +import type { FieldProps, OldFieldBase } from '@popup-maker/fields'; interface FieldWithErrorProps { fieldId: string; - field: FieldProps; + field: FieldProps & { errorFieldIds?: string[] }; value: any; onChange: ( value: any ) => void; } @@ -29,7 +33,10 @@ export const FieldWithError: React.FC< FieldWithErrorProps > = ( { value, onChange, } ) => { - const { error, clearError } = useFieldError( fieldId ); + const { error, clearError } = useFieldError( + fieldId, + field.errorFieldIds ?? [] + ); // Clear field error when value changes const handleChange = ( newValue: any ) => { @@ -40,21 +47,57 @@ export const FieldWithError: React.FC< FieldWithErrorProps > = ( { onChange( newValue ); }; + const legacyDescription = ( + field as FieldProps & Pick< OldFieldBase, 'desc' > + ).desc; + const { heading: _heading, help, description, ...controlField } = field; + const effectiveValue = + value ?? normalizeFieldDefault( getFieldDefault( field ), field ); + const fieldHelp = help ?? description ?? legacyDescription; + const needsExternalHelp = [ + 'color', + 'html', + 'multicheck', + 'rangeslider', + 'tokenselect', + ].includes( field.type ); + const fieldControl = + field.type === 'url' ? ( + <> + handleChange( urlValue.url ) } + /> + { fieldHelp && ( +

+ { fieldHelp } +

+ ) } + + ) : ( + <> + + { needsExternalHelp && fieldHelp && ( +

+ { fieldHelp } +

+ ) } + + ); return ( - { field.type === 'url' ? ( - handleChange( urlValue.url ) } - /> - ) : ( - - ) } + { fieldControl } ); }; diff --git a/packages/cta-editor/src/editor/editor.scss b/packages/cta-editor/src/editor/editor.scss index ee61bba2c..2f1c86013 100644 --- a/packages/cta-editor/src/editor/editor.scss +++ b/packages/cta-editor/src/editor/editor.scss @@ -96,7 +96,7 @@ $gap4: $gap * 4; svg { margin-right: 3px; fill: #fff; - stroke: green; + stroke: #008000; } } } @@ -114,6 +114,11 @@ $gap4: $gap * 4; &.is-active { background-color: #f0f0f0; } + + &.error { + background-color: #f4a2a2; + border-left: 4px solid #cc1818; + } } } @@ -204,7 +209,7 @@ $gap4: $gap * 4; } &:not(:focus) { - box-shadow: inset 0 0 0 2px #dddddd; + box-shadow: inset 0 0 0 2px #ddd; } } } @@ -322,15 +327,10 @@ $gap4: $gap * 4; } } - .editor-tabs { - .components-tab-panel__tabs { - .components-tab-panel__tabs-item { - &.error { - background-color: #f4a2a2; - border-left: 4px solid #cc1818; - } - } - } + .pum-field__selected-help { + border-left: 2px solid #ddd; + margin-top: 8px; + padding-left: 10px; } // Loading placeholder layout while the record resolves. diff --git a/packages/cta-editor/src/editor/hocs/with-modal.tsx b/packages/cta-editor/src/editor/hocs/with-modal.tsx index 0693c5ddc..cd11663ed 100644 --- a/packages/cta-editor/src/editor/hocs/with-modal.tsx +++ b/packages/cta-editor/src/editor/hocs/with-modal.tsx @@ -59,6 +59,7 @@ export const withModal = ( closeOnSave = true, showDocumentationLink = true, showActions = true, + onSave, onClose, // @ts-ignore It exists but is not typed. onRequestClose, @@ -193,8 +194,11 @@ export const withModal = ( return; } - // Call the onSave callback if it exists - componentProps?.onSave?.( values ); + // Read the canonical record after save preparation and persistence. + const savedValues = storeSelectors.getCurrentEditorValues(); + if ( savedValues ) { + onSave?.( savedValues ); + } const hasRemainingEdits = storeSelectors.hasEdits( values.id @@ -209,7 +213,14 @@ export const withModal = ( } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [ closeOnSave, closeModal, storeSelectors, hasAnyError, values ] + [ + closeOnSave, + closeModal, + storeSelectors, + hasAnyError, + onSave, + values, + ] ); return ( diff --git a/packages/cta-editor/src/hooks/use-field-error.tsx b/packages/cta-editor/src/hooks/use-field-error.tsx index 3bebc5b55..6c6dfe814 100644 --- a/packages/cta-editor/src/hooks/use-field-error.tsx +++ b/packages/cta-editor/src/hooks/use-field-error.tsx @@ -12,7 +12,8 @@ import useFields from './use-fields'; * @return {Object} Object containing error message and clearError function. */ export const useFieldError = ( - fieldId: string + fieldId: string, + errorFieldIds: string[] = [] ): { error: string | null; clearError: () => void; @@ -26,23 +27,27 @@ export const useFieldError = ( const error = useSelect( ( select ) => { const notices = select( noticesStore ).getNotices( NOTICE_CONTEXT ); - const fieldNotice = notices.find( - ( notice ) => - notice.id === `field-error-${ ctaId || 'new' }-${ fieldId }` + const noticeIds = [ fieldId, ...errorFieldIds ].map( + ( id ) => `field-error-${ ctaId || 'new' }-${ id }` + ); + const fieldNotice = notices.find( ( notice ) => + noticeIds.includes( notice.id ) ); return fieldNotice?.content || null; }, - [ ctaId, fieldId ] + [ ctaId, fieldId, errorFieldIds ] ); const clearError = useCallback( () => { if ( ctaId !== undefined ) { - removeNotice( - `field-error-${ ctaId || 'new' }-${ fieldId }`, - NOTICE_CONTEXT + [ fieldId, ...errorFieldIds ].forEach( ( id ) => + removeNotice( + `field-error-${ ctaId || 'new' }-${ id }`, + NOTICE_CONTEXT + ) ); } - }, [ ctaId, fieldId, removeNotice ] ); + }, [ ctaId, fieldId, errorFieldIds, removeNotice ] ); return { error, clearError }; }; @@ -71,7 +76,12 @@ export const useTabErrors = ( ( select ) => { const notices = select( noticesStore ).getNotices( NOTICE_CONTEXT ); const tabFields = getTabFields( tabName ); - const fieldIds = new Set( tabFields.map( ( f ) => f.id ) ); + const fieldIds = new Set( + tabFields.flatMap( ( field ) => [ + field.id, + ...( field.errorFieldIds ?? [] ), + ] ) + ); return notices.filter( ( notice ) => { if ( diff --git a/packages/cta-editor/src/hooks/use-fields.tsx b/packages/cta-editor/src/hooks/use-fields.tsx index fcaa1f7e1..f9b8289c8 100644 --- a/packages/cta-editor/src/hooks/use-fields.tsx +++ b/packages/cta-editor/src/hooks/use-fields.tsx @@ -8,6 +8,7 @@ import type { EditableCta } from '@popup-maker/core-data'; export type FieldDef = { component: JSX.Element; id: keyof EditableCta[ 'settings' ]; + errorFieldIds?: Array< keyof EditableCta[ 'settings' ] >; priority: number; }; @@ -133,10 +134,10 @@ const useFields = () => { /** * Allow external overrides via a filter with null default. * - * @param {JSX.Element} component The current field component. - * @param {string} id The field name. - * @param {string} tab The current tab name. - * @return {JSX.Element} The new field component. + * @param {React.ReactElement} component The current field component. + * @param {string} id The field name. + * @param {string} tab The current tab name. + * @return {React.ReactElement} The new field component. */ const component = applyFilters( 'popupMaker.callToActionEditor.renderField', diff --git a/packages/cta-editor/src/registries/fields/__tests__/field-visibility.test.ts b/packages/cta-editor/src/registries/fields/__tests__/field-visibility.test.ts new file mode 100644 index 000000000..3b971032d --- /dev/null +++ b/packages/cta-editor/src/registries/fields/__tests__/field-visibility.test.ts @@ -0,0 +1,393 @@ +import { describe, expect, it } from '@jest/globals'; + +import { + getFieldDefaults, + getMissingFieldDefaults, + normalizeFieldDefault, + shouldHideField, +} from '../field-visibility'; + +import type { FieldProps } from '@popup-maker/fields'; +import type { CallToAction } from '@popup-maker/core-data'; + +const dependentField = { + type: 'text', + dependencies: { discountSource: 'existing' }, +} as FieldProps; + +describe( 'custom CTA field visibility', () => { + it( 'uses a controlling field default before settings are persisted', () => { + const fields = { + general: { + discountSource: { + type: 'select', + default: 'existing', + }, + discountId: dependentField, + }, + } as Record< string, Record< string, FieldProps > >; + + expect( + shouldHideField( + dependentField, + {} as CallToAction[ 'settings' ], + getFieldDefaults( fields ) + ) + ).toBe( false ); + } ); + + it( 'uses a legacy PHP field std value before settings are persisted', () => { + const fields = { + general: { + discountSource: { + type: 'select', + std: 'existing', + }, + discountId: dependentField, + }, + } as unknown as Record< string, Record< string, FieldProps > >; + + expect( + shouldHideField( + dependentField, + {} as CallToAction[ 'settings' ], + getFieldDefaults( fields ) + ) + ).toBe( false ); + } ); + + it( 'prefers a persisted setting over the declared default', () => { + expect( + shouldHideField( + dependentField, + { discountSource: 'generated' }, + { discountSource: 'existing' } + ) + ).toBe( true ); + } ); + + it( 'ignores invalid placeholder entries when collecting defaults', () => { + const fields = { + general: { + missing: null, + disabled: false, + emptyType: { type: '', default: 'not-rendered' }, + discountSource: { + type: 'select', + default: 'existing', + }, + }, + }; + + expect( getFieldDefaults( fields ) ).toEqual( { + discountSource: 'existing', + } ); + } ); + + it( 'treats null defaults as undeclared', () => { + const fields = { + general: { + modern: { type: 'text', default: null }, + legacy: { type: 'select', std: null }, + }, + } as unknown as Record< string, Record< string, FieldProps > >; + + expect( getFieldDefaults( fields ) ).toEqual( {} ); + } ); + + it( 'returns every declared default missing from editable settings', () => { + expect( + getMissingFieldDefaults( + { type: 'link', existingValue: 'saved' }, + { + existingValue: 'default', + discountSource: 'existing', + allowStacking: false, + } + ) + ).toEqual( { + discountSource: 'existing', + allowStacking: false, + } ); + } ); + + it( 'treats persisted null values as missing defaults', () => { + expect( + getMissingFieldDefaults( + { discountSource: null }, + { discountSource: 'existing' } + ) + ).toEqual( { discountSource: 'existing' } ); + expect( + shouldHideField( + dependentField, + { discountSource: null }, + { discountSource: 'existing' } + ) + ).toBe( false ); + } ); + + it.each( [ + [ 'string', '' ], + [ 'boolean', false ], + [ 'number', 0 ], + ] )( + 'treats null as an implicit empty %s dependency value', + ( _type, expected ) => { + expect( + shouldHideField( + { + type: 'text', + dependencies: { controller: expected }, + } as FieldProps, + { controller: null }, + {} + ) + ).toBe( false ); + } + ); + + it.each( [ '0', 'false', 'no', 0, false ] )( + 'normalizes the legacy false checkbox default %p', + ( value ) => { + expect( + normalizeFieldDefault( value, { + type: 'checkbox', + } as FieldProps ) + ).toBe( false ); + } + ); + + it.each( [ '1', 'yes', 'true', 1, true ] )( + 'normalizes the legacy true checkbox default %p', + ( value ) => { + expect( + normalizeFieldDefault( value, { + type: 'checkbox', + } as FieldProps ) + ).toBe( true ); + } + ); + + it( 'collects normalized checkbox defaults for dependency checks', () => { + const fields = { + general: { + allowStacking: { + type: 'checkbox', + std: '0', + }, + }, + } as unknown as Record< string, Record< string, FieldProps > >; + + expect( getFieldDefaults( fields ) ).toEqual( { + allowStacking: false, + } ); + } ); + + it.each( [ + [ '0', 0 ], + [ '12', 12 ], + [ '1.5', 1.5 ], + [ '', '' ], + [ 'not-a-number', 'not-a-number' ], + ] )( 'normalizes the legacy number default %p', ( value, expected ) => { + expect( + normalizeFieldDefault( value, { + type: 'number', + } as FieldProps ) + ).toBe( expected ); + } ); + + it.each( [ + [ '0', 0 ], + [ '12', 12 ], + [ '1.5', 1.5 ], + [ '', '' ], + ] )( 'normalizes the legacy range default %p', ( value, expected ) => { + expect( + normalizeFieldDefault( value, { + type: 'rangeslider', + } as FieldProps ) + ).toBe( expected ); + } ); + + it.each( [ + [ 'basic,premium', [ 'basic', 'premium' ] ], + [ '', [] ], + ] )( + 'normalizes the legacy multi-option default %p', + ( value, expected ) => { + expect( + normalizeFieldDefault( value, { + type: 'multicheck', + } as FieldProps ) + ).toEqual( expected ); + expect( + normalizeFieldDefault( value, { + type: 'multiselect', + } as FieldProps ) + ).toEqual( expected ); + } + ); + + it.each( [ + [ 'basic', [ 'basic' ] ], + [ 'basic,premium', [ 'basic', 'premium' ] ], + [ '', [] ], + ] )( 'normalizes a token select default %p', ( value, expected ) => { + expect( + normalizeFieldDefault( value, { + type: 'tokenselect', + } as FieldProps ) + ).toEqual( expected ); + } ); + + it.each( [ 'select', 'select2' ] )( + 'normalizes a legacy multiple %s default', + ( type ) => { + expect( + normalizeFieldDefault( 'basic,premium', { + type, + multiple: true, + } as FieldProps ) + ).toEqual( [ 'basic', 'premium' ] ); + } + ); + + it.each( [ + 'color', + 'date', + 'email', + 'hidden', + 'measure', + 'password', + 'radio', + 'select', + 'select2', + 'tel', + 'text', + 'textarea', + 'url', + ] )( + 'normalizes a scalar %s default to the control string shape', + ( type ) => { + expect( + normalizeFieldDefault( 0, { + type, + } as FieldProps ) + ).toBe( '0' ); + } + ); + + it( 'normalizes associative multicheck defaults to option-key strings', () => { + expect( + normalizeFieldDefault( [ 1, 2 ], { + type: 'multicheck', + options: { 1: 'One', 2: 'Two' }, + } as unknown as FieldProps ) + ).toEqual( [ '1', '2' ] ); + } ); + + it( 'preserves numeric multicheck defaults for numeric option arrays', () => { + const field = { + type: 'multicheck', + options: [ + { value: 1, label: 'One' }, + { value: 2, label: 'Two' }, + ], + } as FieldProps; + + expect( normalizeFieldDefault( [ 1, 2 ], field ) ).toEqual( [ 1, 2 ] ); + expect( normalizeFieldDefault( '1', field ) ).toEqual( [ 1 ] ); + expect( normalizeFieldDefault( '1,2', field ) ).toEqual( [ 1, 2 ] ); + } ); + + it.each( [ 'select', 'select2', 'multiselect', 'tokenselect' ] )( + 'normalizes %s array defaults to the control string shape', + ( type ) => { + expect( + normalizeFieldDefault( [ 1, 2 ], { + type, + multiple: true, + } as FieldProps ) + ).toEqual( [ '1', '2' ] ); + } + ); + + it.each( [ 'objectselect', 'postselect', 'taxonomyselect', 'userselect' ] )( + 'normalizes a legacy single %s scalar default to a numeric ID list', + ( type ) => { + expect( + normalizeFieldDefault( 12, { + type, + } as FieldProps ) + ).toEqual( [ 12 ] ); + } + ); + + it.each( [ 'objectselect', 'postselect', 'taxonomyselect', 'userselect' ] )( + 'keeps only the first legacy single %s default', + ( type ) => { + expect( + normalizeFieldDefault( '12,34', { + type, + } as FieldProps ) + ).toEqual( [ 12 ] ); + } + ); + + it.each( [ 'objectselect', 'postselect', 'taxonomyselect', 'userselect' ] )( + 'normalizes a legacy multiple %s scalar default', + ( type ) => { + expect( + normalizeFieldDefault( 12, { + type, + multiple: true, + } as FieldProps ) + ).toEqual( [ 12 ] ); + } + ); + + it.each( [ 'objectselect', 'postselect', 'taxonomyselect', 'userselect' ] )( + 'normalizes a legacy multiple %s array to numeric IDs', + ( type ) => { + expect( + normalizeFieldDefault( [ '12', '34' ], { + type, + multiple: true, + } as FieldProps ) + ).toEqual( [ 12, 34 ] ); + } + ); + + it.each( [ 'objectselect', 'postselect', 'taxonomyselect', 'userselect' ] )( + 'keeps an absent multiple %s default empty', + ( type ) => { + expect( + normalizeFieldDefault( undefined, { + type, + multiple: true, + } as FieldProps ) + ).toEqual( [] ); + } + ); + + it.each( [ + [ 12, [ '12' ] ], + [ + [ 12, 34 ], + [ '12', '34' ], + ], + [ '', [] ], + ] )( + 'normalizes a multiple custom select default %p', + ( value, expected ) => { + expect( + normalizeFieldDefault( value, { + type: 'customselect', + multiple: true, + } as FieldProps ) + ).toEqual( expected ); + } + ); +} ); diff --git a/packages/cta-editor/src/registries/fields/custom-fields.tsx b/packages/cta-editor/src/registries/fields/custom-fields.tsx index 4eb2aec12..97e75f269 100644 --- a/packages/cta-editor/src/registries/fields/custom-fields.tsx +++ b/packages/cta-editor/src/registries/fields/custom-fields.tsx @@ -1,9 +1,15 @@ import { Fragment } from '@wordpress/element'; import { addFilter } from '@wordpress/hooks'; + import { FieldWithError } from '../../components'; import type { FieldProps } from '@popup-maker/fields'; import type { CallToAction } from '@popup-maker/core-data'; +import { + getFieldDefaults, + getMissingFieldDefaults, + shouldHideField, +} from './field-visibility'; const { cta_types: callToActions } = window.popupMakerCtaEditor; @@ -22,6 +28,33 @@ const getCtaFields = ( }; export const initCustomFields = () => { + // Materialize displayed defaults only when saving. This keeps dependency + // checks and persistence aligned without marking an untouched editor dirty. + addFilter( + 'popupMaker.callToAction.prepareForSave', + 'popup-maker/custom-field-defaults', + ( callToAction: CallToAction ) => { + const settings = callToAction.settings ?? {}; + const defaults = getFieldDefaults( getCtaFields( settings.type ) ); + const missingDefaults = getMissingFieldDefaults( + settings, + defaults + ); + + if ( Object.keys( missingDefaults ).length === 0 ) { + return callToAction; + } + + return { + ...callToAction, + settings: { + ...settings, + ...missingDefaults, + }, + }; + } + ); + // Initialize custom fields by adding them to the tab fields filter addFilter( 'popupMaker.callToActionEditor.tabFields', @@ -42,100 +75,56 @@ export const initCustomFields = () => { return fields; } - return Object.entries( extraFields ).reduce( + const fieldDefaults = getFieldDefaults( extraFields ); + const result = Object.entries( extraFields ).reduce( ( acc, [ tab, tabFields ] ) => { if ( ! acc[ tab ] ) { acc[ tab ] = []; } - acc[ tab ] = [ - ...acc[ tab ], - ...Object.entries( tabFields ) - .map( ( [ fieldId, field ] ) => { - if ( ! field || ! field.type ) { - return null; - } - - const shouldHide = () => { - if ( ! field.dependencies ) { - return false; - } - - const dependencies = field.dependencies; - - return ! Object.entries( - dependencies - ).every( ( [ key, value ] ) => { - const dependencyValue = settings[ key ]; - - if ( typeof value === 'string' ) { - if ( - typeof dependencyValue === - 'undefined' - ) { - return value === ''; - } - return value === dependencyValue; - } - - if ( typeof value === 'boolean' ) { - if ( - typeof dependencyValue === - 'undefined' - ) { - return value === false; - } - return value === dependencyValue; - } - - if ( typeof value === 'number' ) { - if ( - typeof dependencyValue === - 'undefined' - ) { - return value === 0; - } - return value === dependencyValue; - } - - return false; - } ); - }; - - return { - ...field, - id: fieldId, - priority: field?.priority ?? 0, - component: ( - - { ! shouldHide() && ( - - updateSettings( { - [ fieldId ]: value, - } ) - } - /> - ) } - - ), - }; - } ) - .filter( - ( item ): item is NonNullable< typeof item > => - item !== null - ), - ]; + const entries = Object.entries( tabFields ).filter( + ( entry ): entry is [ string, FieldProps ] => + Boolean( entry[ 1 ]?.type ) + ); + + const customFields = entries.map( + ( [ fieldId, field ] ) => { + return { + ...field, + id: fieldId, + priority: field.priority ?? 0, + component: ( + + { ! shouldHideField( + field, + settings, + fieldDefaults + ) && ( + + updateSettings( { + [ fieldId ]: value, + } ) + } + /> + ) } + + ), + }; + } + ); + + acc[ tab ] = [ ...acc[ tab ], ...customFields ]; return acc; }, { ...fields } ); + + return result; } ); }; diff --git a/packages/cta-editor/src/registries/fields/field-visibility.ts b/packages/cta-editor/src/registries/fields/field-visibility.ts new file mode 100644 index 000000000..b6211e24d --- /dev/null +++ b/packages/cta-editor/src/registries/fields/field-visibility.ts @@ -0,0 +1,294 @@ +import type { FieldProps, OldFieldBase } from '@popup-maker/fields'; +import type { CallToAction } from '@popup-maker/core-data'; + +export type FieldDefaults = Record< string, unknown >; + +const STRING_VALUE_FIELD_TYPES = [ + 'color', + 'date', + 'email', + 'hidden', + 'measure', + 'password', + 'radio', + 'select', + 'select2', + 'tel', + 'text', + 'textarea', + 'url', +]; + +const OBJECT_SELECT_FIELD_TYPES = [ + 'objectselect', + 'postselect', + 'taxonomyselect', + 'userselect', +]; + +const normalizeDefaultList = ( defaultValue: unknown ): unknown[] => { + if ( Array.isArray( defaultValue ) ) { + return defaultValue; + } + + if ( null === defaultValue || typeof defaultValue === 'undefined' ) { + return []; + } + + if ( 'string' === typeof defaultValue ) { + return '' === defaultValue ? [] : defaultValue.split( ',' ); + } + + return [ defaultValue ]; +}; + +const isFieldDefinition = ( field: unknown ): field is FieldProps => + typeof field === 'object' && + field !== null && + 'type' in field && + typeof field.type === 'string' && + Boolean( field.type ); + +/** + * Read defaults from both current field props and legacy PHP field definitions. + * + * @param field Field definition to inspect. + */ +export const getFieldDefault = ( field: FieldProps ): unknown => { + if ( typeof field.default !== 'undefined' ) { + return field.default; + } + + return ( field as FieldProps & Pick< OldFieldBase, 'std' > ).std; +}; + +/** + * Normalize legacy defaults before the generic field sees them. + * + * @param defaultValue Default value supplied by the field schema. + * @param field Field definition associated with the default. + */ +export const normalizeFieldDefault = ( + defaultValue: unknown, + field: FieldProps +): unknown => { + if ( + [ 'number', 'rangeslider' ].includes( field.type ) && + 'string' === typeof defaultValue + ) { + if ( '' === defaultValue.trim() ) { + return defaultValue; + } + + const numericDefault = Number( defaultValue ); + return Number.isFinite( numericDefault ) + ? numericDefault + : defaultValue; + } + + const isMultiple = + [ 'multicheck', 'multiselect', 'tokenselect' ].includes( field.type ) || + ( [ + 'select', + 'select2', + 'customselect', + 'objectselect', + 'postselect', + 'taxonomyselect', + 'userselect', + ].includes( field.type ) && + 'multiple' in field && + Boolean( field.multiple ) ); + + if ( 'customselect' === field.type ) { + const values = normalizeDefaultList( defaultValue ); + const stringValues = values.map( String ); + + return isMultiple ? stringValues : stringValues[ 0 ] ?? defaultValue; + } + + if ( OBJECT_SELECT_FIELD_TYPES.includes( field.type ) ) { + const values = normalizeDefaultList( defaultValue ); + const numericValues = values + .map( ( value ) => Number.parseInt( String( value ), 10 ) ) + .filter( Number.isFinite ); + + return isMultiple ? numericValues : numericValues.slice( 0, 1 ); + } + + if ( 'multicheck' === field.type ) { + const values = normalizeDefaultList( defaultValue ); + + if ( 'options' in field && Array.isArray( field.options ) ) { + const optionValues = field.options.map( ( option ) => + typeof option === 'string' ? option : option.value + ); + + return values.map( + ( value ) => + optionValues.find( + ( optionValue ) => + String( optionValue ) === String( value ) + ) ?? value + ); + } + + return values.map( String ); + } + + if ( isMultiple && ! Array.isArray( defaultValue ) ) { + if ( null === defaultValue || typeof defaultValue === 'undefined' ) { + return []; + } + + if ( 'string' === typeof defaultValue ) { + return '' === defaultValue ? [] : defaultValue.split( ',' ); + } + + return [ defaultValue ]; + } + + if ( + Array.isArray( defaultValue ) && + [ 'select', 'select2', 'multiselect', 'tokenselect' ].includes( + field.type + ) + ) { + return defaultValue.map( String ); + } + + if ( + STRING_VALUE_FIELD_TYPES.includes( field.type ) && + defaultValue !== null && + typeof defaultValue !== 'undefined' && + ! Array.isArray( defaultValue ) + ) { + return String( defaultValue ); + } + + if ( 'checkbox' !== field.type ) { + return defaultValue; + } + + if ( Array.isArray( defaultValue ) ) { + return defaultValue.length === 1 && String( defaultValue[ 0 ] ) === '1'; + } + + if ( 'string' === typeof defaultValue ) { + return ( + [ 'true', 'yes', '1' ].includes( defaultValue ) || + parseInt( defaultValue, 10 ) > 0 + ); + } + + if ( 'number' === typeof defaultValue ) { + return defaultValue > 0; + } + + return Boolean( defaultValue ); +}; + +/** + * Collect declared field defaults so dependency checks match the values that + * controls display before a new CTA has persisted any settings. + * + * @param fields Fields grouped by editor tab. + */ +export const getFieldDefaults = ( + fields: Record< string, Record< string, unknown > > +): FieldDefaults => + Object.values( fields ).reduce< FieldDefaults >( + ( defaults, tabFields ) => { + Object.entries( tabFields ).forEach( ( [ fieldId, field ] ) => { + if ( ! isFieldDefinition( field ) ) { + return; + } + + const defaultValue = getFieldDefault( field ); + + if ( + typeof defaultValue !== 'undefined' && + defaultValue !== null + ) { + defaults[ fieldId ] = normalizeFieldDefault( + defaultValue, + field + ); + } + } ); + + return defaults; + }, + {} + ); + +/** + * Return defaults that have not yet been written to the editable CTA settings. + * + * @param settings Current CTA settings. + * @param fieldDefaults Declared defaults keyed by field ID. + */ +export const getMissingFieldDefaults = ( + settings: CallToAction[ 'settings' ], + fieldDefaults: FieldDefaults +): Partial< CallToAction[ 'settings' ] > => { + const missingDefaults: Partial< CallToAction[ 'settings' ] > = {}; + + Object.entries( fieldDefaults ).forEach( ( [ fieldId, defaultValue ] ) => { + if ( + settings[ fieldId ] === null || + typeof settings[ fieldId ] === 'undefined' + ) { + ( missingDefaults as Record< string, unknown > )[ fieldId ] = + defaultValue; + } + } ); + + return missingDefaults; +}; + +/** + * Determine whether a field's dependencies are unmet. + * + * @param field Field definition to evaluate. + * @param settings Current CTA settings. + * @param fieldDefaults Declared defaults keyed by field ID. + */ +export const shouldHideField = ( + field: FieldProps, + settings: CallToAction[ 'settings' ], + fieldDefaults: FieldDefaults = {} +): boolean => { + if ( ! field.dependencies ) { + return false; + } + + return ! Object.entries( field.dependencies ).every( ( [ key, value ] ) => { + let dependencyValue = settings[ key ]; + + if ( + ( dependencyValue === null || + typeof dependencyValue === 'undefined' ) && + Object.prototype.hasOwnProperty.call( fieldDefaults, key ) + ) { + dependencyValue = fieldDefaults[ key ]; + } + + if ( + dependencyValue === null || + typeof dependencyValue === 'undefined' + ) { + if ( typeof value === 'string' ) { + return value === ''; + } + if ( typeof value === 'boolean' ) { + return value === false; + } + if ( typeof value === 'number' ) { + return value === 0; + } + } + + return value === dependencyValue; + } ); +}; diff --git a/packages/fields/src/lib/__tests__/utils.test.ts b/packages/fields/src/lib/__tests__/utils.test.ts new file mode 100644 index 000000000..ef5176dd5 --- /dev/null +++ b/packages/fields/src/lib/__tests__/utils.test.ts @@ -0,0 +1,28 @@ +import { parseOldArgsToProps } from '../utils'; +import { parseNumberControlValue } from '../number'; + +import type { OldCustomSelectField } from '../../types/old-field'; + +describe( 'legacy field conversion', () => { + it( 'preserves multiple selection for custom selects', () => { + const field = { + id: 'audience', + type: 'customselect', + entityType: 'audience', + multiple: true, + std: 'basic,premium', + } as OldCustomSelectField; + + expect( parseOldArgsToProps( field ) ).toMatchObject( { + type: 'customselect', + multiple: true, + entityType: 'audience', + } ); + } ); +} ); + +describe( 'number control values', () => { + it( 'preserves fractional values', () => { + expect( parseNumberControlValue( '1.5' ) ).toBe( 1.5 ); + } ); +} ); diff --git a/packages/fields/src/lib/checkbox.tsx b/packages/fields/src/lib/checkbox.tsx index 18c3cfc58..7426a4c6f 100644 --- a/packages/fields/src/lib/checkbox.tsx +++ b/packages/fields/src/lib/checkbox.tsx @@ -10,19 +10,25 @@ const CheckboxField = ( { value, onChange, label, + help, ...fieldProps }: WithOnChange< CheckboxFieldProps > ): JSX.Element => { const toggle = false; if ( ! toggle ) { return ( - + <> + + { help && ( +

{ help }

+ ) } + ); } @@ -30,6 +36,7 @@ const CheckboxField = ( { { setQueryText( text ); @@ -43,7 +46,7 @@ const CustomSelectField = ( { let apiUrl = `${ apiEndpoint }?object_type=custom_entity&entity_type=${ entityType }`; // Include selected values for prefill - if ( value ) { + if ( hasValue ) { const includeIds = Array.isArray( value ) ? value : [ value ]; @@ -56,24 +59,24 @@ const CustomSelectField = ( { } const response = await fetchFromWPApi< { - items: Array< { id: string; text: string } >; + items: Array< { id: string | number; text: string } >; total_count: number; } >( apiUrl ); // Map API response const allOptions: CustomEntityOption[] = response.items.map( ( item ) => ( { - id: item.id, + id: item.id.toString(), text: item.text, } ) ); // Extract prefill data from the same response if we have selected values let prefillData: CustomEntityOption[] = []; - if ( value ) { - const includeIds = Array.isArray( value ) - ? value - : [ value ]; + if ( hasValue ) { + const includeIds = ( + Array.isArray( value ) ? value : [ value ] + ).map( ( item ) => item.toString() ); prefillData = allOptions.filter( ( item ) => includeIds.includes( item.id ) ); @@ -83,8 +86,8 @@ const CustomSelectField = ( { prefill: prefillData, suggestions: allOptions, } ); - } catch ( error ) { - // Silently fail and set empty data + } catch { + // Silently fail and set empty data. setApiData( { prefill: [], suggestions: [] } ); } finally { setIsLoading( false ); @@ -92,7 +95,7 @@ const CustomSelectField = ( { }; fetchApiData(); - }, [ value, queryText, entityType, apiEndpoint ] ); + }, [ value, queryText, entityType, apiEndpoint, hasValue ] ); const findSuggestion = ( id: string ) => { const findInList = ( list: CustomEntityOption[] ) => { @@ -107,12 +110,14 @@ const CustomSelectField = ( { return findInList( apiData.prefill ); }; - const values = ( () => { - if ( ! value ) { + const values: string[] = ( () => { + if ( ! hasValue ) { return []; } - return typeof value === 'string' ? [ value ] : value; + return ( Array.isArray( value ) ? value : [ value ] ).map( ( item ) => + item.toString() + ); } )(); const getTokenValue = ( token: string | { value: string } ) => { @@ -191,6 +196,9 @@ const CustomSelectField = ( { : undefined } /> + { help && ( +

{ help }

+ ) } ); }; diff --git a/packages/fields/src/lib/field.tsx b/packages/fields/src/lib/field.tsx index 5f19bdbf4..ee4b2d552 100644 --- a/packages/fields/src/lib/field.tsx +++ b/packages/fields/src/lib/field.tsx @@ -24,47 +24,51 @@ import type { FieldPropsWithOnChange } from '../types'; import { parseFieldProps } from './utils'; const FieldComponent = ( fieldProps: FieldPropsWithOnChange ): JSX.Element => { - const { type } = fieldProps; + const controlProps = { ...fieldProps }; + delete controlProps.heading; + delete controlProps.dependencies; + delete controlProps.priority; + const { type } = controlProps; switch ( type ) { case 'checkbox': - return ; + return ; case 'color': - return ; + return ; case 'customselect': - return ; + return ; case 'date': - return ; + return ; case 'measure': - return ; + return ; case 'multicheck': - return ; + return ; case 'select': case 'multiselect': - return ; + return ; case 'objectselect': case 'postselect': case 'taxonomyselect': case 'userselect': - return ; + return ; case 'radio': - return ; + return ; case 'rangeslider': - return ; + return ; case 'number': - return ; + return ; case 'email': case 'tel': case 'hidden': case 'text': case 'password': - return ; + return ; case 'textarea': - return ; + return ; case 'tokenselect': - return ; + return ; case 'html': - return ; + return ; } return ( diff --git a/packages/fields/src/lib/number.tsx b/packages/fields/src/lib/number.tsx index 8ef53b70b..25ad96c57 100644 --- a/packages/fields/src/lib/number.tsx +++ b/packages/fields/src/lib/number.tsx @@ -6,6 +6,9 @@ import { import type { NumberFieldProps, WithOnChange } from '../types'; +export const parseNumberControlValue = ( value: string = '0' ): number => + Number.parseFloat( value ); + const NumberField = ( { value, onChange, @@ -16,7 +19,7 @@ const NumberField = ( { { ...fieldProps } value={ value } onChange={ ( newValue = '0' ) => - onChange( parseInt( newValue, 10 ) ) + onChange( parseNumberControlValue( newValue ) ) } // @ts-ignore __nextHasNoMarginBottom={ true } diff --git a/packages/fields/src/lib/object-select.tsx b/packages/fields/src/lib/object-select.tsx index 7f305074c..841a51e0e 100644 --- a/packages/fields/src/lib/object-select.tsx +++ b/packages/fields/src/lib/object-select.tsx @@ -26,6 +26,7 @@ interface ObjectOption { const ObjectSelectField = ( { label, + help, value, onChange, entityKind = 'postType', @@ -235,7 +236,7 @@ const ObjectSelectField = ( { prefill: prefillData, suggestions: allOptions, } ); - } catch ( error ) { + } catch { // Silently fail and set empty data - API fallback failed. setApiData( { prefill: [], suggestions: [] } ); } @@ -362,6 +363,9 @@ const ObjectSelectField = ( { : undefined } /> + { help && ( +

{ help }

+ ) } ); }; diff --git a/packages/fields/src/lib/select.tsx b/packages/fields/src/lib/select.tsx index 3c4cb9a45..9e876f062 100644 --- a/packages/fields/src/lib/select.tsx +++ b/packages/fields/src/lib/select.tsx @@ -1,4 +1,5 @@ import { SelectControl } from '@wordpress/components'; +import { __ } from '@popup-maker/i18n'; import { parseFieldOptions } from './utils'; @@ -55,6 +56,7 @@ const OptGroups = ( { optGroups }: { optGroups: OptGroupsProp } ) => ( const SelectField = ( { value, onChange, + optionDescriptions, ...fieldProps }: | WithOnChange< SelectFieldProps > @@ -62,28 +64,56 @@ const SelectField = ( { const { multiple = false } = fieldProps; const options = fieldProps.options ?? {}; + const selectedValue = Array.isArray( value ) + ? '' + : String( value ?? fieldProps.default ?? '' ); + let selectedValues = [ selectedValue ]; + if ( Array.isArray( value ) ) { + selectedValues = value.map( String ); + } else if ( multiple && typeof value === 'string' ) { + selectedValues = value.split( ',' ); + } + const selectedDescriptions = selectedValues + .map( ( selected ) => optionDescriptions?.[ selected ] ) + .filter( ( description ): description is string => + Boolean( description ) + ); + let controlValue = value; + + if ( ! multiple ) { + controlValue = selectedValue; + } else if ( typeof value === 'string' ) { + // Correct older string type values (here for sanity). + controlValue = value.split( ',' ); + } return ( - // @ts-ignore - - { hasOptGroups( options ) ? ( - - ) : ( - + <> + { /* @ts-ignore */ } + + { hasOptGroups( options ) ? ( + + ) : ( + + ) } + + { selectedDescriptions.length > 0 && ( +

+ { __( 'Selected:', 'popup-maker' ) }{ ' ' } + { selectedDescriptions.join( ' ' ) } +

) } -
+ ); }; diff --git a/packages/fields/src/lib/utils.tsx b/packages/fields/src/lib/utils.tsx index dfbc2f8f6..7d7261ebc 100644 --- a/packages/fields/src/lib/utils.tsx +++ b/packages/fields/src/lib/utils.tsx @@ -23,7 +23,9 @@ export const oldFieldDefaults = { label: '', placeholder: '', desc: null, + description: null, dynamic_desc: null, + heading: null, content: '', size: 'regular', classes: [], @@ -70,6 +72,7 @@ export const parseOldArgsToProps = ( 'id', 'name', 'label', + 'heading', 'value', 'required', 'dependencies' @@ -97,8 +100,8 @@ export const parseOldArgsToProps = ( // Dynamic Descriptions if ( args.dynamic_desc ) { fieldProps.help = <>{ args.dynamic_desc }; - } else if ( args.desc ) { - fieldProps.help = args.desc; + } else if ( args.desc || args.description ) { + fieldProps.help = args.desc || args.description; } //* Dependencies @@ -127,9 +130,12 @@ export const parseOldArgsToProps = ( return fieldProps; case 'customselect': - // customselect is a new field type, return as-is return { ...fieldProps, + multiple: + args.type === 'customselect' + ? args.multiple ?? false + : false, entityType: args.type === 'customselect' ? args.entityType ?? @@ -172,6 +178,7 @@ export const parseOldArgsToProps = ( if ( fieldProps.type === args.type || 'select2' === args.type ) { // Handle options migration for optgroups. fieldProps.options = parseFieldOptions( args.options ?? [] ); + fieldProps.optionDescriptions = args.optionDescriptions ?? {}; fieldProps.searchable = 'select2' === args.type; fieldProps.multiple = fieldProps.type === 'multiselect' || args.multiple; @@ -454,7 +461,14 @@ export const parseFieldProps = ( if ( isOldFieldType( props ) ) { fieldProps = parseOldArgsToProps( props as OldFieldProps ); } else { - fieldProps = props as FieldProps; + const { description, ...normalizedProps } = props as FieldProps; + + fieldProps = { + ...normalizedProps, + ...( normalizedProps.help === undefined && description !== undefined + ? { help: description } + : {} ), + } as FieldProps; } // Ensure prop completeness. diff --git a/packages/fields/src/types/fields.ts b/packages/fields/src/types/fields.ts index 1cb3a7809..cdb1dfe44 100644 --- a/packages/fields/src/types/fields.ts +++ b/packages/fields/src/types/fields.ts @@ -20,10 +20,12 @@ export interface FieldBaseProps { value?: any; name?: string; label?: string; + heading?: string; className?: string; default?: any; required?: boolean; help?: string | React.ReactElement; + description?: string; priority?: number; dependencies?: { [ key: string ]: string | boolean | number }; } @@ -42,6 +44,7 @@ export interface InputFieldProps< V extends string | number = string | number > export interface SelectFieldBaseProps extends FieldBaseProps { options: Options | OptGroups; + optionDescriptions?: StringObject; multiple?: boolean; searchable?: boolean; } @@ -49,7 +52,6 @@ export interface SelectFieldBaseProps extends FieldBaseProps { export interface CheckboxFieldProps extends FieldBaseProps { type: 'checkbox'; value?: boolean; - heading?: string; } export interface HexColorFieldProps extends FieldBaseProps { @@ -129,7 +131,7 @@ export interface UserSelectFieldProps export interface CustomSelectFieldProps extends FieldBaseProps { type: 'customselect'; placeholder?: string; - value?: string | string[]; + value?: string | number | Array< string | number >; multiple?: boolean; entityType: string; apiEndpoint?: string; diff --git a/packages/fields/src/types/old-field.ts b/packages/fields/src/types/old-field.ts index 366a74b63..741a1db0f 100644 --- a/packages/fields/src/types/old-field.ts +++ b/packages/fields/src/types/old-field.ts @@ -17,8 +17,10 @@ export interface OldFieldArgs { classes?: string | string[]; dependencies?: { [ key: string ]: string | boolean | number }; desc?: string; + description?: string; desc_position?: string; dynamic_desc?: string; + heading?: string; id?: string; id_prefix?: string; label?: string; @@ -52,7 +54,9 @@ export interface OldFieldBase { label?: string; std?: any; desc?: string; + description?: string; dynamic_desc?: string; + heading?: string; desc_position?: string; class?: string; classes?: string | string[]; @@ -129,6 +133,7 @@ export interface OldSelectField extends OldFieldBase { multiple?: boolean; as_array?: boolean; options: OldSelectOptions; + optionDescriptions?: StringObject; } export interface OldSelect2Field extends Omit< OldSelectField, 'type' > {