values. */ public const META_PANELS = '_tflexforms_panels'; /** Pattern for field type, panel and action IDs. */ private const ID_PATTERN = '/^[a-z][a-z0-9_]{1,31}$/'; /** Names that can never be registered as field types. */ private const RESERVED_TYPES = array( 'if', 'group', 'html', 'heading', 'divider', 'tflexforms' ); /** * Registered field types. * * @var array> */ private static array $field_types = array(); /** * Registered settings panels. * * @var array> */ private static array $panels = array(); /** * Registered actions. * * @var array> */ private static array $actions = array(); /** * Whether the tflexforms_register action has fired. * * @var bool */ private static bool $loaded = false; /** * Registers a field type. * * Keys of $args: * - label: Name in the builder palette. Required. * - render: fn( Field $field, array $attrs, mixed $value ): string. Returns the * control's escaped HTML. $attrs holds id, name, class, * aria-invalid and aria-describedby. Required. * - sanitize: fn( mixed $raw, Field $field ): string. Default: sanitize_text_field() * for scalars, '' otherwise. * - validate: fn( string $value, Field $field, Form $form ): ?string. Returns an * error message, or null. Only called for non-empty values; * "required" is checked by core. * - group: Palette group: basic, choice, advanced (default) or layout. * - icon: Dashicon name without the "dashicons-" prefix. * - props: Builder settings shown for this type (core properties or keys * of $properties). Default: label, name, required, help, id, class, tabindex. * - properties: Extra builder settings, key => definition as in * Builder_Schema::properties(). A key listed in $options is written * to the tag as key:value; a key listed in $flags as a bare flag. * - options: Tag options this type accepts, key => regex the value must match. * - flags: Bare-word tag flags this type accepts. * - feature: Feature (see Features) the type needs; empty = always available. * - help: Explanation shown in the builder's settings panel. * * @param string $type Tag name, e.g. "rating" for [rating* score]. * @param array $args Definition, see the keys above. * @return bool Whether the type was registered. */ public static function register_field_type( string $type, array $args ): bool { $type = strtolower( $type ); if ( ! preg_match( self::ID_PATTERN, $type ) || in_array( $type, Tag_Parser::TYPES, true ) || in_array( $type, self::RESERVED_TYPES, true ) ) { return self::refuse( __METHOD__, sprintf( 'Field type "%s" has an invalid or reserved name.', $type ) ); } if ( ! is_callable( $args['render'] ?? null ) || '' === trim( (string) ( $args['label'] ?? '' ) ) ) { return self::refuse( __METHOD__, sprintf( 'Field type "%s" needs a label and a render callback.', $type ) ); } foreach ( array( 'sanitize', 'validate' ) as $callback ) { if ( isset( $args[ $callback ] ) && ! is_callable( $args[ $callback ] ) ) { return self::refuse( __METHOD__, sprintf( 'Field type "%1$s": "%2$s" is not callable.', $type, $callback ) ); } } // Checked against core settings in builder_config(), which runs after // translations are loaded; registration may happen earlier. $properties = array(); foreach ( (array) ( $args['properties'] ?? array() ) as $key => $definition ) { if ( is_string( $key ) && preg_match( self::ID_PATTERN, $key ) && is_array( $definition ) ) { $properties[ $key ] = $definition; } } $options = array(); foreach ( (array) ( $args['options'] ?? array() ) as $key => $pattern ) { if ( is_string( $key ) && preg_match( self::ID_PATTERN, $key ) && ! Tag_Parser::is_core_option( $key ) && is_string( $pattern ) && false !== @preg_match( $pattern, '' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Testing that the add-on's regex compiles. $options[ $key ] = $pattern; } } $flags = array_values( array_filter( array_map( 'strval', (array) ( $args['flags'] ?? array() ) ), static fn( string $flag ): bool => 1 === preg_match( self::ID_PATTERN, $flag ) && ! in_array( $flag, Tag_Parser::FLAGS, true ) && ! isset( $options[ $flag ] ) ) ); $props = isset( $args['props'] ) ? array_values( array_map( 'strval', (array) $args['props'] ) ) : array( 'label', 'name', 'required', 'help', 'id', 'class', 'tabindex' ); self::$field_types[ $type ] = array( 'label' => (string) $args['label'], 'render' => $args['render'], 'sanitize' => $args['sanitize'] ?? null, 'validate' => $args['validate'] ?? null, 'group' => in_array( $args['group'] ?? '', array( 'basic', 'choice', 'advanced', 'layout' ), true ) ? $args['group'] : 'advanced', 'icon' => sanitize_html_class( (string) ( $args['icon'] ?? 'admin-generic' ) ), 'props' => $props, 'properties' => $properties, 'options' => $options, 'flags' => $flags, 'feature' => sanitize_key( (string) ( $args['feature'] ?? '' ) ), 'help' => (string) ( $args['help'] ?? '' ), ); return true; } /** * Registers a form editor panel (an extra tab next to Mail and Settings). * * Keys of $args: * - label: Tab label. Required. * - render: fn( Form $form, array $values, string $input_name ): void. Echoes the * panel's escaped controls; name them $input_name . '[key]'. Required. * - sanitize: fn( array $raw, Form $form ): array. Returns the values to store. Required. * - feature: Feature the panel needs; empty = always available. * * @param string $id Panel ID. * @param array $args Definition, see the keys above. * @return bool Whether the panel was registered. */ public static function register_settings_panel( string $id, array $args ): bool { if ( ! preg_match( self::ID_PATTERN, $id ) || in_array( $id, array( 'form', 'mail', 'messages', 'settings' ), true ) ) { return self::refuse( __METHOD__, sprintf( 'Settings panel "%s" has an invalid or reserved ID.', $id ) ); } if ( '' === trim( (string) ( $args['label'] ?? '' ) ) || ! is_callable( $args['render'] ?? null ) || ! is_callable( $args['sanitize'] ?? null ) ) { return self::refuse( __METHOD__, sprintf( 'Settings panel "%s" needs a label, a render callback and a sanitize callback.', $id ) ); } self::$panels[ $id ] = array( 'label' => (string) $args['label'], 'render' => $args['render'], 'sanitize' => $args['sanitize'], 'feature' => sanitize_key( (string) ( $args['feature'] ?? '' ) ), ); return true; } /** * Registers an action that runs after each successful submission (after * the entry is stored and mail is sent). * * Keys of $args: * - label: Human-readable name. Required. * - callback: fn( Form $form, array $values, int $entry_id ): void. Required. * - feature: Feature the action needs; empty = always available. * - priority: Lower runs first. Default 10. * * @param string $id Action ID. * @param array $args Definition, see the keys above. * @return bool Whether the action was registered. */ public static function register_action( string $id, array $args ): bool { if ( ! preg_match( self::ID_PATTERN, $id ) ) { return self::refuse( __METHOD__, sprintf( 'Action "%s" has an invalid ID.', $id ) ); } if ( '' === trim( (string) ( $args['label'] ?? '' ) ) || ! is_callable( $args['callback'] ?? null ) ) { return self::refuse( __METHOD__, sprintf( 'Action "%s" needs a label and a callback.', $id ) ); } self::$actions[ $id ] = array( 'label' => (string) $args['label'], 'callback' => $args['callback'], 'feature' => sanitize_key( (string) ( $args['feature'] ?? '' ) ), 'priority' => (int) ( $args['priority'] ?? 10 ), ); return true; } /** * All registered field types, active or not. * * @return array> */ public static function field_types(): array { self::load(); return self::$field_types; } /** * A registered field type whose feature is available, or null. * * @param string $type Type. * @return array|null */ public static function field_type( string $type ): ?array { $def = self::field_types()[ $type ] ?? null; return null !== $def && self::available( $def ) ? $def : null; } /** * Settings panels whose feature is available. * * @return array> */ public static function settings_panels(): array { self::load(); return array_filter( self::$panels, array( self::class, 'available' ) ); } /** * Actions whose feature is available, in run order. * * @return array> */ public static function actions(): array { self::load(); $actions = array_filter( self::$actions, array( self::class, 'available' ) ); uasort( $actions, static fn( array $a, array $b ): int => $a['priority'] <=> $b['priority'] ); return $actions; } /** * Stored values of one settings panel for a form. * * @param int $form_id Form ID. * @param string $panel Panel ID. * @return array */ public static function panel_values( int $form_id, string $panel ): array { $all = get_post_meta( $form_id, self::META_PANELS, true ); return is_array( $all ) && is_array( $all[ $panel ] ?? null ) ? $all[ $panel ] : array(); } /** * Sanitizes and stores the submitted values of every active panel. Values * of inactive panels are kept untouched, so a lapsed licence loses nothing. * * @param int $form_id Form ID. * @param array $raw Unslashed $_POST['tflexforms_panels']. */ public static function save_panels( int $form_id, array $raw ): void { $panels = self::settings_panels(); if ( array() === $panels ) { return; } $post = get_post( $form_id ); $form = $post instanceof \WP_Post ? Form::from_post( $post ) : null; $stored = get_post_meta( $form_id, self::META_PANELS, true ); $stored = is_array( $stored ) ? $stored : array(); foreach ( $panels as $id => $panel ) { if ( null === $form ) { break; } $clean = call_user_func( $panel['sanitize'], is_array( $raw[ $id ] ?? null ) ? $raw[ $id ] : array(), $form ); $stored[ $id ] = is_array( $clean ) ? $clean : array(); } update_post_meta( $form_id, self::META_PANELS, wp_slash( $stored ) ); } /** * Runs registered actions. Hooked to tflexforms_submitted. * * @param Form $form Form. * @param array $values Sanitized values. * @param Submission_Result $result Result shown to the visitor. * @param int $entry_id Entry ID or 0. */ public static function run_actions( Form $form, array $values, Submission_Result $result, int $entry_id = 0 ): void { // Demo mode sends nothing, so add-on actions are skipped too. if ( ! $result->is_success() || ! empty( $form->settings['demo_mode'] ) ) { return; } foreach ( self::actions() as $id => $action ) { try { call_user_func( $action['callback'], $form, $values, $entry_id ); } catch ( \Throwable $error ) { // One broken add-on must not break the visitor's submission or other actions. /** * Fires when an add-on action throws. * * @param string $id Action ID. * @param \Throwable $error The error. * @param Form $form Form. */ do_action( 'tflexforms_action_failed', $id, $error, $form ); } } } /** * Builder data for the registered types whose feature is available: * palette entries plus the tag options and flags each one writes. * * @return array{types:array>,properties:array>} */ public static function builder_config(): array { $core = Builder_Schema::core_properties(); $types = array(); $properties = array(); foreach ( self::field_types() as $type => $def ) { if ( ! self::available( $def ) ) { continue; } // Core settings keep their meaning; an add-on only adds new ones. $own = array_diff_key( $def['properties'], $core ); $props = array_values( array_filter( $def['props'], static fn( string $p ): bool => isset( $core[ $p ] ) || isset( $own[ $p ] ) ) ); $types[ $type ] = array( 'label' => $def['label'], 'group' => $def['group'], 'icon' => $def['icon'], 'props' => $props, 'tagOptions' => array_keys( $def['options'] ), 'tagFlags' => $def['flags'], ); if ( '' !== $def['help'] ) { $types[ $type ]['help'] = $def['help']; } $properties += $own; } return array( 'types' => $types, 'properties' => $properties, ); } /** * Clears everything registered. For tests only. * * @internal */ public static function reset(): void { self::$field_types = array(); self::$panels = array(); self::$actions = array(); self::$loaded = false; } /** * Fires tflexforms_register once, the first time the registry is read. */ private static function load(): void { if ( self::$loaded ) { return; } self::$loaded = true; /** * Register field types, settings panels and actions here. */ do_action( 'tflexforms_register' ); } /** * Whether a registered item's feature is available. * * @param array $item Item. */ private static function available( array $item ): bool { return '' === $item['feature'] || Features::enabled( $item['feature'] ); } /** * Reports a refused registration to developers and returns false. * * @param string $method Method name. * @param string $message Problem. */ private static function refuse( string $method, string $message ): bool { _doing_it_wrong( esc_html( $method ), esc_html( $message ), '0.7.0' ); return false; } }