From 989ad2e9858ca674cb5b1b278b286ded547e76b7 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 22 Aug 2025 21:09:12 +0200 Subject: [PATCH 1/5] feat: add trmnlp support --- app/Services/PluginExportService.php | 186 +++++++++++ app/Services/PluginImportService.php | 62 ++-- composer.lock | 66 ++-- ..._22_231823_add_trmnlp_to_plugins_table.php | 28 ++ routes/api.php | 46 +++ routes/web.php | 10 + .../Feature/Api/PluginSettingsArchiveTest.php | 73 +++++ tests/Feature/PluginArchiveTest.php | 308 ++++++++++++++++++ 8 files changed, 718 insertions(+), 61 deletions(-) create mode 100644 app/Services/PluginExportService.php create mode 100644 database/migrations/2025_08_22_231823_add_trmnlp_to_plugins_table.php create mode 100644 tests/Feature/Api/PluginSettingsArchiveTest.php create mode 100644 tests/Feature/PluginArchiveTest.php diff --git a/app/Services/PluginExportService.php b/app/Services/PluginExportService.php new file mode 100644 index 0000000..3c2c3d0 --- /dev/null +++ b/app/Services/PluginExportService.php @@ -0,0 +1,186 @@ +generateSettingsYaml($plugin); + $settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + File::put($tempDir.'/settings.yml', $settingsYaml); + + // Generate full template content + $fullTemplate = $this->generateFullTemplate($plugin); + $extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php'; + File::put($tempDir.'/full.'.$extension, $fullTemplate); + + // Generate shared.liquid if needed (for liquid templates) + if ($plugin->markup_language === 'liquid') { + $sharedTemplate = $this->generateSharedTemplate($plugin); + if ($sharedTemplate) { + File::put($tempDir.'/shared.liquid', $sharedTemplate); + } + } + + // Create ZIP file + $zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip'; + $zip = new ZipArchive(); + + if ($zip->open($zipPath, ZipArchive::CREATE) !== true) { + throw new Exception('Could not create ZIP file.'); + } + + // Add files directly to ZIP root + $this->addDirectoryToZip($zip, $tempDir, ''); + $zip->close(); + + // Return the ZIP file as a download response + return response()->download($zipPath, 'plugin_'.$plugin->trmnlp_id.'.zip'); + + } catch (Exception $e) { + throw $e; + } + } + + /** + * Generate the settings.yml content for the plugin + */ + private function generateSettingsYaml(Plugin $plugin): array + { + $settings = []; + + // Add fields in the specific order requested + $settings['name'] = $plugin->name; + $settings['no_screen_padding'] = 'no'; // Default value + $settings['dark_mode'] = 'no'; // Default value + $settings['strategy'] = $plugin->data_strategy; + + // Add static data if available + if ($plugin->data_payload) { + $settings['static_data'] = json_encode($plugin->data_payload, JSON_PRETTY_PRINT); + } + + // Add polling configuration if applicable + if ($plugin->data_strategy === 'polling') { + if ($plugin->polling_verb) { + $settings['polling_verb'] = $plugin->polling_verb; + } + if ($plugin->polling_url) { + $settings['polling_url'] = $plugin->polling_url; + } + if ($plugin->polling_header) { + // Convert header format from "key: value" to "key=value" + $settings['polling_headers'] = str_replace(':', '=', $plugin->polling_header); + } + if ($plugin->polling_body) { + $settings['polling_body'] = $plugin->polling_body; + } + } + + $settings['refresh_interval'] = $plugin->data_stale_minutes; + $settings['id'] = $plugin->trmnlp_id; + + // Add custom fields from configuration template + if (isset($plugin->configuration_template['custom_fields'])) { + $settings['custom_fields'] = $plugin->configuration_template['custom_fields']; + } + + return $settings; + } + + /** + * Generate the full template content + */ + private function generateFullTemplate(Plugin $plugin): string + { + $markup = $plugin->render_markup; + + // Remove the wrapper div if it exists (it will be added during import) + $markup = preg_replace('/^
\s*/', '', $markup); + $markup = preg_replace('/\s*<\/div>\s*$/', '', $markup); + + return trim($markup); + } + + /** + * Generate the shared template content (for liquid templates) + */ + private function generateSharedTemplate(Plugin $plugin) + { + // For now, we don't have a way to store shared templates separately + // TODO - add support for shared templates + return null; + } + + /** + * Add a directory and its contents to a ZIP file + */ + private function addDirectoryToZip(ZipArchive $zip, string $dirPath, string $zipPath): void + { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dirPath), + RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($files as $file) { + if (! $file->isDir()) { + $filePath = $file->getRealPath(); + $fileName = basename($filePath); + + // For root directory, just use the filename + if ($zipPath === '') { + $relativePath = $fileName; + } else { + $relativePath = $zipPath.'/'.mb_substr($filePath, mb_strlen($dirPath) + 1); + } + + $zip->addFile($filePath, $relativePath); + } + } + } +} diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index 9cf3d76..ff52bf4 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use Symfony\Component\Uid\Uuid; use Symfony\Component\Yaml\Yaml; use ZipArchive; @@ -82,34 +83,45 @@ class PluginImportService 'custom_fields' => $settings['custom_fields'], ]; - // Extract default values from custom_fields and populate configuration - $configuration = []; - if (isset($settings['custom_fields']) && is_array($settings['custom_fields'])) { + $plugin_updated = 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, + 'name' => $settings['name'] ?? 'Imported Plugin', + 'trmnlp_id' => $settings['id'] ?? Uuid::v7(), + 'data_stale_minutes' => $settings['refresh_interval'] ?? 15, + 'data_strategy' => $settings['strategy'] ?? 'static', + 'polling_url' => $settings['polling_url'] ?? null, + 'polling_verb' => $settings['polling_verb'] ?? 'get', + 'polling_header' => isset($settings['polling_headers']) + ? str_replace('=', ':', $settings['polling_headers']) + : null, + 'polling_body' => $settings['polling_body'] ?? null, + 'markup_language' => $markupLanguage, + 'render_markup' => $fullLiquid, + 'configuration_template' => $configurationTemplate, + 'data_payload' => json_decode($settings['static_data'] ?? '{}', true), + ]); + + if (! $plugin_updated) { + // Extract default values from custom_fields and populate configuration + $configuration = []; foreach ($settings['custom_fields'] as $field) { if (isset($field['keyname']) && isset($field['default'])) { $configuration[$field['keyname']] = $field['default']; } } + // set only if plugin is new + $plugin->update([ + 'configuration' => $configuration, + ]); } - - // Create a new plugin - $plugin = Plugin::create([ - 'user_id' => $user->id, - 'name' => $settings['name'] ?? 'Imported Plugin', - 'data_stale_minutes' => $settings['refresh_interval'] ?? 15, - 'data_strategy' => $settings['strategy'] ?? 'static', - 'polling_url' => $settings['polling_url'] ?? null, - 'polling_verb' => $settings['polling_verb'] ?? 'get', - 'polling_header' => isset($settings['polling_headers']) - ? str_replace('=', ':', $settings['polling_headers']) - : null, - 'polling_body' => $settings['polling_body'] ?? null, - 'markup_language' => $markupLanguage, - 'render_markup' => $fullLiquid, - 'configuration_template' => $configurationTemplate, - 'configuration' => $configuration, - 'data_payload' => json_decode($settings['static_data'] ?? '{}', true), - ]); + $plugin['trmnlp_yaml'] = $settingsYaml; return $plugin; @@ -119,12 +131,6 @@ class PluginImportService } } - /** - * Find required files in the extracted ZIP directory - * - * @param string $tempDir The temporary directory path - * @return array Array containing paths to required files - */ private function findRequiredFiles(string $tempDir): array { $settingsYamlPath = null; diff --git a/composer.lock b/composer.lock index e53543f..58435b1 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.356.2", + "version": "3.356.3", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "d4c61078d5f371c9a8a844946e96e12d6fa18080" + "reference": "9c61b26408664c76d51101381bb64feda9bcfe2e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d4c61078d5f371c9a8a844946e96e12d6fa18080", - "reference": "d4c61078d5f371c9a8a844946e96e12d6fa18080", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/9c61b26408664c76d51101381bb64feda9bcfe2e", + "reference": "9c61b26408664c76d51101381bb64feda9bcfe2e", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.356.2" + "source": "https://github.com/aws/aws-sdk-php/tree/3.356.3" }, - "time": "2025-08-21T18:09:26+00:00" + "time": "2025-08-22T18:13:45+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", @@ -942,22 +942,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.9.3", + "version": "7.10.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", - "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0.3", - "guzzlehttp/psr7": "^2.7.0", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" @@ -1048,7 +1048,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.9.3" + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" }, "funding": [ { @@ -1064,7 +1064,7 @@ "type": "tidelift" } ], - "time": "2025-03-27T13:37:11+00:00" + "time": "2025-08-23T22:36:01+00:00" }, { "name": "guzzlehttp/promises", @@ -1151,16 +1151,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" + "reference": "21dc724a0583619cd1652f673303492272778051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", "shasum": "" }, "require": { @@ -1176,7 +1176,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -1247,7 +1247,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.7.1" + "source": "https://github.com/guzzle/psr7/tree/2.8.0" }, "funding": [ { @@ -1263,7 +1263,7 @@ "type": "tidelift" } ], - "time": "2025-03-27T12:30:47+00:00" + "time": "2025-08-23T21:21:41+00:00" }, { "name": "guzzlehttp/uri-template", @@ -2857,16 +2857,16 @@ }, { "name": "livewire/flux", - "version": "v2.2.5", + "version": "v2.2.4", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", - "reference": "2295f14766f86006d889f37ee208bcf347a0d013" + "reference": "af81b5fd34c6490d5b5e05ed0f8140c0250e5069" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/2295f14766f86006d889f37ee208bcf347a0d013", - "reference": "2295f14766f86006d889f37ee208bcf347a0d013", + "url": "https://api.github.com/repos/livewire/flux/zipball/af81b5fd34c6490d5b5e05ed0f8140c0250e5069", + "reference": "af81b5fd34c6490d5b5e05ed0f8140c0250e5069", "shasum": "" }, "require": { @@ -2914,9 +2914,9 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.2.5" + "source": "https://github.com/livewire/flux/tree/v2.2.4" }, - "time": "2025-08-19T22:41:54+00:00" + "time": "2025-08-09T01:46:51+00:00" }, { "name": "livewire/livewire", @@ -8371,16 +8371,16 @@ }, { "name": "larastan/larastan", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "6431d010dd383a9279eb8874a76ddb571738564a" + "reference": "3c223047e374befd1b64959784685d6ecccf66aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/6431d010dd383a9279eb8874a76ddb571738564a", - "reference": "6431d010dd383a9279eb8874a76ddb571738564a", + "url": "https://api.github.com/repos/larastan/larastan/zipball/3c223047e374befd1b64959784685d6ecccf66aa", + "reference": "3c223047e374befd1b64959784685d6ecccf66aa", "shasum": "" }, "require": { @@ -8448,7 +8448,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.6.0" + "source": "https://github.com/larastan/larastan/tree/v3.6.1" }, "funding": [ { @@ -8456,7 +8456,7 @@ "type": "github" } ], - "time": "2025-07-11T06:52:52+00:00" + "time": "2025-08-25T07:24:56+00:00" }, { "name": "laravel/boost", diff --git a/database/migrations/2025_08_22_231823_add_trmnlp_to_plugins_table.php b/database/migrations/2025_08_22_231823_add_trmnlp_to_plugins_table.php new file mode 100644 index 0000000..4c90d29 --- /dev/null +++ b/database/migrations/2025_08_22_231823_add_trmnlp_to_plugins_table.php @@ -0,0 +1,28 @@ +string('trmnlp_id')->nullable()->after('uuid'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('trmnlp_id'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 2881796..435ee7d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,7 @@ use App\Models\DeviceModel; use App\Models\Plugin; use App\Models\User; use App\Services\ImageGenerationService; +use App\Services\PluginImportService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Log; @@ -511,3 +512,48 @@ Route::post('custom_plugins/{plugin_uuid}', function (string $plugin_uuid) { return response()->json(['message' => 'Data updated successfully']); })->name('api.custom_plugins.webhook'); + +Route::get('plugin_settings/{trmnlp_id}/archive', function (Request $request, string $trmnlp_id) { + if (! $trmnlp_id || trim($trmnlp_id) === '') { + return response()->json([ + 'message' => 'trmnlp_id is required', + ], 400); + } + + // Find the plugin by trmnlp_id and ensure it belongs to the authenticated user + $plugin = Plugin::where('trmnlp_id', $trmnlp_id) + ->where('user_id', auth()->user()->id) + ->firstOrFail(); + + // Use the export service to create the ZIP file + /** @var App\Services\PluginExportService $exporter */ + $exporter = app(App\Services\PluginExportService::class); + + return $exporter->exportToZip($plugin, auth()->user()); +})->middleware('auth:sanctum'); + +Route::post('plugin_settings/{trmnlp_id}/archive', function (Request $request, string $trmnlp_id) { + if (! $trmnlp_id) { + return response()->json([ + 'message' => 'trmnl_id is required', + ]); + } + + $validated = $request->validate([ + 'file' => 'required|file|mimes:zip', + ]); + + /** @var Illuminate\Http\UploadedFile $file */ + $file = $request->file('file'); + // Apply archive to existing plugin using the import service + /** @var PluginImportService $importer */ + $importer = app(PluginImportService::class); + $plugin = $importer->importFromZip($file, auth()->user()); + + return response()->json([ + 'message' => 'Plugin settings archive processed successfully', + 'data' => [ + 'settings_yaml' => $plugin['trmnlp_yaml'], + ], + ]); +})->middleware('auth:sanctum'); diff --git a/routes/web.php b/routes/web.php index 3be1c66..e6afc1a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,7 @@ group(function () { Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup'); Volt::route('plugins/api', 'plugins.api')->name('plugins.api'); Volt::route('playlists', 'playlists.index')->name('playlists.index'); + + Route::get('plugin_settings/{trmnlp_id}/edit', function (Request $request, string $trmnlp_id) { + $plugin = Plugin::query() + ->where('user_id', $request->user()->id) + ->where('trmnlp_id', $trmnlp_id)->firstOrFail(); + + return redirect()->route('plugins.recipe', ['plugin' => $plugin]); + }); }); require __DIR__.'/auth.php'; diff --git a/tests/Feature/Api/PluginSettingsArchiveTest.php b/tests/Feature/Api/PluginSettingsArchiveTest.php new file mode 100644 index 0000000..517f2f8 --- /dev/null +++ b/tests/Feature/Api/PluginSettingsArchiveTest.php @@ -0,0 +1,73 @@ +create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'uuid' => (string) Str::uuid(), + ]); + + // Authenticate via Sanctum (endpoint requires auth:sanctum) + Sanctum::actingAs($user); + + // Build a temporary ZIP with required structure: src/settings.yml and src/full.liquid + $tempDir = sys_get_temp_dir().'/trmnl_zip_'.uniqid(); + $srcDir = $tempDir.'/src'; + if (! is_dir($srcDir)) { + mkdir($srcDir, 0777, true); + } + + $settingsYaml = <<<'YAML' +name: Sample Imported +strategy: static +refresh_interval: 10 +custom_fields: + - keyname: title + default: "Hello" +static_data: '{"message":"world"}' +YAML; + + $fullLiquid = <<<'LIQUID' +

{{ config.title }}

+
{{ data.message }}
+LIQUID; + + file_put_contents($srcDir.'/settings.yml', $settingsYaml); + file_put_contents($srcDir.'/full.liquid', $fullLiquid); + + $zipPath = sys_get_temp_dir().'/plugin_'.uniqid().'.zip'; + $zip = new ZipArchive(); + $zip->open($zipPath, ZipArchive::CREATE); + $zip->addFile($srcDir.'/settings.yml', 'src/settings.yml'); + $zip->addFile($srcDir.'/full.liquid', 'src/full.liquid'); + $zip->close(); + + // Prepare UploadedFile + $uploaded = new UploadedFile($zipPath, 'plugin.zip', 'application/zip', null, true); + + // Make request (multipart form-data) + $response = $this->post('/api/plugin_settings/'.$plugin->uuid.'/archive', [ + 'file' => $uploaded, + ], ['Accept' => 'application/json']); + + $response->assertSuccessful(); + + $imported = Plugin::query() + ->where('user_id', $user->id) + ->where('name', 'Sample Imported') + ->first(); + + expect($imported)->not->toBeNull(); + expect($imported->markup_language)->toBe('liquid'); + expect($imported->render_markup)->toContain('

{{ config.title }}

'); + // Configuration should have default for title (set on create) + expect($imported->configuration['title'] ?? null)->toBe('Hello'); +}); diff --git a/tests/Feature/PluginArchiveTest.php b/tests/Feature/PluginArchiveTest.php new file mode 100644 index 0000000..34014ec --- /dev/null +++ b/tests/Feature/PluginArchiveTest.php @@ -0,0 +1,308 @@ +create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'trmnlp_id' => 'test-plugin-123', + 'data_stale_minutes' => 30, + 'data_strategy' => 'static', + 'markup_language' => 'liquid', + 'render_markup' => '
Hello {{ config.name }}
', + 'configuration_template' => [ + 'custom_fields' => [ + [ + 'keyname' => 'name', + 'field_type' => 'text', + 'default' => 'World', + ], + ], + ], + 'data_payload' => ['message' => 'Hello World'], + ]); + + $exporter = app(PluginExportService::class); + $response = $exporter->exportToZip($plugin, $user); + + expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class); + expect($response->getFile()->getFilename())->toContain('test-plugin-123.zip'); +}); + +it('exports plugin with polling configuration', function () { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Polling Plugin', + 'trmnlp_id' => 'polling-plugin-456', + 'data_strategy' => 'polling', + 'polling_url' => 'https://api.example.com/data', + 'polling_verb' => 'post', + 'polling_header' => 'Authorization: Bearer token', + 'polling_body' => '{"key": "value"}', + 'markup_language' => 'blade', + 'render_markup' => '
Hello {{ $config["name"] }}
', + ]); + + $exporter = app(PluginExportService::class); + $response = $exporter->exportToZip($plugin, $user); + + expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class); +}); + +it('exports and imports plugin maintaining all data', function () { + $user = User::factory()->create(); + $originalPlugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Round Trip Plugin', + 'trmnlp_id' => 'round-trip-789', + 'data_stale_minutes' => 45, + 'data_strategy' => 'static', + 'markup_language' => 'liquid', + 'render_markup' => '
Hello {{ config.name }}!
', + 'configuration_template' => [ + 'custom_fields' => [ + [ + 'keyname' => 'name', + 'field_type' => 'text', + 'default' => 'Test User', + ], + [ + 'keyname' => 'color', + 'field_type' => 'select', + 'default' => 'blue', + 'options' => ['red', 'green', 'blue'], + ], + ], + ], + 'data_payload' => ['items' => [1, 2, 3]], + ]); + + // Export the plugin + $exporter = app(PluginExportService::class); + $exportResponse = $exporter->exportToZip($originalPlugin, $user); + + // Get the exported file path + $exportedFilePath = $exportResponse->getFile()->getPathname(); + + // Create an UploadedFile from the exported ZIP + $uploadedFile = new UploadedFile( + $exportedFilePath, + 'plugin_round-trip-789.zip', + 'application/zip', + null, + true + ); + + // Import the plugin back + $importer = app(PluginImportService::class); + $importedPlugin = $importer->importFromZip($uploadedFile, $user); + + // Verify the imported plugin has the same data + expect($importedPlugin->name)->toBe('Round Trip Plugin'); + expect($importedPlugin->trmnlp_id)->toBe('round-trip-789'); + expect($importedPlugin->data_stale_minutes)->toBe(45); + expect($importedPlugin->data_strategy)->toBe('static'); + expect($importedPlugin->markup_language)->toBe('liquid'); + expect($importedPlugin->render_markup)->toContain('Hello {{ config.name }}!'); + expect($importedPlugin->configuration_template['custom_fields'])->toHaveCount(2); + expect($importedPlugin->data_payload)->toBe(['items' => [1, 2, 3]]); +}); + +it('handles blade templates correctly', function () { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Blade Plugin', + 'trmnlp_id' => 'blade-plugin-101', + 'markup_language' => 'blade', + 'render_markup' => '
Hello {{ $config["name"] }}!
', + ]); + + $exporter = app(PluginExportService::class); + $response = $exporter->exportToZip($plugin, $user); + + expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class); +}); + +it('removes wrapper div from exported markup', function () { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Wrapped Plugin', + 'trmnlp_id' => 'wrapped-plugin-202', + 'markup_language' => 'liquid', + 'render_markup' => '
Hello World
', + ]); + + $exporter = app(PluginExportService::class); + $response = $exporter->exportToZip($plugin, $user); + + expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class); +}); + +it('converts polling headers correctly', function () { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Header Plugin', + 'trmnlp_id' => 'header-plugin-303', + 'data_strategy' => 'polling', + 'polling_header' => 'Authorization: Bearer token', + ]); + + $exporter = app(PluginExportService::class); + $response = $exporter->exportToZip($plugin, $user); + + expect($response)->toBeInstanceOf(Symfony\Component\HttpFoundation\BinaryFileResponse::class); +}); + +it('api route returns zip file for authenticated user', function () { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'API Test Plugin', + 'trmnlp_id' => 'api-test-404', + 'markup_language' => 'liquid', + 'render_markup' => '
API Test
', + ]); + + $response = $this->actingAs($user) + ->getJson("/api/plugin_settings/{$plugin->trmnlp_id}/archive"); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'application/zip'); + $response->assertHeader('Content-Disposition', 'attachment; filename=plugin_api-test-404.zip'); +}); + +it('api route returns 404 for non-existent plugin', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->getJson('/api/plugin_settings/non-existent-id/archive'); + + $response->assertStatus(404); +}); + +it('api route returns 401 for unauthenticated user', function () { + $response = $this->getJson('/api/plugin_settings/test-id/archive'); + + $response->assertStatus(401); +}); + +it('api route returns 404 for plugin belonging to different user', function () { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user1->id, + 'trmnlp_id' => 'other-user-plugin', + ]); + + $response = $this->actingAs($user2) + ->getJson("/api/plugin_settings/{$plugin->trmnlp_id}/archive"); + + $response->assertStatus(404); +}); + +it('exports zip with files in root directory', function () { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Root Directory Test', + 'trmnlp_id' => 'root-test-123', + 'markup_language' => 'liquid', + 'render_markup' => '
Test content
', + ]); + + $exporter = app(PluginExportService::class); + $response = $exporter->exportToZip($plugin, $user); + + $zipPath = $response->getFile()->getPathname(); + $zip = new ZipArchive(); + $zip->open($zipPath); + + // Check that files are in the root, not in src/ + expect($zip->locateName('settings.yml'))->not->toBeFalse(); + expect($zip->locateName('full.liquid'))->not->toBeFalse(); + expect($zip->locateName('src/settings.yml'))->toBeFalse(); + expect($zip->locateName('src/full.liquid'))->toBeFalse(); + + $zip->close(); +}); + +it('maintains correct yaml field order', function () { + $user = User::factory()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'YAML Order Test', + 'trmnlp_id' => 'yaml-order-test', + 'data_strategy' => 'polling', + 'polling_url' => 'https://api.example.com/data', + 'polling_verb' => 'post', + 'data_stale_minutes' => 30, + 'markup_language' => 'liquid', + 'render_markup' => '
Test
', + ]); + + $exporter = app(PluginExportService::class); + $response = $exporter->exportToZip($plugin, $user); + + $zipPath = $response->getFile()->getPathname(); + $zip = new ZipArchive(); + $zip->open($zipPath); + + // Extract and read the settings.yml file + $zip->extractTo(sys_get_temp_dir(), 'settings.yml'); + $yamlContent = file_get_contents(sys_get_temp_dir() . '/settings.yml'); + $zip->close(); + + // Check that the YAML content has the expected field order + $expectedOrder = [ + 'name:', + 'no_screen_padding:', + 'dark_mode:', + 'strategy:', + 'static_data:', + 'polling_verb:', + 'polling_url:', + 'refresh_interval:', + 'id:', + 'custom_fields:', + ]; + + $lines = explode("\n", $yamlContent); + $fieldLines = []; + + foreach ($lines as $line) { + $line = trim($line); + if (preg_match('/^([a-zA-Z_]+):/', $line, $matches)) { + $fieldLines[] = $matches[1] . ':'; + } + } + + // Verify that the fields appear in the expected order (allowing for missing optional fields) + $currentIndex = 0; + foreach ($expectedOrder as $expectedField) { + $foundIndex = array_search($expectedField, $fieldLines); + if ($foundIndex !== false) { + expect($foundIndex)->toBeGreaterThanOrEqual($currentIndex); + $currentIndex = $foundIndex; + } + } + + // Clean up + unlink(sys_get_temp_dir() . '/settings.yml'); +}); From e50cbc14ec338f7049fd73676404f41576ec7e5f Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Sun, 24 Aug 2025 21:31:42 +0200 Subject: [PATCH 2/5] feat: add xhrSelect Configuration feat: add xhrSelectSearch Configuration --- .../views/livewire/plugins/recipe.blade.php | 108 +++++++++++++++++ .../PluginRequiredConfigurationTest.php | 114 +++++++++++++----- 2 files changed, 191 insertions(+), 31 deletions(-) diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index 0edee8a..7a73358 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -6,6 +6,7 @@ use Keepsuit\Liquid\Exceptions\LiquidException; use Livewire\Volt\Component; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Http; new class extends Component { public Plugin $plugin; @@ -32,6 +33,8 @@ new class extends Component { public array $mashup_plugins = []; public array $configuration_template = []; public array $configuration = []; + public array $xhrSelectOptions = []; + public array $searchQueries = []; public function mount(): void { @@ -345,6 +348,41 @@ HTML; $this->plugin->delete(); $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); + + 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); + } + } } ?> @@ -644,6 +682,76 @@ HTML; @endif @endif + @elseif($field['field_type'] === 'xhrSelect') + + + @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 + + @elseif($field['field_type'] === 'xhrSelectSearch') +
+ + {{ $field['name'] }} + {{ $field['description'] ?? '' }} + + + + + + @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 +
@else

{{ $field['name'] }}: Field type "{{ $field['field_type'] }}" not yet supported

@endif diff --git a/tests/Feature/PluginRequiredConfigurationTest.php b/tests/Feature/PluginRequiredConfigurationTest.php index 9d378ba..552b996 100644 --- a/tests/Feature/PluginRequiredConfigurationTest.php +++ b/tests/Feature/PluginRequiredConfigurationTest.php @@ -23,17 +23,17 @@ test('hasMissingRequiredConfigurationFields returns true when required field is 'field_type' => 'time_zone', 'name' => 'Timezone', 'description' => 'Select your timezone', - 'optional' => true // Marked as optional - ] - ] + 'optional' => true, // Marked as optional + ], + ], ]; $plugin = Plugin::factory()->create([ 'user_id' => $user->id, 'configuration_template' => $configurationTemplate, 'configuration' => [ - 'timezone' => 'UTC' // Only timezone is set, api_key is missing - ] + 'timezone' => 'UTC', // Only timezone is set, api_key is missing + ], ]); expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); @@ -56,9 +56,9 @@ test('hasMissingRequiredConfigurationFields returns false when all required fiel 'field_type' => 'time_zone', 'name' => 'Timezone', 'description' => 'Select your timezone', - 'optional' => true // Marked as optional - ] - ] + 'optional' => true, // Marked as optional + ], + ], ]; $plugin = Plugin::factory()->create([ @@ -66,8 +66,8 @@ test('hasMissingRequiredConfigurationFields returns false when all required fiel 'configuration_template' => $configurationTemplate, 'configuration' => [ 'api_key' => 'test-api-key', // Required field is set - 'timezone' => 'UTC' - ] + 'timezone' => 'UTC', + ], ]); expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); @@ -79,7 +79,7 @@ test('hasMissingRequiredConfigurationFields returns false when no custom fields $plugin = Plugin::factory()->create([ 'user_id' => $user->id, 'configuration_template' => [], - 'configuration' => [] + 'configuration' => [], ]); expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); @@ -96,16 +96,16 @@ test('hasMissingRequiredConfigurationFields returns true when explicitly require 'name' => 'API Key', 'description' => 'Your API key', // Not marked as optional, so it's required - ] - ] + ], + ], ]; $plugin = Plugin::factory()->create([ 'user_id' => $user->id, 'configuration_template' => $configurationTemplate, 'configuration' => [ - 'api_key' => null // Explicitly set to null - ] + 'api_key' => null, // Explicitly set to null + ], ]); expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); @@ -122,16 +122,16 @@ test('hasMissingRequiredConfigurationFields returns true when required field is 'name' => 'API Key', 'description' => 'Your API key', // Not marked as optional, so it's required - ] - ] + ], + ], ]; $plugin = Plugin::factory()->create([ 'user_id' => $user->id, 'configuration_template' => $configurationTemplate, 'configuration' => [ - 'api_key' => '' // Empty string - ] + 'api_key' => '', // Empty string + ], ]); expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); @@ -149,16 +149,16 @@ test('hasMissingRequiredConfigurationFields returns true when required array fie 'description' => 'Select items', 'multiple' => true, // Not marked as optional, so it's required - ] - ] + ], + ], ]; $plugin = Plugin::factory()->create([ 'user_id' => $user->id, 'configuration_template' => $configurationTemplate, 'configuration' => [ - 'selected_items' => [] // Empty array - ] + 'selected_items' => [], // Empty array + ], ]); expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); @@ -178,16 +178,16 @@ test('hasMissingRequiredConfigurationFields returns false when author_bio field 'keyname' => 'plugin_field', 'name' => 'Field Name', 'field_type' => 'string', - ] - ] + ], + ], ]; $plugin = Plugin::factory()->create([ 'user_id' => $user->id, 'configuration_template' => $configurationTemplate, 'configuration' => [ - 'plugin_field' => 'set' // Required field is set - ] + 'plugin_field' => 'set', // Required field is set + ], ]); expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); @@ -203,15 +203,67 @@ test('hasMissingRequiredConfigurationFields returns false when field has default 'field_type' => 'string', 'name' => 'API Key', 'description' => 'Your API key', - 'default' => 'default-api-key' // Has default value - ] - ] + 'default' => 'default-api-key', // Has default value + ], + ], ]; $plugin = Plugin::factory()->create([ 'user_id' => $user->id, 'configuration_template' => $configurationTemplate, - 'configuration' => [] // Empty configuration, but field has default + 'configuration' => [], // Empty configuration, but field has default + ]); + + expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); +}); + +test('hasMissingRequiredConfigurationFields returns true when required xhrSelect field is missing', function () { + $user = User::factory()->create(); + + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'team', + 'field_type' => 'xhrSelect', + 'name' => 'Baseball Team', + 'description' => 'Select your team', + 'endpoint' => 'https://usetrmnl.com/custom_plugin_example_xhr_select.json', + // Not marked as optional, so it's required + ], + ], + ]; + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => $configurationTemplate, + 'configuration' => [], // Empty configuration + ]); + + expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue(); +}); + +test('hasMissingRequiredConfigurationFields returns false when required xhrSelect field is set', function () { + $user = User::factory()->create(); + + $configurationTemplate = [ + 'custom_fields' => [ + [ + 'keyname' => 'team', + 'field_type' => 'xhrSelect', + 'name' => 'Baseball Team', + 'description' => 'Select your team', + 'endpoint' => 'https://usetrmnl.com/custom_plugin_example_xhr_select.json', + // Not marked as optional, so it's required + ], + ], + ]; + + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'configuration_template' => $configurationTemplate, + 'configuration' => [ + 'team' => '123', // Required field is set + ], ]); expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse(); From 6cd00943a1beea8c5128d091c8342be4dc4c745c Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 25 Aug 2025 14:26:01 +0200 Subject: [PATCH 3/5] fix: wrap view around full + shared view --- app/Services/PluginImportService.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index ff52bf4..dbd8ec8 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -59,7 +59,6 @@ class PluginImportService // Read full.liquid content $fullLiquid = File::get($filePaths['fullLiquidPath']); - $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; // Prepend shared.liquid content if available if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { @@ -67,6 +66,8 @@ class PluginImportService $fullLiquid = $sharedLiquid."\n".$fullLiquid; } + $fullLiquid = '
'."\n".$fullLiquid."\n".'
'; + // Check if the file ends with .liquid to set markup language $markupLanguage = 'blade'; if (pathinfo($filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') { From f4f8ab518133263faa38dbb57048375fcb9e7be0 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 25 Aug 2025 14:33:26 +0200 Subject: [PATCH 4/5] feat: add Liquid filter 'find_by' --- app/Liquid/Filters/Data.php | 20 ++++++ tests/Feature/PluginInlineTemplatesTest.php | 56 +++++++++++++++ tests/Unit/Liquid/Filters/DataTest.php | 80 +++++++++++++++++++++ tests/Unit/Liquid/InlineTemplatesTest.php | 2 + 4 files changed, 158 insertions(+) diff --git a/app/Liquid/Filters/Data.php b/app/Liquid/Filters/Data.php index 5b1f92f..61343b2 100644 --- a/app/Liquid/Filters/Data.php +++ b/app/Liquid/Filters/Data.php @@ -19,4 +19,24 @@ class Data extends FiltersProvider { return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } + + /** + * Find an object in a collection by a specific key-value pair + * + * @param array $collection The collection to search in + * @param string $key The key to search for + * @param mixed $value The value to match + * @param mixed $fallback Optional fallback value if no match is found + * @return mixed The matching object or fallback value + */ + public function find_by(array $collection, string $key, mixed $value, mixed $fallback = null): mixed + { + foreach ($collection as $item) { + if (is_array($item) && isset($item[$key]) && $item[$key] === $value) { + return $item; + } + } + + return $fallback; + } } diff --git a/tests/Feature/PluginInlineTemplatesTest.php b/tests/Feature/PluginInlineTemplatesTest.php index ef2b96d..7451d93 100644 --- a/tests/Feature/PluginInlineTemplatesTest.php +++ b/tests/Feature/PluginInlineTemplatesTest.php @@ -172,4 +172,60 @@ LIQUID $this->assertStringContainsString('This is a test', $result); $this->assertStringContainsString('class="simple"', $result); } + + public function test_plugin_with_find_by_filter(): void + { + $plugin = Plugin::factory()->create([ + 'markup_language' => 'liquid', + 'render_markup' => <<<'LIQUID' +{% template user_info %} +
+

{{ user.name }}

+

Age: {{ user.age }}

+
+{% endtemplate %} + +{% assign found_user = collection | find_by: 'name', 'Ryan' %} +{% render "user_info", user: found_user %} +LIQUID + , + 'data_payload' => [ + 'collection' => [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ], + ], + ]); + + $result = $plugin->render('full'); + + // Should render the user info for Ryan + $this->assertStringContainsString('Ryan', $result); + $this->assertStringContainsString('Age: 35', $result); + $this->assertStringContainsString('class="user"', $result); + } + + public function test_plugin_with_find_by_filter_and_fallback(): void + { + $plugin = Plugin::factory()->create([ + 'markup_language' => 'liquid', + 'render_markup' => <<<'LIQUID' +{{ collection | find_by: 'name', 'ronak', 'Not Found' }} +LIQUID + , + 'data_payload' => [ + 'collection' => [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ], + ], + ]); + + $result = $plugin->render('full'); + + // Should return the fallback value + $this->assertStringContainsString('Not Found', $result); + } } diff --git a/tests/Unit/Liquid/Filters/DataTest.php b/tests/Unit/Liquid/Filters/DataTest.php index ffb4088..131dc24 100644 --- a/tests/Unit/Liquid/Filters/DataTest.php +++ b/tests/Unit/Liquid/Filters/DataTest.php @@ -53,3 +53,83 @@ test('json filter does not escape slashes', function () { expect($filter->json($data))->toBe('{"url":"https://example.com/path"}'); }); + +test('find_by filter finds object by key-value pair', function () { + $filter = new Data(); + $collection = [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ]; + + $result = $filter->find_by($collection, 'name', 'Ryan'); + expect($result)->toBe(['name' => 'Ryan', 'age' => 35]); +}); + +test('find_by filter returns null when no match found', function () { + $filter = new Data(); + $collection = [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ]; + + $result = $filter->find_by($collection, 'name', 'ronak'); + expect($result)->toBeNull(); +}); + +test('find_by filter returns fallback when no match found', function () { + $filter = new Data(); + $collection = [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ]; + + $result = $filter->find_by($collection, 'name', 'ronak', 'Not Found'); + expect($result)->toBe('Not Found'); +}); + +test('find_by filter finds by age', function () { + $filter = new Data(); + $collection = [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ]; + + $result = $filter->find_by($collection, 'age', 29); + expect($result)->toBe(['name' => 'Sara', 'age' => 29]); +}); + +test('find_by filter handles empty collection', function () { + $filter = new Data(); + $collection = []; + + $result = $filter->find_by($collection, 'name', 'Ryan'); + expect($result)->toBeNull(); +}); + +test('find_by filter handles collection with non-array items', function () { + $filter = new Data(); + $collection = [ + 'not an array', + ['name' => 'Ryan', 'age' => 35], + null, + ]; + + $result = $filter->find_by($collection, 'name', 'Ryan'); + expect($result)->toBe(['name' => 'Ryan', 'age' => 35]); +}); + +test('find_by filter handles items without the specified key', function () { + $filter = new Data(); + $collection = [ + ['age' => 35], + ['name' => 'Ryan', 'age' => 35], + ['title' => 'Developer'], + ]; + + $result = $filter->find_by($collection, 'name', 'Ryan'); + expect($result)->toBe(['name' => 'Ryan', 'age' => 35]); +}); diff --git a/tests/Unit/Liquid/InlineTemplatesTest.php b/tests/Unit/Liquid/InlineTemplatesTest.php index 4535740..bf68ddf 100644 --- a/tests/Unit/Liquid/InlineTemplatesTest.php +++ b/tests/Unit/Liquid/InlineTemplatesTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Unit\Liquid; use App\Liquid\FileSystems\InlineTemplatesFileSystem; +use App\Liquid\Filters\Data; use App\Liquid\Tags\TemplateTag; use Keepsuit\Liquid\Environment; use Keepsuit\Liquid\Exceptions\LiquidException; @@ -27,6 +28,7 @@ class InlineTemplatesTest extends TestCase ); $this->environment->tagRegistry->register(TemplateTag::class); $this->environment->tagRegistry->register(RenderTag::class); + $this->environment->filterRegistry->register(Data::class); } public function test_template_tag_registers_template(): void From 25f36eaf54ce074746e925650a845b3096b16ffa Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 25 Aug 2025 14:43:22 +0200 Subject: [PATCH 5/5] feat: add Liquid filter 'group_by' --- app/Liquid/Filters/Data.php | 24 +++++ tests/Feature/PluginInlineTemplatesTest.php | 24 +++++ tests/Unit/Liquid/Filters/DataTest.php | 104 ++++++++++++++++++++ 3 files changed, 152 insertions(+) diff --git a/app/Liquid/Filters/Data.php b/app/Liquid/Filters/Data.php index 61343b2..4437032 100644 --- a/app/Liquid/Filters/Data.php +++ b/app/Liquid/Filters/Data.php @@ -39,4 +39,28 @@ class Data extends FiltersProvider return $fallback; } + + /** + * Group a collection by a specific key + * + * @param array $collection The collection to group + * @param string $key The key to group by + * @return array The grouped collection + */ + public function group_by(array $collection, string $key): array + { + $grouped = []; + + foreach ($collection as $item) { + if (is_array($item) && array_key_exists($key, $item)) { + $groupKey = $item[$key]; + if (! isset($grouped[$groupKey])) { + $grouped[$groupKey] = []; + } + $grouped[$groupKey][] = $item; + } + } + + return $grouped; + } } diff --git a/tests/Feature/PluginInlineTemplatesTest.php b/tests/Feature/PluginInlineTemplatesTest.php index 7451d93..ce83d9d 100644 --- a/tests/Feature/PluginInlineTemplatesTest.php +++ b/tests/Feature/PluginInlineTemplatesTest.php @@ -228,4 +228,28 @@ LIQUID // Should return the fallback value $this->assertStringContainsString('Not Found', $result); } + + public function test_plugin_with_group_by_filter(): void + { + $plugin = Plugin::factory()->create([ + 'markup_language' => 'liquid', + 'render_markup' => <<<'LIQUID' +{{ collection | group_by: 'age' | json }} +LIQUID + , + 'data_payload' => [ + 'collection' => [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ], + ], + ]); + + $result = $plugin->render('full'); + + // Should output JSON representation of grouped data + $this->assertStringContainsString('"35":[{"name":"Ryan","age":35}]', $result); + $this->assertStringContainsString('"29":[{"name":"Sara","age":29},{"name":"Jimbob","age":29}]', $result); + } } diff --git a/tests/Unit/Liquid/Filters/DataTest.php b/tests/Unit/Liquid/Filters/DataTest.php index 131dc24..8145088 100644 --- a/tests/Unit/Liquid/Filters/DataTest.php +++ b/tests/Unit/Liquid/Filters/DataTest.php @@ -133,3 +133,107 @@ test('find_by filter handles items without the specified key', function () { $result = $filter->find_by($collection, 'name', 'Ryan'); expect($result)->toBe(['name' => 'Ryan', 'age' => 35]); }); + +test('group_by filter groups collection by age', function () { + $filter = new Data(); + $collection = [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ]; + + $result = $filter->group_by($collection, 'age'); + + expect($result)->toBe([ + 35 => [['name' => 'Ryan', 'age' => 35]], + 29 => [ + ['name' => 'Sara', 'age' => 29], + ['name' => 'Jimbob', 'age' => 29], + ], + ]); +}); + +test('group_by filter groups collection by name', function () { + $filter = new Data(); + $collection = [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Sara', 'age' => 29], + ['name' => 'Ryan', 'age' => 30], + ]; + + $result = $filter->group_by($collection, 'name'); + + expect($result)->toBe([ + 'Ryan' => [ + ['name' => 'Ryan', 'age' => 35], + ['name' => 'Ryan', 'age' => 30], + ], + 'Sara' => [['name' => 'Sara', 'age' => 29]], + ]); +}); + +test('group_by filter handles empty collection', function () { + $filter = new Data(); + $collection = []; + + $result = $filter->group_by($collection, 'age'); + expect($result)->toBe([]); +}); + +test('group_by filter handles collection with non-array items', function () { + $filter = new Data(); + $collection = [ + 'not an array', + ['name' => 'Ryan', 'age' => 35], + null, + ['name' => 'Sara', 'age' => 29], + ]; + + $result = $filter->group_by($collection, 'age'); + + expect($result)->toBe([ + 35 => [['name' => 'Ryan', 'age' => 35]], + 29 => [['name' => 'Sara', 'age' => 29]], + ]); +}); + +test('group_by filter handles items without the specified key', function () { + $filter = new Data(); + $collection = [ + ['age' => 35], + ['name' => 'Ryan', 'age' => 35], + ['title' => 'Developer'], + ['name' => 'Sara', 'age' => 29], + ]; + + $result = $filter->group_by($collection, 'age'); + + expect($result)->toBe([ + 35 => [ + ['age' => 35], + ['name' => 'Ryan', 'age' => 35], + ], + 29 => [['name' => 'Sara', 'age' => 29]], + ]); +}); + +test('group_by filter handles mixed data types as keys', function () { + $filter = new Data(); + $collection = [ + ['name' => 'Ryan', 'active' => true], + ['name' => 'Sara', 'active' => false], + ['name' => 'Jimbob', 'active' => true], + ['name' => 'Alice', 'active' => null], + ]; + + $result = $filter->group_by($collection, 'active'); + + expect($result)->toBe([ + 1 => [ // PHP converts true to 1 + ['name' => 'Ryan', 'active' => true], + ['name' => 'Jimbob', 'active' => true], + ], + 0 => [['name' => 'Sara', 'active' => false]], // PHP converts false to 0 + '' => [['name' => 'Alice', 'active' => null]], // PHP converts null keys to empty string + ]); +});