mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 15:07:49 +00:00
feat: recipes zip import support, add trmnlp compatible recipe configuration
* recipes zip import support * add trmnlp compatible recipe configuration * support for multiple polling urls
This commit is contained in:
parent
a927c0fb97
commit
5d39fe89e3
17 changed files with 2409 additions and 125 deletions
61
app/Liquid/FileSystems/InlineTemplatesFileSystem.php
Normal file
61
app/Liquid/FileSystems/InlineTemplatesFileSystem.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Liquid\FileSystems;
|
||||||
|
|
||||||
|
use Keepsuit\Liquid\Contracts\LiquidFileSystem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file system that allows registering inline templates defined with the template tag
|
||||||
|
*/
|
||||||
|
class InlineTemplatesFileSystem implements LiquidFileSystem
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected array $templates = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a template with the given name and content
|
||||||
|
*/
|
||||||
|
public function register(string $name, string $content): void
|
||||||
|
{
|
||||||
|
$this->templates[$name] = $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a template exists
|
||||||
|
*/
|
||||||
|
public function hasTemplate(string $templateName): bool
|
||||||
|
{
|
||||||
|
return isset($this->templates[$templateName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered template names
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public function getTemplateNames(): array
|
||||||
|
{
|
||||||
|
return array_keys($this->templates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all registered templates
|
||||||
|
*/
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$this->templates = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function readTemplateFile(string $templateName): string
|
||||||
|
{
|
||||||
|
if (!isset($this->templates[$templateName])) {
|
||||||
|
throw new \InvalidArgumentException("Template '{$templateName}' not found in inline templates");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->templates[$templateName];
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/Liquid/Tags/TemplateTag.php
Normal file
99
app/Liquid/Tags/TemplateTag.php
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Liquid\Tags;
|
||||||
|
|
||||||
|
use App\Liquid\FileSystems\InlineTemplatesFileSystem;
|
||||||
|
use Keepsuit\Liquid\Exceptions\SyntaxException;
|
||||||
|
use Keepsuit\Liquid\Nodes\BodyNode;
|
||||||
|
use Keepsuit\Liquid\Nodes\Raw;
|
||||||
|
use Keepsuit\Liquid\Nodes\VariableLookup;
|
||||||
|
use Keepsuit\Liquid\Parse\TagParseContext;
|
||||||
|
use Keepsuit\Liquid\Render\RenderContext;
|
||||||
|
use Keepsuit\Liquid\TagBlock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {% template [name] %} tag block is used to define custom templates within the context of the current Liquid template.
|
||||||
|
* These templates are registered with the InlineTemplatesFileSystem and can be rendered using the render tag.
|
||||||
|
*/
|
||||||
|
class TemplateTag extends TagBlock
|
||||||
|
{
|
||||||
|
protected string $templateName;
|
||||||
|
protected Raw $body;
|
||||||
|
|
||||||
|
public static function tagName(): string
|
||||||
|
{
|
||||||
|
return 'template';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function hasRawBody(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parse(TagParseContext $context): static
|
||||||
|
{
|
||||||
|
// Get the template name from the tag parameters
|
||||||
|
$templateNameExpression = $context->params->expression();
|
||||||
|
|
||||||
|
$this->templateName = match (true) {
|
||||||
|
is_string($templateNameExpression) => trim($templateNameExpression),
|
||||||
|
is_numeric($templateNameExpression) => (string) $templateNameExpression,
|
||||||
|
$templateNameExpression instanceof VariableLookup => (string) $templateNameExpression,
|
||||||
|
default => throw new SyntaxException("Template name must be a string, number, or variable"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate template name (letters, numbers, underscores, and slashes only)
|
||||||
|
if (!preg_match('/^[a-zA-Z0-9_\/]+$/', $this->templateName)) {
|
||||||
|
throw new SyntaxException("Invalid template name '{$this->templateName}' - template names must contain only letters, numbers, underscores, and slashes");
|
||||||
|
}
|
||||||
|
|
||||||
|
$context->params->assertEnd();
|
||||||
|
|
||||||
|
assert($context->body instanceof BodyNode);
|
||||||
|
|
||||||
|
$body = $context->body->children()[0] ?? null;
|
||||||
|
$this->body = match (true) {
|
||||||
|
$body instanceof Raw => $body,
|
||||||
|
default => throw new SyntaxException('template tag must have a single raw body'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register the template with the file system during parsing
|
||||||
|
$fileSystem = $context->getParseContext()->environment->fileSystem;
|
||||||
|
if ($fileSystem instanceof InlineTemplatesFileSystem) {
|
||||||
|
// Store the raw content for later rendering
|
||||||
|
$fileSystem->register($this->templateName, $this->body->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(RenderContext $context): string
|
||||||
|
{
|
||||||
|
// Get the file system from the environment
|
||||||
|
$fileSystem = $context->environment->fileSystem;
|
||||||
|
|
||||||
|
if (!$fileSystem instanceof InlineTemplatesFileSystem) {
|
||||||
|
// If no inline file system is available, just return empty string
|
||||||
|
// This allows the template to be used in contexts where inline templates aren't supported
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the template with the file system
|
||||||
|
$fileSystem->register($this->templateName, $this->body->render($context));
|
||||||
|
|
||||||
|
// Return empty string as template tags don't output anything
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTemplateName(): string
|
||||||
|
{
|
||||||
|
return $this->templateName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBody(): Raw
|
||||||
|
{
|
||||||
|
return $this->body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,18 +2,23 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Liquid\FileSystems\InlineTemplatesFileSystem;
|
||||||
use App\Liquid\Filters\Data;
|
use App\Liquid\Filters\Data;
|
||||||
use App\Liquid\Filters\Localization;
|
use App\Liquid\Filters\Localization;
|
||||||
use App\Liquid\Filters\Numbers;
|
use App\Liquid\Filters\Numbers;
|
||||||
use App\Liquid\Filters\StringMarkup;
|
use App\Liquid\Filters\StringMarkup;
|
||||||
use App\Liquid\Filters\Uniqueness;
|
use App\Liquid\Filters\Uniqueness;
|
||||||
|
use App\Liquid\Tags\TemplateTag;
|
||||||
|
use Exception;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\App;
|
use Illuminate\Support\Facades\App;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Keepsuit\Liquid\Exceptions\LiquidException;
|
use Keepsuit\Liquid\Exceptions\LiquidException;
|
||||||
|
use Keepsuit\Liquid\Extensions\StandardExtension;
|
||||||
|
|
||||||
class Plugin extends Model
|
class Plugin extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -26,6 +31,8 @@ class Plugin extends Model
|
||||||
'data_payload_updated_at' => 'datetime',
|
'data_payload_updated_at' => 'datetime',
|
||||||
'is_native' => 'boolean',
|
'is_native' => 'boolean',
|
||||||
'markup_language' => 'string',
|
'markup_language' => 'string',
|
||||||
|
'configuration' => 'json',
|
||||||
|
'configuration_template' => 'json',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function boot()
|
protected static function boot()
|
||||||
|
|
@ -39,6 +46,49 @@ class Plugin extends Model
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasMissingRequiredConfigurationFields(): bool
|
||||||
|
{
|
||||||
|
if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->configuration_template['custom_fields'] as $field) {
|
||||||
|
// Skip fields as they are informational only
|
||||||
|
if ($field['field_type'] === 'author_bio') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field['field_type'] === 'copyable') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($field['field_type'] === 'copyable_webhook_url') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name'];
|
||||||
|
|
||||||
|
// Check if field is required (not marked as optional)
|
||||||
|
$isRequired = ! isset($field['optional']) || $field['optional'] !== true;
|
||||||
|
|
||||||
|
if ($isRequired) {
|
||||||
|
$currentValue = $this->configuration[$fieldKey] ?? null;
|
||||||
|
|
||||||
|
// If the field has a default value and no current value is set, it's not missing
|
||||||
|
if (($currentValue === null || $currentValue === '' || (is_array($currentValue) && empty($currentValue))) && ! isset($field['default'])) {
|
||||||
|
return true; // Found a required field that is not set and has no default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false; // All required fields are set
|
||||||
|
}
|
||||||
|
|
||||||
public function isDataStale(): bool
|
public function isDataStale(): bool
|
||||||
{
|
{
|
||||||
if ($this->data_strategy === 'webhook') {
|
if ($this->data_strategy === 'webhook') {
|
||||||
|
|
@ -59,7 +109,9 @@ class Plugin extends Model
|
||||||
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
|
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
|
||||||
|
|
||||||
if ($this->polling_header) {
|
if ($this->polling_header) {
|
||||||
$headerLines = explode("\n", trim($this->polling_header));
|
// Resolve Liquid variables in the polling header
|
||||||
|
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
|
||||||
|
$headerLines = explode("\n", trim($resolvedHeader));
|
||||||
foreach ($headerLines as $line) {
|
foreach ($headerLines as $line) {
|
||||||
$parts = explode(':', $line, 2);
|
$parts = explode(':', $line, 2);
|
||||||
if (count($parts) === 2) {
|
if (count($parts) === 2) {
|
||||||
|
|
@ -68,26 +120,138 @@ class Plugin extends Model
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$httpRequest = Http::withHeaders($headers);
|
// Split URLs by newline and filter out empty lines
|
||||||
|
$urls = array_filter(
|
||||||
|
array_map('trim', explode("\n", $this->polling_url)),
|
||||||
|
fn ($url) => ! empty($url)
|
||||||
|
);
|
||||||
|
|
||||||
if ($this->polling_verb === 'post' && $this->polling_body) {
|
// If only one URL, use the original logic without nesting
|
||||||
$httpRequest = $httpRequest->withBody($this->polling_body);
|
if (count($urls) === 1) {
|
||||||
|
$url = reset($urls);
|
||||||
|
$httpRequest = Http::withHeaders($headers);
|
||||||
|
|
||||||
|
if ($this->polling_verb === 'post' && $this->polling_body) {
|
||||||
|
// Resolve Liquid variables in the polling body
|
||||||
|
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
||||||
|
$httpRequest = $httpRequest->withBody($resolvedBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve Liquid variables in the polling URL
|
||||||
|
$resolvedUrl = $this->resolveLiquidVariables($url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Make the request based on the verb
|
||||||
|
if ($this->polling_verb === 'post') {
|
||||||
|
$response = $httpRequest->post($resolvedUrl)->json();
|
||||||
|
} else {
|
||||||
|
$response = $httpRequest->get($resolvedUrl)->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->update([
|
||||||
|
'data_payload' => $response,
|
||||||
|
'data_payload_updated_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
|
||||||
|
$this->update([
|
||||||
|
'data_payload' => ['error' => 'Failed to fetch data'],
|
||||||
|
'data_payload_updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make the request based on the verb
|
// Multiple URLs - use nested response logic
|
||||||
if ($this->polling_verb === 'post') {
|
$combinedResponse = [];
|
||||||
$response = $httpRequest->post($this->polling_url)->json();
|
|
||||||
} else {
|
foreach ($urls as $index => $url) {
|
||||||
$response = $httpRequest->get($this->polling_url)->json();
|
$httpRequest = Http::withHeaders($headers);
|
||||||
|
|
||||||
|
if ($this->polling_verb === 'post' && $this->polling_body) {
|
||||||
|
// Resolve Liquid variables in the polling body
|
||||||
|
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
||||||
|
$httpRequest = $httpRequest->withBody($resolvedBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve Liquid variables in the polling URL
|
||||||
|
$resolvedUrl = $this->resolveLiquidVariables($url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Make the request based on the verb
|
||||||
|
if ($this->polling_verb === 'post') {
|
||||||
|
$response = $httpRequest->post($resolvedUrl)->json();
|
||||||
|
} else {
|
||||||
|
$response = $httpRequest->get($resolvedUrl)->json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if response is an array at root level
|
||||||
|
if (is_array($response) && array_keys($response) === range(0, count($response) - 1)) {
|
||||||
|
// Response is a sequential array, nest under .data
|
||||||
|
$combinedResponse["IDX_{$index}"] = ['data' => $response];
|
||||||
|
} else {
|
||||||
|
// Response is an object or associative array, keep as is
|
||||||
|
$combinedResponse["IDX_{$index}"] = $response;
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Log error and continue with other URLs
|
||||||
|
Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
|
||||||
|
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->update([
|
$this->update([
|
||||||
'data_payload' => $response,
|
'data_payload' => $combinedResponse,
|
||||||
'data_payload_updated_at' => now(),
|
'data_payload_updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply Liquid template replacements (converts 'with' syntax to comma syntax)
|
||||||
|
*/
|
||||||
|
private function applyLiquidReplacements(string $template): string
|
||||||
|
{
|
||||||
|
$replacements = [
|
||||||
|
'date: "%N"' => 'date: "u"',
|
||||||
|
'%-m/%-d/%Y' => 'm/d/Y',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Apply basic replacements
|
||||||
|
$template = str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||||
|
|
||||||
|
// Convert {% render "template" with %} syntax to {% render "template", %} syntax
|
||||||
|
$template = preg_replace(
|
||||||
|
'/{%\s*render\s+([^}]+?)\s+with\s+/i',
|
||||||
|
'{% render $1, ',
|
||||||
|
$template
|
||||||
|
);
|
||||||
|
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve Liquid variables in a template string using the Liquid template engine
|
||||||
|
*
|
||||||
|
* @param string $template The template string containing Liquid variables
|
||||||
|
* @return string The resolved template with variables replaced with their values
|
||||||
|
*
|
||||||
|
* @throws LiquidException
|
||||||
|
*/
|
||||||
|
public function resolveLiquidVariables(string $template): string
|
||||||
|
{
|
||||||
|
// Get configuration variables - make them available at root level
|
||||||
|
$variables = $this->configuration ?? [];
|
||||||
|
|
||||||
|
// Use the Liquid template engine to resolve variables
|
||||||
|
$environment = App::make('liquid.environment');
|
||||||
|
$liquidTemplate = $environment->parseString($template);
|
||||||
|
$context = $environment->newRenderContext(data: $variables);
|
||||||
|
|
||||||
|
return $liquidTemplate->render($context);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the plugin's markup
|
* Render the plugin's markup
|
||||||
*
|
*
|
||||||
|
|
@ -99,7 +263,12 @@ class Plugin extends Model
|
||||||
$renderedContent = '';
|
$renderedContent = '';
|
||||||
|
|
||||||
if ($this->markup_language === 'liquid') {
|
if ($this->markup_language === 'liquid') {
|
||||||
$environment = App::make('liquid.environment');
|
// Create a custom environment with inline templates support
|
||||||
|
$inlineFileSystem = new InlineTemplatesFileSystem();
|
||||||
|
$environment = new \Keepsuit\Liquid\Environment(
|
||||||
|
fileSystem: $inlineFileSystem,
|
||||||
|
extensions: [new StandardExtension()]
|
||||||
|
);
|
||||||
|
|
||||||
// Register all custom filters
|
// Register all custom filters
|
||||||
$environment->filterRegistry->register(Numbers::class);
|
$environment->filterRegistry->register(Numbers::class);
|
||||||
|
|
@ -108,11 +277,47 @@ class Plugin extends Model
|
||||||
$environment->filterRegistry->register(Uniqueness::class);
|
$environment->filterRegistry->register(Uniqueness::class);
|
||||||
$environment->filterRegistry->register(Localization::class);
|
$environment->filterRegistry->register(Localization::class);
|
||||||
|
|
||||||
$template = $environment->parseString($this->render_markup);
|
// Register the template tag for inline templates
|
||||||
$context = $environment->newRenderContext(data: ['size' => $size, 'data' => $this->data_payload]);
|
$environment->tagRegistry->register(TemplateTag::class);
|
||||||
|
|
||||||
|
// Apply Liquid replacements (including 'with' syntax conversion)
|
||||||
|
$processedMarkup = $this->applyLiquidReplacements($this->render_markup);
|
||||||
|
|
||||||
|
$template = $environment->parseString($processedMarkup);
|
||||||
|
$context = $environment->newRenderContext(
|
||||||
|
data: [
|
||||||
|
'size' => $size,
|
||||||
|
'data' => $this->data_payload,
|
||||||
|
'config' => $this->configuration ?? [],
|
||||||
|
...(is_array($this->data_payload) ? $this->data_payload : []),
|
||||||
|
'trmnl' => [
|
||||||
|
'user' => [
|
||||||
|
'utc_offset' => '0',
|
||||||
|
'name' => $this->user->name ?? 'Unknown User',
|
||||||
|
'locale' => 'en',
|
||||||
|
'time_zone_iana' => config('app.timezone'),
|
||||||
|
],
|
||||||
|
'plugin_settings' => [
|
||||||
|
'instance_name' => $this->name,
|
||||||
|
'strategy' => $this->data_strategy,
|
||||||
|
'dark_mode' => 'no',
|
||||||
|
'no_screen_padding' => 'no',
|
||||||
|
'polling_headers' => $this->polling_header,
|
||||||
|
'polling_url' => $this->polling_url,
|
||||||
|
'custom_fields_values' => [
|
||||||
|
...(is_array($this->configuration) ? $this->configuration : []),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
$renderedContent = $template->render($context);
|
$renderedContent = $template->render($context);
|
||||||
} else {
|
} else {
|
||||||
$renderedContent = Blade::render($this->render_markup, ['size' => $size, 'data' => $this->data_payload]);
|
$renderedContent = Blade::render($this->render_markup, [
|
||||||
|
'size' => $size,
|
||||||
|
'data' => $this->data_payload,
|
||||||
|
'config' => $this->configuration ?? [],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($standalone) {
|
if ($standalone) {
|
||||||
|
|
@ -130,6 +335,7 @@ class Plugin extends Model
|
||||||
'slot' => view($this->render_markup_view, [
|
'slot' => view($this->render_markup_view, [
|
||||||
'size' => $size,
|
'size' => $size,
|
||||||
'data' => $this->data_payload,
|
'data' => $this->data_payload,
|
||||||
|
'config' => $this->configuration ?? [],
|
||||||
])->render(),
|
])->render(),
|
||||||
])->render();
|
])->render();
|
||||||
}
|
}
|
||||||
|
|
@ -137,10 +343,19 @@ class Plugin extends Model
|
||||||
return view($this->render_markup_view, [
|
return view($this->render_markup_view, [
|
||||||
'size' => $size,
|
'size' => $size,
|
||||||
'data' => $this->data_payload,
|
'data' => $this->data_payload,
|
||||||
|
'config' => $this->configuration ?? [],
|
||||||
])->render();
|
])->render();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return '<p>No render markup yet defined for this plugin.</p>';
|
return '<p>No render markup yet defined for this plugin.</p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a configuration value by key
|
||||||
|
*/
|
||||||
|
public function getConfiguration(string $key, $default = null)
|
||||||
|
{
|
||||||
|
return $this->configuration[$key] ?? $default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Services\OidcProvider;
|
use App\Services\OidcProvider;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Laravel\Socialite\Facades\Socialite;
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
|
@ -26,6 +27,17 @@ class AppServiceProvider extends ServiceProvider
|
||||||
URL::forceScheme('https');
|
URL::forceScheme('https');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Request::macro('hasValidSignature', function ($absolute = true, array $ignoreQuery = []) {
|
||||||
|
$https = clone $this;
|
||||||
|
$https->server->set('HTTPS', 'on');
|
||||||
|
|
||||||
|
$http = clone $this;
|
||||||
|
$http->server->set('HTTPS', 'off');
|
||||||
|
|
||||||
|
return URL::hasValidSignature($https, $absolute, $ignoreQuery)
|
||||||
|
|| URL::hasValidSignature($http, $absolute, $ignoreQuery);
|
||||||
|
});
|
||||||
|
|
||||||
// Register OIDC provider with Socialite
|
// Register OIDC provider with Socialite
|
||||||
Socialite::extend('oidc', function ($app) {
|
Socialite::extend('oidc', function ($app) {
|
||||||
$config = $app['config']['services.oidc'] ?? [];
|
$config = $app['config']['services.oidc'] ?? [];
|
||||||
|
|
|
||||||
206
app/Services/PluginImportService.php
Normal file
206
app/Services/PluginImportService.php
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Plugin;
|
||||||
|
use App\Models\User;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use RecursiveDirectoryIterator;
|
||||||
|
use RecursiveIteratorIterator;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
class PluginImportService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Import a plugin from a ZIP file
|
||||||
|
*
|
||||||
|
* @param UploadedFile $zipFile The uploaded ZIP file
|
||||||
|
* @param User $user The user importing the plugin
|
||||||
|
* @return Plugin The created plugin instance
|
||||||
|
*
|
||||||
|
* @throws Exception If the ZIP file is invalid or required files are missing
|
||||||
|
*/
|
||||||
|
public function importFromZip(UploadedFile $zipFile, User $user): Plugin
|
||||||
|
{
|
||||||
|
// Create a temporary directory using Laravel's temporary directory helper
|
||||||
|
$tempDirName = 'temp/'.uniqid('plugin_import_', true);
|
||||||
|
Storage::makeDirectory($tempDirName);
|
||||||
|
$tempDir = Storage::path($tempDirName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the real path of the temporary file
|
||||||
|
$zipFullPath = $zipFile->getRealPath();
|
||||||
|
|
||||||
|
// Extract the ZIP file using ZipArchive
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->open($zipFullPath) !== true) {
|
||||||
|
throw new Exception('Could not open the ZIP file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->extractTo($tempDir);
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
// Find the required files (settings.yml and full.liquid/full.blade.php)
|
||||||
|
$filePaths = $this->findRequiredFiles($tempDir);
|
||||||
|
|
||||||
|
// Validate that we found the required files
|
||||||
|
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
|
||||||
|
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse settings.yml
|
||||||
|
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
||||||
|
$settings = Yaml::parse($settingsYaml);
|
||||||
|
|
||||||
|
// Read full.liquid content
|
||||||
|
$fullLiquid = File::get($filePaths['fullLiquidPath']);
|
||||||
|
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||||
|
|
||||||
|
// Prepend shared.liquid content if available
|
||||||
|
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||||
|
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
||||||
|
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the file ends with .liquid to set markup language
|
||||||
|
$markupLanguage = 'blade';
|
||||||
|
if (pathinfo($filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
|
||||||
|
$markupLanguage = 'liquid';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure custom_fields is properly formatted
|
||||||
|
if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) {
|
||||||
|
$settings['custom_fields'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create configuration template with the custom fields
|
||||||
|
$configurationTemplate = [
|
||||||
|
'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'])) {
|
||||||
|
foreach ($settings['custom_fields'] as $field) {
|
||||||
|
if (isset($field['keyname']) && isset($field['default'])) {
|
||||||
|
$configuration[$field['keyname']] = $field['default'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $plugin;
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
// Clean up temporary directory
|
||||||
|
Storage::deleteDirectory($tempDirName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
$fullLiquidPath = null;
|
||||||
|
$sharedLiquidPath = null;
|
||||||
|
|
||||||
|
// First, check if files are directly in the src folder
|
||||||
|
if (File::exists($tempDir.'/src/settings.yml')) {
|
||||||
|
$settingsYamlPath = $tempDir.'/src/settings.yml';
|
||||||
|
|
||||||
|
// Check for full.liquid or full.blade.php
|
||||||
|
if (File::exists($tempDir.'/src/full.liquid')) {
|
||||||
|
$fullLiquidPath = $tempDir.'/src/full.liquid';
|
||||||
|
} elseif (File::exists($tempDir.'/src/full.blade.php')) {
|
||||||
|
$fullLiquidPath = $tempDir.'/src/full.blade.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for shared.liquid in the same directory
|
||||||
|
if (File::exists($tempDir.'/src/shared.liquid')) {
|
||||||
|
$sharedLiquidPath = $tempDir.'/src/shared.liquid';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Search for the files in the extracted directory structure
|
||||||
|
$directories = new RecursiveDirectoryIterator($tempDir, RecursiveDirectoryIterator::SKIP_DOTS);
|
||||||
|
$files = new RecursiveIteratorIterator($directories);
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$filename = $file->getFilename();
|
||||||
|
$filepath = $file->getPathname();
|
||||||
|
|
||||||
|
if ($filename === 'settings.yml') {
|
||||||
|
$settingsYamlPath = $filepath;
|
||||||
|
} elseif ($filename === 'full.liquid' || $filename === 'full.blade.php') {
|
||||||
|
$fullLiquidPath = $filepath;
|
||||||
|
} elseif ($filename === 'shared.liquid') {
|
||||||
|
$sharedLiquidPath = $filepath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found both required files, break the loop
|
||||||
|
if ($settingsYamlPath && $fullLiquidPath) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found the files but they're not in the src folder,
|
||||||
|
// check if they're in the root of the ZIP or in a subfolder
|
||||||
|
if ($settingsYamlPath && $fullLiquidPath) {
|
||||||
|
// If the files are in the root of the ZIP, create a src folder and move them there
|
||||||
|
$srcDir = dirname($settingsYamlPath);
|
||||||
|
|
||||||
|
// If the parent directory is not named 'src', create a src directory
|
||||||
|
if (basename($srcDir) !== 'src') {
|
||||||
|
$newSrcDir = $tempDir.'/src';
|
||||||
|
File::makeDirectory($newSrcDir, 0755, true);
|
||||||
|
|
||||||
|
// Copy the files to the src directory
|
||||||
|
File::copy($settingsYamlPath, $newSrcDir.'/settings.yml');
|
||||||
|
File::copy($fullLiquidPath, $newSrcDir.'/full.liquid');
|
||||||
|
|
||||||
|
// Copy shared.liquid if it exists
|
||||||
|
if ($sharedLiquidPath) {
|
||||||
|
File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid');
|
||||||
|
$sharedLiquidPath = $newSrcDir.'/shared.liquid';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the paths
|
||||||
|
$settingsYamlPath = $newSrcDir.'/settings.yml';
|
||||||
|
$fullLiquidPath = $newSrcDir.'/full.liquid';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'settingsYamlPath' => $settingsYamlPath,
|
||||||
|
'fullLiquidPath' => $fullLiquidPath,
|
||||||
|
'sharedLiquidPath' => $sharedLiquidPath,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"ext-imagick": "*",
|
"ext-imagick": "*",
|
||||||
|
"ext-zip": "*",
|
||||||
"bnussbau/laravel-trmnl-blade": "1.2.*",
|
"bnussbau/laravel-trmnl-blade": "1.2.*",
|
||||||
"intervention/image": "^3.11",
|
"intervention/image": "^3.11",
|
||||||
"keepsuit/laravel-liquid": "^0.5.2",
|
"keepsuit/laravel-liquid": "^0.5.2",
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
"livewire/flux": "^2.0",
|
"livewire/flux": "^2.0",
|
||||||
"livewire/volt": "^1.7",
|
"livewire/volt": "^1.7",
|
||||||
"spatie/browsershot": "^5.0",
|
"spatie/browsershot": "^5.0",
|
||||||
|
"symfony/yaml": "^7.3",
|
||||||
"wnx/sidecar-browsershot": "^2.6"
|
"wnx/sidecar-browsershot": "^2.6"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|
|
||||||
157
composer.lock
generated
157
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "9143c36674f3ae13a9e9bad15014d508",
|
"content-hash": "fea763810f7e3a912c2221d2fe0a751e",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "aws/aws-crt-php",
|
"name": "aws/aws-crt-php",
|
||||||
|
|
@ -7405,6 +7405,82 @@
|
||||||
],
|
],
|
||||||
"time": "2025-07-10T08:47:49+00:00"
|
"time": "2025-07-10T08:47:49+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/yaml",
|
||||||
|
"version": "v7.3.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/yaml.git",
|
||||||
|
"reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30",
|
||||||
|
"reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2",
|
||||||
|
"symfony/deprecation-contracts": "^2.5|^3.0",
|
||||||
|
"symfony/polyfill-ctype": "^1.8"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"symfony/console": "<6.4"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"symfony/console": "^6.4|^7.0"
|
||||||
|
},
|
||||||
|
"bin": [
|
||||||
|
"Resources/bin/yaml-lint"
|
||||||
|
],
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\Yaml\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Loads and dumps YAML files",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/yaml/tree/v7.3.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-07-10T08:47:49+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "tijsverkoyen/css-to-inline-styles",
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
"version": "v2.3.0",
|
"version": "v2.3.0",
|
||||||
|
|
@ -11377,82 +11453,6 @@
|
||||||
],
|
],
|
||||||
"time": "2024-10-20T05:08:20+00:00"
|
"time": "2024-10-20T05:08:20+00:00"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "symfony/yaml",
|
|
||||||
"version": "v7.3.2",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/symfony/yaml.git",
|
|
||||||
"reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/b8d7d868da9eb0919e99c8830431ea087d6aae30",
|
|
||||||
"reference": "b8d7d868da9eb0919e99c8830431ea087d6aae30",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.2",
|
|
||||||
"symfony/deprecation-contracts": "^2.5|^3.0",
|
|
||||||
"symfony/polyfill-ctype": "^1.8"
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"symfony/console": "<6.4"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"symfony/console": "^6.4|^7.0"
|
|
||||||
},
|
|
||||||
"bin": [
|
|
||||||
"Resources/bin/yaml-lint"
|
|
||||||
],
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Symfony\\Component\\Yaml\\": ""
|
|
||||||
},
|
|
||||||
"exclude-from-classmap": [
|
|
||||||
"/Tests/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"MIT"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Fabien Potencier",
|
|
||||||
"email": "fabien@symfony.com"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Symfony Community",
|
|
||||||
"homepage": "https://symfony.com/contributors"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Loads and dumps YAML files",
|
|
||||||
"homepage": "https://symfony.com",
|
|
||||||
"support": {
|
|
||||||
"source": "https://github.com/symfony/yaml/tree/v7.3.2"
|
|
||||||
},
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"url": "https://symfony.com/sponsor",
|
|
||||||
"type": "custom"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/fabpot",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://github.com/nicolas-grekas",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
|
||||||
"type": "tidelift"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"time": "2025-07-10T08:47:49+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "ta-tikoma/phpunit-architecture-test",
|
"name": "ta-tikoma/phpunit-architecture-test",
|
||||||
"version": "0.8.5",
|
"version": "0.8.5",
|
||||||
|
|
@ -11570,7 +11570,8 @@
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"ext-imagick": "*"
|
"ext-imagick": "*",
|
||||||
|
"ext-zip": "*"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.6.0"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('plugins', function (Blueprint $table) {
|
||||||
|
$table->json('configuration_template')->nullable();
|
||||||
|
$table->json('configuration')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('plugins', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('configuration_template');
|
||||||
|
$table->dropColumn('configuration');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
42
resources/views/flux/icon/github.blade.php
Normal file
42
resources/views/flux/icon/github.blade.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{{-- Credit: Lucide (https://lucide.dev) --}}
|
||||||
|
|
||||||
|
@props([
|
||||||
|
'variant' => 'outline',
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
if ($variant === 'solid') {
|
||||||
|
throw new \Exception('The "solid" variant is not supported in Lucide.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$classes = Flux::classes('shrink-0')
|
||||||
|
->add(match($variant) {
|
||||||
|
'outline' => '[:where(&)]:size-6',
|
||||||
|
'solid' => '[:where(&)]:size-6',
|
||||||
|
'mini' => '[:where(&)]:size-5',
|
||||||
|
'micro' => '[:where(&)]:size-4',
|
||||||
|
});
|
||||||
|
|
||||||
|
$strokeWidth = match ($variant) {
|
||||||
|
'outline' => 2,
|
||||||
|
'mini' => 2.25,
|
||||||
|
'micro' => 2.5,
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<svg
|
||||||
|
{{ $attributes->class($classes) }}
|
||||||
|
data-flux-icon
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="{{ $strokeWidth }}"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-slot="icon"
|
||||||
|
>
|
||||||
|
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
|
||||||
|
<path d="M9 18c-4.51 2-5-2-7-2" />
|
||||||
|
</svg>
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Console\Commands\ExampleRecipesSeederCommand;
|
||||||
|
use App\Services\PluginImportService;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component {
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
public string $name;
|
public string $name;
|
||||||
public int $data_stale_minutes = 60;
|
public int $data_stale_minutes = 60;
|
||||||
|
|
@ -12,6 +17,7 @@ new class extends Component {
|
||||||
public $polling_header;
|
public $polling_header;
|
||||||
public $polling_body;
|
public $polling_body;
|
||||||
public array $plugins;
|
public array $plugins;
|
||||||
|
public $zipFile;
|
||||||
|
|
||||||
public array $native_plugins = [
|
public array $native_plugins = [
|
||||||
'markup' =>
|
'markup' =>
|
||||||
|
|
@ -50,7 +56,7 @@ new class extends Component {
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
\App\Models\Plugin::create([
|
\App\Models\Plugin::create([
|
||||||
'uuid' => \Illuminate\Support\Str::uuid(),
|
'uuid' => Str::uuid(),
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
'name' => $this->name,
|
'name' => $this->name,
|
||||||
'data_stale_minutes' => $this->data_stale_minutes,
|
'data_stale_minutes' => $this->data_stale_minutes,
|
||||||
|
|
@ -69,8 +75,32 @@ new class extends Component {
|
||||||
|
|
||||||
public function seedExamplePlugins(): void
|
public function seedExamplePlugins(): void
|
||||||
{
|
{
|
||||||
\Artisan::call(\App\Console\Commands\ExampleRecipesSeederCommand::class, ['user_id' => auth()->id()]);
|
Artisan::call(ExampleRecipesSeederCommand::class, ['user_id' => auth()->id()]);
|
||||||
$this->refreshPlugins();
|
$this->refreshPlugins();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function importZip(PluginImportService $pluginImportService): void
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user() !== null, 403);
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'zipFile' => 'required|file|mimes:zip|max:10240', // 10MB max
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$plugin = $pluginImportService->importFromZip($this->zipFile, auth()->user());
|
||||||
|
|
||||||
|
$this->refreshPlugins();
|
||||||
|
$this->reset(['zipFile']);
|
||||||
|
|
||||||
|
Flux::modal('import-zip')->close();
|
||||||
|
$this->dispatch('notify', ['type' => 'success', 'message' => 'Plugin imported successfully!']);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->dispatch('notify', ['type' => 'error', 'message' => 'Error importing plugin: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
@ -89,15 +119,10 @@ new class extends Component {
|
||||||
<flux:dropdown>
|
<flux:dropdown>
|
||||||
<flux:button icon="chevron-down" variant="primary"></flux:button>
|
<flux:button icon="chevron-down" variant="primary"></flux:button>
|
||||||
<flux:menu>
|
<flux:menu>
|
||||||
|
<flux:modal.trigger name="import-zip">
|
||||||
|
<flux:menu.item icon="archive-box">Import Recipe</flux:menu.item>
|
||||||
|
</flux:modal.trigger>
|
||||||
<flux:menu.item icon="beaker" wire:click="seedExamplePlugins">Seed Example Recipes</flux:menu.item>
|
<flux:menu.item icon="beaker" wire:click="seedExamplePlugins">Seed Example Recipes</flux:menu.item>
|
||||||
{{-- <flux:menu.separator/>--}}
|
|
||||||
{{-- <flux:modal.trigger name="import-recipe">--}}
|
|
||||||
{{-- <flux:menu.item icon="paper-clip">Import Recipe ZIP File</flux:menu.item>--}}
|
|
||||||
{{-- </flux:modal.trigger>--}}
|
|
||||||
{{-- <flux:menu.separator/>--}}
|
|
||||||
{{-- <flux:modal.trigger name="add-native-plugin">--}}
|
|
||||||
{{-- <flux:menu.item icon="code-bracket">New Native Plugin</flux:menu.item>--}}
|
|
||||||
{{-- </flux:modal.trigger>--}}
|
|
||||||
</flux:menu>
|
</flux:menu>
|
||||||
</flux:dropdown>
|
</flux:dropdown>
|
||||||
</flux:button.group>
|
</flux:button.group>
|
||||||
|
|
@ -105,6 +130,62 @@ new class extends Component {
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<flux:modal name="import-zip" class="md:w-96">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Import Recipe
|
||||||
|
<flux:badge color="yellow" class="ml-2">Alpha</flux:badge>
|
||||||
|
</flux:heading>
|
||||||
|
<flux:subheading>Upload a ZIP archive containing a TRMNL recipe — either exported from the cloud service or structured using the <a href="https://github.com/usetrmnl/trmnlp" target="_blank" class="underline">trmnlp</a> project structure.</flux:subheading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:text>The archive must at least contain <code>settings.yml</code> and <code>full.liquid</code> files.</flux:text>
|
||||||
|
{{-- <p>The ZIP file should contain the following structure:</p>--}}
|
||||||
|
{{-- <pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-auto">--}}
|
||||||
|
{{--.--}}
|
||||||
|
{{--├── src--}}
|
||||||
|
{{--│ ├── full.liquid (required)--}}
|
||||||
|
{{--│ ├── settings.yml (required)--}}
|
||||||
|
{{--│ └── ...--}}
|
||||||
|
{{--└── ...--}}
|
||||||
|
{{-- </pre>--}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:heading size="sm">Limitations</flux:heading>
|
||||||
|
<ul class="list-disc pl-5 mt-2">
|
||||||
|
<li><flux:text>Only full view will be imported; shared markup will be prepended</flux:text></li>
|
||||||
|
<li><flux:text>Some Liquid filters may be not supported or behave differently</flux:text></li>
|
||||||
|
<li><flux:text>API responses in formats other than JSON are not yet supported</flux:text></li>
|
||||||
|
{{-- <ul class="list-disc pl-5 mt-2">--}}
|
||||||
|
{{-- <li><flux:text><code>date: "%N"</code> is unsupported. Use <code>date: "u"</code> instead </flux:text></li>--}}
|
||||||
|
{{-- </ul>--}}
|
||||||
|
</ul>
|
||||||
|
<flux:text class="mt-1">Please report <a href="https://github.com/usetrmnl/byos_laravel/issues/new" target="_blank" class="underline">issues on GitHub</a>. Include your example zip file.</flux:text></li>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="importZip">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="zipFile" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">.zip Archive</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
wire:model="zipFile"
|
||||||
|
id="zipFile"
|
||||||
|
accept=".zip"
|
||||||
|
class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 p-2.5"
|
||||||
|
/>
|
||||||
|
@error('zipFile') <span class="text-red-500 text-xs mt-1">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<flux:spacer/>
|
||||||
|
<flux:button type="submit" variant="primary">Import</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
||||||
<flux:modal name="add-plugin" class="md:w-96">
|
<flux:modal name="add-plugin" class="md:w-96">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
|
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Keepsuit\Liquid\Exceptions\LiquidException;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component {
|
||||||
public Plugin $plugin;
|
public Plugin $plugin;
|
||||||
|
|
@ -28,10 +30,15 @@ new class extends Component {
|
||||||
public string $selected_playlist = '';
|
public string $selected_playlist = '';
|
||||||
public string $mashup_layout = 'full';
|
public string $mashup_layout = 'full';
|
||||||
public array $mashup_plugins = [];
|
public array $mashup_plugins = [];
|
||||||
|
public array $configuration_template = [];
|
||||||
|
public array $configuration = [];
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
$this->blade_code = $this->plugin->render_markup;
|
||||||
|
$this->configuration_template = $this->plugin->configuration_template ?? [];
|
||||||
|
$this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : [];
|
||||||
|
|
||||||
if ($this->plugin->render_markup_view) {
|
if ($this->plugin->render_markup_view) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -86,7 +93,7 @@ new class extends Component {
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'data_stale_minutes' => 'required|integer|min:1',
|
'data_stale_minutes' => 'required|integer|min:1',
|
||||||
'data_strategy' => 'required|string|in:polling,webhook,static',
|
'data_strategy' => 'required|string|in:polling,webhook,static',
|
||||||
'polling_url' => 'required_if:data_strategy,polling|nullable|url',
|
'polling_url' => 'required_if:data_strategy,polling|nullable',
|
||||||
'polling_verb' => 'required|string|in:get,post',
|
'polling_verb' => 'required|string|in:get,post',
|
||||||
'polling_header' => 'nullable|string|max:255',
|
'polling_header' => 'nullable|string|max:255',
|
||||||
'polling_body' => 'nullable|string',
|
'polling_body' => 'nullable|string',
|
||||||
|
|
@ -104,11 +111,30 @@ new class extends Component {
|
||||||
public function editSettings()
|
public function editSettings()
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
|
||||||
|
// Custom validation for polling_url with Liquid variable resolution
|
||||||
|
$this->validatePollingUrl();
|
||||||
|
|
||||||
$validated = $this->validate();
|
$validated = $this->validate();
|
||||||
$validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true);
|
$validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true);
|
||||||
$this->plugin->update($validated);
|
$this->plugin->update($validated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function validatePollingUrl(): void
|
||||||
|
{
|
||||||
|
if ($this->data_strategy === 'polling' && !empty($this->polling_url)) {
|
||||||
|
try {
|
||||||
|
$resolvedUrl = $this->plugin->resolveLiquidVariables($this->polling_url);
|
||||||
|
|
||||||
|
if (!filter_var($resolvedUrl, FILTER_VALIDATE_URL)) {
|
||||||
|
$this->addError('polling_url', 'The polling URL must be a valid URL after resolving configuration variables.');
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->addError('polling_url', 'Error resolving Liquid variables: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function updateData(): void
|
public function updateData(): void
|
||||||
{
|
{
|
||||||
if ($this->plugin->data_strategy === 'polling') {
|
if ($this->plugin->data_strategy === 'polling') {
|
||||||
|
|
@ -197,11 +223,39 @@ new class extends Component {
|
||||||
Flux::modal('add-to-playlist')->close();
|
Flux::modal('add-to-playlist')->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function saveConfiguration()
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
|
||||||
|
$configurationValues = [];
|
||||||
|
if (isset($this->configuration_template['custom_fields'])) {
|
||||||
|
foreach ($this->configuration_template['custom_fields'] as $field) {
|
||||||
|
$fieldKey = $field['keyname'];
|
||||||
|
if (isset($this->configuration[$fieldKey])) {
|
||||||
|
$configurationValues[$fieldKey] = $this->configuration[$fieldKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->plugin->update([
|
||||||
|
'configuration' => $configurationValues
|
||||||
|
]);
|
||||||
|
|
||||||
|
Flux::modal('configuration-modal')->close();
|
||||||
|
}
|
||||||
|
|
||||||
public function getDevicePlaylists($deviceId)
|
public function getDevicePlaylists($deviceId)
|
||||||
{
|
{
|
||||||
return \App\Models\Playlist::where('device_id', $deviceId)->get();
|
return \App\Models\Playlist::where('device_id', $deviceId)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getConfigurationValue($key, $default = null)
|
||||||
|
{
|
||||||
|
return $this->configuration[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public function renderExample(string $example)
|
public function renderExample(string $example)
|
||||||
{
|
{
|
||||||
switch ($example) {
|
switch ($example) {
|
||||||
|
|
@ -270,9 +324,16 @@ HTML;
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
|
||||||
|
// If data strategy is polling and data_payload is null, fetch the data first
|
||||||
|
if ($this->plugin->data_strategy === 'polling' && $this->plugin->data_payload === null) {
|
||||||
|
$this->updateData();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$previewMarkup = $this->plugin->render($size);
|
$previewMarkup = $this->plugin->render($size);
|
||||||
$this->dispatch('preview-updated', preview: $previewMarkup);
|
$this->dispatch('preview-updated', preview: $previewMarkup);
|
||||||
|
} catch (LiquidException $e) {
|
||||||
|
$this->dispatch('preview-error', message: $e->toLiquidErrorMessage());
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->dispatch('preview-error', message: $e->getMessage());
|
$this->dispatch('preview-error', message: $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
@ -297,23 +358,23 @@ HTML;
|
||||||
|
|
||||||
<flux:button.group>
|
<flux:button.group>
|
||||||
<flux:modal.trigger name="preview-plugin">
|
<flux:modal.trigger name="preview-plugin">
|
||||||
<flux:button icon="eye" wire:click="renderPreview">Preview</flux:button>
|
<flux:button icon="eye" wire:click="renderPreview" :disabled="$plugin->hasMissingRequiredConfigurationFields()">Preview</flux:button>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
<flux:dropdown>
|
<flux:dropdown>
|
||||||
<flux:button icon="chevron-down"></flux:button>
|
<flux:button icon="chevron-down" :disabled="$plugin->hasMissingRequiredConfigurationFields()"></flux:button>
|
||||||
<flux:menu>
|
<flux:menu>
|
||||||
<flux:modal.trigger name="preview-plugin">
|
<flux:modal.trigger name="preview-plugin">
|
||||||
<flux:menu.item icon="mashup-1Tx1B" wire:click="renderPreview('half_horizontal')">Half-Horizontal
|
<flux:menu.item icon="mashup-1Tx1B" wire:click="renderPreview('half_horizontal')" :disabled="$plugin->hasMissingRequiredConfigurationFields()">Half-Horizontal
|
||||||
</flux:menu.item>
|
</flux:menu.item>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
|
|
||||||
<flux:modal.trigger name="preview-plugin">
|
<flux:modal.trigger name="preview-plugin">
|
||||||
<flux:menu.item icon="mashup-1Lx1R" wire:click="renderPreview('half_vertical')">Half-Vertical
|
<flux:menu.item icon="mashup-1Lx1R" wire:click="renderPreview('half_vertical')" :disabled="$plugin->hasMissingRequiredConfigurationFields()">Half-Vertical
|
||||||
</flux:menu.item>
|
</flux:menu.item>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
|
|
||||||
<flux:modal.trigger name="preview-plugin">
|
<flux:modal.trigger name="preview-plugin">
|
||||||
<flux:menu.item icon="mashup-2x2" wire:click="renderPreview('quadrant')">Quadrant</flux:menu.item>
|
<flux:menu.item icon="mashup-2x2" wire:click="renderPreview('quadrant')" :disabled="$plugin->hasMissingRequiredConfigurationFields()">Quadrant</flux:menu.item>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
</flux:menu>
|
</flux:menu>
|
||||||
</flux:dropdown>
|
</flux:dropdown>
|
||||||
|
|
@ -321,7 +382,7 @@ HTML;
|
||||||
</flux:button.group>
|
</flux:button.group>
|
||||||
<flux:button.group>
|
<flux:button.group>
|
||||||
<flux:modal.trigger name="add-to-playlist">
|
<flux:modal.trigger name="add-to-playlist">
|
||||||
<flux:button icon="play" variant="primary">Add to Playlist</flux:button>
|
<flux:button icon="play" variant="primary" :disabled="$plugin->hasMissingRequiredConfigurationFields()">Add to Playlist</flux:button>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
|
|
||||||
<flux:dropdown>
|
<flux:dropdown>
|
||||||
|
|
@ -429,7 +490,7 @@ HTML;
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<flux:spacer/>
|
<flux:spacer/>
|
||||||
<flux:button type="submit" variant="primary">Add to Playlist</flux:button>
|
<flux:button type="submit" variant="primary" :disabled="$plugin->hasMissingRequiredConfigurationFields()">Add to Playlist</flux:button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -461,6 +522,143 @@ HTML;
|
||||||
</div>
|
</div>
|
||||||
</flux:modal>
|
</flux:modal>
|
||||||
|
|
||||||
|
<flux:modal name="configuration-modal" class="md:w-96">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Configuration</flux:heading>
|
||||||
|
<flux:subheading>Configure your plugin settings</flux:subheading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="saveConfiguration">
|
||||||
|
@if(isset($configuration_template['custom_fields']) && is_array($configuration_template['custom_fields']))
|
||||||
|
@foreach($configuration_template['custom_fields'] as $field)
|
||||||
|
@php
|
||||||
|
$fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name'];
|
||||||
|
$currentValue = $configuration[$fieldKey] ?? '';
|
||||||
|
@endphp
|
||||||
|
<div class="mb-8">
|
||||||
|
@if($field['field_type'] === 'author_bio')
|
||||||
|
@continue
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($field['field_type'] === 'copyable_webhook_url')
|
||||||
|
@continue
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($field['field_type'] === 'string' || $field['field_type'] === 'url')
|
||||||
|
<flux:input
|
||||||
|
label="{{ $field['name'] }}"
|
||||||
|
description="{{ $field['description'] ?? '' }}"
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
value="{{ $currentValue }}"
|
||||||
|
/>
|
||||||
|
@elseif($field['field_type'] === 'password')
|
||||||
|
<flux:input
|
||||||
|
type="password"
|
||||||
|
label="{{ $field['name'] }}"
|
||||||
|
description="{{ $field['description'] ?? '' }}"
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
value="{{ $currentValue }}"
|
||||||
|
viewable
|
||||||
|
/>
|
||||||
|
@elseif($field['field_type'] === 'copyable')
|
||||||
|
<flux:input
|
||||||
|
label="{{ $field['name'] }}"
|
||||||
|
description="{{ $field['description'] ?? '' }}"
|
||||||
|
value="{{ $field['value'] }}"
|
||||||
|
copyable
|
||||||
|
/>
|
||||||
|
@elseif($field['field_type'] === 'time_zone')
|
||||||
|
<flux:select
|
||||||
|
label="{{ $field['name'] }}"
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
description="{{ $field['description'] ?? '' }}"
|
||||||
|
>
|
||||||
|
<option value="">Select timezone...</option>
|
||||||
|
@foreach(timezone_identifiers_list() as $timezone)
|
||||||
|
<option value="{{ $timezone }}" {{ $currentValue === $timezone ? 'selected' : '' }}>{{ $timezone }}</option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
@elseif($field['field_type'] === 'number')
|
||||||
|
<flux:input
|
||||||
|
type="number"
|
||||||
|
label="{{ $field['name'] }}"
|
||||||
|
description="{{ $field['description'] ?? $field['name'] }}"
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
value="{{ $currentValue }}"
|
||||||
|
/>
|
||||||
|
@elseif($field['field_type'] === 'boolean')
|
||||||
|
<flux:checkbox
|
||||||
|
label="{{ $field['name'] }}"
|
||||||
|
description="{{ $field['description'] ?? $field['name'] }}"
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
:checked="$currentValue"
|
||||||
|
/>
|
||||||
|
@elseif($field['field_type'] === 'date')
|
||||||
|
<flux:input
|
||||||
|
type="date"
|
||||||
|
label="{{ $field['name'] }}"
|
||||||
|
description="{{ $field['description'] ?? $field['name'] }}"
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
value="{{ $currentValue }}"
|
||||||
|
/>
|
||||||
|
@elseif($field['field_type'] === 'select')
|
||||||
|
@if(isset($field['multiple']) && $field['multiple'] === true)
|
||||||
|
<flux:checkbox.group
|
||||||
|
label="{{ $field['name'] }}"
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
description="{{ $field['description'] ?? '' }}"
|
||||||
|
>
|
||||||
|
@if(isset($field['options']) && is_array($field['options']))
|
||||||
|
@foreach($field['options'] as $option)
|
||||||
|
@if(is_array($option))
|
||||||
|
@foreach($option as $label => $value)
|
||||||
|
<flux:checkbox label="{{ $label }}" value="{{ $value }}"/>
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
<flux:checkbox label="{{ $option }}" value="{{ $option }}"/>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
</flux:checkbox.group>
|
||||||
|
@else
|
||||||
|
<flux:select
|
||||||
|
label="{{ $field['name'] }}"
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
description="{{ $field['description'] ?? '' }}"
|
||||||
|
>
|
||||||
|
<option value="">Select {{ $field['name'] }}...</option>
|
||||||
|
@if(isset($field['options']) && is_array($field['options']))
|
||||||
|
@foreach($field['options'] as $option)
|
||||||
|
@if(is_array($option))
|
||||||
|
@foreach($option as $label => $value)
|
||||||
|
<option value="{{ $value }}" {{ $currentValue === $value ? 'selected' : '' }}>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
@php
|
||||||
|
$key = mb_strtolower(str_replace(' ', '_', $option));
|
||||||
|
@endphp
|
||||||
|
<option value="{{ $key }}" {{ $currentValue === $key ? 'selected' : '' }}>{{ $option }}</option>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
</flux:select>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<p>{{ $field['name'] }}: Field type "{{ $field['field_type'] }}" not yet supported</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<flux:spacer/>
|
||||||
|
<flux:button type="submit" variant="primary">Save Configuration</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
||||||
<div class="mt-5 mb-5">
|
<div class="mt-5 mb-5">
|
||||||
<h3 class="text-xl font-semibold dark:text-gray-100">Settings</h3>
|
<h3 class="text-xl font-semibold dark:text-gray-100">Settings</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -472,6 +670,85 @@ HTML;
|
||||||
name="name" autofocus/>
|
name="name" autofocus/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$authorField = null;
|
||||||
|
if (isset($configuration_template['custom_fields'])) {
|
||||||
|
foreach ($configuration_template['custom_fields'] as $field) {
|
||||||
|
if ($field['field_type'] === 'author_bio') {
|
||||||
|
$authorField = $field;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if($authorField)
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-4">
|
||||||
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ $authorField['description'] }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(isset($authorField['github_url']) || isset($authorField['learn_more_url']) || isset($authorField['email_address']))
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
@if(isset($authorField['github_url']))
|
||||||
|
@php
|
||||||
|
$githubUrl = $authorField['github_url'];
|
||||||
|
$githubUsername = null;
|
||||||
|
|
||||||
|
// Extract username from various GitHub URL formats
|
||||||
|
if (preg_match('/github\.com\/([^\/\?]+)/', $githubUrl, $matches)) {
|
||||||
|
$githubUsername = $matches[1];
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
@if($githubUsername)<flux:label badge="{{ $githubUsername }}"/>@endif
|
||||||
|
@endif
|
||||||
|
@if(isset($authorField['learn_more_url']))
|
||||||
|
<flux:button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon:trailing="arrow-up-right"
|
||||||
|
href="{{ $authorField['learn_more_url'] }}"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(isset($authorField['github_url']))
|
||||||
|
<flux:button
|
||||||
|
size="sm"
|
||||||
|
icon="github"
|
||||||
|
variant="ghost"
|
||||||
|
href="{{ $authorField['github_url'] }}"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(isset($authorField['email_address']))
|
||||||
|
<flux:button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon="envelope"
|
||||||
|
href="mailto:{{ $authorField['email_address'] }}"
|
||||||
|
>
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(isset($configuration_template['custom_fields']) && !empty($configuration_template['custom_fields']))
|
||||||
|
@if($plugin->hasMissingRequiredConfigurationFields())
|
||||||
|
<flux:callout class="mb-2" variant="warning" icon="exclamation-circle" heading="Please set required configuration fields." />
|
||||||
|
@endif
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:modal.trigger name="configuration-modal">
|
||||||
|
<flux:button icon="cog" class="block mt-1 w-full">Configuration</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:radio.group wire:model.live="data_strategy" label="Data Strategy" variant="segmented">
|
<flux:radio.group wire:model.live="data_strategy" label="Data Strategy" variant="segmented">
|
||||||
<flux:radio value="polling" label="Polling"/>
|
<flux:radio value="polling" label="Polling"/>
|
||||||
|
|
@ -482,15 +759,13 @@ HTML;
|
||||||
|
|
||||||
@if($data_strategy === 'polling')
|
@if($data_strategy === 'polling')
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:input label="Polling URL" wire:model="polling_url" id="polling_url"
|
<flux:textarea label="Polling URL" description="You can use configuration variables with Liquid syntax. Supports multiple requests via line break separation" wire:model="polling_url" id="polling_url"
|
||||||
placeholder="https://example.com/api"
|
placeholder="https://example.com/api"
|
||||||
class="block mt-1 w-full" type="text" name="polling_url" autofocus>
|
class="block w-full" type="text" name="polling_url" autofocus>
|
||||||
<x-slot name="iconTrailing">
|
|
||||||
<flux:button size="sm" variant="subtle" icon="cloud-arrow-down"
|
|
||||||
wire:click="updateData"
|
|
||||||
tooltip="Fetch data now" class="-mr-1"/>
|
|
||||||
</x-slot>
|
|
||||||
</flux:input>
|
</flux:input>
|
||||||
|
<flux:button icon="cloud-arrow-down" wire:click="updateData" class="block mt-2 w-full">
|
||||||
|
Fetch data now
|
||||||
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
|
|
@ -533,6 +808,7 @@ HTML;
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:input
|
<flux:input
|
||||||
label="Webhook URL"
|
label="Webhook URL"
|
||||||
|
descriptionTrailing="Send JSON payload with key <code>merge_variables</code> to the webhook URL. The payload will be merged with the plugin data."
|
||||||
:value="route('api.custom_plugins.webhook', ['plugin_uuid' => $plugin->uuid])"
|
:value="route('api.custom_plugins.webhook', ['plugin_uuid' => $plugin->uuid])"
|
||||||
class="block mt-1 w-full font-mono"
|
class="block mt-1 w-full font-mono"
|
||||||
readonly
|
readonly
|
||||||
|
|
@ -540,19 +816,13 @@ HTML;
|
||||||
>
|
>
|
||||||
</flux:input>
|
</flux:input>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p>Send JSON payload with key <code>merge_variables</code> to the webhook URL. The payload
|
|
||||||
will be merged with the plugin data.</p>
|
|
||||||
</div>
|
|
||||||
@elseif($data_strategy === 'static')
|
@elseif($data_strategy === 'static')
|
||||||
<div>
|
<flux:text class="mb-2">Enter static JSON data in the Data Payload field.</flux:text>
|
||||||
<p>Enter static JSON data in the Data Payload field.</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<flux:spacer/>
|
<flux:spacer/>
|
||||||
<flux:button type="submit" variant="primary">Save</flux:button>
|
<flux:button type="submit" variant="primary" class="w-full">Save</flux:button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -627,6 +897,8 @@ HTML;
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@script
|
@script
|
||||||
<script>
|
<script>
|
||||||
$wire.on('preview-updated', ({preview}) => {
|
$wire.on('preview-updated', ({preview}) => {
|
||||||
|
|
|
||||||
126
tests/Feature/PluginDefaultValuesTest.php
Normal file
126
tests/Feature/PluginDefaultValuesTest.php
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Plugin;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('plugin import extracts default values from custom_fields and stores in configuration', function () {
|
||||||
|
// Create a user
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
// Test the functionality directly by creating a plugin with the expected configuration
|
||||||
|
$configurationTemplate = [
|
||||||
|
'custom_fields' => [
|
||||||
|
[
|
||||||
|
'keyname' => 'reading_days',
|
||||||
|
'field_type' => 'string',
|
||||||
|
'name' => 'Reading Days',
|
||||||
|
'description' => 'Select days of the week to read',
|
||||||
|
'default' => 'Monday,Friday,Saturday,Sunday'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'keyname' => 'refresh_interval',
|
||||||
|
'field_type' => 'number',
|
||||||
|
'name' => 'Refresh Interval',
|
||||||
|
'description' => 'How often to refresh data',
|
||||||
|
'default' => 15
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'keyname' => 'timezone',
|
||||||
|
'field_type' => 'time_zone',
|
||||||
|
'name' => 'Timezone',
|
||||||
|
'description' => 'Select your timezone'
|
||||||
|
// No default value
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Extract default values from custom_fields and populate configuration
|
||||||
|
$configuration = [];
|
||||||
|
if (isset($configurationTemplate['custom_fields']) && is_array($configurationTemplate['custom_fields'])) {
|
||||||
|
foreach ($configurationTemplate['custom_fields'] as $field) {
|
||||||
|
if (isset($field['keyname']) && isset($field['default'])) {
|
||||||
|
$configuration[$field['keyname']] = $field['default'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the plugin directly
|
||||||
|
$plugin = Plugin::create([
|
||||||
|
'uuid' => \Illuminate\Support\Str::uuid(),
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Test Plugin with Defaults',
|
||||||
|
'data_stale_minutes' => 30,
|
||||||
|
'data_strategy' => 'static',
|
||||||
|
'configuration_template' => $configurationTemplate,
|
||||||
|
'configuration' => $configuration,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert the plugin was created with correct configuration
|
||||||
|
expect($plugin)->not->toBeNull();
|
||||||
|
expect($plugin->configuration)->toBeArray();
|
||||||
|
expect($plugin->configuration)->toHaveKey('reading_days');
|
||||||
|
expect($plugin->configuration)->toHaveKey('refresh_interval');
|
||||||
|
expect($plugin->configuration)->not->toHaveKey('timezone');
|
||||||
|
|
||||||
|
expect($plugin->getConfiguration('reading_days'))->toBe('Monday,Friday,Saturday,Sunday');
|
||||||
|
expect($plugin->getConfiguration('refresh_interval'))->toBe(15);
|
||||||
|
expect($plugin->getConfiguration('timezone'))->toBeNull();
|
||||||
|
|
||||||
|
// Verify configuration template was stored correctly
|
||||||
|
expect($plugin->configuration_template)->toBeArray();
|
||||||
|
expect($plugin->configuration_template['custom_fields'])->toHaveCount(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin import handles custom_fields without default values', function () {
|
||||||
|
// Create a user
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
// Test the functionality directly by creating a plugin with no default values
|
||||||
|
$configurationTemplate = [
|
||||||
|
'custom_fields' => [
|
||||||
|
[
|
||||||
|
'keyname' => 'timezone',
|
||||||
|
'field_type' => 'time_zone',
|
||||||
|
'name' => 'Timezone',
|
||||||
|
'description' => 'Select your timezone'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Extract default values from custom_fields and populate configuration
|
||||||
|
$configuration = [];
|
||||||
|
if (isset($configurationTemplate['custom_fields']) && is_array($configurationTemplate['custom_fields'])) {
|
||||||
|
foreach ($configurationTemplate['custom_fields'] as $field) {
|
||||||
|
if (isset($field['keyname']) && isset($field['default'])) {
|
||||||
|
$configuration[$field['keyname']] = $field['default'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the plugin directly
|
||||||
|
$plugin = Plugin::create([
|
||||||
|
'uuid' => \Illuminate\Support\Str::uuid(),
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Test Plugin No Defaults',
|
||||||
|
'data_stale_minutes' => 30,
|
||||||
|
'data_strategy' => 'static',
|
||||||
|
'configuration_template' => $configurationTemplate,
|
||||||
|
'configuration' => $configuration,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert the plugin was created with empty configuration
|
||||||
|
expect($plugin)->not->toBeNull();
|
||||||
|
expect($plugin->configuration)->toBeArray();
|
||||||
|
expect($plugin->configuration)->toBeEmpty();
|
||||||
|
|
||||||
|
// Verify configuration template was stored correctly
|
||||||
|
expect($plugin->configuration_template)->toBeArray();
|
||||||
|
expect($plugin->configuration_template['custom_fields'])->toHaveCount(1);
|
||||||
|
});
|
||||||
179
tests/Feature/PluginImportTest.php
Normal file
179
tests/Feature/PluginImportTest.php
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Plugin;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\PluginImportService;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Storage::fake('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports plugin from valid zip file', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
// Create a mock ZIP file with the required structure
|
||||||
|
$zipContent = createMockZipFile([
|
||||||
|
'src/settings.yml' => getValidSettingsYaml(),
|
||||||
|
'src/full.liquid' => getValidFullLiquid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
|
||||||
|
|
||||||
|
$pluginImportService = new PluginImportService();
|
||||||
|
$plugin = $pluginImportService->importFromZip($zipFile, $user);
|
||||||
|
|
||||||
|
expect($plugin)->toBeInstanceOf(Plugin::class)
|
||||||
|
->and($plugin->user_id)->toBe($user->id)
|
||||||
|
->and($plugin->name)->toBe('Test Plugin')
|
||||||
|
->and($plugin->data_stale_minutes)->toBe(30)
|
||||||
|
->and($plugin->data_strategy)->toBe('static')
|
||||||
|
->and($plugin->markup_language)->toBe('liquid')
|
||||||
|
->and($plugin->configuration_template)->toHaveKey('custom_fields')
|
||||||
|
->and($plugin->configuration)->toHaveKey('api_key')
|
||||||
|
->and($plugin->configuration['api_key'])->toBe('default-api-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports plugin with shared.liquid file', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$zipContent = createMockZipFile([
|
||||||
|
'src/settings.yml' => getValidSettingsYaml(),
|
||||||
|
'src/full.liquid' => getValidFullLiquid(),
|
||||||
|
'src/shared.liquid' => '{% comment %}Shared styles{% endcomment %}',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
|
||||||
|
|
||||||
|
$pluginImportService = new PluginImportService();
|
||||||
|
$plugin = $pluginImportService->importFromZip($zipFile, $user);
|
||||||
|
|
||||||
|
expect($plugin->render_markup)->toContain('{% comment %}Shared styles{% endcomment %}')
|
||||||
|
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports plugin with files in root directory', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$zipContent = createMockZipFile([
|
||||||
|
'settings.yml' => getValidSettingsYaml(),
|
||||||
|
'full.liquid' => getValidFullLiquid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
|
||||||
|
|
||||||
|
$pluginImportService = new PluginImportService();
|
||||||
|
$plugin = $pluginImportService->importFromZip($zipFile, $user);
|
||||||
|
|
||||||
|
expect($plugin)->toBeInstanceOf(Plugin::class)
|
||||||
|
->and($plugin->name)->toBe('Test Plugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws exception for invalid zip file', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$zipFile = UploadedFile::fake()->createWithContent('invalid.zip', 'not a zip file');
|
||||||
|
|
||||||
|
$pluginImportService = new PluginImportService();
|
||||||
|
expect(fn () => $pluginImportService->importFromZip($zipFile, $user))
|
||||||
|
->toThrow(Exception::class, 'Could not open the ZIP file.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws exception for missing required files', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$zipContent = createMockZipFile([
|
||||||
|
'src/settings.yml' => getValidSettingsYaml(),
|
||||||
|
// Missing full.liquid
|
||||||
|
]);
|
||||||
|
|
||||||
|
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
|
||||||
|
|
||||||
|
$pluginImportService = new PluginImportService();
|
||||||
|
expect(fn () => $pluginImportService->importFromZip($zipFile, $user))
|
||||||
|
->toThrow(Exception::class, 'Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets default values when settings are missing', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$zipContent = createMockZipFile([
|
||||||
|
'src/settings.yml' => "name: Minimal Plugin\n",
|
||||||
|
'src/full.liquid' => getValidFullLiquid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
|
||||||
|
|
||||||
|
$pluginImportService = new PluginImportService();
|
||||||
|
$plugin = $pluginImportService->importFromZip($zipFile, $user);
|
||||||
|
|
||||||
|
expect($plugin->name)->toBe('Minimal Plugin')
|
||||||
|
->and($plugin->data_stale_minutes)->toBe(15) // default value
|
||||||
|
->and($plugin->data_strategy)->toBe('static') // default value
|
||||||
|
->and($plugin->polling_verb)->toBe('get'); // default value
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles blade markup language correctly', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$zipContent = createMockZipFile([
|
||||||
|
'src/settings.yml' => getValidSettingsYaml(),
|
||||||
|
'src/full.blade.php' => '<div>Blade template</div>',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
|
||||||
|
|
||||||
|
$pluginImportService = new PluginImportService();
|
||||||
|
$plugin = $pluginImportService->importFromZip($zipFile, $user);
|
||||||
|
|
||||||
|
expect($plugin->markup_language)->toBe('blade');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
function createMockZipFile(array $files): string
|
||||||
|
{
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
$tempFile = tempnam(sys_get_temp_dir(), 'test_zip_');
|
||||||
|
|
||||||
|
$zip->open($tempFile, ZipArchive::CREATE);
|
||||||
|
|
||||||
|
foreach ($files as $path => $content) {
|
||||||
|
$zip->addFromString($path, $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
$content = file_get_contents($tempFile);
|
||||||
|
unlink($tempFile);
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValidSettingsYaml(): string
|
||||||
|
{
|
||||||
|
return <<<'YAML'
|
||||||
|
name: Test Plugin
|
||||||
|
refresh_interval: 30
|
||||||
|
strategy: static
|
||||||
|
polling_verb: get
|
||||||
|
static_data: '{"test": "data"}'
|
||||||
|
custom_fields:
|
||||||
|
- keyname: api_key
|
||||||
|
field_type: text
|
||||||
|
default: default-api-key
|
||||||
|
label: API Key
|
||||||
|
YAML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValidFullLiquid(): string
|
||||||
|
{
|
||||||
|
return <<<'LIQUID'
|
||||||
|
<div class="plugin-content">
|
||||||
|
<h1>{{ data.title }}</h1>
|
||||||
|
<p>{{ data.description }}</p>
|
||||||
|
</div>
|
||||||
|
LIQUID;
|
||||||
|
}
|
||||||
175
tests/Feature/PluginInlineTemplatesTest.php
Normal file
175
tests/Feature/PluginInlineTemplatesTest.php
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Plugin;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PluginInlineTemplatesTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_plugin_with_inline_templates(): void
|
||||||
|
{
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'name' => 'Test Plugin',
|
||||||
|
'markup_language' => 'liquid',
|
||||||
|
'render_markup' => <<<'LIQUID'
|
||||||
|
{% assign min = 1 %}
|
||||||
|
{% assign max = facts | size %}
|
||||||
|
{% assign diff = max | minus: min %}
|
||||||
|
{% assign randomNumber = "now" | date: "u" | modulo: diff | plus: min %}
|
||||||
|
|
||||||
|
{% template session %}
|
||||||
|
<div class="layout">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="markdown gap--large">
|
||||||
|
<div class="value{{ size_mod }} text--center">
|
||||||
|
{{ facts[randomNumber] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endtemplate %}
|
||||||
|
|
||||||
|
{% template title_bar %}
|
||||||
|
<div class="title_bar">
|
||||||
|
<img class="image" src="https://res.jwq.lol/img/lumon.svg">
|
||||||
|
<span class="title">{{ trmnl.plugin_settings.instance_name }}</span>
|
||||||
|
<span class="instance">{{ instance }}</span>
|
||||||
|
</div>
|
||||||
|
{% endtemplate %}
|
||||||
|
|
||||||
|
<div class="view view--{{ size }}">
|
||||||
|
{% render "session",
|
||||||
|
trmnl: trmnl,
|
||||||
|
facts: facts,
|
||||||
|
randomNumber: randomNumber,
|
||||||
|
size_mod: ""
|
||||||
|
%}
|
||||||
|
|
||||||
|
{% render "title_bar",
|
||||||
|
trmnl: trmnl,
|
||||||
|
instance: "Please try to enjoy each fact equally."
|
||||||
|
%}
|
||||||
|
</div>
|
||||||
|
LIQUID
|
||||||
|
,
|
||||||
|
'data_payload' => [
|
||||||
|
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $plugin->render('full');
|
||||||
|
|
||||||
|
// Should render both templates
|
||||||
|
// Check for any of the facts (since random number generation is non-deterministic)
|
||||||
|
$this->assertTrue(
|
||||||
|
str_contains($result, 'Fact 1') ||
|
||||||
|
str_contains($result, 'Fact 2') ||
|
||||||
|
str_contains($result, 'Fact 3')
|
||||||
|
);
|
||||||
|
$this->assertStringContainsString('Test Plugin', $result);
|
||||||
|
$this->assertStringContainsString('Please try to enjoy each fact equally', $result);
|
||||||
|
$this->assertStringContainsString('class="view view--full"', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_plugin_with_inline_templates_using_with_syntax(): void
|
||||||
|
{
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'name' => 'Test Plugin',
|
||||||
|
'markup_language' => 'liquid',
|
||||||
|
'render_markup' => <<<'LIQUID'
|
||||||
|
{% assign min = 1 %}
|
||||||
|
{% assign max = facts | size %}
|
||||||
|
{% assign diff = max | minus: min %}
|
||||||
|
{% assign randomNumber = "now" | date: "u" | modulo: diff | plus: min %}
|
||||||
|
|
||||||
|
{% template session %}
|
||||||
|
<div class="layout">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="markdown gap--large">
|
||||||
|
<div class="value{{ size_mod }} text--center">
|
||||||
|
{{ facts[randomNumber] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endtemplate %}
|
||||||
|
|
||||||
|
{% template title_bar %}
|
||||||
|
<div class="title_bar">
|
||||||
|
<img class="image" src="https://res.jwq.lol/img/lumon.svg">
|
||||||
|
<span class="title">{{ trmnl.plugin_settings.instance_name }}</span>
|
||||||
|
<span class="instance">{{ instance }}</span>
|
||||||
|
</div>
|
||||||
|
{% endtemplate %}
|
||||||
|
|
||||||
|
<div class="view view--{{ size }}">
|
||||||
|
{% render "session" with
|
||||||
|
trmnl: trmnl,
|
||||||
|
facts: facts,
|
||||||
|
randomNumber: randomNumber,
|
||||||
|
size_mod: ""
|
||||||
|
%}
|
||||||
|
|
||||||
|
{% render "title_bar" with
|
||||||
|
trmnl: trmnl,
|
||||||
|
instance: "Please try to enjoy each fact equally."
|
||||||
|
%}
|
||||||
|
</div>
|
||||||
|
LIQUID
|
||||||
|
,
|
||||||
|
'data_payload' => [
|
||||||
|
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $plugin->render('full');
|
||||||
|
|
||||||
|
// Should render both templates
|
||||||
|
// Check for any of the facts (since random number generation is non-deterministic)
|
||||||
|
$this->assertTrue(
|
||||||
|
str_contains($result, 'Fact 1') ||
|
||||||
|
str_contains($result, 'Fact 2') ||
|
||||||
|
str_contains($result, 'Fact 3')
|
||||||
|
);
|
||||||
|
$this->assertStringContainsString('Test Plugin', $result);
|
||||||
|
$this->assertStringContainsString('Please try to enjoy each fact equally', $result);
|
||||||
|
$this->assertStringContainsString('class="view view--full"', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_plugin_with_simple_inline_template(): void
|
||||||
|
{
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'markup_language' => 'liquid',
|
||||||
|
'render_markup' => <<<'LIQUID'
|
||||||
|
{% template simple %}
|
||||||
|
<div class="simple">
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
<p>{{ content }}</p>
|
||||||
|
</div>
|
||||||
|
{% endtemplate %}
|
||||||
|
|
||||||
|
{% render "simple",
|
||||||
|
title: "Hello World",
|
||||||
|
content: "This is a test"
|
||||||
|
%}
|
||||||
|
LIQUID
|
||||||
|
,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $plugin->render('full');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('Hello World', $result);
|
||||||
|
$this->assertStringContainsString('This is a test', $result);
|
||||||
|
$this->assertStringContainsString('class="simple"', $result);
|
||||||
|
}
|
||||||
|
}
|
||||||
218
tests/Feature/PluginRequiredConfigurationTest.php
Normal file
218
tests/Feature/PluginRequiredConfigurationTest.php
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Plugin;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('hasMissingRequiredConfigurationFields returns true when required field is null', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$configurationTemplate = [
|
||||||
|
'custom_fields' => [
|
||||||
|
[
|
||||||
|
'keyname' => 'api_key',
|
||||||
|
'field_type' => 'string',
|
||||||
|
'name' => 'API Key',
|
||||||
|
'description' => 'Your API key',
|
||||||
|
// Not marked as optional, so it's required
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'keyname' => 'timezone',
|
||||||
|
'field_type' => 'time_zone',
|
||||||
|
'name' => 'Timezone',
|
||||||
|
'description' => 'Select your timezone',
|
||||||
|
'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
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasMissingRequiredConfigurationFields returns false when all required fields are set', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$configurationTemplate = [
|
||||||
|
'custom_fields' => [
|
||||||
|
[
|
||||||
|
'keyname' => 'api_key',
|
||||||
|
'field_type' => 'string',
|
||||||
|
'name' => 'API Key',
|
||||||
|
'description' => 'Your API key',
|
||||||
|
// Not marked as optional, so it's required
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'keyname' => 'timezone',
|
||||||
|
'field_type' => 'time_zone',
|
||||||
|
'name' => 'Timezone',
|
||||||
|
'description' => 'Select your timezone',
|
||||||
|
'optional' => true // Marked as optional
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'configuration_template' => $configurationTemplate,
|
||||||
|
'configuration' => [
|
||||||
|
'api_key' => 'test-api-key', // Required field is set
|
||||||
|
'timezone' => 'UTC'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasMissingRequiredConfigurationFields returns false when no custom fields exist', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'configuration_template' => [],
|
||||||
|
'configuration' => []
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasMissingRequiredConfigurationFields returns true when explicitly required field is null', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$configurationTemplate = [
|
||||||
|
'custom_fields' => [
|
||||||
|
[
|
||||||
|
'keyname' => 'api_key',
|
||||||
|
'field_type' => 'string',
|
||||||
|
'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
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasMissingRequiredConfigurationFields returns true when required field is empty string', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$configurationTemplate = [
|
||||||
|
'custom_fields' => [
|
||||||
|
[
|
||||||
|
'keyname' => 'api_key',
|
||||||
|
'field_type' => 'string',
|
||||||
|
'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
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasMissingRequiredConfigurationFields returns true when required array field is empty', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$configurationTemplate = [
|
||||||
|
'custom_fields' => [
|
||||||
|
[
|
||||||
|
'keyname' => 'selected_items',
|
||||||
|
'field_type' => 'select',
|
||||||
|
'name' => 'Selected Items',
|
||||||
|
'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
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($plugin->hasMissingRequiredConfigurationFields())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasMissingRequiredConfigurationFields returns false when author_bio field is present but other required field is set', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$configurationTemplate = [
|
||||||
|
'custom_fields' => [
|
||||||
|
[
|
||||||
|
'keyname' => 'author_bio',
|
||||||
|
'name' => 'About This Plugin',
|
||||||
|
'field_type' => 'author_bio',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'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
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasMissingRequiredConfigurationFields returns false when field has default value', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$configurationTemplate = [
|
||||||
|
'custom_fields' => [
|
||||||
|
[
|
||||||
|
'keyname' => 'api_key',
|
||||||
|
'field_type' => 'string',
|
||||||
|
'name' => 'API Key',
|
||||||
|
'description' => 'Your API key',
|
||||||
|
'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
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($plugin->hasMissingRequiredConfigurationFields())->toBeFalse();
|
||||||
|
});
|
||||||
297
tests/Unit/Liquid/InlineTemplatesTest.php
Normal file
297
tests/Unit/Liquid/InlineTemplatesTest.php
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Liquid;
|
||||||
|
|
||||||
|
use App\Liquid\FileSystems\InlineTemplatesFileSystem;
|
||||||
|
use App\Liquid\Tags\TemplateTag;
|
||||||
|
use Keepsuit\Liquid\Environment;
|
||||||
|
use Keepsuit\Liquid\Exceptions\LiquidException;
|
||||||
|
use Keepsuit\Liquid\Tags\RenderTag;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class InlineTemplatesTest extends TestCase
|
||||||
|
{
|
||||||
|
protected Environment $environment;
|
||||||
|
|
||||||
|
protected InlineTemplatesFileSystem $fileSystem;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->fileSystem = new InlineTemplatesFileSystem();
|
||||||
|
$this->environment = new Environment(
|
||||||
|
fileSystem: $this->fileSystem
|
||||||
|
);
|
||||||
|
$this->environment->tagRegistry->register(TemplateTag::class);
|
||||||
|
$this->environment->tagRegistry->register(RenderTag::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_template_tag_registers_template(): void
|
||||||
|
{
|
||||||
|
$template = $this->environment->parseString(<<<'LIQUID'
|
||||||
|
{% template session %}
|
||||||
|
<div class="layout">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="markdown gap--large">
|
||||||
|
<div class="value{{ size_mod }} text--center">
|
||||||
|
{{ facts[randomNumber] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endtemplate %}
|
||||||
|
LIQUID
|
||||||
|
);
|
||||||
|
|
||||||
|
$context = $this->environment->newRenderContext(
|
||||||
|
data: [
|
||||||
|
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
|
||||||
|
'randomNumber' => 1,
|
||||||
|
'size_mod' => '--large',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $template->render($context);
|
||||||
|
|
||||||
|
// Template tag should not output anything
|
||||||
|
$this->assertEquals('', $result);
|
||||||
|
|
||||||
|
// Template should be registered in the file system
|
||||||
|
$this->assertTrue($this->fileSystem->hasTemplate('session'));
|
||||||
|
|
||||||
|
$registeredTemplate = $this->fileSystem->readTemplateFile('session');
|
||||||
|
$this->assertStringContainsString('{{ facts[randomNumber] }}', $registeredTemplate);
|
||||||
|
$this->assertStringContainsString('{{ size_mod }}', $registeredTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_template_tag_with_render_tag(): void
|
||||||
|
{
|
||||||
|
$template = $this->environment->parseString(<<<'LIQUID'
|
||||||
|
{% template session %}
|
||||||
|
<div class="layout">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="markdown gap--large">
|
||||||
|
<div class="value{{ size_mod }} text--center">
|
||||||
|
{{ facts[randomNumber] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endtemplate %}
|
||||||
|
|
||||||
|
{% render "session",
|
||||||
|
trmnl: trmnl,
|
||||||
|
facts: facts,
|
||||||
|
randomNumber: randomNumber,
|
||||||
|
size_mod: ""
|
||||||
|
%}
|
||||||
|
LIQUID
|
||||||
|
);
|
||||||
|
|
||||||
|
$context = $this->environment->newRenderContext(
|
||||||
|
data: [
|
||||||
|
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
|
||||||
|
'randomNumber' => 1,
|
||||||
|
'trmnl' => ['plugin_settings' => ['instance_name' => 'Test']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $template->render($context);
|
||||||
|
|
||||||
|
// Should render the template content
|
||||||
|
$this->assertStringContainsString('Fact 2', $result); // facts[1]
|
||||||
|
$this->assertStringContainsString('class="layout"', $result);
|
||||||
|
$this->assertStringContainsString('class="value text--center"', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_apply_liquid_replacements_converts_with_syntax(): void
|
||||||
|
{
|
||||||
|
// This test simulates the applyLiquidReplacements method from the Plugin model
|
||||||
|
$originalLiquid = <<<'LIQUID'
|
||||||
|
{% template session %}
|
||||||
|
<div class="layout">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="markdown gap--large">
|
||||||
|
<div class="value{{ size_mod }} text--center">
|
||||||
|
{{ facts[randomNumber] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endtemplate %}
|
||||||
|
|
||||||
|
{% render "session" with
|
||||||
|
trmnl: trmnl,
|
||||||
|
facts: facts,
|
||||||
|
randomNumber: randomNumber,
|
||||||
|
size_mod: ""
|
||||||
|
%}
|
||||||
|
LIQUID;
|
||||||
|
|
||||||
|
// Apply the same replacement logic as in Plugin::applyLiquidReplacements
|
||||||
|
$convertedLiquid = preg_replace(
|
||||||
|
'/{%\s*render\s+([^}]+?)\s+with\s+/i',
|
||||||
|
'{% render $1, ',
|
||||||
|
$originalLiquid
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the conversion worked
|
||||||
|
$this->assertStringContainsString('{% render "session",', $convertedLiquid);
|
||||||
|
$this->assertStringNotContainsString('{% render "session" with', $convertedLiquid);
|
||||||
|
|
||||||
|
// Verify the rest of the content is preserved
|
||||||
|
$this->assertStringContainsString('trmnl: trmnl,', $convertedLiquid);
|
||||||
|
$this->assertStringContainsString('facts: facts,', $convertedLiquid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_template_tag_with_render_with_tag(): void
|
||||||
|
{
|
||||||
|
$originalLiquid = <<<'LIQUID'
|
||||||
|
{% template session %}
|
||||||
|
<div class="layout">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="markdown gap--large">
|
||||||
|
<div class="value{{ size_mod }} text--center">
|
||||||
|
{{ facts[randomNumber] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endtemplate %}
|
||||||
|
|
||||||
|
{% render "session" with
|
||||||
|
trmnl: trmnl,
|
||||||
|
facts: facts,
|
||||||
|
randomNumber: randomNumber,
|
||||||
|
size_mod: ""
|
||||||
|
%}
|
||||||
|
LIQUID;
|
||||||
|
|
||||||
|
// Apply the same replacement logic as in applyLiquidReplacements
|
||||||
|
$convertedLiquid = preg_replace(
|
||||||
|
'/{%\s*render\s+([^}]+?)\s+with\s+/i',
|
||||||
|
'{% render $1, ',
|
||||||
|
$originalLiquid
|
||||||
|
);
|
||||||
|
|
||||||
|
$template = $this->environment->parseString($convertedLiquid);
|
||||||
|
|
||||||
|
$context = $this->environment->newRenderContext(
|
||||||
|
data: [
|
||||||
|
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
|
||||||
|
'randomNumber' => 1,
|
||||||
|
'trmnl' => ['plugin_settings' => ['instance_name' => 'Test']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $template->render($context);
|
||||||
|
|
||||||
|
// Should render the template content
|
||||||
|
$this->assertStringContainsString('Fact 2', $result); // facts[1]
|
||||||
|
$this->assertStringContainsString('class="layout"', $result);
|
||||||
|
$this->assertStringContainsString('class="value text--center"', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_template_tag_with_multiple_templates(): void
|
||||||
|
{
|
||||||
|
$template = $this->environment->parseString(<<<'LIQUID'
|
||||||
|
{% template session %}
|
||||||
|
<div class="layout">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<div class="markdown gap--large">
|
||||||
|
<div class="value{{ size_mod }} text--center">
|
||||||
|
{{ facts[randomNumber] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endtemplate %}
|
||||||
|
|
||||||
|
{% template title_bar %}
|
||||||
|
<div class="title_bar">
|
||||||
|
<img class="image" src="https://res.jwq.lol/img/lumon.svg">
|
||||||
|
<span class="title">{{ trmnl.plugin_settings.instance_name }}</span>
|
||||||
|
<span class="instance">{{ instance }}</span>
|
||||||
|
</div>
|
||||||
|
{% endtemplate %}
|
||||||
|
|
||||||
|
<div class="view view--{{ size }}">
|
||||||
|
{% render "session",
|
||||||
|
trmnl: trmnl,
|
||||||
|
facts: facts,
|
||||||
|
randomNumber: randomNumber,
|
||||||
|
size_mod: ""
|
||||||
|
%}
|
||||||
|
|
||||||
|
{% render "title_bar",
|
||||||
|
trmnl: trmnl,
|
||||||
|
instance: "Please try to enjoy each fact equally."
|
||||||
|
%}
|
||||||
|
</div>
|
||||||
|
LIQUID
|
||||||
|
);
|
||||||
|
|
||||||
|
$context = $this->environment->newRenderContext(
|
||||||
|
data: [
|
||||||
|
'size' => 'full',
|
||||||
|
'facts' => ['Fact 1', 'Fact 2', 'Fact 3'],
|
||||||
|
'randomNumber' => 1,
|
||||||
|
'trmnl' => ['plugin_settings' => ['instance_name' => 'Test Plugin']],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $template->render($context);
|
||||||
|
|
||||||
|
// Should render both templates
|
||||||
|
$this->assertStringContainsString('Fact 2', $result);
|
||||||
|
$this->assertStringContainsString('Test Plugin', $result);
|
||||||
|
$this->assertStringContainsString('Please try to enjoy each fact equally', $result);
|
||||||
|
$this->assertStringContainsString('class="view view--full"', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_template_tag_invalid_name(): void
|
||||||
|
{
|
||||||
|
$this->expectException(LiquidException::class);
|
||||||
|
|
||||||
|
$template = $this->environment->parseString(<<<'LIQUID'
|
||||||
|
{% template invalid-name %}
|
||||||
|
<div>Content</div>
|
||||||
|
{% endtemplate %}
|
||||||
|
LIQUID
|
||||||
|
);
|
||||||
|
|
||||||
|
$context = $this->environment->newRenderContext();
|
||||||
|
|
||||||
|
$template->render($context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_template_tag_without_file_system(): void
|
||||||
|
{
|
||||||
|
$template = $this->environment->parseString(<<<'LIQUID'
|
||||||
|
{% template session %}
|
||||||
|
<div>Content</div>
|
||||||
|
{% endtemplate %}
|
||||||
|
LIQUID
|
||||||
|
);
|
||||||
|
|
||||||
|
$context = $this->environment->newRenderContext();
|
||||||
|
|
||||||
|
$result = $template->render($context);
|
||||||
|
|
||||||
|
// Should not throw an error and should return empty string
|
||||||
|
$this->assertEquals('', $result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -72,6 +72,109 @@ test('updateDataPayload sends POST request with body when polling_verb is post',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('updateDataPayload handles multiple URLs with IDX_ prefixes', function () {
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'data_strategy' => 'polling',
|
||||||
|
'polling_url' => "https://api1.example.com/data\nhttps://api2.example.com/weather\nhttps://api3.example.com/news",
|
||||||
|
'polling_verb' => 'get',
|
||||||
|
'configuration' => [
|
||||||
|
'api_key' => 'test123',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock HTTP responses
|
||||||
|
Http::fake([
|
||||||
|
'https://api1.example.com/data' => Http::response(['data' => 'test1'], 200),
|
||||||
|
'https://api2.example.com/weather' => Http::response(['temp' => 25], 200),
|
||||||
|
'https://api3.example.com/news' => Http::response(['headline' => 'test'], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin->updateDataPayload();
|
||||||
|
|
||||||
|
expect($plugin->data_payload)->toHaveKey('IDX_0');
|
||||||
|
expect($plugin->data_payload)->toHaveKey('IDX_1');
|
||||||
|
expect($plugin->data_payload)->toHaveKey('IDX_2');
|
||||||
|
expect($plugin->data_payload['IDX_0'])->toBe(['data' => 'test1']);
|
||||||
|
expect($plugin->data_payload['IDX_1'])->toBe(['temp' => 25]);
|
||||||
|
expect($plugin->data_payload['IDX_2'])->toBe(['headline' => 'test']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateDataPayload handles single URL without nesting', function () {
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'data_strategy' => 'polling',
|
||||||
|
'polling_url' => 'https://api.example.com/data',
|
||||||
|
'polling_verb' => 'get',
|
||||||
|
'configuration' => [
|
||||||
|
'api_key' => 'test123',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock HTTP response
|
||||||
|
Http::fake([
|
||||||
|
'https://api.example.com/data' => Http::response(['data' => 'test'], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin->updateDataPayload();
|
||||||
|
|
||||||
|
expect($plugin->data_payload)->toBe(['data' => 'test']);
|
||||||
|
expect($plugin->data_payload)->not->toHaveKey('IDX_0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateDataPayload resolves Liquid variables in polling_header', function () {
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'data_strategy' => 'polling',
|
||||||
|
'polling_url' => 'https://api.example.com/data',
|
||||||
|
'polling_verb' => 'get',
|
||||||
|
'polling_header' => "Authorization: Bearer {{ api_key }}\nX-Custom-Header: {{ custom_value }}",
|
||||||
|
'configuration' => [
|
||||||
|
'api_key' => 'test123',
|
||||||
|
'custom_value' => 'custom_header_value',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock HTTP response
|
||||||
|
Http::fake([
|
||||||
|
'https://api.example.com/data' => Http::response(['data' => 'test'], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin->updateDataPayload();
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
return $request->url() === 'https://api.example.com/data' &&
|
||||||
|
$request->method() === 'GET' &&
|
||||||
|
$request->header('Authorization')[0] === 'Bearer test123' &&
|
||||||
|
$request->header('X-Custom-Header')[0] === 'custom_header_value';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateDataPayload resolves Liquid variables in polling_body', function () {
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'data_strategy' => 'polling',
|
||||||
|
'polling_url' => 'https://api.example.com/data',
|
||||||
|
'polling_verb' => 'post',
|
||||||
|
'polling_body' => '{"query": "query { user { id name } }", "api_key": "{{ api_key }}", "user_id": "{{ user_id }}"}',
|
||||||
|
'configuration' => [
|
||||||
|
'api_key' => 'test123',
|
||||||
|
'user_id' => '456',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock HTTP response
|
||||||
|
Http::fake([
|
||||||
|
'https://api.example.com/data' => Http::response(['data' => 'test'], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin->updateDataPayload();
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
$expectedBody = '{"query": "query { user { id name } }", "api_key": "test123", "user_id": "456"}';
|
||||||
|
|
||||||
|
return $request->url() === 'https://api.example.com/data' &&
|
||||||
|
$request->method() === 'POST' &&
|
||||||
|
$request->body() === $expectedBody;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('webhook plugin is stale if webhook event occurred', function () {
|
test('webhook plugin is stale if webhook event occurred', function () {
|
||||||
$plugin = Plugin::factory()->create([
|
$plugin = Plugin::factory()->create([
|
||||||
'data_strategy' => 'webhook',
|
'data_strategy' => 'webhook',
|
||||||
|
|
@ -93,3 +196,168 @@ test('webhook plugin data not stale if no webhook event occurred for 1 hour', fu
|
||||||
expect($plugin->isDataStale())->toBeFalse();
|
expect($plugin->isDataStale())->toBeFalse();
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('plugin configuration is cast to array', function () {
|
||||||
|
$config = ['timezone' => 'UTC', 'refresh_interval' => 30];
|
||||||
|
$plugin = Plugin::factory()->create(['configuration' => $config]);
|
||||||
|
|
||||||
|
expect($plugin->configuration)
|
||||||
|
->toBeArray()
|
||||||
|
->toBe($config);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin can get configuration value by key', function () {
|
||||||
|
$config = ['timezone' => 'UTC', 'refresh_interval' => 30];
|
||||||
|
$plugin = Plugin::factory()->create(['configuration' => $config]);
|
||||||
|
|
||||||
|
expect($plugin->getConfiguration('timezone'))->toBe('UTC');
|
||||||
|
expect($plugin->getConfiguration('refresh_interval'))->toBe(30);
|
||||||
|
expect($plugin->getConfiguration('nonexistent', 'default'))->toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin configuration template is cast to array', function () {
|
||||||
|
$template = [
|
||||||
|
'custom_fields' => [
|
||||||
|
[
|
||||||
|
'name' => 'Timezone',
|
||||||
|
'keyname' => 'timezone',
|
||||||
|
'field_type' => 'time_zone',
|
||||||
|
'description' => 'Select your timezone',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$plugin = Plugin::factory()->create(['configuration_template' => $template]);
|
||||||
|
|
||||||
|
expect($plugin->configuration_template)
|
||||||
|
->toBeArray()
|
||||||
|
->toBe($template);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveLiquidVariables resolves variables from configuration', function () {
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'configuration' => [
|
||||||
|
'api_key' => '12345',
|
||||||
|
'username' => 'testuser',
|
||||||
|
'count' => 42,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Test simple variable replacement
|
||||||
|
$template = 'API Key: {{ api_key }}';
|
||||||
|
$result = $plugin->resolveLiquidVariables($template);
|
||||||
|
expect($result)->toBe('API Key: 12345');
|
||||||
|
|
||||||
|
// Test multiple variables
|
||||||
|
$template = 'User: {{ username }}, Count: {{ count }}';
|
||||||
|
$result = $plugin->resolveLiquidVariables($template);
|
||||||
|
expect($result)->toBe('User: testuser, Count: 42');
|
||||||
|
|
||||||
|
// Test with missing variable (should keep original)
|
||||||
|
$template = 'Missing: {{ missing }}';
|
||||||
|
$result = $plugin->resolveLiquidVariables($template);
|
||||||
|
expect($result)->toBe('Missing: ');
|
||||||
|
|
||||||
|
// Test with Liquid control structures
|
||||||
|
$template = '{% if count > 40 %}High{% else %}Low{% endif %}';
|
||||||
|
$result = $plugin->resolveLiquidVariables($template);
|
||||||
|
expect($result)->toBe('High');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveLiquidVariables handles invalid Liquid syntax gracefully', function () {
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'configuration' => [
|
||||||
|
'api_key' => '12345',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Test with unclosed Liquid tag (should throw exception)
|
||||||
|
$template = 'Unclosed tag: {{ config.api_key';
|
||||||
|
|
||||||
|
expect(fn () => $plugin->resolveLiquidVariables($template))
|
||||||
|
->toThrow(Keepsuit\Liquid\Exceptions\SyntaxException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin can extract default values from custom fields configuration template', function () {
|
||||||
|
$configurationTemplate = [
|
||||||
|
'custom_fields' => [
|
||||||
|
[
|
||||||
|
'keyname' => 'reading_days',
|
||||||
|
'field_type' => 'string',
|
||||||
|
'name' => 'Reading Days',
|
||||||
|
'description' => 'Select days of the week to read',
|
||||||
|
'default' => 'Monday,Friday,Saturday,Sunday',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'keyname' => 'refresh_interval',
|
||||||
|
'field_type' => 'number',
|
||||||
|
'name' => 'Refresh Interval',
|
||||||
|
'description' => 'How often to refresh data',
|
||||||
|
'default' => 30,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'keyname' => 'timezone',
|
||||||
|
'field_type' => 'time_zone',
|
||||||
|
'name' => 'Timezone',
|
||||||
|
'description' => 'Select your timezone',
|
||||||
|
// No default value
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'configuration_template' => $configurationTemplate,
|
||||||
|
'configuration' => [
|
||||||
|
'reading_days' => 'Monday,Friday,Saturday,Sunday',
|
||||||
|
'refresh_interval' => 30,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($plugin->configuration)
|
||||||
|
->toBeArray()
|
||||||
|
->toHaveKey('reading_days')
|
||||||
|
->toHaveKey('refresh_interval')
|
||||||
|
->not->toHaveKey('timezone');
|
||||||
|
|
||||||
|
expect($plugin->getConfiguration('reading_days'))->toBe('Monday,Friday,Saturday,Sunday');
|
||||||
|
expect($plugin->getConfiguration('refresh_interval'))->toBe(30);
|
||||||
|
expect($plugin->getConfiguration('timezone'))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveLiquidVariables resolves configuration variables correctly', function () {
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'configuration' => [
|
||||||
|
'Latitude' => '48.2083',
|
||||||
|
'Longitude' => '16.3731',
|
||||||
|
'api_key' => 'test123',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$template = 'https://suntracker.me/?lat={{ Latitude }}&lon={{ Longitude }}';
|
||||||
|
$expected = 'https://suntracker.me/?lat=48.2083&lon=16.3731';
|
||||||
|
|
||||||
|
expect($plugin->resolveLiquidVariables($template))->toBe($expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveLiquidVariables handles missing variables gracefully', function () {
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'configuration' => [
|
||||||
|
'Latitude' => '48.2083',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$template = 'https://suntracker.me/?lat={{ Latitude }}&lon={{ Longitude }}&key={{ api_key }}';
|
||||||
|
$expected = 'https://suntracker.me/?lat=48.2083&lon=&key=';
|
||||||
|
|
||||||
|
expect($plugin->resolveLiquidVariables($template))->toBe($expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveLiquidVariables handles empty configuration', function () {
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'configuration' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$template = 'https://suntracker.me/?lat={{ Latitude }}&lon={{ Longitude }}';
|
||||||
|
$expected = 'https://suntracker.me/?lat=&lon=';
|
||||||
|
|
||||||
|
expect($plugin->resolveLiquidVariables($template))->toBe($expected);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue