#12 - form live preview #14

Merged
Martas merged 1 commits from feat/claude/#12-form-live-preview into main 2026-06-14 12:14:39 +00:00
8 changed files with 375 additions and 32 deletions
@@ -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;
}
+1
View File
@@ -211,6 +211,7 @@
.rsv-timetable-selector {
display: grid;
grid-template-columns: repeat(2, 1fr);
background-color: white;
}
@media (max-width: 768px) {
@@ -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<id>\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);
}
@@ -0,0 +1,112 @@
<?php
use Reservair\Templating\RsvTemplateEngine;
/**
* Validates a form definition before it is persisted.
*
* Template checks (symbols, syntax, custom elements) are delegated to the
* common template validator; on top of that this enforces form-level rules,
* such as requiring a submit button once the form defines any fields.
*/
final class RsvFormDefinitionValidator {
/**
* @param array<string,mixed> $definition The inner definition (elements, email_key, success_message).
* @return list<string> 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<int,mixed> $elements
* @return list<string>
*/
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<string,mixed> $definition
* @param array<int,mixed> $elements
* @return array<string,string>
*/
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<int,mixed> $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);
}
}
+71 -13
View File
@@ -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 <reservation-summary></reservation-summary> 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 <reservation-summary></reservation-summary> to display the selected reservations. Leave blank for the default message.', $definition['success_message'] ?? '')
->render();
?>
<hr>
<?php
RsvColumnLayout::split('3:2')
->column(function (): void { ?>
<h2>Form Elements</h2>
<p>Define the fields that will appear in this form.</p>
<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();
?>
<?php $this->elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?>
<?php
@@ -235,6 +243,50 @@ class RsvFormsPage extends RsvAdminPage {
};
})(<?= $elements_json ?>, <?= $next_id ?>);
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);
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('<?= $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);
});
RsvAdminForm.bind(document.getElementById('<?= $form_id ?>'), {
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();
</script>
<?php
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace Reservair\Forms;
/**
* Wraps WordPress' bundled CodeMirror (wp_enqueue_code_editor) as a drop-in
* replacement for a <textarea>.
*
* 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 <textarea> and arranges for it to be
* upgraded to CodeMirror on the page.
*
* @param array{name?:string,value?:string,mode?:string,rows?:int,class?:string} $args
* mode is a MIME type understood by wp_enqueue_code_editor, e.g.
* 'text/html' or 'text/css'.
*/
public static function render(string $id, array $args = []): string
{
$name = $args['name'] ?? $id;
$value = $args['value'] ?? '';
$mode = $args['mode'] ?? 'text/html';
$rows = $args['rows'] ?? 8;
$class = $args['class'] ?? 'large-text code';
$textarea = '<textarea id="' . esc_attr($id) . '" name="' . esc_attr($name) . '"'
. ' rows="' . $rows . '" class="' . esc_attr($class) . '">'
. esc_textarea($value)
. '</textarea>';
$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<array-key,mixed> $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);
}
}
+24
View File
@@ -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 <textarea>
* stays the source of truth.
*
* @param string $mode MIME type for highlighting, e.g. 'text/html'.
*/
public function code(
string $id,
string $label,
string $desc = '',
string $value = '',
string $mode = 'text/html',
int $rows = 8
): static {
$ctrl = RsvCodeEditor::render($id, [
'value' => $value,
'mode' => $mode,
'rows' => $rows,
]);
return $this->row($id, $label, $ctrl, $desc);
}
public function custom(string $label, callable $fn) : static {
$this->rows[] = '<tr>'
. '<th>' . esc_html($label) . '</th>'
+32 -10
View File
@@ -49,18 +49,27 @@ class RsvTemplateEngine {
/**
* Lists a template's problems without rendering it (empty = valid): empty
* interpolations, unregistered custom elements, and attributes an element
* does not declare.
* interpolations, references to unknown symbols, unregistered custom
* elements, and attributes an element does not declare.
*
* @param list<string>|null $symbols When given, the roots an interpolation may
* reference; a path rooted outside the set is reported as unknown. Null
* skips the reference check (any path is accepted).
* @return list<string>
*/
public function validate(string $source): array {
public function validate(string $source, ?array $symbols = null): array {
$errors = [];
if (preg_match_all('/{{\s*([^}]*?)\s*}}/', $source, $matches)) {
foreach ($matches[1] as $path) {
if (trim($path) === '') {
$expr = trim($path);
if ($expr === '') {
$errors[] = 'Empty interpolation: {{ }}';
continue;
}
$root = $this->tokens($expr)[0] ?? null;
if ($symbols !== null && $root !== null && !in_array($root, $symbols, true)) {
$errors[] = "Unknown reference: {{ {$expr} }}";
}
}
}
@@ -148,14 +157,9 @@ class RsvTemplateEngine {
* @param array<string, mixed> $data
*/
private function resolve(string $path, array $data): mixed {
$tokens = preg_split('/[\.\[\]]+/', $path, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$current = $data;
foreach ($tokens as $token) {
if ($token === '$') {
continue; // root sigil
}
$token = trim($token, "'\""); // strip bracket-notation quotes
foreach ($this->tokens($path) as $token) {
if (!is_array($current) || !array_key_exists($token, $current)) {
return null;
}
@@ -165,6 +169,24 @@ class RsvTemplateEngine {
return $current;
}
/**
* Splits a JSON Path into its key tokens, dropping the root sigil and
* bracket-notation quotes — e.g. "$.items[0]" yields ['items', '0'].
*
* @return list<string>
*/
private function tokens(string $path): array {
$raw = preg_split('/[\.\[\]]+/', $path, -1, PREG_SPLIT_NO_EMPTY) ?: [];
$tokens = [];
foreach ($raw as $token) {
if ($token === '$') {
continue; // root sigil
}
$tokens[] = trim($token, "'\""); // strip bracket-notation quotes
}
return $tokens;
}
// -------------------------------------------------------------------------
// DOM helpers
// -------------------------------------------------------------------------