import { Fragment } from '@wordpress/element';
import {
Input,
TextArea,
Select,
RadioButton,
EditorInput,
Label,
Switch,
Container,
Checkbox,
Title,
toast,
} from '@bsf/force-ui';
import { __ } from '@wordpress/i18n';
import { cn, editorValueToString, stringValueToFormatJSON } from './utils';
import { InfoIcon } from 'lucide-react';
import { Tooltip } from '@AdminComponents/tooltip';
import CharacterLimitStatus from '@AdminComponents/character-limit-status';
import Tabs from '@AdminComponents/tabs';
import { STORE_NAME } from '@AdminStore/constants';
import { useDispatch, useSuspenseSelect } from '@wordpress/data';
import { motion } from 'framer-motion';
import { SaveSettingsButton } from '@/apps/admin-components/global-save-button';
/**
* Collect field configurations from PAGE_CONTENT
* @param {Array} formJson - The form configuration array
*/
const collectFieldConfigurations = ( formJson ) => {
const globalFieldConfigMap = {};
if ( ! formJson || ! Array.isArray( formJson ) ) {
return;
}
const extractFields = ( content ) => {
if ( ! content || ! Array.isArray( content ) ) {
return;
}
content.forEach( ( item ) => {
if ( item.content && Array.isArray( item.content ) ) {
extractFields( item.content );
} else if ( item.storeKey && item.shouldReload ) {
globalFieldConfigMap[ item.storeKey ] = true;
}
} );
};
formJson.forEach( ( section ) => {
if ( section.content ) {
extractFields( section.content );
}
} );
return globalFieldConfigMap;
};
// Types
/**
* @typedef {Object} FieldProps
* @property {string} id - Field identifier
* @property {string} type - Field type
* @property {string} [storeKey] - Key for storing in state
* @property {string} [label] - Field label
* @property {string} [description] - Field description
* @property {string} [className] - Additional CSS classes
* @property {any} [defaultValue] - Default field value
* @property {string} [dataType] - Data type for the field
* @property {Function} [transform] - Value transformer function
*/
// Constants
const CUSTOM_LABEL_EXCLUSIONS = [ 'checkbox', 'switch' ];
const GAP_CLASSNAMES = {
0: 'gap-0',
1: 'gap-1',
1.5: 'gap-1.5',
2: 'gap-2',
3: 'gap-3',
4: 'gap-4',
5: 'gap-5',
6: 'gap-6',
7: 'gap-7',
8: 'gap-8',
9: 'gap-9',
10: 'gap-10',
};
// Helper Functions
const getNestedValue = ( obj, path ) => {
if ( ! path || ! obj ) {
return obj;
}
const keys = typeof path === 'string' ? path.split( '.' ) : path;
return keys.reduce( ( acc, key ) => acc?.[ key ], obj );
};
const handleArrayValue = ( currentValue, newValue, action ) => {
if ( ! Array.isArray( currentValue ) ) {
currentValue = [];
}
switch ( action ) {
case 'add':
return [ ...new Set( [ ...currentValue, newValue ] ) ];
case 'toggle':
return currentValue.includes( newValue )
? currentValue.filter( ( item ) => item !== newValue )
: [ ...new Set( [ ...currentValue, newValue ] ) ];
default:
return currentValue.filter( ( item ) => item !== newValue );
}
};
// Custom Hooks
const useFieldActions = ( field, formValues, setFormValues ) => {
const stateValue = getNestedValue( formValues, field.storeKey );
const isChecked =
field?.dataType === 'array'
? stateValue?.includes( field.value )
: !! stateValue;
const handleChange = ( newValue ) => {
let transformedValue = newValue;
let currentValue = stateValue;
switch ( field?.dataType ) {
case 'array':
transformedValue = handleArrayValue(
currentValue || [],
field.value,
transformedValue ? 'add' : 'remove'
);
break;
case 'boolean':
transformedValue = !! newValue;
break;
case 'object':
currentValue = Array.isArray( currentValue )
? {}
: currentValue;
transformedValue = {
...currentValue,
[ field.name ]: newValue,
};
break;
default:
if (
field.transform &&
typeof field.transform === 'function'
) {
transformedValue = field.transform( newValue );
}
break;
}
const [ mainKey, subKey ] = field.storeKey.split( '.' );
if ( subKey ) {
setFormValues( {
[ mainKey ]: {
...formValues[ mainKey ],
[ subKey ]: transformedValue,
},
} );
return;
}
setFormValues( {
[ mainKey ]: transformedValue,
} );
};
let fieldValue;
switch ( field?.dataType ) {
case 'object':
fieldValue = stateValue[ field.name ];
break;
case 'array':
case 'boolean':
fieldValue = isChecked;
break;
default:
fieldValue = stateValue;
}
return {
handleChange,
stateValue,
isChecked,
fieldValue,
};
};
// Components
const FormFieldLabel = ( {
label,
tag = 'label',
size = 'sm',
variant = 'neutral',
className = '',
required = false,
tooltip = '',
currentLength = null,
maxLength = null,
htmlFor = '',
} ) => {
if ( ! label ) {
return null;
}
const labelContent = tooltip ? (
<>
<span>{ label }</span>
<Tooltip
content={ tooltip }
placement="top"
arrow
className="z-999999"
>
<InfoIcon className="size-4" />
</Tooltip>
</>
) : (
label
);
const labelElement = (
<Label
tag={ tag }
className={ cn(
'space-x-0.5',
className,
'[&>svg]:text-icon-secondary'
) }
variant={ variant }
size={ size }
required={ required }
{ ...( htmlFor ? { htmlFor } : {} ) }
>
{ labelContent }
</Label>
);
if ( ! maxLength ) {
return labelElement;
}
return (
<Container
direction="row"
align="center"
justify="start"
className="gap-1 w-full"
>
<div className="inline-flex">{ labelElement }</div>
<CharacterLimitStatus
length={ currentLength }
maxLength={ maxLength }
align="right"
/>
</Container>
);
};
const FieldHelperText = ( {
tag = 'p',
size = 'xs',
variant = 'help',
className = '',
description = '',
} ) => {
if ( ! description ) {
return null;
}
return (
<Label
tag={ tag }
size={ size }
variant={ variant }
className={ cn( 'm-0', className ) }
>
{ description }
</Label>
);
};
const InputField = ( { id, name, value, onChange, field } ) => (
<div className="w-full">
<Input
id={ id }
name={ name }
className={ cn( 'w-full', field?.className ) }
value={ value }
onChange={ onChange }
type={ field.type }
placeholder={ field?.placeholder }
size={ field?.size ?? 'md' }
autoComplete="off"
/>
</div>
);
const EditorField = ( { id, name, value, onChange, field } ) => (
<EditorInput
id={ id }
{ ...( field?.className ? { className: field.className } : {} ) }
name={ name }
defaultValue={ stringValueToFormatJSON( value ) }
onChange={ ( editorState ) => {
onChange( editorValueToString( editorState.toJSON() ) );
} }
trigger="@"
by={ field?.by ?? 'label' }
options={ field?.options ?? [] }
placeholder={
field?.placeholder ??
__( 'Type @ to view variable suggestions', 'surerank' )
}
/>
);
const RadioField = ( { id, name, value, onChange, field } ) => (
<RadioButton.Group
id={ id }
name={ name }
value={ value }
onChange={ onChange }
style={ field?.style ?? 'simple' }
columns={ field?.options?.length ?? 2 }
size={ field?.size ?? 'sm' }
>
{ field?.options?.map( ( option, index ) => (
<RadioButton.Button
buttonWrapperClasses={ field?.optionWrapperClassName ?? '' }
borderOn={ field?.showBorder ?? false }
borderOnActive={ field?.showBorderOnActive ?? false }
key={ option.id || `${ option.value }-${ index }` }
value={ option.value }
label={ {
heading: option.label,
description: option?.description,
} }
/>
) ) }
</RadioButton.Group>
);
const FormField = ( { field, formValues, setFormValues } ) => {
const { handleChange, stateValue, isChecked, fieldValue } = useFieldActions(
field,
formValues,
setFormValues
);
const value = fieldValue ?? field.defaultValue;
const id = field?.name ?? field?.id;
const name = field?.name ?? field?.id;
const disabled =
typeof field?.disabled === 'function'
? field.disabled( formValues )
: field?.disabled;
const className = typeof field?.className === 'function'
? field.className( formValues )
: field?.className;
const additionalProps = {
...( disabled && { disabled } ),
...( className && { className } ),
};
const renderFieldByType = () => {
switch ( field.type ) {
case 'text':
case 'number':
case 'email':
case 'password':
return (
<InputField
id={ id }
name={ name }
value={ value }
onChange={ handleChange }
field={ field }
{ ...additionalProps }
/>
);
case 'editor':
return (
<EditorField
id={ id }
name={ name }
value={ value }
onChange={ handleChange }
field={ field }
/>
);
case 'textarea':
return (
<TextArea
id={ id }
name={ name }
value={ value }
onChange={ handleChange }
{ ...additionalProps }
/>
);
case 'select':
return (
<Select
id={ id }
name={ name }
value={ value }
onChange={ handleChange }
{ ...additionalProps }
/>
);
case 'checkbox':
return (
<Checkbox
id={ id }
name={ name }
value={ field?.value }
checked={ isChecked }
onChange={ handleChange }
label={ {
heading: field?.label,
description: field?.description,
} }
size={ field?.size ?? 'sm' }
{ ...additionalProps }
/>
);
case 'switch':
return (
<Switch
id={ id }
name={ name }
value={ fieldValue }
onChange={ handleChange }
label={ {
heading: field?.label,
description: field?.description,
} }
size={ field?.size ?? 'sm' }
{ ...additionalProps }
/>
);
case 'radio':
return (
<RadioField
id={ id }
name={ name }
value={ value }
onChange={ handleChange }
field={ field }
/>
);
case 'custom':
if ( Object.keys( additionalProps ).length ) {
return (
<div { ...additionalProps }>
{ field?.component ?? null }
</div>
);
}
return field?.component ?? null;
default:
return null;
}
};
return (
<Container
direction="column"
align="start"
justify="start"
className={ cn(
'gap-1.5 w-full',
typeof field.wrapperClassName === 'function'
? field.wrapperClassName( formValues )
: field.wrapperClassName
) }
>
{ ! CUSTOM_LABEL_EXCLUSIONS.includes( field?.type ) && (
<FormFieldLabel
htmlFor={ id }
label={ field?.label }
tag={ field?.label?.tag }
size={ field?.label?.size }
variant={ field?.label?.variant }
className={ field?.label?.className }
required={ field?.label?.required }
tooltip={ field?.tooltip }
currentLength={ stateValue?.length }
maxLength={ field?.maxLength }
/>
) }
{ renderFieldByType() }
{ ! CUSTOM_LABEL_EXCLUSIONS.includes( field?.type ) && (
<FieldHelperText description={ field?.description } />
) }
</Container>
);
};
// Field Renderer
export const renderField = ( field, formValues, setFormValues ) => {
if ( field.container !== undefined ) {
return generateForm(
field.content,
formValues,
setFormValues,
field.container
);
}
switch ( field.type ) {
case 'label':
return (
<FormFieldLabel
key={ field?.id }
id={ field?.id }
label={ field?.label }
tag={ field?.tag ?? 'label' }
size={ field?.size ?? 'sm' }
variant={ field?.variant ?? 'neutral' }
className={ cn( 'm-0', field?.className ) }
tooltip={ field?.tooltip }
/>
);
case 'title':
return (
<div id={ field?.id }>
<Title
key={ field?.id }
tag={ field?.tag ?? 'h5' }
className={ cn( 'm-0', field?.className ) }
title={ field?.label }
/>
</div>
);
case 'tabs':
return (
<Tabs
key={ field?.id }
field={ field }
formValues={ formValues }
setFormValues={ setFormValues }
/>
);
default:
return (
<FormField
key={ field?.id }
field={ field }
formValues={ formValues }
setFormValues={ setFormValues }
/>
);
}
};
// Section Content Renderer
const renderSectionContent = (
contentItem,
contentIndex,
formValues,
setFormValues
) => {
if ( contentItem?.content ) {
const containerContent = contentItem.content.map(
( field, fieldIndex ) => {
const uniqueId = field.id || `field-${ fieldIndex }`;
return (
<Fragment key={ uniqueId }>
{ renderField(
{ ...field, id: uniqueId },
formValues,
setFormValues
) }
</Fragment>
);
}
);
if ( contentItem.container ) {
return (
<Container
key={
contentItem.container?.id || `content-${ contentIndex }`
}
direction={ contentItem.container?.direction || 'column' }
align={ contentItem.container?.align || 'start' }
justify={ contentItem.container?.justify || 'start' }
className={ cn(
GAP_CLASSNAMES[ contentItem.container?.gap ?? 6 ],
contentItem.container?.className
) }
>
{ containerContent }
</Container>
);
}
// Wrap the array in a Fragment with a key to avoid React warning
return (
<Fragment key={ `content-fragment-${ contentIndex }` }>
{ containerContent }
</Fragment>
);
}
return renderField(
{ ...contentItem, id: contentItem.id || `field-${ contentIndex }` },
formValues,
setFormValues
);
};
// Main Form Generator
export const generateForm = (
formJson,
formValues,
setFormValues,
containerProps,
unsavedSettings = {},
hideGlobalSaveButton = false
) => {
if ( ! formJson?.length ) {
return null;
}
const globalFieldConfigMap = collectFieldConfigurations( formJson );
// Define onSuccess callback for SaveSettingsButton
const onSuccess = () => {
toast.success( __( 'Settings saved successfully', 'surerank' ), {
description: __(
'To apply the new settings, the page will refresh automatically in 3 seconds.',
'surerank'
),
} );
setTimeout( () => {
window.location.reload();
}, 500 );
};
const shouldReload = Object.keys( unsavedSettings ).some(
( key ) => globalFieldConfigMap[ key ]
);
const renderedFields = formJson.map( ( section, index ) => (
<Container
key={ section.container?.id || `section-${ index }` }
direction={ section.container?.direction || 'column' }
align={ section.container?.align || 'start' }
justify={ section.container?.justify || 'start' }
className={ cn(
'p-6 bg-white shadow-sm rounded-xl',
GAP_CLASSNAMES[ section.container?.gap ?? 6 ],
section.container?.className
) }
>
{ section.content?.map( ( contentItem, contentIndex ) => (
<Fragment
key={ contentItem.id || `content-item-${ contentIndex }` }
>
{ renderSectionContent(
contentItem,
contentIndex,
formValues,
setFormValues
) }
</Fragment>
) ) }
{ ! hideGlobalSaveButton && (
<SaveSettingsButton
onSuccess={ shouldReload ? onSuccess : undefined }
/>
) }
</Container>
) );
return (
<motion.div
key={ containerProps?.id }
className="w-full"
initial={ { opacity: 0 } }
animate={ { opacity: 1 } }
exit={ { opacity: 0 } }
transition={ {
duration: 0.2,
type: 'tween',
ease: 'easeInOut',
delay: 0.1,
} }
>
<Container
direction={ containerProps?.direction || 'column' }
align={ containerProps?.align || '' }
justify={ containerProps?.justify || '' }
className={ cn(
'w-full',
GAP_CLASSNAMES[ containerProps?.gap ?? 6 ],
containerProps?.className
) }
>
{ renderedFields }
</Container>
</motion.div>
);
};
// Main Component
const GeneratePageContent = ( { json, hideGlobalSaveButton = false } ) => {
const { setMetaSettings } = useDispatch( STORE_NAME );
const { stateValue, unsavedSettings } = useSuspenseSelect( ( select ) => {
const { getMetaSettings, getUnsavedSettings } = select( STORE_NAME );
return {
stateValue: getMetaSettings(),
unsavedSettings: getUnsavedSettings(),
};
}, [] );
return generateForm(
json,
stateValue,
setMetaSettings,
{},
unsavedSettings,
hideGlobalSaveButton
);
};
export default GeneratePageContent;