From 2890a9b9937acc28e5b5e8a94ae17e61efd36ab8 Mon Sep 17 00:00:00 2001 From: Martin Slachta Date: Sun, 14 Jun 2026 14:13:37 +0200 Subject: [PATCH] #12 - form live preview --- assets/css/components/RsvCalendarStyles.css | 16 +++ assets/css/components/RsvFormStyles.css | 1 + .../RsvFormDefinitionController.php | 38 +++++- .../Forms/RsvFormDefinitionValidator.php | 112 ++++++++++++++++++ includes/Views/RsvFormsPage.php | 98 +++++++++++---- modules/Forms/RsvCodeEditor.php | 76 ++++++++++++ modules/Forms/RsvFormBuilder.php | 24 ++++ modules/Templating/RsvTemplateEngine.php | 42 +++++-- 8 files changed, 375 insertions(+), 32 deletions(-) create mode 100644 includes/Services/Forms/RsvFormDefinitionValidator.php create mode 100644 modules/Forms/RsvCodeEditor.php diff --git a/assets/css/components/RsvCalendarStyles.css b/assets/css/components/RsvCalendarStyles.css index 584b4c2..b1599c3 100644 --- a/assets/css/components/RsvCalendarStyles.css +++ b/assets/css/components/RsvCalendarStyles.css @@ -74,6 +74,21 @@ overflow: hidden; } +/* The component renders inside the host page (e.g. wp-admin in the form editor + preview), whose form/table stylesheets restyle bare table cells and reveal + the day radios that only carry the [hidden] attribute. Assert the calendar's + own appearance here, scoped tightly enough to win that cascade. */ +.rsv-calendar input[type="radio"] { + display: none; +} + +.rsv-calendar table, +.rsv-calendar th, +.rsv-calendar td { + border: 0; + background: none; +} + /*.calendar button { border: none; background-color: transparent; @@ -115,6 +130,7 @@ .rsv-calendar td { -webkit-user-select:none;user-select:none; + padding: 0; z-index: -1; } diff --git a/assets/css/components/RsvFormStyles.css b/assets/css/components/RsvFormStyles.css index 781e937..fc5660a 100644 --- a/assets/css/components/RsvFormStyles.css +++ b/assets/css/components/RsvFormStyles.css @@ -211,6 +211,7 @@ .rsv-timetable-selector { display: grid; grid-template-columns: repeat(2, 1fr); + background-color: white; } @media (max-width: 768px) { diff --git a/includes/Controllers/RsvFormDefinitionController.php b/includes/Controllers/RsvFormDefinitionController.php index cca9d5a..e2cdd88 100644 --- a/includes/Controllers/RsvFormDefinitionController.php +++ b/includes/Controllers/RsvFormDefinitionController.php @@ -42,6 +42,12 @@ class RsvFormDefinitionController { ], ]); + register_rest_route($this->namespace, '/' . $this->resource_name . '/preview', [ + 'methods' => 'POST', + 'callback' => [$this, 'preview'], + 'permission_callback' => [RsvRestPolicy::class, 'admin'], + ]); + register_rest_route($this->namespace, '/' . $this->resource_name . '/(?P\d+)', [ [ 'methods' => 'GET', @@ -79,10 +85,17 @@ class RsvFormDefinitionController { } function create(WP_REST_Request $request): WP_REST_Response { + $definition = $request->get_param('definition') ?? []; + + $errors = (new RsvFormDefinitionValidator())->validate($definition); + if ($errors !== []) { + return new WP_REST_Response(['error' => implode(' ', $errors)], 422); + } + try { $id = (new RsvFormDefinitionRepository())->add( $request->get_param('name'), - $request->get_param('definition') ?? [] + $definition ); } catch(Throwable $e) { Logger::error($e); @@ -105,6 +118,20 @@ class RsvFormDefinitionController { return new WP_REST_Response(null, 204); } + /** 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); + } + function update(WP_REST_Request $request): WP_REST_Response { $id = (int) $request->get_param('id'); $repo = new RsvFormDefinitionRepository(); @@ -113,7 +140,14 @@ class RsvFormDefinitionController { return new WP_REST_Response(['error' => 'Not found'], 404); } - $repo->update($id, $request->get_param('name'), $request->get_param('definition')); + $definition = $request->get_param('definition') ?? []; + + $errors = (new RsvFormDefinitionValidator())->validate($definition); + if ($errors !== []) { + return new WP_REST_Response(['error' => implode(' ', $errors)], 422); + } + + $repo->update($id, $request->get_param('name'), $definition); return new WP_REST_Response(null, 204); } diff --git a/includes/Services/Forms/RsvFormDefinitionValidator.php b/includes/Services/Forms/RsvFormDefinitionValidator.php new file mode 100644 index 0000000..61ce50b --- /dev/null +++ b/includes/Services/Forms/RsvFormDefinitionValidator.php @@ -0,0 +1,112 @@ + $definition The inner definition (elements, email_key, success_message). + * @return list Human-readable problems; empty when the definition is valid. + */ + public function validate(array $definition): array { + $elements = is_array($definition['elements'] ?? null) ? $definition['elements'] : []; + $symbols = $this->symbols($elements); + $engine = $this->engine(); + + $errors = []; + + // Templates reference submitted values by form-element name. + foreach ($this->templates($definition, $elements) as $label => $template) { + foreach ($engine->validate($template, $symbols) as $problem) { + $errors[] = "{$label}: {$problem}"; + } + } + + // A form that collects fields must give the visitor a way to send them. + if ($elements !== [] && !$this->has_submit($elements)) { + $errors[] = 'Form must contain a submit button.'; + } + + return $errors; + } + + /** + * Names that templates may reference — the form's symbol table. + * + * @param array $elements + * @return list + */ + private function symbols(array $elements): array { + $names = []; + foreach ($elements as $el) { + $name = is_array($el) ? ($el['name'] ?? '') : ''; + if (is_string($name) && $name !== '') { + $names[] = $name; + } + } + return $names; + } + + /** + * The definition's admin-authored templates, keyed by a label used to + * prefix any problems found in them. + * + * @param array $definition + * @param array $elements + * @return array + */ + private function templates(array $definition, array $elements): array { + $templates = []; + + $success = $definition['success_message'] ?? ''; + if (is_string($success) && trim($success) !== '') { + $templates['Success message'] = $success; + } + + foreach ($elements as $el) { + if (!is_array($el) || ($el['type'] ?? '') !== 'reservation') { + continue; + } + $email_templates = $el['email_templates'] ?? []; + if (!is_array($email_templates)) { + continue; + } + foreach (['on_accepted' => 'accepted', 'on_refused' => 'refused'] as $key => $human) { + $tpl = $email_templates[$key] ?? []; + if (!is_array($tpl)) { + continue; + } + foreach (['subject', 'body'] as $part) { + $value = $tpl[$part] ?? ''; + if (is_string($value) && trim($value) !== '') { + $templates["Email ({$human} {$part})"] = $value; + } + } + } + } + + return $templates; + } + + /** @param array $elements */ + private function has_submit(array $elements): bool { + foreach ($elements as $el) { + if (is_array($el) && ($el['type'] ?? '') === 'button') { + return true; + } + } + return false; + } + + private function engine(): RsvTemplateEngine { + global $rsv_template_registry; + return new RsvTemplateEngine(registry: $rsv_template_registry); + } +} diff --git a/includes/Views/RsvFormsPage.php b/includes/Views/RsvFormsPage.php index f17b350..84ca30e 100644 --- a/includes/Views/RsvFormsPage.php +++ b/includes/Views/RsvFormsPage.php @@ -126,20 +126,28 @@ class RsvFormsPage extends RsvAdminPage { echo RsvFormBuilder::create('edit_form_definition', get_rest_url(null, 'reservations/v1/form-definition/' . $id), 'PUT', 'Form definition updated.') ->text('name', 'Name', '', true, $form_def['name']) ->select('definition.email_key', 'Email Key', $email_key_options, "Form field that holds the submitter's email address.", true, $definition['email_key'] ?? '') - ->textarea('definition.success_message', 'Success message', 'Shown to the visitor after a successful submission. HTML is allowed. Use to display the selected reservations. Leave blank for the default message.', false, $definition['success_message'] ?? '') + ->code('definition.success_message', 'Success message', 'Shown to the visitor after a successful submission. HTML is allowed. Use to display the selected reservations. Leave blank for the default message.', $definition['success_message'] ?? '') ->render(); ?>
-

Form Elements

-

Define the fields that will appear in this form.

-
-

- -

-

- -

+ column(function (): void { ?> +

Form Elements

+

Define the fields that will appear in this form.

+
+

+ + +

+ column(function (): void { ?> +

Live preview

+
+ output(); + ?> elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?> , ); + function rsv_collect_definition() { + const form = document.getElementById(''); + 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('', { + 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 || '

No fields to preview yet.

'; + }) + .catch(() => { rsv_preview_el.innerHTML = '

Preview unavailable.

'; }); + } + + // The preview form is inert: block submission (capture so it works after re-render). + rsv_preview_el?.addEventListener('submit', (e) => e.preventDefault(), true); + function rsv_render_element_inline_form(dt, row, data) { const builder = RsvInlineFormBuilder.create(rsv_elements_source) .fieldset('Element', '50%') @@ -297,8 +349,8 @@ class RsvFormsPage extends RsvAdminPage { id: data?.id, colspan: 6, save_label: 'Save', - on_success: () => elements_dt.refresh(), - on_cancel: () => elements_dt.refresh(), + on_success: () => { elements_dt.refresh(); rsv_schedule_preview(); }, + on_cancel: () => { elements_dt.refresh(); rsv_schedule_preview(); }, }); // Type swaps whole fieldsets, so re-render the inline form on change. @@ -342,14 +394,17 @@ class RsvFormsPage extends RsvAdminPage { 'Move Up': RsvDataGrid.func_action(function(dt, row, data) { rsv_elements_source.move_up(data.id); dt.refresh(); + rsv_schedule_preview(); }), 'Move Down': RsvDataGrid.func_action(function(dt, row, data) { rsv_elements_source.move_down(data.id); dt.refresh(); + rsv_schedule_preview(); }), 'Remove': RsvDataGrid.func_action(function(dt, row, data) { rsv_elements_source.remove(data.id); dt.refresh(); + rsv_schedule_preview(); }), }), 'label': RsvDataGrid.column('Label', false), @@ -382,6 +437,7 @@ class RsvFormsPage extends RsvAdminPage { document.getElementById('rsv_add_element_btn').onclick = function() { rsv_elements_source.add(); elements_dt.refresh(); + rsv_schedule_preview(); }; } @@ -402,17 +458,19 @@ class RsvFormsPage extends RsvAdminPage { } rsv_email_key_select?.addEventListener('focus', rsv_sync_email_key_options); + const rsv_meta_form = document.getElementById(''); + ['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); + }); + RsvAdminForm.bind(document.getElementById(''), { - transform: (body) => ({ - name: body.name, - definition: { - email_key: body.definition?.email_key ?? '', - success_message: body.definition?.success_message ?? '', - elements: rsv_elements_source.get_all(), - }, - }), + transform: () => rsv_collect_definition(), refresh: () => { if (typeof forms_dt !== 'undefined') forms_dt.refresh(); }, }); + + rsv_render_preview(); . + * + * The textarea stays the source of truth: CodeMirror mirrors its content back + * on every edit, so any form serialization that reads the textarea's value + * keeps working unchanged. When the user has turned syntax highlighting off in + * their profile, this degrades to the plain textarea. + */ +class RsvCodeEditor +{ + /** + * Renders a code-editor-backed '; + + $settings = wp_enqueue_code_editor(['type' => $mode]); + + // Syntax highlighting disabled in the user's profile — keep it plain. + if ($settings === false) { + return $textarea; + } + + self::schedule_init($id, $settings); + + return $textarea; + } + + /** + * Initializes CodeMirror on $id after the code editor script loads, and + * keeps the underlying textarea in sync — including dispatching `input` so + * listeners (live previews, change tracking) still fire while typing. + * + * @param array $settings As returned by wp_enqueue_code_editor. + */ + private static function schedule_init(string $id, array $settings): void + { + $id_json = wp_json_encode($id); + $settings_json = wp_json_encode($settings); + if ($id_json === false || $settings_json === false) { + return; + } + + $script = '(function(){' + . 'var ta=document.getElementById(' . $id_json . ');' + . 'if(!ta||!window.wp||!wp.codeEditor)return;' + . 'var ed=wp.codeEditor.initialize(ta,' . $settings_json . ');' + . 'ed.codemirror.on("change",function(cm){' + . 'cm.save();' + . 'ta.dispatchEvent(new Event("input",{bubbles:true}));' + . '});' + . '})();'; + + wp_add_inline_script('code-editor', $script); + } +} diff --git a/modules/Forms/RsvFormBuilder.php b/modules/Forms/RsvFormBuilder.php index bb2ca7c..4f1e73c 100644 --- a/modules/Forms/RsvFormBuilder.php +++ b/modules/Forms/RsvFormBuilder.php @@ -194,6 +194,30 @@ class RsvFormBuilder return $this->row($id, $label, $ctrl, $desc); } + /** + * Syntax-highlighted editor backed by WordPress' bundled CodeMirror. + * + * Serializes exactly like {@see textarea()} — the underlying