From 4e345c493d2cc6b9a494d8da290844a93f12359b Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Wed, 11 Feb 2026 22:16:21 +0100 Subject: [PATCH] feat: added UI for configuration template in recipe settings --- app/Models/Plugin.php | 58 +++++++++++++ package-lock.json | 29 ++++++- package.json | 1 + resources/js/codemirror-core.js | 3 + .../livewire/plugins/config-modal.blade.php | 15 +++- .../views/livewire/plugins/recipe.blade.php | 5 +- .../plugins/recipes/settings.blade.php | 79 +++++++++++++++++- .../Livewire/Plugins/RecipeSettingsTest.php | 81 +++++++++++++++++++ 8 files changed, 262 insertions(+), 9 deletions(-) diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index fc49f3c..fab8203 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -29,6 +29,7 @@ use InvalidArgumentException; use Keepsuit\LaravelLiquid\LaravelLiquidExtension; use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Extensions\StandardExtension; +use Symfony\Component\Yaml\Yaml; class Plugin extends Model { @@ -79,11 +80,68 @@ class Plugin extends Model }); } + public const CUSTOM_FIELDS_KEY = 'custom_fields'; + public function user() { return $this->belongsTo(User::class); } + /** + * YAML for the custom_fields editor + */ + public function getCustomFieldsEditorYaml(): string + { + $template = $this->configuration_template; + $list = $template[self::CUSTOM_FIELDS_KEY] ?? null; + if ($list === null || $list === []) { + return ''; + } + + return Yaml::dump($list, 4, 2); + } + + /** + * Parse editor YAML and return configuration_template for DB (custom_fields key). Returns null when empty. + */ + public static function configurationTemplateFromCustomFieldsYaml(string $yaml, ?array $existingTemplate): ?array + { + $list = $yaml !== '' ? Yaml::parse($yaml) : []; + if ($list === null || (is_array($list) && $list === [])) { + return null; + } + + $template = $existingTemplate ?? []; + $template[self::CUSTOM_FIELDS_KEY] = is_array($list) ? $list : []; + + return $template; + } + + /** + * Validate that each custom field entry has field_type and name. For use with parsed editor YAML. + * + * @param array> $list + * + * @throws \Illuminate\Validation\ValidationException + */ + public static function validateCustomFieldsList(array $list): void + { + $validator = \Illuminate\Support\Facades\Validator::make( + ['custom_fields' => $list], + [ + 'custom_fields' => ['required', 'array'], + 'custom_fields.*.field_type' => ['required', 'string'], + 'custom_fields.*.name' => ['required', 'string'], + ], + [ + 'custom_fields.*.field_type.required' => 'Each custom field must have a field_type.', + 'custom_fields.*.name.required' => 'Each custom field must have a name.', + ] + ); + + $validator->validate(); + } + // sanitize configuration template descriptions and help texts (since they allow HTML rendering) protected function sanitizeTemplate(): void { diff --git a/package-lock.json b/package-lock.json index 82749cf..8c24285 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "laravel", + "name": "laravel-trmnl-server", "lockfileVersion": 3, "requires": true, "packages": { @@ -11,6 +11,7 @@ "@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-liquid": "^6.3.0", + "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.11.3", "@codemirror/search": "^6.5.11", "@codemirror/state": "^6.5.2", @@ -151,6 +152,21 @@ "@lezer/lr": "^1.3.1" } }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz", + "integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, "node_modules/@codemirror/language": { "version": "6.12.1", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", @@ -761,6 +777,17 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", diff --git a/package.json b/package.json index 7262ad1..830c825 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-liquid": "^6.3.0", + "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.11.3", "@codemirror/search": "^6.5.11", "@codemirror/state": "^6.5.2", diff --git a/resources/js/codemirror-core.js b/resources/js/codemirror-core.js index f23389f..be9e15d 100644 --- a/resources/js/codemirror-core.js +++ b/resources/js/codemirror-core.js @@ -9,6 +9,7 @@ import { javascript } from '@codemirror/lang-javascript'; import { json } from '@codemirror/lang-json'; import { css } from '@codemirror/lang-css'; import { liquid } from '@codemirror/lang-liquid'; +import { yaml } from '@codemirror/lang-yaml'; import { oneDark } from '@codemirror/theme-one-dark'; import { githubLight } from '@fsegurai/codemirror-theme-github-light'; @@ -20,6 +21,8 @@ const LANGUAGE_MAP = { 'css': css, 'liquid': liquid, 'html': html, + 'yaml': yaml, + 'yml': yaml, }; // Theme support mapping diff --git a/resources/views/livewire/plugins/config-modal.blade.php b/resources/views/livewire/plugins/config-modal.blade.php index 7ba3208..49d6ed3 100644 --- a/resources/views/livewire/plugins/config-modal.blade.php +++ b/resources/views/livewire/plugins/config-modal.blade.php @@ -2,6 +2,7 @@ use App\Models\Plugin; use Illuminate\Support\Facades\Http; +use Livewire\Attributes\On; use Livewire\Component; /* @@ -54,12 +55,22 @@ new class extends Component /** * Triggered by @close on the modal to discard any typed but unsaved changes */ - public int $resetIndex = 0; // Add this property + public int $resetIndex = 0; + + /** + * When recipe settings (or this modal) save, reload so Configuration Fields form stays in sync. + */ + #[On('config-updated')] + public function refreshFromParent(): void + { + $this->loadData(); + $this->resetIndex++; + } public function resetForm(): void { $this->loadData(); - ++$this->resetIndex; // Increment to force DOM refresh + ++$this->resetIndex; } public function saveConfiguration() diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index a7b3918..59b0f2f 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -569,11 +569,10 @@ HTML; } #[On('config-updated')] - public function refreshPlugin() + public function refreshPlugin(): void { - // This pulls the fresh 'configuration' from the DB - // and re-triggers the @if check in the Blade template $this->plugin = $this->plugin->fresh(); + $this->configuration_template = $this->plugin->configuration_template ?? []; } // Laravel Livewire computed property: access with $this->parsed_urls diff --git a/resources/views/livewire/plugins/recipes/settings.blade.php b/resources/views/livewire/plugins/recipes/settings.blade.php index 540af89..4291834 100644 --- a/resources/views/livewire/plugins/recipes/settings.blade.php +++ b/resources/views/livewire/plugins/recipes/settings.blade.php @@ -3,9 +3,11 @@ use App\Models\Plugin; use Illuminate\Validation\Rule; use Livewire\Component; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; /* - * This component contains the TRMNL Plugin Settings modal + * This component contains the TRMNL Plugin Settings modal. */ new class extends Component { @@ -19,17 +21,19 @@ new class extends Component public bool $use_trmnl_liquid_renderer = false; + public string $configurationTemplateYaml = ''; + public int $resetIndex = 0; public function mount(): void { $this->resetErrorBag(); - // Reload data $this->plugin = $this->plugin->fresh(); $this->trmnlp_id = $this->plugin->trmnlp_id; $this->uuid = $this->plugin->uuid; $this->alias = $this->plugin->alias ?? false; $this->use_trmnl_liquid_renderer = $this->plugin->preferred_renderer === 'trmnl-liquid'; + $this->configurationTemplateYaml = $this->plugin->getCustomFieldsEditorYaml(); } public function saveTrmnlpId(): void @@ -47,14 +51,46 @@ new class extends Component ], 'alias' => 'boolean', 'use_trmnl_liquid_renderer' => 'boolean', + 'configurationTemplateYaml' => [ + 'nullable', + 'string', + function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === '') { + return; + } + try { + $parsed = Yaml::parse($value); + if (! is_array($parsed)) { + $fail('The configuration must be valid YAML and evaluate to an object/array.'); + return; + } + Plugin::validateCustomFieldsList($parsed); + } catch (ParseException) { + $fail('The configuration must be valid YAML.'); + } catch (\Illuminate\Validation\ValidationException $e) { + foreach ($e->errors() as $messages) { + foreach ($messages as $message) { + $fail($message); + } + } + } + }, + ], ]); + $configurationTemplate = Plugin::configurationTemplateFromCustomFieldsYaml( + $this->configurationTemplateYaml, + $this->plugin->configuration_template + ); + $this->plugin->update([ 'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id, 'alias' => $this->alias, 'preferred_renderer' => $this->use_trmnl_liquid_renderer ? 'trmnl-liquid' : null, + 'configuration_template' => $configurationTemplate, ]); + $this->dispatch('config-updated'); Flux::modal('trmnlp-settings')->close(); } @@ -64,7 +100,7 @@ new class extends Component } }; ?> - +
Recipe Settings @@ -98,6 +134,43 @@ new class extends Component @endif + + Configuration template + + Build forms visually in the TRMNL YML Form Builder. + Check the docs for more information. + + @php + $configTemplateTextareaId = 'config-template-' . uniqid(); + @endphp + + @if($alias) Alias URL diff --git a/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php b/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php index e15ee8b..8c977b4 100644 --- a/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php +++ b/tests/Feature/Livewire/Plugins/RecipeSettingsTest.php @@ -149,3 +149,84 @@ test('recipe settings clears preferred_renderer when checkbox unchecked', functi expect($plugin->fresh()->preferred_renderer)->toBeNull(); }); + +test('recipe settings saves configuration_template from valid YAML', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => [], + ]); + + $yaml = "- keyname: reading_days\n field_type: text\n name: Reading Days\n"; + + Livewire::test('plugins.recipes.settings', ['plugin' => $plugin]) + ->set('configurationTemplateYaml', $yaml) + ->call('saveTrmnlpId') + ->assertHasNoErrors(); + + $expected = [ + 'custom_fields' => [ + [ + 'keyname' => 'reading_days', + 'field_type' => 'text', + 'name' => 'Reading Days', + ], + ], + ]; + expect($plugin->fresh()->configuration_template)->toBe($expected); +}); + +test('recipe settings validates invalid YAML', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => [], + ]); + + Livewire::test('plugins.recipes.settings', ['plugin' => $plugin]) + ->set('configurationTemplateYaml', "foo: bar: baz\n") + ->call('saveTrmnlpId') + ->assertHasErrors(['configurationTemplateYaml']); + + expect($plugin->fresh()->configuration_template)->toBe([]); +}); + +test('recipe settings validates YAML must evaluate to object or array', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => ['custom_fields' => []], + ]); + + Livewire::test('plugins.recipes.settings', ['plugin' => $plugin]) + ->set('configurationTemplateYaml', '123') + ->call('saveTrmnlpId') + ->assertHasErrors(['configurationTemplateYaml']); + + expect($plugin->fresh()->configuration_template)->toBe(['custom_fields' => []]); +}); + +test('recipe settings validates each custom field has field_type and name', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => [], + ]); + + $yaml = "- keyname: only_key\n field_type: text\n name: Has Name\n- keyname: missing_type\n name: No type\n"; + + Livewire::test('plugins.recipes.settings', ['plugin' => $plugin]) + ->set('configurationTemplateYaml', $yaml) + ->call('saveTrmnlpId') + ->assertHasErrors(['configurationTemplateYaml']); + + expect($plugin->fresh()->configuration_template)->toBeEmpty(); +});