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']], ]]); }