#6 - Output elements #11
@@ -0,0 +1,181 @@
|
|||||||
|
# Implementation Brief: Live form preview in the form editor
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Add a real-time form preview pane beside the elements editor on the form **edit** page. Reuse the existing PHP renderer (`RsvFormHtmlRenderer`) via a new admin REST endpoint — do **not** build a JS renderer.
|
||||||
|
|
||||||
|
## Context you need (don't re-derive)
|
||||||
|
- **Editor**: `includes/Views/RsvFormsPage.php`. `show_edit()` renders the edit page; `elements_table_script()` emits the inline `<script>` that drives the elements table. Elements live in an in-memory JS object `rsv_elements_source`; `rsv_elements_source.get_all()` returns the elements array (without internal `id`). Nothing is persisted until the form is saved (PUT).
|
||||||
|
- **Renderer**: `includes/Services/Forms/RsvFormHtmlRenderer.php`. `draw(RsvFormDefinition $form): bool` **echoes** the real `<form>` HTML; returns `false` and echoes nothing when the form has no elements. This is the same renderer the frontend uses (`src/render.php`).
|
||||||
|
- **Definition object**: `new RsvFormDefinition(string $id, array $definition)` where `$definition` has keys `elements` (array), `email_key`, `success_message`.
|
||||||
|
- **Assets**: admin already enqueues the `rsv-client` bundle (`rsv_enqueue_admin_assets` in `includes/RsvAssetsDefinition.php`). It defines the custom elements (`<rsv-reservation-selector>`, `<rsv-reservation-summary>`) and bundles the form CSS (`RsvFormStyles.css` -> confirmed in `build/client.css`). **So form HTML injected via `innerHTML` into the admin page is styled and auto-upgrades its custom elements — no extra assets needed.**
|
||||||
|
- `ReservairServiceAPI.nonce` and `ReservairServiceAPI.restUrl` are available globally in admin (localized onto `rsv-client`).
|
||||||
|
- The registries `$rsv_form_registry` / `$rsv_template_registry` are populated on `plugins_loaded` (`rsv_bootstrap`), which runs for REST requests too.
|
||||||
|
- REST errors are handled globally by the `rest_dispatch_request` filter in `includes/RsvRestApiDefinition.php` (any `Throwable` -> 500 JSON `{error}`), so the endpoint needs no try/catch.
|
||||||
|
- `RsvColumnLayout::split('3:2')->column(fn)->column(fn)->output()` (`modules/Layout/RsvColumnLayout.php`) renders side-by-side columns; each callable just echoes. CSS for `.rsv-cols`/`.rsv-col` already exists (used on the list page).
|
||||||
|
|
||||||
|
## The plan is JS-inline only — no webpack rebuild
|
||||||
|
All new editor JS goes inside the existing inline `<script>` in `elements_table_script()`. Do **not** add CSS to `assets/css/*` (that would require a webpack rebuild); use inline `style=""`/a `<style>` block in the PHP if you want the sticky preview.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 — Preview endpoint
|
||||||
|
**File:** `includes/Controllers/RsvFormDefinitionController.php`
|
||||||
|
|
||||||
|
In `register_routes()`, add a standalone route (the `\d+` id route won't match the non-numeric `preview`, so order is safe):
|
||||||
|
|
||||||
|
```php
|
||||||
|
register_rest_route($this->namespace, '/' . $this->resource_name . '/preview', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [$this, 'preview'],
|
||||||
|
'permission_callback' => [RsvRestPolicy::class, 'admin'],
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the method:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/** Renders an unsaved definition to HTML for the editor's live preview. */
|
||||||
|
function preview(WP_REST_Request $request): WP_REST_Response {
|
||||||
|
$definition = $request->get_json_params()['definition'] ?? [];
|
||||||
|
if (!is_array($definition)) {
|
||||||
|
$definition = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
(new RsvFormHtmlRenderer())->draw(new RsvFormDefinition('preview', $definition));
|
||||||
|
$html = ob_get_clean();
|
||||||
|
|
||||||
|
return new WP_REST_Response(['html' => $html], 200);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes: no-elements case -> `$html` is `''` (handled client-side). A malformed element payload would throw, but the global filter turns it into a 500 the client catch handles gracefully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 — Editor layout (two columns + preview pane)
|
||||||
|
**File:** `includes/Views/RsvFormsPage.php`, `show_edit()`.
|
||||||
|
|
||||||
|
Replace the current Form Elements block (the `<h2>Form Elements</h2>` heading, `#form_elements_table`, the add button, and the submit button — currently around lines 133–142) with a split layout. **Keep the existing IDs `form_elements_table` and `rsv_add_element_btn`.** `RsvColumnLayout` is already imported at the top of the file.
|
||||||
|
|
||||||
|
```php
|
||||||
|
<hr>
|
||||||
|
<h2>Form Elements</h2>
|
||||||
|
<p>Define the fields that will appear in this form.</p>
|
||||||
|
<?php
|
||||||
|
RsvColumnLayout::split('3:2')
|
||||||
|
->column(function (): void { ?>
|
||||||
|
<div id="form_elements_table"></div>
|
||||||
|
<p>
|
||||||
|
<button type="button" class="button" id="rsv_add_element_btn">+ Add Element</button>
|
||||||
|
</p>
|
||||||
|
<p class="submit">
|
||||||
|
<button type="submit" form="edit_form_definition" class="button button-primary">Update Form Definition</button>
|
||||||
|
</p>
|
||||||
|
<?php })
|
||||||
|
->column(function (): void { ?>
|
||||||
|
<h3>Live preview</h3>
|
||||||
|
<div id="rsv_form_preview" class="rsv-form-preview" style="position: sticky; top: 40px;"></div>
|
||||||
|
<?php })
|
||||||
|
->output();
|
||||||
|
?>
|
||||||
|
```
|
||||||
|
|
||||||
|
The container is `rsv-form-preview` (a plain wrapper) — **not** `reservair-form`; the injected HTML brings its own `<form class="reservair-form ...">`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 — Editor wiring (inline JS in `elements_table_script()`)
|
||||||
|
|
||||||
|
### 3a. Shared definition collector + preview functions
|
||||||
|
Add at the top level of the script (right after the `rsv_elements_source` IIFE). `<?= $form_id ?>` is the meta form's id (`edit_form_definition` on the edit page):
|
||||||
|
|
||||||
|
```js
|
||||||
|
function rsv_collect_definition() {
|
||||||
|
const form = document.getElementById('<?= $form_id ?>');
|
||||||
|
const get = (n) => form?.querySelector(`[name="${n}"]`)?.value ?? '';
|
||||||
|
return {
|
||||||
|
name: get('name'),
|
||||||
|
definition: {
|
||||||
|
email_key: get('definition.email_key'),
|
||||||
|
success_message: get('definition.success_message'),
|
||||||
|
elements: rsv_elements_source.get_all(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsv_preview_el = document.getElementById('rsv_form_preview');
|
||||||
|
let rsv_preview_timer = null;
|
||||||
|
|
||||||
|
function rsv_schedule_preview() {
|
||||||
|
if (!rsv_preview_el) return;
|
||||||
|
clearTimeout(rsv_preview_timer);
|
||||||
|
rsv_preview_timer = setTimeout(rsv_render_preview, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rsv_render_preview() {
|
||||||
|
if (!rsv_preview_el) return;
|
||||||
|
fetch('<?= get_rest_url(null, 'reservations/v1/form-definition/preview') ?>', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-WP-Nonce': ReservairServiceAPI.nonce,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(rsv_collect_definition()),
|
||||||
|
})
|
||||||
|
.then(r => r.ok ? r.json() : r.json().then(e => { throw new Error(e.error || 'Preview failed'); }))
|
||||||
|
.then(data => {
|
||||||
|
rsv_preview_el.innerHTML = data.html || '<p class="rsv-preview-empty">No fields to preview yet.</p>';
|
||||||
|
})
|
||||||
|
.catch(() => { rsv_preview_el.innerHTML = '<p class="rsv-preview-empty">Preview unavailable.</p>'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The preview form is inert: block submission (capture so it works after re-render).
|
||||||
|
rsv_preview_el?.addEventListener('submit', (e) => e.preventDefault(), true);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3b. Trigger preview on every mutation
|
||||||
|
- **Inline edit** (`rsv_render_element_inline_form`, the `builder.build({...})` options): change the two callbacks to also schedule a preview:
|
||||||
|
```js
|
||||||
|
on_success: () => { elements_dt.refresh(); rsv_schedule_preview(); },
|
||||||
|
on_cancel: () => { elements_dt.refresh(); rsv_schedule_preview(); },
|
||||||
|
```
|
||||||
|
- **Move Up / Move Down / Remove** `func_action`s and the **`rsv_add_element_btn` onclick**: add `rsv_schedule_preview();` right after each `dt.refresh()` / `elements_dt.refresh()`.
|
||||||
|
- **Meta fields**: after the existing `rsv_email_key_select` block, add:
|
||||||
|
```js
|
||||||
|
const rsv_meta_form = document.getElementById('<?= $form_id ?>');
|
||||||
|
['name', 'definition.email_key', 'definition.success_message'].forEach((n) => {
|
||||||
|
const el = rsv_meta_form?.querySelector(`[name="${n}"]`);
|
||||||
|
el?.addEventListener('input', rsv_schedule_preview);
|
||||||
|
el?.addEventListener('change', rsv_schedule_preview);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3c. Reuse the collector for saving
|
||||||
|
Simplify the existing `RsvAdminForm.bind(...)` `transform` to reuse the collector (keeps preview == saved shape):
|
||||||
|
```js
|
||||||
|
transform: () => rsv_collect_definition(),
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3d. Initial render
|
||||||
|
At the end of the script add:
|
||||||
|
```js
|
||||||
|
rsv_render_preview();
|
||||||
|
```
|
||||||
|
(`rsv_render_preview` no-ops when `#rsv_form_preview` is absent, e.g. the create page, so the shared script stays safe there.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge cases / constraints
|
||||||
|
- A freshly-added element defaults to `type: 'text'`, which has no registered handler -> silently skipped in the preview until a real type is chosen. Expected.
|
||||||
|
- The `<rsv-reservation-selector>` in the preview will fetch availability read-only by `timetable-id` — fine, makes the preview realistic.
|
||||||
|
- Use form id `'preview'` (already in the endpoint) so it never collides with a real numeric form id.
|
||||||
|
- Don't touch `RsvFormHtmlRenderer` — submission is neutralized client-side.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- `php -l includes/Controllers/RsvFormDefinitionController.php includes/Views/RsvFormsPage.php`
|
||||||
|
- `composer lint` (runs phpcs, phpstan, psalm) — must pass.
|
||||||
|
- No JS build needed (all editor JS is inline).
|
||||||
|
- Manual: open a form's edit page -> edit/add/reorder elements and change the meta fields -> preview updates within ~300 ms and matches the frontend form.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
class RsvOutputTextElementHandler implements RsvFormElementHandler {
|
||||||
|
|
||||||
|
private const ALLOWED_TAGS = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
|
||||||
|
|
||||||
|
public function draw(RsvFormElementDefinition $def): void {
|
||||||
|
$raw = $def->getAttr('tag', 'p');
|
||||||
|
$tag = in_array($raw, self::ALLOWED_TAGS, true) ? $raw : 'p';
|
||||||
|
echo '<' . $tag . ' class="rsv-form-output-text">' . esc_html($def->getLabel()) . '</' . $tag . '>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): bool {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void {
|
||||||
|
// No side effects to undo.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,7 +176,7 @@ class RsvFormsPage extends RsvAdminPage {
|
|||||||
if (idx === -1) return Promise.reject(new Error('Element not found'));
|
if (idx === -1) return Promise.reject(new Error('Element not found'));
|
||||||
// Destructure reservation-specific fields so they don't bleed into extra_attrs.
|
// Destructure reservation-specific fields so they don't bleed into extra_attrs.
|
||||||
const { id: _id, name: _n, label: _l, type: _t, desc: _d, required: _r,
|
const { id: _id, name: _n, label: _l, type: _t, desc: _d, required: _r,
|
||||||
price_per_block: _p, email_templates: _et, timetable_id: _ti, ...extra_attrs } = items[idx];
|
price_per_block: _p, email_templates: _et, timetable_id: _ti, tag: _tag, ...extra_attrs } = items[idx];
|
||||||
items[idx] = {
|
items[idx] = {
|
||||||
...extra_attrs,
|
...extra_attrs,
|
||||||
id,
|
id,
|
||||||
@@ -190,6 +190,9 @@ class RsvFormsPage extends RsvAdminPage {
|
|||||||
pattern: data.pattern ?? '',
|
pattern: data.pattern ?? '',
|
||||||
pattern_message: data.pattern_message ?? '',
|
pattern_message: data.pattern_message ?? '',
|
||||||
} : {}),
|
} : {}),
|
||||||
|
...(data.type === 'output-text' ? {
|
||||||
|
tag: data.tag ?? 'p',
|
||||||
|
} : {}),
|
||||||
...(data.type === 'reservation' ? {
|
...(data.type === 'reservation' ? {
|
||||||
timetable_id: data.timetable_id ? parseInt(data.timetable_id) : null,
|
timetable_id: data.timetable_id ? parseInt(data.timetable_id) : null,
|
||||||
price_per_block: parseFloat(data.price_per_block ?? '0') || 0,
|
price_per_block: parseFloat(data.price_per_block ?? '0') || 0,
|
||||||
@@ -262,6 +265,19 @@ class RsvFormsPage extends RsvAdminPage {
|
|||||||
.input_textarea('email_refused_body', 'Body', refused.body ?? RSV_EMAIL_DEFAULTS.refused_body);
|
.input_textarea('email_refused_body', 'Body', refused.body ?? RSV_EMAIL_DEFAULTS.refused_body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((data?.type ?? rsv_element_types[0]) === 'output-text') {
|
||||||
|
builder
|
||||||
|
.input_select('tag', 'Tag', [
|
||||||
|
{ value: 'p', label: 'Paragraph (p)' },
|
||||||
|
{ value: 'h1', label: 'Heading 1 (h1)' },
|
||||||
|
{ value: 'h2', label: 'Heading 2 (h2)' },
|
||||||
|
{ value: 'h3', label: 'Heading 3 (h3)' },
|
||||||
|
{ value: 'h4', label: 'Heading 4 (h4)' },
|
||||||
|
{ value: 'h5', label: 'Heading 5 (h5)' },
|
||||||
|
{ value: 'h6', label: 'Heading 6 (h6)' },
|
||||||
|
], data?.tag ?? 'p');
|
||||||
|
}
|
||||||
|
|
||||||
if ((data?.type ?? rsv_element_types[0]) === 'input-text') {
|
if ((data?.type ?? rsv_element_types[0]) === 'input-text') {
|
||||||
builder
|
builder
|
||||||
.input_select('validation', 'Validation', [
|
.input_select('validation', 'Validation', [
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ function rsv_bootstrap(): void {
|
|||||||
$rsv_form_registry->register( 'button', new RsvButtonElementHandler() );
|
$rsv_form_registry->register( 'button', new RsvButtonElementHandler() );
|
||||||
$rsv_form_registry->register( 'reservation', new RsvFormReservationElementHandler() );
|
$rsv_form_registry->register( 'reservation', new RsvFormReservationElementHandler() );
|
||||||
$rsv_form_registry->register( 'output-reservation-summary', new RsvReservationSummaryElementHandler() );
|
$rsv_form_registry->register( 'output-reservation-summary', new RsvReservationSummaryElementHandler() );
|
||||||
|
$rsv_form_registry->register( 'output-text', new RsvOutputTextElementHandler() );
|
||||||
|
|
||||||
// Template custom-element registry. Extensions register via the action.
|
// Template custom-element registry. Extensions register via the action.
|
||||||
add_action( 'rsv-template-register-custom-elements', function ( \Reservair\Templating\RsvTemplateRegistry $reg ): void {
|
add_action( 'rsv-template-register-custom-elements', function ( \Reservair\Templating\RsvTemplateRegistry $reg ): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user