diff --git a/app/Liquid/Filters/Data.php b/app/Liquid/Filters/Data.php
index 5b1f92f..4437032 100644
--- a/app/Liquid/Filters/Data.php
+++ b/app/Liquid/Filters/Data.php
@@ -19,4 +19,48 @@ 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;
+ }
+
+ /**
+ * 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/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..dbd8ec8 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;
@@ -58,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'])) {
@@ -66,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') {
@@ -82,34 +84,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 +132,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/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/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');
+});
diff --git a/tests/Feature/PluginInlineTemplatesTest.php b/tests/Feature/PluginInlineTemplatesTest.php
index ef2b96d..ce83d9d 100644
--- a/tests/Feature/PluginInlineTemplatesTest.php
+++ b/tests/Feature/PluginInlineTemplatesTest.php
@@ -172,4 +172,84 @@ 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);
+ }
+
+ 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/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();
diff --git a/tests/Unit/Liquid/Filters/DataTest.php b/tests/Unit/Liquid/Filters/DataTest.php
index ffb4088..8145088 100644
--- a/tests/Unit/Liquid/Filters/DataTest.php
+++ b/tests/Unit/Liquid/Filters/DataTest.php
@@ -53,3 +53,187 @@ 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]);
+});
+
+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
+ ]);
+});
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