Changeset 3749195 for roxyapi/trunk/src/Support/FormRenderer.php
- Timestamp:
- 10/15/2026 07:29:29 PM (4 days ago)
- File:
-
- 1 edited
-
roxyapi/trunk/src/Support/FormRenderer.php (modified) (7 diffs)
Legend:
- Unmodified
- Added
- Removed
-
roxyapi/trunk/src/Support/FormRenderer.php
r3747703 r3749195 7 7 * renderer walks the spec, emits a `<form method="post">` with a nonce, the 8 8 * `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. 10 10 * 11 11 * If the request follows a PRG redirect carrying `?roxyapi_r=<key>`, the … … 113 113 } 114 114 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 } 116 123 foreach ( ( $spec['sections'] ?? array() ) as $section ) { 117 124 $section_name = (string) $section['name']; 118 125 $section_prev = isset( $prev[ $section_name ] ) && is_array( $prev[ $section_name ] ) ? $prev[ $section_name ] : array(); 119 126 $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();121 127 $section_id = 'roxyapi-section-' . sanitize_html_class( $form_id . '-' . $section_name ); 122 $ optional = self::section_is_optional( $section);128 $fold = ! empty( $section['fold'] ); 123 129 $out .= self::open_group( 124 130 $section_id, 125 131 (string) $section['label'], 126 $ optional,127 array_filter( $section_prev, static fn( $v ) => (string) $v !== '' ) || $section_errs132 $fold, 133 (bool) ( array_filter( $section_prev, static fn( $v ) => is_scalar( $v ) && (string) $v !== '' ) || $section_errs ) 128 134 ); 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 ) { 175 140 wp_enqueue_script( 'roxyapi-geocode' ); 176 141 } … … 194 159 $out .= '</div>'; 195 160 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; 196 198 } 197 199 … … 263 265 $out .= '<option value=""></option>'; 264 266 } 265 foreach ( $enum as $ opt ) {267 foreach ( $enum as $index => $opt ) { 266 268 $opt_str = (string) $opt; 267 269 $selected = $opt_str === $value_str ? ' selected' : ''; 268 270 $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 ) ) ) ) 270 272 . '</option>'; 271 273 } … … 292 294 293 295 /** 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 309 297 * fold opens on its own when it holds a value or an error. 310 298 * 311 299 * @param string $id DOM id of the group. 312 300 * @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. 314 302 * @param bool $open Whether a fold starts open. 315 303 * @return string 316 304 */ 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 ) { 319 307 return '<details class="roxyapi-form-section roxyapi-form-section--optional" id="' . esc_attr( $id ) . '"' . ( $open ? ' open' : '' ) . '>' 320 308 . '<summary>' . esc_html( $label ) . '</summary>'; … … 325 313 326 314 /** 327 * The field a city search sits beside, or null when the fields hold no328 * latitude and longitude pair: the first required location field, else the329 * 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|null334 */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 /**355 315 * Render the visible city-search combobox. The matching JS attaches 356 * autocomplete behaviour and writes to the lat / lon / tz inputs inside316 * autocomplete behaviour and writes to the exact posted names inside 357 317 * the scope on selection. Without JS the field is a labelled hint and the 358 318 * coordinate inputs remain fully usable. 359 319 * 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 : '' ) ); 365 327 $out = '<p class="roxyapi-form-field roxyapi-form-geocode">'; 366 328 $out .= '<label for="' . esc_attr( $input_id ) . '">' . esc_html__( 'Find by city', 'roxyapi' ) . '</label>'; … … 369 331 . ' placeholder="' . esc_attr__( 'Start typing a city name', 'roxyapi' ) . '"' 370 332 . ' data-roxyapi-geocode="' . esc_attr( '#' . $scope_id ) . '"' 333 . ' data-roxyapi-geocode-fields="' . esc_attr( (string) wp_json_encode( $fields ) ) . '"' 371 334 . ' autocomplete="off">'; 372 335 $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.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)