Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2890a9b993 | |||
| 0fc0addf47 | |||
| 3225ff1e10 | |||
| 7d7f748f7a | |||
| f4d3972d07 |
@@ -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.
|
||||
@@ -1,209 +0,0 @@
|
||||
# Implementation prompt — Templating module
|
||||
|
||||
> Hand this whole file to a Claude Sonnet model as its task. It is written to be
|
||||
> self-contained but assumes the model can read the repository.
|
||||
|
||||
---
|
||||
|
||||
## Mission
|
||||
|
||||
Implement the templating engine specified in `TEMPLATING.md` as a **new,
|
||||
self-contained module** in this WordPress plugin (Reservair). Today the plugin
|
||||
has two ad-hoc, regex-based templaters; your job is to replace them with one
|
||||
properly-designed module and wire the existing call sites into it.
|
||||
|
||||
Build the smallest thing that satisfies the spec. `TEMPLATING.md` is explicit
|
||||
that the language must stay simple ("Helm being the example of what not to do") —
|
||||
do **not** add conditionals, loops, or expression evaluation. The only language
|
||||
features are `{{ jsonpath }}` interpolation and custom elements.
|
||||
|
||||
## Read these first (in order)
|
||||
|
||||
1. `TEMPLATING.md` — the requirements you are implementing. Treat it as the spec.
|
||||
2. `modules/Database/README.md` and `modules/Logger/README.md` — the **module
|
||||
template** to imitate: a `modules/<Name>/` folder, namespace
|
||||
`Reservair\<Name>`, a `README.md` with *Why / Usage / API reference* sections,
|
||||
`Rsv`-prefixed classes, snake_case method names, a module-specific exception.
|
||||
3. `modules/Database/Db.php` and `modules/Logger/Logger.php` — the coding style
|
||||
to match (PHP 8.0 union types, typed properties, named args, `Logger` for
|
||||
errors, a `\RuntimeException` subclass for failures).
|
||||
4. The two things you are replacing:
|
||||
- `includes/Services/Emails/RsvEmailTemplater.php` — `{{ word }}` → `$data[word]`.
|
||||
- `includes/Services/Forms/RsvFormHtmlRenderer.php::draw_success_template()` —
|
||||
swaps `{{ reservation_summary }}` for a client-filled placeholder.
|
||||
5. `reservair.php::rsv_bootstrap()` — where runtime services and registries are
|
||||
wired on `plugins_loaded`. New registration hooks fire from here.
|
||||
6. `includes/Events/RsvEventDispatcher.php` — the `do_action`/`add_action` hook
|
||||
convention used in this codebase.
|
||||
|
||||
## The module to create
|
||||
|
||||
- Path: `modules/Templating/`
|
||||
- Namespace: `Reservair\Templating` (PSR-4 is already configured in
|
||||
`composer.json`: `"Reservair\\": "modules/"` — no autoload changes needed).
|
||||
- Must include `modules/Templating/README.md` following the Database/Logger
|
||||
README shape.
|
||||
|
||||
## Architecture (a tiny compiler)
|
||||
|
||||
`TEMPLATING.md` describes a compiler with a swappable **frontend**. Honour that:
|
||||
|
||||
```
|
||||
source string ──[ frontend ]──▶ internal node tree ──[ renderer ]──▶ output string
|
||||
RsvHtmlTemplateParser (frontend-agnostic)
|
||||
```
|
||||
|
||||
- **Frontend** `RsvHtmlTemplateParser`: parses HTML into an internal node tree
|
||||
(the "internal structure" from the spec). The renderer must depend only on the
|
||||
node tree, never on HTML — so a future Markdown/other frontend can be dropped in
|
||||
by producing the same tree. Define the parser behind a small interface (e.g.
|
||||
`RsvTemplateParser` with `parse(string $source): RsvTemplateNode`) so the engine
|
||||
takes a frontend by constructor injection and defaults to the HTML one.
|
||||
- **Internal node tree**: a minimal set of node types is enough — e.g. a text
|
||||
node, an interpolation node (holds a JSON Path), and a custom-element node
|
||||
(holds tag name, attribute map, children). Keep it small and explicit.
|
||||
- **Renderer/evaluator**: walks the tree with (a) the submitted-data symbol table
|
||||
and (b) the custom-element registry, and concatenates strings.
|
||||
|
||||
You may use PHP's `DOMDocument` to do the actual HTML tokenizing inside the
|
||||
frontend, but convert its output into your own node tree — do not leak DOM
|
||||
objects into the renderer. Watch the usual `DOMDocument::loadHTML` pitfalls
|
||||
(wrapping `<html>/<body>`, entity handling, UTF-8); strip the synthetic wrappers.
|
||||
|
||||
## Language semantics
|
||||
|
||||
### `{{ jsonpath }}` interpolation
|
||||
- Resolves a JSON Path (RFC 9535) expression against the submitted data array and
|
||||
substitutes the **atomic value** (string/number/bool) as text. Non-scalar
|
||||
results (objects/arrays) resolve to empty string.
|
||||
- Keep the resolver to the minimal subset the use cases need: child access by dot
|
||||
and by bracket, and array index — e.g. `name`, `$.name`, `$.guest.email`,
|
||||
`$.items[0]`. Do **not** implement wildcards, filters, slices, or recursion.
|
||||
- **Use a simple token-walk implementation**, not a real JSON Path parser. Split
|
||||
the path on `.`/`[`/`]` and walk the data array one key at a time:
|
||||
```php
|
||||
function resolve(string $path, array $data): mixed {
|
||||
$tokens = preg_split('/[\.\[\]]+/', $path, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$current = $data;
|
||||
foreach ($tokens as $token) {
|
||||
if (!isset($current[$token])) return null;
|
||||
$current = $current[$token];
|
||||
}
|
||||
return $current;
|
||||
}
|
||||
```
|
||||
This already handles bare keys, dotted access, and `[index]` in one pass. Make
|
||||
two small additions so the `$`-rooted and quoted-bracket forms resolve: drop a
|
||||
leading `$` token, and strip surrounding `'`/`"` from bracket keys (so
|
||||
`$['guest']` works). A `null` return means "no such path" — render it as the
|
||||
empty string.
|
||||
- **Backward compatibility (required):** existing templates use a bare key,
|
||||
`{{ reservation_summary }}` and email bodies like `{{ name }}`. A bare
|
||||
identifier must resolve as a top-level key, identically to today.
|
||||
- **Security:** the output target is HTML, so HTML-escape interpolated scalar
|
||||
values by default (`esc_html`). This lets you delete the manual
|
||||
`RsvEmailListener::escape_values()` pre-escaping once the engine owns escaping —
|
||||
do that. Custom elements (below) are trusted and return their own markup.
|
||||
|
||||
### Custom elements
|
||||
- A custom element is a registered handler keyed by tag name. The renderer, on
|
||||
meeting `<some-element attr="x">…</some-element>`, builds a **symbol table** and
|
||||
asks the handler to render a string.
|
||||
- Per the spec: the symbol table contains the submitted-data symbols **plus** one
|
||||
symbol per attribute (`<el attr="test">` adds `attr` => `"test"`). Decide and
|
||||
document how children/inner content are exposed (a captured inner string is the
|
||||
simplest; do the simplest thing that the reservation-summary case needs).
|
||||
- Define a small interface, e.g.:
|
||||
```php
|
||||
interface RsvTemplateElement {
|
||||
/** Render this element to a string given its symbol table. */
|
||||
public function render(RsvTemplateSymbols $symbols): string;
|
||||
/** Symbols/attributes this element understands — used by validation. */
|
||||
public function symbols(): array;
|
||||
}
|
||||
```
|
||||
(Adjust names to fit, but keep "has a symbol table, outputs a string".)
|
||||
|
||||
### Registration hook
|
||||
- The module fires `do_action('rsv-template-register-custom-elements', $registry)`
|
||||
so the plugin and any extension can register elements in the handler. Register
|
||||
the core element(s) the same way the plugin registers everything else (from a
|
||||
callback wired in `rsv_bootstrap()`), not by hard-coding them inside the module.
|
||||
|
||||
### Validation
|
||||
- Provide a `validate(string $source): array` (or a small result object) that
|
||||
returns the problems in a template without rendering it: unknown/invalid JSON
|
||||
Path symbols and unregistered custom elements. The spec calls this out as a
|
||||
first-class capability ("The language can be easily validated for symbols
|
||||
existence and validity").
|
||||
|
||||
## Public API (the facade)
|
||||
|
||||
Expose one obvious entry point — e.g. `RsvTemplateEngine` (or `RsvTemplate`):
|
||||
|
||||
```php
|
||||
$engine = new RsvTemplateEngine(); // defaults to the HTML frontend + registry
|
||||
$html = $engine->render($source, $data); // data = submitted values array
|
||||
$errors = $engine->validate($source); // [] when valid
|
||||
```
|
||||
|
||||
Keep method names snake_case to match the other modules. Throw a module
|
||||
exception (e.g. `RsvTemplateException extends \RuntimeException`) for malformed
|
||||
templates, and log via `Reservair\Logger\Logger` where the existing modules do.
|
||||
|
||||
## Integration (wire the new module into the live call sites)
|
||||
|
||||
1. **Email bodies** — replace `RsvEmailTemplater` with the engine.
|
||||
`RsvEmailListener` currently calls `(new RsvEmailTemplater())->render(...)` and
|
||||
pre-escapes values; switch it to the engine and drop the manual escaping (the
|
||||
engine escapes interpolations). Keep `RsvEmailTemplater` only if something else
|
||||
needs it; otherwise remove it and update references.
|
||||
2. **Form success message** — replace the regex in
|
||||
`RsvFormHtmlRenderer::draw_success_template()` with the engine.
|
||||
**Critical caveat:** `reservation_summary` in the success message is **not**
|
||||
server data — it is the visitor's live selection, filled in the browser by
|
||||
`assets/js/forms/RsvFormSender.js`, which looks for the
|
||||
`<div class="rsv-success-summary">` placeholder. So model `reservation-summary`
|
||||
as a registered custom element that, here, emits that exact placeholder div, so
|
||||
the existing client JS keeps working unchanged. Do not try to compute the
|
||||
summary server-side. The admin HTML is still sanitized with `wp_kses_post`
|
||||
before/around templating, as today.
|
||||
3. **Bootstrap** — in `rsv_bootstrap()`, after the form registry block, fire the
|
||||
custom-element registration (or instantiate the engine's registry and register
|
||||
core elements) consistent with how the form element registry is built there.
|
||||
|
||||
## Conventions & constraints (do not skip)
|
||||
|
||||
- **Match module style**, not `includes/` style: `Rsv` class prefix, snake_case
|
||||
methods, typed properties, PHP 8.0 features. Files namespaced `Reservair\Templating`.
|
||||
- **Doc comments state intent, not mechanics** — say *what/why*, never narrate the
|
||||
loop. (This is a standing rule in this repo.)
|
||||
- **No new Composer dependencies** unless genuinely unavoidable; the JSON Path
|
||||
subset and HTML parsing are small enough to implement in-module. If you believe
|
||||
a dependency is warranted, stop and justify it instead of adding it silently.
|
||||
- **No database, no migrations** — this module is pure text transformation.
|
||||
- **Security:** never emit unescaped submitted data; escape interpolations,
|
||||
sanitize admin HTML with `wp_kses_post` at the boundary as today, and treat
|
||||
custom-element output as trusted-by-registration.
|
||||
- Ship `modules/Templating/README.md` (Why / Usage / API reference / Notes), and
|
||||
delete or update `TEMPLATING.md`-superseded code so there is exactly one
|
||||
templating path.
|
||||
|
||||
## Definition of done
|
||||
|
||||
- [ ] `modules/Templating/` exists with the engine, HTML frontend, node tree,
|
||||
custom-element registry + interface, JSON Path resolver, validation, a
|
||||
module exception, and `README.md`.
|
||||
- [ ] `rsv-template-register-custom-elements` action is fired by the module and
|
||||
consumed from `rsv_bootstrap()`; `reservation-summary` is registered there.
|
||||
- [ ] Email bodies and form success messages render through the engine; existing
|
||||
templates (`{{ name }}`, `{{ reservation_summary }}`) behave exactly as
|
||||
before, including client-side summary fill.
|
||||
- [ ] `RsvEmailTemplater` and the success-message regex are gone (or justified).
|
||||
- [ ] `composer lint` (phpcs, phpstan, psalm — see `composer.json` scripts) passes
|
||||
clean on the new and changed files.
|
||||
- [ ] README includes at least one worked example for each use case (email,
|
||||
success message) and a custom-element registration example.
|
||||
|
||||
If anything in `TEMPLATING.md` is ambiguous, prefer the **simpler** reading and
|
||||
note the decision in the README rather than expanding the language.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
.rsv-timetable-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -87,7 +87,7 @@ label.rsv-slots-slot-time>input:checked + .content>.capacity {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rsv-slots-slot:hover:not(.rsv-slots-slot-full):not(.rsv-slots-slot-selected) {
|
||||
.rsv-slots-slot:hover:not(.rsv-slots-slot-full):not(.rsv-slots-slot-too-soon):not(.rsv-slots-slot-selected) {
|
||||
border-color: #2563eb;
|
||||
background: #f5f8ff;
|
||||
}
|
||||
@@ -115,6 +115,12 @@ label.rsv-slots-slot-time>input:checked + .content>.capacity {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Within minimum lead time — available but not yet bookable */
|
||||
.rsv-slots-slot-too-soon {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Selected */
|
||||
.rsv-slots-slot-selected {
|
||||
background: #2563eb;
|
||||
|
||||
@@ -47,7 +47,7 @@ class RsvTimeline extends HTMLElement {
|
||||
|
||||
_on_click(event) {
|
||||
const slot = event.target.closest('.rsv-slots-slot');
|
||||
if (slot && !slot.classList.contains('rsv-slots-slot-full')) {
|
||||
if (slot && !slot.classList.contains('rsv-slots-slot-full') && !slot.classList.contains('rsv-slots-slot-too-soon')) {
|
||||
slot.classList.toggle('rsv-slots-slot-selected');
|
||||
slot.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
@@ -81,7 +81,7 @@ class RsvTimeline extends HTMLElement {
|
||||
|
||||
const blocks = [];
|
||||
|
||||
for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ } of occupancy) {
|
||||
for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ, lead_time_minutes } of occupancy) {
|
||||
if (from_minutes === to_minutes || block_occ.length === 0) {
|
||||
continue;
|
||||
}
|
||||
@@ -89,7 +89,7 @@ class RsvTimeline extends HTMLElement {
|
||||
const from_block = parseInt(from_minutes) / block_size_in_minutes;
|
||||
|
||||
const time_slots = block_occ.map((occ, i) =>
|
||||
this._block(this.date, occ, block_size_in_minutes, from_block + i)
|
||||
this._block(this.date, occ, block_size_in_minutes, from_block + i, lead_time_minutes?.[i] ?? 0)
|
||||
);
|
||||
|
||||
const time_slot_group = document.createElement('div');
|
||||
@@ -105,7 +105,7 @@ class RsvTimeline extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
_block(date, left, block_size, idx) {
|
||||
_block(date, left, block_size, idx, min_lead_time_minutes = 0) {
|
||||
const from = new Date(date);
|
||||
from.setHours(0, idx * block_size, 0, 0);
|
||||
|
||||
@@ -117,6 +117,7 @@ class RsvTimeline extends HTMLElement {
|
||||
cell.dataset.start_utc = from.toISOString();
|
||||
cell.dataset.end_utc = to.toISOString();
|
||||
if (left <= 0) cell.classList.add('rsv-slots-slot-full');
|
||||
else if (from < new Date(Date.now() + min_lead_time_minutes * 60_000)) cell.classList.add('rsv-slots-slot-too-soon');
|
||||
|
||||
const time_el = document.createElement('span');
|
||||
time_el.classList.add('rsv-slots-slot-time');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6,16 +6,19 @@
|
||||
class RsvTimetableAvailability {
|
||||
/**
|
||||
* @param array<int,int> $occupancy Number of available seats for each time block
|
||||
* @param array<int,int> $lead_time_minutes Minimum lead time in minutes required for each block
|
||||
*/
|
||||
public function __construct(
|
||||
public int $from_minutes,
|
||||
public int $to_minutes,
|
||||
public int $block_size_in_minutes,
|
||||
public array $occupancy
|
||||
public array $occupancy,
|
||||
public array $lead_time_minutes = []
|
||||
) { }
|
||||
|
||||
public function push_block(int $capacity) {
|
||||
public function push_block(int $capacity, int $min_lead_time_minutes = 0) {
|
||||
$this->occupancy[] = $capacity;
|
||||
$this->lead_time_minutes[] = $min_lead_time_minutes;
|
||||
$this->to_minutes += $this->block_size_in_minutes;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,14 @@ class RsvTimetableReservationService {
|
||||
return false;
|
||||
}
|
||||
|
||||
$max_lead_time = max(array_map(fn($c) => (int) $c->min_lead_time_minutes, $overlapping_capacity));
|
||||
$earliest_allowed = new DateTime('now', new DateTimeZone('UTC'));
|
||||
$earliest_allowed->modify("+{$max_lead_time} minutes");
|
||||
if ($start_utc < $earliest_allowed) {
|
||||
Logger::error("Reservation rejected: minimum lead time of {$max_lead_time} minutes not met for timetable_id: $timetable_id");
|
||||
return false;
|
||||
}
|
||||
|
||||
$start_min = $this->time_of_day_minutes($start_utc);
|
||||
$end_min = $this->time_of_day_minutes($end_utc);
|
||||
|
||||
|
||||
@@ -98,7 +98,8 @@ class RsvTimetableService {
|
||||
$availabilities[] = new RsvTimetableAvailability($i * $block_length, ($i + 1) * $block_length, $block_length, []);
|
||||
}
|
||||
|
||||
$availabilities[$availability_idx]->push_block($total_capacity - count($reservation_stack));
|
||||
$max_lead_time = empty($capacity_stack) ? 0 : max(array_map(fn($x) => $x->min_lead_time_minutes, $capacity_stack));
|
||||
$availabilities[$availability_idx]->push_block($total_capacity - count($reservation_stack), $max_lead_time);
|
||||
} else if($total_capacity === 0 && count($availabilities) !== $availability_idx) {
|
||||
$availability_idx++;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -176,7 +184,7 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
if (idx === -1) return Promise.reject(new Error('Element not found'));
|
||||
// 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,
|
||||
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] = {
|
||||
...extra_attrs,
|
||||
id,
|
||||
@@ -190,6 +198,9 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
pattern: data.pattern ?? '',
|
||||
pattern_message: data.pattern_message ?? '',
|
||||
} : {}),
|
||||
...(data.type === 'output-text' ? {
|
||||
tag: data.tag ?? 'p',
|
||||
} : {}),
|
||||
...(data.type === 'reservation' ? {
|
||||
timetable_id: data.timetable_id ? parseInt(data.timetable_id) : null,
|
||||
price_per_block: parseFloat(data.price_per_block ?? '0') || 0,
|
||||
@@ -232,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%')
|
||||
@@ -262,6 +317,19 @@ class RsvFormsPage extends RsvAdminPage {
|
||||
.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') {
|
||||
builder
|
||||
.input_select('validation', 'Validation', [
|
||||
@@ -281,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.
|
||||
@@ -326,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),
|
||||
@@ -366,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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -386,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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>'
|
||||
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -51,6 +51,7 @@ function rsv_bootstrap(): void {
|
||||
$rsv_form_registry->register( 'button', new RsvButtonElementHandler() );
|
||||
$rsv_form_registry->register( 'reservation', new RsvFormReservationElementHandler() );
|
||||
$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.
|
||||
add_action( 'rsv-template-register-custom-elements', function ( \Reservair\Templating\RsvTemplateRegistry $reg ): void {
|
||||
|
||||
Reference in New Issue
Block a user