Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<meta name="csrf-token">` 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) }`.

Expand All @@ -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.
Expand Down
27 changes: 23 additions & 4 deletions resources/dist/mail-editor.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 9 additions & 3 deletions resources/js/editor/filament-field.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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) {
Expand Down
15 changes: 14 additions & 1 deletion resources/js/editor/odden-mail-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function fetchAdapter(element) {
export class OddenMailEditor extends HTMLElement {
#adapter = null;
#schema = new Map();
#labels = {};
#slots = [];
#base = {};
#selectedId = null;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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) {
Expand Down
1 change: 0 additions & 1 deletion resources/views/filament/mail-editor.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
})"
>
Expand Down
21 changes: 14 additions & 7 deletions src/Filament/Components/MailEditor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,15 +23,18 @@ class MailEditor extends Field
{
protected string $view = 'mail-builder::filament.mail-editor';

/** @var array<string, mixed> */
protected array $theme = [];
/** @var array<string, mixed>|Closure(): (array<string, mixed>|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<string, mixed> $theme
* ->theme(fn (Get $get): array => $get('theme') ?? [])
*
* @param array<string, mixed>|Closure(): (array<string, mixed>|null) $theme
*/
public function theme(array $theme): static
public function theme(array|Closure $theme): static
{
$this->theme = $theme;

Expand All @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
11 changes: 10 additions & 1 deletion src/Schema/SlotSchemaRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, array<string, mixed>>}
* `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<int, array<string, mixed>>, labels: array<string, string>}
*/
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,
];
}

Expand Down
31 changes: 31 additions & 0 deletions tests/Feature/MailEditorFieldTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions tests/Unit/SlotSchemaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions workbench/routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '<p>Hello there.</p>']],
['type' => 'testimonial', 'data' => ['quote' => 'A block the editor cannot edit yet is kept.', 'author' => 'Someone']],
]]);
}

Expand Down
Loading