mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-03-14 12:23:33 +00:00
feat: added UI for configuration template in recipe settings
Some checks are pending
tests / ci (push) Waiting to run
Some checks are pending
tests / ci (push) Waiting to run
This commit is contained in:
parent
49222838c4
commit
4e345c493d
8 changed files with 262 additions and 9 deletions
|
|
@ -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
29
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue