diff --git a/README.md b/README.md
index b02e534..96224df 100644
--- a/README.md
+++ b/README.md
@@ -245,7 +245,7 @@ $schema = SlotSchemaRegistry::for(SlotType::Hero); // null for a type that is
$schema->defaults(); // the data of a new hero slot
$schema->basicFields(); // the fields of the basic form
-return response()->json(SlotSchemaRegistry::toArray()); // everything an editor needs, with a "version"
+return response()->json(SlotSchemaRegistry::toArray()); // everything an editor needs: a "version", the described slots, and a "labels" map that names every slot type, also those not described yet
```
A field has a `key`, a `type` (`text`, `textarea`, `rich_text`, `url`, `image`, `color`, `select`, `toggle`, `number`, `items`), a `label`, `required`, a `default`, and, where they apply, `placeholder`, `help`, `options` and the item fields of a list. Fields marked `advanced` are read by the slot's view but kept out of the basic form: an editor shows them as style options.
@@ -295,6 +295,7 @@ php artisan vendor:publish --tag=mail-builder-assets
- `value` is the document, in the shape `EmailDocument::fromArray()` takes: `{subject?, preview_text?, theme?, slots: [{type, data, visibility?}]}`. Keys other than `slots` are kept untouched.
- The `change` event carries the new document. With a `name` attribute the element also keeps a hidden input in sync with the JSON, for a plain form post.
+- The element's own `change` event is a `CustomEvent`; the browser's native `change` and `input` events from the fields inside the editor do not leave it, so a surrounding form never sees them.
- The page needs `` for Laravel's CSRF check; the editor sends it as `X-CSRF-TOKEN`.
- To talk to the server some other way, set `editor.adapter = { loadSchema(), preview(document) }`.
@@ -311,9 +312,17 @@ use Odden\MailBuilder\Filament\Components\MailEditor;
MailEditor::make('slots')
->label('Email content')
- ->theme(['container_width' => 640]); // optional: theme for the preview and the compiled email
+ ->theme(['container_width' => 640]); // optional: the theme of the preview (see `mail-builder.defaults`)
```
+The theme can also be a closure, evaluated for every preview, so the preview follows the form. For example, with the theme's fields in the same form under `theme.*`:
+
+```php
+MailEditor::make('slots')->theme(fn (Get $get): array => $get('theme') ?? []);
+```
+
+The field's theme wins over a theme sent by the browser.
+
- **No routes and no extra setup for the preview.** The field renders the preview itself, through Livewire, so it runs under the panel's own authentication and authorization. (The routes in section 8 are for use outside Filament.)
- **Publish the assets** with Filament's own command, the first time and after an update: `php artisan filament:assets`.
- **State from either shape.** A column holding Filament's keyed Builder items or a plain list both load, and the field saves a plain list. Slots of a type that is not editable yet are kept as they are.
diff --git a/resources/dist/mail-editor.js b/resources/dist/mail-editor.js
index 6595ab1..a174b39 100644
--- a/resources/dist/mail-editor.js
+++ b/resources/dist/mail-editor.js
@@ -585,6 +585,7 @@ function fetchAdapter(element) {
class OddenMailEditor extends HTMLElement {
#adapter = null;
#schema = new Map();
+ #labels = {};
#slots = [];
#base = {};
#selectedId = null;
@@ -739,6 +740,16 @@ class OddenMailEditor extends HTMLElement {
refs.live,
);
+ // The browser's own change and input events from the editor's fields stay inside it. A host's form must not take
+ // them for the editor's `change` event (a CustomEvent that carries the document), nor react to every keystroke.
+ for (const type of ['change', 'input']) {
+ root.addEventListener(type, (event) => {
+ if (!(event instanceof CustomEvent)) {
+ event.stopPropagation();
+ }
+ });
+ }
+
this.append(root);
this.#built = true;
@@ -770,6 +781,8 @@ class OddenMailEditor extends HTMLElement {
const schema = await this.#adapter.loadSchema();
this.#schema = new Map(schema.slots.map((slotSchema) => [slotSchema.type, slotSchema]));
+ // A label for every slot type, so a block the editor cannot edit yet still has a readable name.
+ this.#labels = schema.labels ?? {};
} catch (error) {
this.#showError(error.message);
@@ -1147,7 +1160,7 @@ class OddenMailEditor extends HTMLElement {
}
#labelOf(slot) {
- return this.#schema.get(slot.type)?.label ?? slot.type.replaceAll('_', ' ');
+ return this.#schema.get(slot.type)?.label ?? this.#labels[slot.type] ?? slot.type.replaceAll('_', ' ');
}
#summaryOf(slot) {
@@ -1463,7 +1476,7 @@ function toList(state) {
return state && typeof state === 'object' ? Object.values(state) : [];
}
-export default function oddenMailEditorField({ state, key, schema, theme, disabled }) {
+export default function oddenMailEditorField({ state, key, schema, disabled }) {
return {
state,
lastEmitted: null,
@@ -1476,9 +1489,15 @@ export default function oddenMailEditorField({ state, key, schema, theme, disabl
preview: async (document) => this.$wire.callSchemaComponentMethod(key, 'renderPreview', { document }),
};
- editor.value = { ...(theme && Object.keys(theme).length > 0 ? { theme } : {}), slots: toList(this.state) };
+ // The theme is applied by the field on the server, with the form's current values, for every preview.
+ editor.value = { slots: toList(this.state) };
editor.addEventListener('change', (event) => {
+ // Only the editor's own event carries the document; ignore anything else that reaches the element.
+ if (!(event instanceof CustomEvent) || !Array.isArray(event.detail?.slots)) {
+ return;
+ }
+
const slots = event.detail.slots;
this.lastEmitted = JSON.stringify(slots);
@@ -1493,7 +1512,7 @@ export default function oddenMailEditorField({ state, key, schema, theme, disabl
return;
}
- editor.value = { ...(theme && Object.keys(theme).length > 0 ? { theme } : {}), slots };
+ editor.value = { slots };
});
if (disabled) {
diff --git a/resources/js/editor/filament-field.js b/resources/js/editor/filament-field.js
index 6653fcc..85af5fc 100644
--- a/resources/js/editor/filament-field.js
+++ b/resources/js/editor/filament-field.js
@@ -12,7 +12,7 @@ function toList(state) {
return state && typeof state === 'object' ? Object.values(state) : [];
}
-export default function oddenMailEditorField({ state, key, schema, theme, disabled }) {
+export default function oddenMailEditorField({ state, key, schema, disabled }) {
return {
state,
lastEmitted: null,
@@ -25,9 +25,15 @@ export default function oddenMailEditorField({ state, key, schema, theme, disabl
preview: async (document) => this.$wire.callSchemaComponentMethod(key, 'renderPreview', { document }),
};
- editor.value = { ...(theme && Object.keys(theme).length > 0 ? { theme } : {}), slots: toList(this.state) };
+ // The theme is applied by the field on the server, with the form's current values, for every preview.
+ editor.value = { slots: toList(this.state) };
editor.addEventListener('change', (event) => {
+ // Only the editor's own event carries the document; ignore anything else that reaches the element.
+ if (!(event instanceof CustomEvent) || !Array.isArray(event.detail?.slots)) {
+ return;
+ }
+
const slots = event.detail.slots;
this.lastEmitted = JSON.stringify(slots);
@@ -42,7 +48,7 @@ export default function oddenMailEditorField({ state, key, schema, theme, disabl
return;
}
- editor.value = { ...(theme && Object.keys(theme).length > 0 ? { theme } : {}), slots };
+ editor.value = { slots };
});
if (disabled) {
diff --git a/resources/js/editor/odden-mail-editor.js b/resources/js/editor/odden-mail-editor.js
index 1237395..bbb2c41 100644
--- a/resources/js/editor/odden-mail-editor.js
+++ b/resources/js/editor/odden-mail-editor.js
@@ -67,6 +67,7 @@ function fetchAdapter(element) {
export class OddenMailEditor extends HTMLElement {
#adapter = null;
#schema = new Map();
+ #labels = {};
#slots = [];
#base = {};
#selectedId = null;
@@ -221,6 +222,16 @@ export class OddenMailEditor extends HTMLElement {
refs.live,
);
+ // The browser's own change and input events from the editor's fields stay inside it. A host's form must not take
+ // them for the editor's `change` event (a CustomEvent that carries the document), nor react to every keystroke.
+ for (const type of ['change', 'input']) {
+ root.addEventListener(type, (event) => {
+ if (!(event instanceof CustomEvent)) {
+ event.stopPropagation();
+ }
+ });
+ }
+
this.append(root);
this.#built = true;
@@ -252,6 +263,8 @@ export class OddenMailEditor extends HTMLElement {
const schema = await this.#adapter.loadSchema();
this.#schema = new Map(schema.slots.map((slotSchema) => [slotSchema.type, slotSchema]));
+ // A label for every slot type, so a block the editor cannot edit yet still has a readable name.
+ this.#labels = schema.labels ?? {};
} catch (error) {
this.#showError(error.message);
@@ -629,7 +642,7 @@ export class OddenMailEditor extends HTMLElement {
}
#labelOf(slot) {
- return this.#schema.get(slot.type)?.label ?? slot.type.replaceAll('_', ' ');
+ return this.#schema.get(slot.type)?.label ?? this.#labels[slot.type] ?? slot.type.replaceAll('_', ' ');
}
#summaryOf(slot) {
diff --git a/resources/views/filament/mail-editor.blade.php b/resources/views/filament/mail-editor.blade.php
index 6af5e71..c92bef9 100644
--- a/resources/views/filament/mail-editor.blade.php
+++ b/resources/views/filament/mail-editor.blade.php
@@ -13,7 +13,6 @@
state: $wire.$entangle(@js($statePath)),
key: @js($key),
schema: @js(\Odden\MailBuilder\Schema\SlotSchemaRegistry::toArray()),
- theme: @js($getTheme()),
disabled: @js($isDisabled()),
})"
>
diff --git a/src/Filament/Components/MailEditor.php b/src/Filament/Components/MailEditor.php
index 39308c4..e57e00d 100644
--- a/src/Filament/Components/MailEditor.php
+++ b/src/Filament/Components/MailEditor.php
@@ -4,6 +4,7 @@
namespace Odden\MailBuilder\Filament\Components;
+use Closure;
use Filament\Forms\Components\Field;
use Filament\Support\Components\Attributes\ExposedLivewireMethod;
use Livewire\Attributes\Renderless;
@@ -22,15 +23,18 @@ class MailEditor extends Field
{
protected string $view = 'mail-builder::filament.mail-editor';
- /** @var array */
- protected array $theme = [];
+ /** @var array|Closure(): (array|null) */
+ protected array|Closure $theme = [];
/**
- * Theme settings for the preview and for the compiled email (see `mail-builder.defaults`).
+ * Theme settings for the preview (see `mail-builder.defaults`): an array, or a closure that is evaluated for every
+ * preview, so it can follow the form. A closure may ask for `$get`:
*
- * @param array $theme
+ * ->theme(fn (Get $get): array => $get('theme') ?? [])
+ *
+ * @param array|Closure(): (array|null) $theme
*/
- public function theme(array $theme): static
+ public function theme(array|Closure $theme): static
{
$this->theme = $theme;
@@ -42,7 +46,9 @@ public function theme(array $theme): static
*/
public function getTheme(): array
{
- return $this->theme;
+ $theme = $this->evaluate($this->theme);
+
+ return is_array($theme) ? $theme : [];
}
protected function setUp(): void
@@ -91,7 +97,8 @@ public static function normalizeState(mixed $state): array
#[Renderless]
public function renderPreview(array $document): string
{
- $document['theme'] = array_merge($this->theme, is_array($document['theme'] ?? null) ? $document['theme'] : []);
+ // The field's theme is applied on the server for every preview, so it wins over any theme the browser sent.
+ $document['theme'] = array_merge(is_array($document['theme'] ?? null) ? $document['theme'] : [], $this->getTheme());
return app(PreviewRenderer::class)->render($document);
}
diff --git a/src/Schema/SlotSchemaRegistry.php b/src/Schema/SlotSchemaRegistry.php
index 3200d81..f8ec295 100644
--- a/src/Schema/SlotSchemaRegistry.php
+++ b/src/Schema/SlotSchemaRegistry.php
@@ -68,13 +68,22 @@ public static function all(): array
/**
* Everything an editor needs, ready to be sent as JSON.
*
- * @return array{version: int, slots: array>}
+ * `labels` names every slot type, also the ones with no schema yet, so an editor can show a block it cannot edit.
+ *
+ * @return array{version: int, slots: array>, labels: array}
*/
public static function toArray(): array
{
+ $labels = [];
+
+ foreach (SlotType::cases() as $type) {
+ $labels[$type->value] = $type->label();
+ }
+
return [
'version' => self::VERSION,
'slots' => array_map(fn (SlotSchema $schema): array => $schema->toArray(), self::all()),
+ 'labels' => $labels,
];
}
diff --git a/tests/Feature/MailEditorFieldTest.php b/tests/Feature/MailEditorFieldTest.php
index 8576380..55d32a7 100644
--- a/tests/Feature/MailEditorFieldTest.php
+++ b/tests/Feature/MailEditorFieldTest.php
@@ -52,6 +52,37 @@ public function test_the_fields_theme_applies_to_the_preview(): void
$this->assertStringContainsString('max-width: 480px', $html);
}
+ public function test_the_theme_can_be_a_closure_evaluated_for_each_preview(): void
+ {
+ $width = 480;
+ $field = MailEditor::make('slots')->theme(function () use (&$width): array {
+ return ['container_width' => $width];
+ });
+ $document = ['slots' => [['type' => 'divider', 'data' => []]]];
+
+ $this->assertStringContainsString('max-width: 480px', $field->renderPreview($document));
+
+ $width = 560;
+
+ $this->assertStringContainsString('max-width: 560px', $field->renderPreview($document));
+ }
+
+ public function test_the_fields_theme_wins_over_a_theme_the_browser_sent(): void
+ {
+ $html = MailEditor::make('slots')->theme(['container_width' => 480])->renderPreview([
+ 'slots' => [['type' => 'divider', 'data' => []]],
+ 'theme' => ['container_width' => 700, 'primary_color' => '#ff0000'],
+ ]);
+
+ $this->assertStringContainsString('max-width: 480px', $html);
+ $this->assertStringNotContainsString('max-width: 700px', $html);
+ }
+
+ public function test_a_closure_that_returns_nothing_means_no_theme(): void
+ {
+ $this->assertSame([], MailEditor::make('slots')->theme(fn (): ?array => null)->getTheme());
+ }
+
public function test_the_preview_rejects_what_the_route_would_reject(): void
{
$this->expectException(ValidationException::class);
diff --git a/tests/Unit/SlotSchemaTest.php b/tests/Unit/SlotSchemaTest.php
index f743081..e6cf18e 100644
--- a/tests/Unit/SlotSchemaTest.php
+++ b/tests/Unit/SlotSchemaTest.php
@@ -130,6 +130,16 @@ public function test_the_schema_exports_as_json_an_editor_can_read(): void
$this->assertSame(2, $items['default_items']);
}
+ public function test_every_slot_type_has_a_label_in_the_export_even_without_a_schema(): void
+ {
+ $labels = SlotSchemaRegistry::toArray()['labels'];
+
+ $this->assertCount(count(SlotType::cases()), $labels);
+ $this->assertSame('Customer Quote & Testimonial', $labels['testimonial']);
+ $this->assertSame(SlotType::Hero->label(), $labels['hero']);
+ $this->assertNull(SlotSchemaRegistry::for(SlotType::Testimonial));
+ }
+
public function test_advanced_fields_are_marked_and_kept_out_of_the_basic_form(): void
{
$hero = SlotSchemaRegistry::for(SlotType::Hero);
diff --git a/workbench/routes/web.php b/workbench/routes/web.php
index 69269d7..0d91a70 100644
--- a/workbench/routes/web.php
+++ b/workbench/routes/web.php
@@ -45,6 +45,7 @@ public function mount(): void
['type' => 'header', 'data' => ['brand_name' => 'Acme Studio', 'tagline' => 'Product updates']],
['type' => 'hero', 'data' => ['title' => 'Edit me inside Filament', 'subtitle' => 'The form state is the same list of slots.']],
['type' => 'body_text', 'data' => ['content' => 'Hello there.
']],
+ ['type' => 'testimonial', 'data' => ['quote' => 'A block the editor cannot edit yet is kept.', 'author' => 'Someone']],
]]);
}