Plugin Directory


Ignore:
Timestamp:
10/15/2026 07:29:29 PM (4 days ago)
Author:
roxyapi
Message:

Update to version 1.21.0 from GitHub

File:
1 edited

Legend:

Unmodified
Added
Removed
  • roxyapi/trunk/src/Support/FormRenderer.php

    r3747703 r3749195  
    77 * renderer walks the spec, emits a `<form method="post">` with a nonce, the
    88 * `roxyapi_form` dispatch hidden input, and a control per field whose type
    9  * derives from the OpenAPI body schema. Submission is handled by FormRouter.
     9 * comes from the vendored UI model. Submission is handled by FormRouter.
    1010 *
    1111 * If the request follows a PRG redirect carrying `?roxyapi_r=<key>`, the
    … …  
    113113                }
    114114
    115                 $needs_geocode = false;
     115                $city_searches = array();
     116                foreach ( $spec['locations'] ?? array() as $location ) {
     117                        $scope_id = empty( $location['section'] )
     118                                ? $form_dom_id
     119                                : 'roxyapi-section-' . sanitize_html_class( $form_id . '-' . $location['section'] );
     120
     121                        $city_searches[ $location['anchor'] ] = self::render_city_search( $scope_id, $location['fields'], (string) ( $location['id'] ?? '' ) );
     122                }
    116123                foreach ( ( $spec['sections'] ?? array() ) as $section ) {
    117124                        $section_name = (string) $section['name'];
    118125                        $section_prev = isset( $prev[ $section_name ] ) && is_array( $prev[ $section_name ] ) ? $prev[ $section_name ] : array();
    119126                        $section_errs = isset( $errors_by_key[ $section_name ] ) && is_array( $errors_by_key[ $section_name ] ) ? $errors_by_key[ $section_name ] : array();
    120                         $fields       = $section['fields'] ?? array();
    121127                        $section_id   = 'roxyapi-section-' . sanitize_html_class( $form_id . '-' . $section_name );
    122                         $optional     = self::section_is_optional( $section );
     128                        $fold         = ! empty( $section['fold'] );
    123129                        $out         .= self::open_group(
    124130                                $section_id,
    125131                                (string) $section['label'],
    126                                 $optional,
    127                                 array_filter( $section_prev, static fn( $v ) => (string) $v !== '' ) || $section_errs
     132                                $fold,
     133                                (bool) ( array_filter( $section_prev, static fn( $v ) => is_scalar( $v ) && (string) $v !== '' ) || $section_errs )
    128134                        );
    129                         if ( self::geocode_anchor( $fields ) !== null ) {
    130                                 $needs_geocode = true;
    131                                 $out          .= self::render_city_search( $section_id );
    132                         }
    133                         foreach ( $fields as $field ) {
    134                                 $out .= self::render_field(
    135                                         $section_name . '[' . $field['name'] . ']',
    136                                         $field,
    137                                         $section_prev[ $field['name'] ] ?? '',
    138                                         $section_errs[ $field['name'] ] ?? ''
    139                                 );
    140                         }
    141                         $out .= $optional ? '</details>' : '</fieldset>';
    142                 }
    143 
    144                 // A spec that sets `fold_optional` keeps the flat fields a visitor must
    145                 // fill in in view and folds the optional rest under one Advanced group;
    146                 // a curated form lays out its own. A city search writes the coordinates
    147                 // and timezone wherever they sit in the form, so it goes beside the
    148                 // first location field a visitor has to fill in.
    149                 $fold        = ! empty( $spec['fold_optional'] );
    150                 $flat        = $spec['flat_fields'] ?? array();
    151                 $anchor      = self::geocode_anchor( $flat );
    152                 $shown_html  = '';
    153                 $folded_html = '';
    154                 $folded_open = false;
    155                 foreach ( $flat as $field ) {
    156                         $name  = (string) $field['name'];
    157                         $value = $prev[ $name ] ?? '';
    158                         $error = is_string( $errors_by_key[ $name ] ?? null ) ? $errors_by_key[ $name ] : '';
    159                         $html  = $name === $anchor ? self::render_city_search( $form_dom_id ) : '';
    160                         $html .= self::render_field( $name, $field, $value, $error );
    161                         if ( ! $fold || ! empty( $field['required'] ) ) {
    162                                 $shown_html .= $html;
    163                                 continue;
    164                         }
    165                         $folded_html .= $html;
    166                         $folded_open  = $folded_open || ( is_scalar( $value ) && (string) $value !== '' ) || $error !== '';
    167                 }
    168                 $out .= $shown_html;
    169                 if ( $folded_html !== '' ) {
    170                         $out .= self::open_group( $form_dom_id . '-advanced', __( 'Advanced', 'roxyapi' ), true, $folded_open )
    171                                 . $folded_html
    172                                 . '</details>';
    173                 }
    174                 if ( $needs_geocode || $anchor !== null ) {
     135                        $out         .= self::render_fields( $section['fields'] ?? array(), $section_name, $section_id, $section_prev, $section_errs, $city_searches, $fold );
     136                        $out         .= $fold ? '</details>' : '</fieldset>';
     137                }
     138                $out .= self::render_fields( $spec['flat_fields'] ?? array(), '', $form_dom_id, $prev, $errors_by_key, $city_searches );
     139                if ( $city_searches ) {
    175140                        wp_enqueue_script( 'roxyapi-geocode' );
    176141                }
    … …  
    194159                $out            .= '</div>';
    195160                return $out;
     161        }
     162
     163        /**
     164         * Render controls in model order, folding only fields marked by the model.
     165         * Values and validation errors open their containing fold without JavaScript.
     166         *
     167         * @param array<int, array<string, mixed>> $fields        Field specs.
     168         * @param string                           $section       Posted section name, or empty for flat fields.
     169         * @param string                           $group_id      DOM id of the containing group.
     170         * @param array<string, mixed>             $previous      Submitted values.
     171         * @param array<string, mixed>             $errors        Validation errors.
     172         * @param array<string, string>            $city_searches City controls keyed by exact posted anchor name.
     173         * @param bool                             $group_folded Whether the containing section already folds.
     174         * @return string
     175         */
     176        private static function render_fields( array $fields, string $section, string $group_id, array $previous, array $errors, array $city_searches, bool $group_folded = false ): string {
     177                $shown_html  = '';
     178                $folded_html = '';
     179                $folded_open = false;
     180                foreach ( $fields as $field ) {
     181                        $key   = (string) $field['name'];
     182                        $name  = $section === '' ? $key : $section . '[' . $key . ']';
     183                        $value = $previous[ $key ] ?? '';
     184                        $error = is_string( $errors[ $key ] ?? null ) ? $errors[ $key ] : '';
     185                        $html  = ( $city_searches[ $name ] ?? '' ) . self::render_field( $name, $field, $value, $error );
     186                        if ( $group_folded || empty( $field['fold'] ) ) {
     187                                $shown_html .= $html;
     188                                continue;
     189                        }
     190                        $folded_html .= $html;
     191                        $folded_open  = $folded_open || ( is_scalar( $value ) && (string) $value !== '' ) || $error !== '';
     192                }
     193                if ( $folded_html !== '' ) {
     194                        $shown_html .= self::open_group( $group_id . '-advanced', __( 'Advanced', 'roxyapi' ), true, $folded_open )
     195                                . $folded_html . '</details>';
     196                }
     197                return $shown_html;
    196198        }
    197199
    … …  
    263265                                        $out .= '<option value=""></option>';
    264266                                }
    265                                 foreach ( $enum as $opt ) {
     267                                foreach ( $enum as $index => $opt ) {
    266268                                        $opt_str  = (string) $opt;
    267269                                        $selected = $opt_str === $value_str ? ' selected' : '';
    268270                                        $out     .= '<option value="' . esc_attr( $opt_str ) . '"' . $selected . '>'
    269                                                 . esc_html( ucwords( str_replace( array( '-', '_' ), ' ', $opt_str ) ) )
     271                                                . esc_html( (string) ( $field['enum_labels'][ $index ] ?? ucwords( str_replace( array( '-', '_' ), ' ', $opt_str ) ) ) )
    270272                                                . '</option>';
    271273                                }
    … …  
    292294
    293295        /**
    294          * Whether every field in a section is optional.
    295          *
    296          * @param array<string,mixed> $section Section spec.
    297          */
    298         private static function section_is_optional( array $section ): bool {
    299                 foreach ( ( $section['fields'] ?? array() ) as $field ) {
    300                         if ( ! empty( $field['required'] ) ) {
    301                                 return false;
    302                         }
    303                 }
    304                 return true;
    305         }
    306 
    307         /**
    308          * Open a fieldset, or a `<details>` fold when nothing in it is required. A
     296         * Open a fieldset, or a `<details>` fold when the model requests it. A
    309297         * fold opens on its own when it holds a value or an error.
    310298         *
    311299         * @param string $id       DOM id of the group.
    312300         * @param string $label    Legend or summary text.
    313          * @param bool   $optional Whether every field in the group is optional.
     301         * @param bool   $fold     Whether the model folds this group.
    314302         * @param bool   $open     Whether a fold starts open.
    315303         * @return string
    316304         */
    317         private static function open_group( string $id, string $label, bool $optional, bool $open ): string {
    318                 if ( $optional ) {
     305        private static function open_group( string $id, string $label, bool $fold, bool $open ): string {
     306                if ( $fold ) {
    319307                        return '<details class="roxyapi-form-section roxyapi-form-section--optional" id="' . esc_attr( $id ) . '"' . ( $open ? ' open' : '' ) . '>'
    320308                                . '<summary>' . esc_html( $label ) . '</summary>';
    … …  
    325313
    326314        /**
    327          * The field a city search sits beside, or null when the fields hold no
    328          * latitude and longitude pair: the first required location field, else the
    329          * first location field. Short (`lat`/`lon`/`tz`) and long (`latitude`/
    330          * `longitude`/`timezone`) names both occur in the spec.
    331          *
    332          * @param array<int, array<string,mixed>> $fields Field specs.
    333          * @return string|null
    334          */
    335         private static function geocode_anchor( array $fields ): ?string {
    336                 $geo   = array_values(
    337                         array_filter(
    338                                 $fields,
    339                                 static fn( $field ) => in_array( strtolower( (string) ( $field['name'] ?? '' ) ), array( 'lat', 'latitude', 'lon', 'longitude', 'tz', 'timezone' ), true )
    340                         )
    341                 );
    342                 $names = array_map( static fn( $field ) => strtolower( (string) $field['name'] ), $geo );
    343                 if ( ! array_intersect( array( 'lat', 'latitude' ), $names ) || ! array_intersect( array( 'lon', 'longitude' ), $names ) ) {
    344                         return null;
    345                 }
    346                 foreach ( $geo as $field ) {
    347                         if ( ! empty( $field['required'] ) ) {
    348                                 return (string) $field['name'];
    349                         }
    350                 }
    351                 return (string) $geo[0]['name'];
    352         }
    353 
    354         /**
    355315         * Render the visible city-search combobox. The matching JS attaches
    356          * autocomplete behaviour and writes to the lat / lon / tz inputs inside
     316         * autocomplete behaviour and writes to the exact posted names inside
    357317         * the scope on selection. Without JS the field is a labelled hint and the
    358318         * coordinate inputs remain fully usable.
    359319         *
    360          * @param string $scope_id DOM id of the element holding the location fields.
    361          * @return string
    362          */
    363         private static function render_city_search( string $scope_id ): string {
    364                 $input_id = 'roxyapi-geocode-' . sanitize_html_class( $scope_id );
     320         * @param string                $scope_id DOM id of the element holding the location fields.
     321         * @param array<string, string> $fields   Exact posted names keyed by latitude, longitude and timezone.
     322         * @param string                $group_id Distinguishes multiple location groups sharing a scope.
     323         * @return string
     324         */
     325        private static function render_city_search( string $scope_id, array $fields, string $group_id = '' ): string {
     326                $input_id = 'roxyapi-geocode-' . sanitize_html_class( $scope_id . ( $group_id !== '' ? '-' . $group_id : '' ) );
    365327                $out      = '<p class="roxyapi-form-field roxyapi-form-geocode">';
    366328                $out     .= '<label for="' . esc_attr( $input_id ) . '">' . esc_html__( 'Find by city', 'roxyapi' ) . '</label>';
    … …  
    369331                        . ' placeholder="' . esc_attr__( 'Start typing a city name', 'roxyapi' ) . '"'
    370332                        . ' data-roxyapi-geocode="' . esc_attr( '#' . $scope_id ) . '"'
     333                        . ' data-roxyapi-geocode-fields="' . esc_attr( (string) wp_json_encode( $fields ) ) . '"'
    371334                        . ' autocomplete="off">';
    372335                $out     .= '<span class="roxyapi-form-help">' . esc_html__( 'Selecting a city auto-fills latitude, longitude, and timezone.', 'roxyapi' ) . '</span>';
Note: See TracChangeset for help on using the changeset viewer.