Files
Reservair/PLAN.md
2026-06-14 11:07:14 +02:00

9.2 KiB
Raw Permalink Blame History

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):

register_rest_route($this->namespace, '/' . $this->resource_name . '/preview', [
    'methods'             => 'POST',
    'callback'            => [$this, 'preview'],
    'permission_callback' => [RsvRestPolicy::class, 'admin'],
]);

Add the method:

/** 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 133142) 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.

        <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):

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:
    on_success: () => { elements_dt.refresh(); rsv_schedule_preview(); },
    on_cancel:  () => { elements_dt.refresh(); rsv_schedule_preview(); },
    
  • Move Up / Move Down / Remove func_actions 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:
    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):

transform: () => rsv_collect_definition(),

3d. Initial render

At the end of the script add:

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.