From caa159cb76f9eb0900adb8e8194a0f537e011b80 Mon Sep 17 00:00:00 2001 From: Darren Odden Date: Wed, 7 Oct 2026 20:17:33 -0700 Subject: [PATCH] Editor fixes: isolate native events, label every slot type, theme closure - The browser's native change and input events from the editor's fields no longer bubble out of it, and the Filament component ignores any event that is not the editor's own. This removed a TypeError on blur. - The schema export carries a label for every slot type, so a block that is not editable yet shows its name. - MailEditor::theme() accepts a closure, evaluated for each preview and winning over a theme from the browser, so the preview can follow the form's theme fields. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 13 ++++++-- resources/dist/mail-editor.js | 27 +++++++++++++--- resources/js/editor/filament-field.js | 12 +++++-- resources/js/editor/odden-mail-editor.js | 15 ++++++++- .../views/filament/mail-editor.blade.php | 1 - src/Filament/Components/MailEditor.php | 21 ++++++++----- src/Schema/SlotSchemaRegistry.php | 11 ++++++- tests/Feature/MailEditorFieldTest.php | 31 +++++++++++++++++++ tests/Unit/SlotSchemaTest.php | 10 ++++++ workbench/routes/web.php | 1 + 10 files changed, 123 insertions(+), 19 deletions(-) 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']], ]]); }