@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<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
|
||||
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>
|
||||
<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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user