feat: added UI for configuration template in recipe settings
Some checks are pending
tests / ci (push) Waiting to run

This commit is contained in:
Benjamin Nussbaum 2026-02-11 22:16:21 +01:00
parent 49222838c4
commit 4e345c493d
8 changed files with 262 additions and 9 deletions

View file

@ -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<int, array<string, mixed>> $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
{

29
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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
}
}; ?>
<flux:modal name="trmnlp-settings" class="min-w-[400px] space-y-6">
<flux:modal name="trmnlp-settings" class="min-w-[600px] max-w-2xl space-y-6">
<div wire:key="trmnlp-settings-form-{{ $resetIndex }}" class="space-y-6">
<div>
<flux:heading size="lg">Recipe Settings</flux:heading>
@ -98,6 +134,43 @@ new class extends Component
</flux:field>
@endif
<flux:field>
<flux:label>Configuration template</flux:label>
<flux:description>
Build forms visually in the <a href="https://usetrmnl.github.io/trmnl-form-builder/" target="_blank" rel="noopener noreferrer">TRMNL YML Form Builder</a>.
Check the <a href="https://help.trmnl.com/en/articles/10513740-custom-plugin-form-builder" target="_blank" rel="noopener noreferrer">docs</a> for more information.
</flux:description>
@php
$configTemplateTextareaId = 'config-template-' . uniqid();
@endphp
<flux:textarea
wire:model="configurationTemplateYaml"
id="{{ $configTemplateTextareaId }}"
placeholder="[]"
rows="12"
hidden
/>
<div
x-data="codeEditorFormComponent({
isDisabled: false,
language: 'yaml',
state: $wire.entangle('configurationTemplateYaml'),
textareaId: @js($configTemplateTextareaId)
})"
wire:ignore
wire:key="cm-{{ $configTemplateTextareaId }}"
class="min-h-[200px] h-[300px] overflow-hidden resize-y"
>
<div x-show="isLoading" class="flex items-center justify-center h-full">
<div class="flex items-center space-x-2">
<flux:icon.loading />
</div>
</div>
<div x-show="!isLoading" x-ref="editor" class="h-full"></div>
</div>
<flux:error name="configurationTemplateYaml" />
</flux:field>
@if($alias)
<flux:field>
<flux:label>Alias URL</flux:label>

View file

@ -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();
});