diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index c4b45c8..c791333 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -11,7 +11,6 @@ use App\Liquid\Filters\StandardFilters; use App\Liquid\Filters\StringMarkup; use App\Liquid\Filters\Uniqueness; use App\Liquid\Tags\TemplateTag; -use App\Services\ImageGenerationService; use App\Services\Plugin\Parsers\ResponseParserRegistry; use App\Services\PluginImportService; use Carbon\Carbon; @@ -25,6 +24,7 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; use Illuminate\Support\Str; +use InvalidArgumentException; use Keepsuit\LaravelLiquid\LaravelLiquidExtension; use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Extensions\StandardExtension; @@ -455,7 +455,7 @@ class Plugin extends Model public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): string { if ($this->plugin_type !== 'recipe') { - throw new \InvalidArgumentException('Render method is only applicable for recipe plugins.'); + throw new InvalidArgumentException('Render method is only applicable for recipe plugins.'); } if ($this->render_markup) { @@ -565,17 +565,30 @@ class Plugin extends Model if ($this->render_markup_view) { if ($standalone) { - return view('trmnl-layouts.single', [ + $renderedView = view($this->render_markup_view, [ + 'size' => $size, + 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], + ])->render(); + + if ($size === 'full') { + return view('trmnl-layouts.single', [ + 'colorDepth' => $device?->colorDepth(), + 'deviceVariant' => $device?->deviceVariant() ?? 'og', + 'noBleed' => $this->no_bleed, + 'darkMode' => $this->dark_mode, + 'scaleLevel' => $device?->scaleLevel(), + 'slot' => $renderedView, + ])->render(); + } + + return view('trmnl-layouts.mashup', [ + 'mashupLayout' => $this->getPreviewMashupLayoutForSize($size), 'colorDepth' => $device?->colorDepth(), 'deviceVariant' => $device?->deviceVariant() ?? 'og', - 'noBleed' => $this->no_bleed, 'darkMode' => $this->dark_mode, 'scaleLevel' => $device?->scaleLevel(), - 'slot' => view($this->render_markup_view, [ - 'size' => $size, - 'data' => $this->data_payload, - 'config' => $this->configuration ?? [], - ])->render(), + 'slot' => $renderedView, ])->render(); } @@ -606,4 +619,61 @@ class Plugin extends Model default => '1Tx1B', }; } + + /** + * Duplicate the plugin, copying all attributes and handling render_markup_view + * + * @param int|null $userId Optional user ID for the duplicate. If not provided, uses the original plugin's user_id. + * @return Plugin The newly created duplicate plugin + */ + public function duplicate(?int $userId = null): self + { + // Get all attributes except id and uuid + // Use toArray() to get cast values (respects JSON casts) + $attributes = $this->toArray(); + unset($attributes['id'], $attributes['uuid']); + + // Handle render_markup_view - copy file content to render_markup + if ($this->render_markup_view) { + try { + $basePath = resource_path('views/'.str_replace('.', '/', $this->render_markup_view)); + $paths = [ + $basePath.'.blade.php', + $basePath.'.liquid', + ]; + + $fileContent = null; + $markupLanguage = null; + foreach ($paths as $path) { + if (file_exists($path)) { + $fileContent = file_get_contents($path); + // Determine markup language based on file extension + $markupLanguage = str_ends_with($path, '.liquid') ? 'liquid' : 'blade'; + break; + } + } + + if ($fileContent !== null) { + $attributes['render_markup'] = $fileContent; + $attributes['markup_language'] = $markupLanguage; + $attributes['render_markup_view'] = null; + } else { + // File doesn't exist, remove the view reference + $attributes['render_markup_view'] = null; + } + } catch (Exception $e) { + // If file reading fails, remove the view reference + $attributes['render_markup_view'] = null; + } + } + + // Append " (Copy)" to the name + $attributes['name'] = $this->name.' (Copy)'; + + // Set user_id - use provided userId or fall back to original plugin's user_id + $attributes['user_id'] = $userId ?? $this->user_id; + + // Create and return the new plugin + return self::create($attributes); + } } diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index 9207e3e..eeb5835 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -17,6 +17,32 @@ use ZipArchive; class PluginImportService { + /** + * Validate YAML settings + * + * @param array $settings The parsed YAML settings + * @throws Exception + */ + private function validateYAML(array $settings): void + { + if (!isset($settings['custom_fields']) || !is_array($settings['custom_fields'])) { + return; + } + + foreach ($settings['custom_fields'] as $field) { + if (isset($field['field_type']) && $field['field_type'] === 'multi_string') { + + if (isset($field['default']) && str_contains($field['default'], ',')) { + throw new Exception("Validation Error: The default value for multistring fields like `{$field['keyname']}` cannot contain commas."); + } + + if (isset($field['placeholder']) && str_contains($field['placeholder'], ',')) { + throw new Exception("Validation Error: The placeholder value for multistring fields like `{$field['keyname']}` cannot contain commas."); + } + + } + } + } /** * Import a plugin from a ZIP file * @@ -58,6 +84,7 @@ class PluginImportService // Parse settings.yml $settingsYaml = File::get($filePaths['settingsYamlPath']); $settings = Yaml::parse($settingsYaml); + $this->validateYAML($settings); // Read full.liquid content $fullLiquid = File::get($filePaths['fullLiquidPath']); @@ -144,11 +171,12 @@ class PluginImportService * @param string|null $zipEntryPath Optional path to specific plugin in monorepo * @param string|null $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid') * @param string|null $iconUrl Optional icon URL to set on the plugin + * @param bool $allowDuplicate If true, generate a new UUID for trmnlp_id if a plugin with the same trmnlp_id already exists * @return Plugin The created plugin instance * * @throws Exception If the ZIP file is invalid or required files are missing */ - public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null): Plugin + public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null, bool $allowDuplicate = false): Plugin { // Download the ZIP file $response = Http::timeout(60)->get($zipUrl); @@ -187,6 +215,7 @@ class PluginImportService // Parse settings.yml $settingsYaml = File::get($filePaths['settingsYamlPath']); $settings = Yaml::parse($settingsYaml); + $this->validateYAML($settings); // Read full.liquid content $fullLiquid = File::get($filePaths['fullLiquidPath']); @@ -217,17 +246,26 @@ class PluginImportService 'custom_fields' => $settings['custom_fields'], ]; - $plugin_updated = isset($settings['id']) + // Determine the trmnlp_id to use + $trmnlpId = $settings['id'] ?? Uuid::v7(); + + // If allowDuplicate is true and a plugin with this trmnlp_id already exists, generate a new UUID + if ($allowDuplicate && isset($settings['id']) && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists()) { + $trmnlpId = Uuid::v7(); + } + + $plugin_updated = ! $allowDuplicate && isset($settings['id']) && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists(); + // Create a new plugin $plugin = Plugin::updateOrCreate( [ - 'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(), + 'user_id' => $user->id, 'trmnlp_id' => $trmnlpId, ], [ 'user_id' => $user->id, 'name' => $settings['name'] ?? 'Imported Plugin', - 'trmnlp_id' => $settings['id'] ?? Uuid::v7(), + 'trmnlp_id' => $trmnlpId, 'data_stale_minutes' => $settings['refresh_interval'] ?? 15, 'data_strategy' => $settings['strategy'] ?? 'static', 'polling_url' => $settings['polling_url'] ?? null, diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php index 7257ab0..fdf7f34 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -113,7 +113,8 @@ class extends Component auth()->user(), $plugin['zip_entry_path'] ?? null, null, - $plugin['logo_url'] ?? null + $plugin['logo_url'] ?? null, + allowDuplicate: true ); $this->dispatch('plugin-installed'); diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 9ecad1a..cc8b070 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -164,7 +164,8 @@ class extends Component auth()->user(), null, config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null, - $recipe['icon_url'] ?? null + $recipe['icon_url'] ?? null, + allowDuplicate: true ); $this->dispatch('plugin-installed'); diff --git a/resources/views/livewire/plugins/config-modal.blade.php b/resources/views/livewire/plugins/config-modal.blade.php new file mode 100644 index 0000000..7aaacbb --- /dev/null +++ b/resources/views/livewire/plugins/config-modal.blade.php @@ -0,0 +1,516 @@ + loadData(); + } + + public function loadData(): void + { + $this->resetErrorBag(); + // Reload data + $this->plugin = $this->plugin->fresh(); + + $this->configuration_template = $this->plugin->configuration_template ?? []; + $this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : []; + + // Initialize multiValues by exploding the CSV strings from the DB + foreach ($this->configuration_template['custom_fields'] ?? [] as $field) { + if (($field['field_type'] ?? null) === 'multi_string') { + $fieldKey = $field['keyname']; + $rawValue = $this->configuration[$fieldKey] ?? ($field['default'] ?? ''); + + $currentValue = is_array($rawValue) ? '' : (string)$rawValue; + + $this->multiValues[$fieldKey] = $currentValue !== '' + ? array_values(array_filter(explode(',', $currentValue))) + : ['']; + } + } + } + + /** + * Triggered by @close on the modal to discard any typed but unsaved changes + */ + public int $resetIndex = 0; // Add this property + public function resetForm(): void + { + $this->loadData(); + $this->resetIndex++; // Increment to force DOM refresh + } + + public function saveConfiguration() + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + // final validation layer + $this->validate([ + 'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'], + ], [ + 'multiValues.*.*.regex' => 'Items cannot contain commas.', + ]); + + // Prepare config copy to send to db + $finalValues = $this->configuration; + foreach ($this->configuration_template['custom_fields'] ?? [] as $field) { + $fieldKey = $field['keyname']; + + // Handle multi_string: Join array back to CSV string + if ($field['field_type'] === 'multi_string' && isset($this->multiValues[$fieldKey])) { + $finalValues[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey]))); + } + + // Handle code fields: Try to JSON decode if necessary (standard TRMNL behavior) + if ($field['field_type'] === 'code' && isset($finalValues[$fieldKey]) && is_string($finalValues[$fieldKey])) { + $decoded = json_decode($finalValues[$fieldKey], true); + if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || is_object($decoded))) { + $finalValues[$fieldKey] = $decoded; + } + } + } + + // send to db + $this->plugin->update(['configuration' => $finalValues]); + $this->configuration = $finalValues; // update local state + $this->dispatch('config-updated'); // notifies listeners + Flux::modal('configuration-modal')->close(); + } + + // ------------------------------------This section contains helper functions for interacting with the form------------------------------------------------ + public function addMultiItem(string $fieldKey): void + { + $this->multiValues[$fieldKey][] = ''; + } + + public function removeMultiItem(string $fieldKey, int $index): void + { + unset($this->multiValues[$fieldKey][$index]); + + $this->multiValues[$fieldKey] = array_values($this->multiValues[$fieldKey]); + + if (empty($this->multiValues[$fieldKey])) { + $this->multiValues[$fieldKey][] = ''; + } + } + + // Livewire magic method to validate MultiValue input boxes + // Runs on every debounce + public function updatedMultiValues($value, $key) + { + $this->validate([ + 'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'], + ], [ + 'multiValues.*.*.regex' => 'Items cannot contain commas.', + ]); + } + + public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + try { + $requestData = []; + if ($query !== null) { + $requestData = [ + 'function' => $fieldKey, + 'query' => $query + ]; + } + + $response = $query !== null + ? Http::post($endpoint, $requestData) + : Http::post($endpoint); + + if ($response->successful()) { + $this->xhrSelectOptions[$fieldKey] = $response->json(); + } else { + $this->xhrSelectOptions[$fieldKey] = []; + } + } catch (\Exception $e) { + $this->xhrSelectOptions[$fieldKey] = []; + } + } + + public function searchXhrSelect(string $fieldKey, string $endpoint): void + { + $query = $this->searchQueries[$fieldKey] ?? ''; + if (!empty($query)) { + $this->loadXhrSelectOptions($fieldKey, $endpoint, $query); + } + } +};?> + + +
+
+
+ Configuration + Configure your plugin settings +
+ +
+ @if(isset($configuration_template['custom_fields']) && is_array($configuration_template['custom_fields'])) + @foreach($configuration_template['custom_fields'] as $field) + @php + $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; + $rawValue = $configuration[$fieldKey] ?? ($field['default'] ?? ''); + + # These are sanitized at Model/Plugin level, safe to render HTML + $safeDescription = $field['description'] ?? ''; + $safeHelp = $field['help_text'] ?? ''; + + // For code fields, if the value is an array, JSON encode it + if ($field['field_type'] === 'code' && is_array($rawValue)) { + $currentValue = json_encode($rawValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } else { + $currentValue = is_array($rawValue) ? '' : (string) $rawValue; + } + @endphp +
+ @if($field['field_type'] === 'author_bio') + @continue + @endif + + @if($field['field_type'] === 'copyable_webhook_url') + @continue + @endif + + @if($field['field_type'] === 'string' || $field['field_type'] === 'url') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'text') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'code') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'password') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'copyable') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'time_zone') + + {{ $field['name'] }} + {!! $safeDescription !!} + + + @foreach(timezone_identifiers_list() as $timezone) + + @endforeach + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'number') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'boolean') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'date') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'time') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'select') + @if(isset($field['multiple']) && $field['multiple'] === true) + + {{ $field['name'] }} + {!! $safeDescription !!} + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + @php + $key = mb_strtolower(str_replace(' ', '_', $option)); + @endphp + + @endif + @endforeach + @endif + + {!! $safeHelp !!} + + @else + + {{ $field['name'] }} + {!! $safeDescription !!} + + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + @php + $key = mb_strtolower(str_replace(' ', '_', $option)); + @endphp + + @endif + @endforeach + @endif + + {!! $safeHelp !!} + + @endif + + @elseif($field['field_type'] === 'xhrSelect') + + {{ $field['name'] }} + {!! $safeDescription !!} + + + @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) + @foreach($xhrSelectOptions[$fieldKey] as $option) + @if(is_array($option)) + @if(isset($option['id']) && isset($option['name'])) + {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} + + @else + {{-- xhrSelect format: { 'Braves' => 123 } --}} + @foreach($option as $label => $value) + + @endforeach + @endif + @else + + @endif + @endforeach + @endif + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'xhrSelectSearch') +
+ + {{ $field['name'] }} + {!! $safeDescription !!} + + + + + {!! $safeHelp !!} + @if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue)) + + + @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) + @foreach($xhrSelectOptions[$fieldKey] as $option) + @if(is_array($option)) + @if(isset($option['id']) && isset($option['name'])) + {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} + + @else + {{-- xhrSelect format: { 'Braves' => 123 } --}} + @foreach($option as $label => $value) + + @endforeach + @endif + @else + + @endif + @endforeach + @endif + @if(!empty($currentValue) && (!isset($xhrSelectOptions[$fieldKey]) || empty($xhrSelectOptions[$fieldKey]))) + {{-- Show current value even if no options are loaded --}} + + @endif + + @endif +
+ @elseif($field['field_type'] === 'multi_string') + + {{ $field['name'] }} + {!! $safeDescription !!} + +
+ @foreach($multiValues[$fieldKey] as $index => $item) +
+ + + + @if(count($multiValues[$fieldKey]) > 1) + + @endif +
+ @error("multiValues.{$fieldKey}.{$index}") +
+ + {{-- $message comes from thrown error --}} + {{ $message }} +
+ @enderror + @endforeach + + + Add Item + +
+ {!! $safeHelp !!} +
+ @else + Field type "{{ $field['field_type'] }}" not yet supported + @endif +
+ @endforeach + @endif + +
+ + + Save Configuration + + @if($errors->any()) +
+ + + Fix errors before saving. + +
+ @endif +
+
+
+
+
diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index bda8221..47b356a 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -7,6 +7,7 @@ use Livewire\Volt\Component; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Http; +use Livewire\Attributes\On; new class extends Component { public Plugin $plugin; @@ -34,17 +35,13 @@ new class extends Component { public string $mashup_layout = 'full'; public array $mashup_plugins = []; public array $configuration_template = []; - public array $configuration = []; - public array $xhrSelectOptions = []; - public array $searchQueries = []; - public array $multiValues = []; public function mount(): void { abort_unless(auth()->user()->plugins->contains($this->plugin), 403); $this->blade_code = $this->plugin->render_markup; + // required to render some stuff $this->configuration_template = $this->plugin->configuration_template ?? []; - $this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : []; if ($this->plugin->render_markup_view) { try { @@ -75,25 +72,6 @@ new class extends Component { $this->fillformFields(); $this->data_payload_updated_at = $this->plugin->data_payload_updated_at; - - foreach ($this->configuration_template['custom_fields'] ?? [] as $field) { - if (($field['field_type'] ?? null) !== 'multi_string') { - continue; - } - - $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; - - // Get the existing value from the plugin's configuration - $rawValue = $this->configuration[$fieldKey] ?? ($field['default'] ?? ''); - - $currentValue = is_array($rawValue) ? '' : (string)$rawValue; - - // Split CSV into array for UI boxes - $this->multiValues[$fieldKey] = $currentValue !== '' - ? array_values(array_filter(explode(',', $currentValue))) - : ['']; - } - } public function fillFormFields(): void @@ -287,47 +265,6 @@ new class extends Component { Flux::modal('add-to-playlist')->close(); } - public function saveConfiguration() - { - abort_unless(auth()->user()->plugins->contains($this->plugin), 403); - - foreach ($this->configuration_template['custom_fields'] ?? [] as $field) { - $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; - - if ($field['field_type'] === 'multi_string' && isset($this->multiValues[$fieldKey])) { - // Join the boxes into a CSV string, trimming whitespace and filtering empties - $this->configuration[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey]))); - } - } - $configurationValues = []; - if (isset($this->configuration_template['custom_fields'])) { - foreach ($this->configuration_template['custom_fields'] as $field) { - $fieldKey = $field['keyname']; - if (isset($this->configuration[$fieldKey])) { - $value = $this->configuration[$fieldKey]; - - // For code fields, if the value is a JSON string and the original was an array, decode it - if ($field['field_type'] === 'code' && is_string($value)) { - $decoded = json_decode($value, true); - // If it's valid JSON and decodes to an array/object, use the decoded value - // Otherwise, keep the string as-is - if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || is_object($decoded))) { - $value = $decoded; - } - } - - $configurationValues[$fieldKey] = $value; - } - } - } - - $this->plugin->update([ - 'configuration' => $configurationValues - ]); - - Flux::modal('configuration-modal')->close(); - } - public function getDevicePlaylists($deviceId) { return \App\Models\Playlist::where('device_id', $deviceId)->get(); @@ -433,6 +370,17 @@ HTML; } } + public function duplicatePlugin(): void + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + // Use the model's duplicate method + $newPlugin = $this->plugin->duplicate(auth()->id()); + + // Redirect to the new plugin's detail page + $this->redirect(route('plugins.recipe', ['plugin' => $newPlugin])); + } + public function deletePlugin(): void { abort_unless(auth()->user()->plugins->contains($this->plugin), 403); @@ -440,58 +388,15 @@ HTML; $this->redirect(route('plugins.index')); } - public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void - { - abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + #[On('config-updated')] + public function refreshPlugin() + { + // This pulls the fresh 'configuration' from the DB + // and re-triggers the @if check in the Blade template + $this->plugin = $this->plugin->fresh(); + } - try { - $requestData = []; - if ($query !== null) { - $requestData = [ - 'function' => $fieldKey, - 'query' => $query - ]; - } - - $response = $query !== null - ? Http::post($endpoint, $requestData) - : Http::post($endpoint); - - if ($response->successful()) { - $this->xhrSelectOptions[$fieldKey] = $response->json(); - } else { - $this->xhrSelectOptions[$fieldKey] = []; - } - } catch (\Exception $e) { - $this->xhrSelectOptions[$fieldKey] = []; - } - } - - public function searchXhrSelect(string $fieldKey, string $endpoint): void - { - $query = $this->searchQueries[$fieldKey] ?? ''; - if (!empty($query)) { - $this->loadXhrSelectOptions($fieldKey, $endpoint, $query); - } - } - - public function addMultiItem(string $fieldKey): void - { - $this->multiValues[$fieldKey][] = ''; - } - - public function removeMultiItem(string $fieldKey, int $index): void - { - unset($this->multiValues[$fieldKey][$index]); - - $this->multiValues[$fieldKey] = array_values($this->multiValues[$fieldKey]); - - if (empty($this->multiValues[$fieldKey])) { - $this->multiValues[$fieldKey][] = ''; - } - } } - ?>
@@ -533,6 +438,7 @@ HTML; + Duplicate Plugin Delete Plugin @@ -683,355 +589,7 @@ HTML;
- -
-
- Configuration - Configure your plugin settings -
- -
- @if(isset($configuration_template['custom_fields']) && is_array($configuration_template['custom_fields'])) - @foreach($configuration_template['custom_fields'] as $field) - @php - $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; - $rawValue = $configuration[$fieldKey] ?? ($field['default'] ?? ''); - - # These are sanitized at Model/Plugin level, safe to render HTML - $safeDescription = $field['description'] ?? ''; - $safeHelp = $field['help_text'] ?? ''; - - // For code fields, if the value is an array, JSON encode it - if ($field['field_type'] === 'code' && is_array($rawValue)) { - $currentValue = json_encode($rawValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - } else { - $currentValue = is_array($rawValue) ? '' : (string) $rawValue; - } - @endphp -
- @if($field['field_type'] === 'author_bio') - @continue - @endif - - @if($field['field_type'] === 'copyable_webhook_url') - @continue - @endif - - @if($field['field_type'] === 'string' || $field['field_type'] === 'url') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'text') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'code') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'password') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'copyable') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'time_zone') - - {{ $field['name'] }} - {!! $safeDescription !!} - - - @foreach(timezone_identifiers_list() as $timezone) - - @endforeach - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'number') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'boolean') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'date') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'time') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'select') - @if(isset($field['multiple']) && $field['multiple'] === true) - - {{ $field['name'] }} - {!! $safeDescription !!} - - @if(isset($field['options']) && is_array($field['options'])) - @foreach($field['options'] as $option) - @if(is_array($option)) - @foreach($option as $label => $value) - - @endforeach - @else - @php - $key = mb_strtolower(str_replace(' ', '_', $option)); - @endphp - - @endif - @endforeach - @endif - - {!! $safeHelp !!} - - @else - - {{ $field['name'] }} - {!! $safeDescription !!} - - - @if(isset($field['options']) && is_array($field['options'])) - @foreach($field['options'] as $option) - @if(is_array($option)) - @foreach($option as $label => $value) - - @endforeach - @else - @php - $key = mb_strtolower(str_replace(' ', '_', $option)); - @endphp - - @endif - @endforeach - @endif - - {!! $safeHelp !!} - - @endif - - @elseif($field['field_type'] === 'xhrSelect') - - {{ $field['name'] }} - {!! $safeDescription !!} - - - @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) - @foreach($xhrSelectOptions[$fieldKey] as $option) - @if(is_array($option)) - @if(isset($option['id']) && isset($option['name'])) - {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} - - @else - {{-- xhrSelect format: { 'Braves' => 123 } --}} - @foreach($option as $label => $value) - - @endforeach - @endif - @else - - @endif - @endforeach - @endif - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'xhrSelectSearch') -
- - {{ $field['name'] }} - {!! $safeDescription !!} - - - - - {!! $safeHelp !!} - @if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue)) - - - @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) - @foreach($xhrSelectOptions[$fieldKey] as $option) - @if(is_array($option)) - @if(isset($option['id']) && isset($option['name'])) - {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} - - @else - {{-- xhrSelect format: { 'Braves' => 123 } --}} - @foreach($option as $label => $value) - - @endforeach - @endif - @else - - @endif - @endforeach - @endif - @if(!empty($currentValue) && (!isset($xhrSelectOptions[$fieldKey]) || empty($xhrSelectOptions[$fieldKey]))) - {{-- Show current value even if no options are loaded --}} - - @endif - - @endif -
- @elseif($field['field_type'] === 'multi_string') - - {{ $field['name'] }} - {!! $safeDescription !!} - -
- @foreach($multiValues[$fieldKey] as $index => $item) -
- - - - @if(count($multiValues[$fieldKey]) > 1) - - @endif -
- @endforeach - - - Add Item - -
- - - {!! $safeHelp !!} -
- {{-- @elseif($field['field_type'] === 'multi_string') - - {{ $field['name'] }} - {!! $safeDescription !!} - - - {{-- --}} - - {{-- {!! $safeHelp !!} - --}} - @else - Field type "{{ $field['field_type'] }}" not yet supported - @endif -
- @endforeach - @endif - -
- - Save Configuration -
-
-
-
+

Settings

diff --git a/tests/Feature/Livewire/Plugins/ConfigModalTest.php b/tests/Feature/Livewire/Plugins/ConfigModalTest.php new file mode 100644 index 0000000..4372991 --- /dev/null +++ b/tests/Feature/Livewire/Plugins/ConfigModalTest.php @@ -0,0 +1,124 @@ +create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static', + 'configuration_template' => [ + 'custom_fields' => [[ + 'keyname' => 'tags', + 'field_type' => 'multi_string', + 'name' => 'Reading Days', + 'default' => 'alpha,beta', + ]] + ], + 'configuration' => ['tags' => 'alpha,beta'] + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->assertSet('multiValues.tags', ['alpha', 'beta']); +}); + +test('config modal validates against commas in multi_string boxes', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static', + 'configuration_template' => [ + 'custom_fields' => [[ + 'keyname' => 'tags', + 'field_type' => 'multi_string', + 'name' => 'Reading Days', + ]] + ] + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->set('multiValues.tags.0', 'no,commas,allowed') + ->call('saveConfiguration') + ->assertHasErrors(['multiValues.tags.0' => 'regex']); + + // Assert DB remains unchanged + expect($plugin->fresh()->configuration['tags'] ?? '')->not->toBe('no,commas,allowed'); +}); + +test('config modal merges multi_string boxes into a single CSV string on save', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static', + 'configuration_template' => [ + 'custom_fields' => [[ + 'keyname' => 'items', + 'field_type' => 'multi_string', + 'name' => 'Reading Days', + ]] + ], + 'configuration' => [] + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->set('multiValues.items.0', 'First') + ->call('addMultiItem', 'items') + ->set('multiValues.items.1', 'Second') + ->call('saveConfiguration') + ->assertHasNoErrors(); + + expect($plugin->fresh()->configuration['items'])->toBe('First,Second'); +}); + +test('config modal resetForm clears dirty state and increments resetIndex', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static', + 'configuration' => ['simple_key' => 'original_value'] + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->set('configuration.simple_key', 'dirty_value') + ->call('resetForm') + ->assertSet('configuration.simple_key', 'original_value') + ->assertSet('resetIndex', 1); +}); + +test('config modal dispatches update event for parent warning refresh', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static' + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->call('saveConfiguration') + ->assertDispatched('config-updated'); +}); diff --git a/tests/Feature/PluginImportTest.php b/tests/Feature/PluginImportTest.php index 1b20f93..fae28a8 100644 --- a/tests/Feature/PluginImportTest.php +++ b/tests/Feature/PluginImportTest.php @@ -427,6 +427,65 @@ YAML; ->and($displayIncidentField['default'])->toBe('true'); }); +it('throws exception when multi_string default value contains a comma', function (): void { + $user = User::factory()->create(); + + // YAML with a comma in the 'default' field of a multi_string + $invalidYaml = << $invalidYaml, + 'src/full.liquid' => getValidFullLiquid(), + ]); + + $zipFile = UploadedFile::fake()->createWithContent('invalid-default.zip', $zipContent); + $pluginImportService = new PluginImportService(); + + expect(fn () => $pluginImportService->importFromZip($zipFile, $user)) + ->toThrow(Exception::class, "Validation Error: The default value for multistring fields like `api_key` cannot contain commas."); +}); + +it('throws exception when multi_string placeholder contains a comma', function (): void { + $user = User::factory()->create(); + + // YAML with a comma in the 'placeholder' field + $invalidYaml = << $invalidYaml, + 'src/full.liquid' => getValidFullLiquid(), + ]); + + $zipFile = UploadedFile::fake()->createWithContent('invalid-placeholder.zip', $zipContent); + $pluginImportService = new PluginImportService(); + + expect(fn () => $pluginImportService->importFromZip($zipFile, $user)) + ->toThrow(Exception::class, "Validation Error: The placeholder value for multistring fields like `api_key` cannot contain commas."); +}); + // Helper methods function createMockZipFile(array $files): string { diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php index 0847e36..2771f81 100644 --- a/tests/Unit/Models/PluginTest.php +++ b/tests/Unit/Models/PluginTest.php @@ -737,3 +737,175 @@ test('plugin model preserves multi_string csv format', function (): void { expect($plugin->fresh()->configuration['tags'])->toBe('laravel,pest,security'); }); + +test('plugin duplicate copies all attributes except id and uuid', function (): void { + $user = User::factory()->create(); + + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Original Plugin', + 'data_stale_minutes' => 30, + 'data_strategy' => 'polling', + 'polling_url' => 'https://api.example.com/data', + 'polling_verb' => 'get', + 'polling_header' => 'Authorization: Bearer token123', + 'polling_body' => '{"query": "test"}', + 'render_markup' => '
Test markup
', + 'markup_language' => 'blade', + 'configuration' => ['api_key' => 'secret123'], + 'configuration_template' => [ + 'custom_fields' => [ + [ + 'keyname' => 'api_key', + 'field_type' => 'string', + ], + ], + ], + 'no_bleed' => true, + 'dark_mode' => true, + 'data_payload' => ['test' => 'data'], + ]); + + $duplicate = $original->duplicate(); + + // Refresh to ensure casts are applied + $original->refresh(); + $duplicate->refresh(); + + expect($duplicate->id)->not->toBe($original->id) + ->and($duplicate->uuid)->not->toBe($original->uuid) + ->and($duplicate->name)->toBe('Original Plugin (Copy)') + ->and($duplicate->user_id)->toBe($original->user_id) + ->and($duplicate->data_stale_minutes)->toBe($original->data_stale_minutes) + ->and($duplicate->data_strategy)->toBe($original->data_strategy) + ->and($duplicate->polling_url)->toBe($original->polling_url) + ->and($duplicate->polling_verb)->toBe($original->polling_verb) + ->and($duplicate->polling_header)->toBe($original->polling_header) + ->and($duplicate->polling_body)->toBe($original->polling_body) + ->and($duplicate->render_markup)->toBe($original->render_markup) + ->and($duplicate->markup_language)->toBe($original->markup_language) + ->and($duplicate->configuration)->toBe($original->configuration) + ->and($duplicate->configuration_template)->toBe($original->configuration_template) + ->and($duplicate->no_bleed)->toBe($original->no_bleed) + ->and($duplicate->dark_mode)->toBe($original->dark_mode) + ->and($duplicate->data_payload)->toBe($original->data_payload) + ->and($duplicate->render_markup_view)->toBeNull(); +}); + +test('plugin duplicate copies render_markup_view file content to render_markup', function (): void { + $user = User::factory()->create(); + + // Create a test blade file + $testViewPath = resource_path('views/recipes/test-duplicate.blade.php'); + $testContent = '
Test Content
'; + + // Ensure directory exists + if (! is_dir(dirname($testViewPath))) { + mkdir(dirname($testViewPath), 0755, true); + } + + file_put_contents($testViewPath, $testContent); + + try { + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'View Plugin', + 'render_markup' => null, + 'render_markup_view' => 'recipes.test-duplicate', + 'markup_language' => null, + ]); + + $duplicate = $original->duplicate(); + + expect($duplicate->render_markup)->toBe($testContent) + ->and($duplicate->markup_language)->toBe('blade') + ->and($duplicate->render_markup_view)->toBeNull() + ->and($duplicate->name)->toBe('View Plugin (Copy)'); + } finally { + // Clean up test file + if (file_exists($testViewPath)) { + unlink($testViewPath); + } + } +}); + +test('plugin duplicate handles liquid file extension', function (): void { + $user = User::factory()->create(); + + // Create a test liquid file + $testViewPath = resource_path('views/recipes/test-duplicate-liquid.liquid'); + $testContent = '
{{ data.message }}
'; + + // Ensure directory exists + if (! is_dir(dirname($testViewPath))) { + mkdir(dirname($testViewPath), 0755, true); + } + + file_put_contents($testViewPath, $testContent); + + try { + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Liquid Plugin', + 'render_markup' => null, + 'render_markup_view' => 'recipes.test-duplicate-liquid', + 'markup_language' => null, + ]); + + $duplicate = $original->duplicate(); + + expect($duplicate->render_markup)->toBe($testContent) + ->and($duplicate->markup_language)->toBe('liquid') + ->and($duplicate->render_markup_view)->toBeNull(); + } finally { + // Clean up test file + if (file_exists($testViewPath)) { + unlink($testViewPath); + } + } +}); + +test('plugin duplicate handles missing view file gracefully', function (): void { + $user = User::factory()->create(); + + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Missing View Plugin', + 'render_markup' => null, + 'render_markup_view' => 'recipes.nonexistent-view', + 'markup_language' => null, + ]); + + $duplicate = $original->duplicate(); + + expect($duplicate->render_markup_view)->toBeNull() + ->and($duplicate->name)->toBe('Missing View Plugin (Copy)'); +}); + +test('plugin duplicate uses provided user_id', function (): void { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $original = Plugin::factory()->create([ + 'user_id' => $user1->id, + 'name' => 'Original Plugin', + ]); + + $duplicate = $original->duplicate($user2->id); + + expect($duplicate->user_id)->toBe($user2->id) + ->and($duplicate->user_id)->not->toBe($original->user_id); +}); + +test('plugin duplicate falls back to original user_id when no user_id provided', function (): void { + $user = User::factory()->create(); + + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Original Plugin', + ]); + + $duplicate = $original->duplicate(); + + expect($duplicate->user_id)->toBe($original->user_id); +});