} */ final class CustomFields { public const MAX_FIELDS = 20; /** * Control types. * * @var list */ private const TYPES = [ 'text', 'textarea', 'select', 'checkbox' ]; /** * Keys reserved for the core details step. * * @var list */ private const RESERVED = [ 'first_name', 'last_name', 'email', 'phone', 'terms_accepted', 'marketing_consent', 'notes', 'custom_fields', ]; /** * Sanitise a settings payload list. * * @param mixed $raw Incoming list. * @return list */ public static function sanitise( mixed $raw ): array { if ( ! is_array( $raw ) ) { return []; } $out = []; $seen = []; foreach ( $raw as $row ) { if ( count( $out ) >= self::MAX_FIELDS ) { break; } $def = self::sanitise_one( $row, $seen ); if ( null === $def ) { continue; } $seen[ $def['key'] ] = true; $out[] = $def; } return $out; } /** * Definitions from form settings. * * @param array $settings Form settings. * @return list */ public static function from_settings( array $settings ): array { return self::sanitise( $settings['custom_fields'] ?? null ); } /** * Widget field descriptors (appended after core fields). * * @param list $defs Definitions. * @return list> */ public static function descriptors( array $defs ): array { $fields = []; foreach ( $defs as $def ) { $field = [ 'key' => 'custom:' . $def['key'], 'type' => 'checkbox' === $def['type'] ? 'boolean' : 'string', 'required' => $def['required'], 'label' => $def['label'], 'control' => $def['type'], 'slug' => $def['key'], ]; if ( 'select' === $def['type'] ) { $field['options'] = $def['options']; } $fields[] = $field; } return $fields; } /** * Validate submitted custom_fields map. * * @param list $defs Definitions. * @param mixed $raw Incoming custom_fields. * @return array */ public static function validate( array $defs, mixed $raw ): array { $values = is_array( $raw ) ? $raw : []; $errors = []; foreach ( $defs as $def ) { $key = $def['key']; $error = self::validate_one( $def, $values[ $key ] ?? null ); if ( null !== $error ) { $errors[ 'custom:' . $key ] = $error; } } return $errors; } /** * Keep only defined keys, typed. * * @param list $defs Definitions. * @param mixed $raw Incoming custom_fields. * @return array */ public static function values( array $defs, mixed $raw ): array { $incoming = is_array( $raw ) ? $raw : []; $out = []; foreach ( $defs as $def ) { $key = $def['key']; if ( ! array_key_exists( $key, $incoming ) ) { continue; } $out[ $key ] = self::normalise_value( $def, $incoming[ $key ] ); } return $out; } /** * One definition, or null when unusable. * * @param mixed $row Row. * @param array $seen Keys already used. * @return CustomFieldDef|null */ private static function sanitise_one( mixed $row, array $seen ): ?array { if ( ! is_array( $row ) ) { return null; } $label = isset( $row['label'] ) ? trim( (string) $row['label'] ) : ''; if ( '' === $label ) { return null; } if ( strlen( $label ) > 120 ) { $label = substr( $label, 0, 120 ); } $key = isset( $row['key'] ) ? self::slug( (string) $row['key'] ) : self::slug( $label ); if ( '' === $key || isset( $seen[ $key ] ) || in_array( $key, self::RESERVED, true ) ) { return null; } $type = isset( $row['type'] ) ? strtolower( trim( (string) $row['type'] ) ) : 'text'; if ( ! in_array( $type, self::TYPES, true ) ) { $type = 'text'; } $options = []; if ( 'select' === $type && isset( $row['options'] ) && is_array( $row['options'] ) ) { foreach ( $row['options'] as $option ) { if ( count( $options ) >= 20 ) { break; } $text = trim( (string) $option ); if ( '' === $text ) { continue; } if ( strlen( $text ) > 80 ) { $text = substr( $text, 0, 80 ); } $options[] = $text; } } if ( 'select' === $type && [] === $options ) { return null; } return [ 'key' => $key, 'label' => $label, 'type' => $type, 'required' => ! empty( $row['required'] ), 'options' => $options, ]; } /** * Slug for a field key. * * @param string $raw Raw. */ private static function slug( string $raw ): string { $slug = strtolower( trim( $raw ) ); $slug = (string) preg_replace( '/[^a-z0-9]+/', '_', $slug ); $slug = trim( $slug, '_' ); if ( strlen( $slug ) > 40 ) { $slug = substr( $slug, 0, 40 ); } return $slug; } /** * Validate one submitted value. * * @param CustomFieldDef $def Definition. * @param mixed $value Value. */ private static function validate_one( array $def, mixed $value ): ?string { if ( 'checkbox' === $def['type'] ) { if ( $def['required'] && true !== $value ) { return 'Please tick this box to continue.'; } if ( null !== $value && ! is_bool( $value ) ) { return 'This field must be true or false.'; } return null; } $text = is_string( $value ) || is_numeric( $value ) ? trim( (string) $value ) : ''; if ( $def['required'] && '' === $text ) { return 'Please complete this field.'; } if ( '' !== $text && 'select' === $def['type'] && ! in_array( $text, $def['options'], true ) ) { return 'Please choose one of the listed options.'; } return null; } /** * Storeable value. * * @param CustomFieldDef $def Definition. * @param mixed $value Value. */ private static function normalise_value( array $def, mixed $value ): mixed { if ( 'checkbox' === $def['type'] ) { return true === $value; } if ( ! is_string( $value ) && ! is_numeric( $value ) ) { return ''; } $text = trim( (string) $value ); if ( strlen( $text ) > 2000 ) { return substr( $text, 0, 2000 ); } return $text; } }