diff --git a/app/Jobs/FetchDeviceModelsJob.php b/app/Jobs/FetchDeviceModelsJob.php index 695041f..cb24d98 100644 --- a/app/Jobs/FetchDeviceModelsJob.php +++ b/app/Jobs/FetchDeviceModelsJob.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Jobs; use App\Models\DeviceModel; +use App\Models\DevicePalette; use Exception; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -20,6 +21,8 @@ final class FetchDeviceModelsJob implements ShouldQueue private const API_URL = 'https://usetrmnl.com/api/models'; + private const PALETTES_API_URL = 'http://usetrmnl.com/api/palettes'; + /** * Create a new job instance. */ @@ -34,6 +37,8 @@ final class FetchDeviceModelsJob implements ShouldQueue public function handle(): void { try { + $this->processPalettes(); + $response = Http::timeout(30)->get(self::API_URL); if (! $response->successful()) { @@ -69,6 +74,86 @@ final class FetchDeviceModelsJob implements ShouldQueue } } + /** + * Process palettes from API and update/create records. + */ + private function processPalettes(): void + { + try { + $response = Http::timeout(30)->get(self::PALETTES_API_URL); + + if (! $response->successful()) { + Log::error('Failed to fetch palettes from API', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return; + } + + $data = $response->json('data', []); + + if (! is_array($data)) { + Log::error('Invalid response format from palettes API', [ + 'response' => $response->json(), + ]); + + return; + } + + foreach ($data as $paletteData) { + try { + $this->updateOrCreatePalette($paletteData); + } catch (Exception $e) { + Log::error('Failed to process palette', [ + 'palette_data' => $paletteData, + 'error' => $e->getMessage(), + ]); + } + } + + Log::info('Successfully fetched and updated palettes', [ + 'count' => count($data), + ]); + + } catch (Exception $e) { + Log::error('Exception occurred while fetching palettes', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } + + /** + * Update or create a palette record. + */ + private function updateOrCreatePalette(array $paletteData): void + { + $name = $paletteData['id'] ?? null; + + if (! $name) { + Log::warning('Palette data missing id field', [ + 'palette_data' => $paletteData, + ]); + + return; + } + + $attributes = [ + 'name' => $name, + 'description' => $paletteData['name'] ?? '', + 'grays' => $paletteData['grays'] ?? 2, + 'colors' => $paletteData['colors'] ?? null, + 'framework_class' => $paletteData['framework_class'] ?? '', + 'source' => 'api', + ]; + + DevicePalette::updateOrCreate( + ['name' => $name], + $attributes + ); + } + /** * Process the device models data and update/create records. */ @@ -117,9 +202,45 @@ final class FetchDeviceModelsJob implements ShouldQueue 'source' => 'api', ]; + // Set palette_id to the first palette from the model's palettes array + $firstPaletteId = $this->getFirstPaletteId($modelData); + if ($firstPaletteId) { + $attributes['palette_id'] = $firstPaletteId; + } + DeviceModel::updateOrCreate( ['name' => $name], $attributes ); } + + /** + * Get the first palette ID from model data. + */ + private function getFirstPaletteId(array $modelData): ?int + { + $paletteName = null; + + // Check for palette_ids array + if (isset($modelData['palette_ids']) && is_array($modelData['palette_ids']) && $modelData['palette_ids'] !== []) { + $paletteName = $modelData['palette_ids'][0]; + } + + // Check for palettes array (array of objects with id) + if (! $paletteName && isset($modelData['palettes']) && is_array($modelData['palettes']) && $modelData['palettes'] !== []) { + $firstPalette = $modelData['palettes'][0]; + if (is_array($firstPalette) && isset($firstPalette['id'])) { + $paletteName = $firstPalette['id']; + } + } + + if (! $paletteName) { + return null; + } + + // Look up palette by name to get the integer ID + $palette = DevicePalette::where('name', $paletteName)->first(); + + return $palette?->id; + } } diff --git a/app/Models/Device.php b/app/Models/Device.php index 6a99fcd..2eeb25b 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Storage; /** * @property-read DeviceModel|null $deviceModel + * @property-read DevicePalette|null $palette */ class Device extends Model { @@ -187,6 +188,11 @@ class Device extends Model return $this->belongsTo(DeviceModel::class); } + public function palette(): BelongsTo + { + return $this->belongsTo(DevicePalette::class, 'palette_id'); + } + /** * Get the color depth string (e.g., "4bit") for the associated device model. */ diff --git a/app/Models/DeviceModel.php b/app/Models/DeviceModel.php index 4dfaf1e..6132a76 100644 --- a/app/Models/DeviceModel.php +++ b/app/Models/DeviceModel.php @@ -6,7 +6,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +/** + * @property-read DevicePalette|null $palette + */ final class DeviceModel extends Model { use HasFactory; @@ -35,7 +39,7 @@ final class DeviceModel extends Model return '2bit'; } - // if higher then 4 return 4bit + // if higher than 4 return 4bit if ($this->bit_depth > 4) { return '4bit'; } @@ -66,4 +70,9 @@ final class DeviceModel extends Model return null; } + + public function palette(): BelongsTo + { + return $this->belongsTo(DevicePalette::class, 'palette_id'); + } } diff --git a/app/Models/DevicePalette.php b/app/Models/DevicePalette.php new file mode 100644 index 0000000..54b0876 --- /dev/null +++ b/app/Models/DevicePalette.php @@ -0,0 +1,23 @@ + 'integer', + 'colors' => 'array', + ]; +} diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php index 76be3bb..4b28e80 100644 --- a/app/Services/ImageGenerationService.php +++ b/app/Services/ImageGenerationService.php @@ -25,7 +25,7 @@ class ImageGenerationService { public static function generateImage(string $markup, $deviceId): string { - $device = Device::with('deviceModel')->find($deviceId); + $device = Device::with(['deviceModel', 'palette', 'deviceModel.palette'])->find($deviceId); $uuid = Uuid::uuid4()->toString(); try { @@ -61,6 +61,14 @@ class ImageGenerationService $browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']); } + // Get palette from device or fallback to device model's default palette + $palette = $device->palette ?? $device->deviceModel?->palette; + $colorPalette = null; + + if ($palette && $palette->colors) { + $colorPalette = $palette->colors; + } + $imageStage = new ImageStage(); $imageStage->format($fileExtension) ->width($imageSettings['width']) @@ -72,6 +80,11 @@ class ImageGenerationService ->offsetY($imageSettings['offset_y']) ->outputPath($outputPath); + // Apply color palette if available + if ($colorPalette) { + $imageStage->colormap($colorPalette); + } + // Apply dithering if requested by markup $shouldDither = self::markupContainsDitherImage($markup); if ($shouldDither) { @@ -338,6 +351,9 @@ class ImageGenerationService $uuid = Uuid::uuid4()->toString(); try { + // Load device with relationships + $device->load(['palette', 'deviceModel.palette']); + // Get image generation settings from DeviceModel if available, otherwise use device settings $imageSettings = self::getImageSettings($device); @@ -372,6 +388,14 @@ class ImageGenerationService $browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']); } + // Get palette from device or fallback to device model's default palette + $palette = $device->palette ?? $device->deviceModel?->palette; + $colorPalette = null; + + if ($palette && $palette->colors) { + $colorPalette = $palette->colors; + } + $imageStage = new ImageStage(); $imageStage->format($fileExtension) ->width($imageSettings['width']) @@ -383,6 +407,11 @@ class ImageGenerationService ->offsetY($imageSettings['offset_y']) ->outputPath($outputPath); + // Apply color palette if available + if ($colorPalette) { + $imageStage->colormap($colorPalette); + } + (new TrmnlPipeline())->pipe($browserStage) ->pipe($imageStage) ->process(); diff --git a/composer.json b/composer.json index 79306ce..9d5deb5 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "ext-simplexml": "*", "ext-zip": "*", "bnussbau/laravel-trmnl-blade": "2.0.*", - "bnussbau/trmnl-pipeline-php": "^0.4.0", + "bnussbau/trmnl-pipeline-php": "^0.5.0", "keepsuit/laravel-liquid": "^0.5.2", "laravel/framework": "^12.1", "laravel/sanctum": "^4.0", diff --git a/composer.lock b/composer.lock index 33b43dc..b8ad138 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3d743ce4dc2742c59ed6f9cc8ed36e04", + "content-hash": "38e8a7dd90ccc1b777a4c8a5a28f9f14", "packages": [ { "name": "aws/aws-crt-php", @@ -243,16 +243,16 @@ }, { "name": "bnussbau/trmnl-pipeline-php", - "version": "0.4.0", + "version": "0.5.0", "source": { "type": "git", "url": "https://github.com/bnussbau/trmnl-pipeline-php.git", - "reference": "b58b937f36e55aa7cbd4859cbe7a902bf1050bf8" + "reference": "eb55b89e1f3991764912505872bbce809874d1aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bnussbau/trmnl-pipeline-php/zipball/b58b937f36e55aa7cbd4859cbe7a902bf1050bf8", - "reference": "b58b937f36e55aa7cbd4859cbe7a902bf1050bf8", + "url": "https://api.github.com/repos/bnussbau/trmnl-pipeline-php/zipball/eb55b89e1f3991764912505872bbce809874d1aa", + "reference": "eb55b89e1f3991764912505872bbce809874d1aa", "shasum": "" }, "require": { @@ -294,7 +294,7 @@ ], "support": { "issues": "https://github.com/bnussbau/trmnl-pipeline-php/issues", - "source": "https://github.com/bnussbau/trmnl-pipeline-php/tree/0.4.0" + "source": "https://github.com/bnussbau/trmnl-pipeline-php/tree/0.5.0" }, "funding": [ { @@ -310,7 +310,7 @@ "type": "github" } ], - "time": "2025-10-30T11:52:17+00:00" + "time": "2025-11-25T17:00:21+00:00" }, { "name": "brick/math", diff --git a/database/factories/DevicePaletteFactory.php b/database/factories/DevicePaletteFactory.php new file mode 100644 index 0000000..a672873 --- /dev/null +++ b/database/factories/DevicePaletteFactory.php @@ -0,0 +1,38 @@ + + */ +class DevicePaletteFactory extends Factory +{ + protected $model = DevicePalette::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'id' => 'test-' . $this->faker->unique()->slug(), + 'name' => $this->faker->words(3, true), + 'grays' => $this->faker->randomElement([2, 4, 16, 256]), + 'colors' => $this->faker->optional()->passthrough([ + '#FF0000', + '#00FF00', + '#0000FF', + '#FFFF00', + '#000000', + '#FFFFFF', + ]), + 'framework_class' => null, + 'source' => 'api', + ]; + } +} diff --git a/database/migrations/2025_08_04_064514_add_oidc_sub_to_users_table.php b/database/migrations/2025_08_04_064514_add_oidc_sub_to_users_table.php index d8dba38..7ec1374 100644 --- a/database/migrations/2025_08_04_064514_add_oidc_sub_to_users_table.php +++ b/database/migrations/2025_08_04_064514_add_oidc_sub_to_users_table.php @@ -22,6 +22,7 @@ return new class extends Migration public function down(): void { Schema::table('users', function (Blueprint $table) { + $table->dropUnique(['oidc_sub']); $table->dropColumn('oidc_sub'); }); } diff --git a/database/migrations/2025_11_22_084119_create_device_palettes_table.php b/database/migrations/2025_11_22_084119_create_device_palettes_table.php new file mode 100644 index 0000000..9262dac --- /dev/null +++ b/database/migrations/2025_11_22_084119_create_device_palettes_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name')->unique(); + $table->string('description')->nullable(); + $table->integer('grays'); + $table->json('colors')->nullable(); + $table->string('framework_class')->default(''); + $table->string('source')->default('api'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('device_palettes'); + } +}; diff --git a/database/migrations/2025_11_22_084208_add_palette_id_to_device_models_table.php b/database/migrations/2025_11_22_084208_add_palette_id_to_device_models_table.php new file mode 100644 index 0000000..1993fcf --- /dev/null +++ b/database/migrations/2025_11_22_084208_add_palette_id_to_device_models_table.php @@ -0,0 +1,29 @@ +foreignId('palette_id')->nullable()->after('source')->constrained('device_palettes')->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('device_models', function (Blueprint $table) { + $table->dropForeign(['palette_id']); + $table->dropColumn('palette_id'); + }); + } +}; diff --git a/database/migrations/2025_11_22_084211_add_palette_id_to_devices_table.php b/database/migrations/2025_11_22_084211_add_palette_id_to_devices_table.php new file mode 100644 index 0000000..3a47afe --- /dev/null +++ b/database/migrations/2025_11_22_084211_add_palette_id_to_devices_table.php @@ -0,0 +1,29 @@ +foreignId('palette_id')->nullable()->constrained('device_palettes')->onDelete('set null'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropForeign(['palette_id']); + $table->dropColumn('palette_id'); + }); + } +}; diff --git a/database/migrations/2025_11_22_084425_seed_palettes_and_relationships.php b/database/migrations/2025_11_22_084425_seed_palettes_and_relationships.php new file mode 100644 index 0000000..c198d81 --- /dev/null +++ b/database/migrations/2025_11_22_084425_seed_palettes_and_relationships.php @@ -0,0 +1,124 @@ + 'bw', + 'description' => 'Black & White', + 'grays' => 2, + 'colors' => null, + 'framework_class' => 'screen--1bit', + 'source' => 'api', + ], + [ + 'name' => 'gray-4', + 'description' => '4 Grays', + 'grays' => 4, + 'colors' => null, + 'framework_class' => 'screen--2bit', + 'source' => 'api', + ], + [ + 'name' => 'gray-16', + 'description' => '16 Grays', + 'grays' => 16, + 'colors' => null, + 'framework_class' => 'screen--4bit', + 'source' => 'api', + ], + [ + 'name' => 'gray-256', + 'description' => '256 Grays', + 'grays' => 256, + 'colors' => null, + 'framework_class' => 'screen--4bit', + 'source' => 'api', + ], + [ + 'name' => 'color-6a', + 'description' => '6 Colors', + 'grays' => 2, + 'colors' => json_encode(['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#000000', '#FFFFFF']), + 'framework_class' => '', + 'source' => 'api', + ], + [ + 'name' => 'color-7a', + 'description' => '7 Colors', + 'grays' => 2, + 'colors' => json_encode(['#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FFA500']), + 'framework_class' => '', + 'source' => 'api', + ], + ]; + + $now = now(); + $paletteIdMap = []; + + foreach ($palettes as $paletteData) { + $paletteName = $paletteData['name']; + $paletteData['created_at'] = $now; + $paletteData['updated_at'] = $now; + + DB::table('device_palettes')->updateOrInsert( + ['name' => $paletteName], + $paletteData + ); + + // Get the ID of the palette (either newly created or existing) + $paletteRecord = DB::table('device_palettes')->where('name', $paletteName)->first(); + $paletteIdMap[$paletteName] = $paletteRecord->id; + } + + // Set default palette_id on DeviceModel based on first palette_ids entry + $models = [ + ['name' => 'og_png', 'palette_name' => 'bw'], + ['name' => 'og_plus', 'palette_name' => 'gray-4'], + ['name' => 'amazon_kindle_2024', 'palette_name' => 'gray-256'], + ['name' => 'amazon_kindle_paperwhite_6th_gen', 'palette_name' => 'gray-256'], + ['name' => 'amazon_kindle_paperwhite_7th_gen', 'palette_name' => 'gray-256'], + ['name' => 'inkplate_10', 'palette_name' => 'gray-4'], + ['name' => 'amazon_kindle_7', 'palette_name' => 'gray-256'], + ['name' => 'inky_impression_7_3', 'palette_name' => 'color-7a'], + ['name' => 'kobo_libra_2', 'palette_name' => 'gray-16'], + ['name' => 'amazon_kindle_oasis_2', 'palette_name' => 'gray-256'], + ['name' => 'kobo_aura_one', 'palette_name' => 'gray-16'], + ['name' => 'kobo_aura_hd', 'palette_name' => 'gray-16'], + ['name' => 'inky_impression_13_3', 'palette_name' => 'color-6a'], + ['name' => 'm5_paper_s3', 'palette_name' => 'gray-16'], + ['name' => 'amazon_kindle_scribe', 'palette_name' => 'gray-256'], + ['name' => 'seeed_e1001', 'palette_name' => 'gray-4'], + ['name' => 'seeed_e1002', 'palette_name' => 'gray-4'], + ['name' => 'waveshare_4_26', 'palette_name' => 'gray-4'], + ['name' => 'waveshare_7_5_bw', 'palette_name' => 'bw'], + ]; + + foreach ($models as $modelData) { + $deviceModel = DeviceModel::where('name', $modelData['name'])->first(); + if ($deviceModel && ! $deviceModel->palette_id && isset($paletteIdMap[$modelData['palette_name']])) { + $deviceModel->update(['palette_id' => $paletteIdMap[$modelData['palette_name']]]); + } + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Remove palette_id from device models but keep palettes + DeviceModel::query()->update(['palette_id' => null]); + } +}; diff --git a/resources/views/livewire/device-models/index.blade.php b/resources/views/livewire/device-models/index.blade.php index a78f2a2..a57085b 100644 --- a/resources/views/livewire/device-models/index.blade.php +++ b/resources/views/livewire/device-models/index.blade.php @@ -1,26 +1,43 @@ 'required|string|max:255|unique:device_models,name', 'label' => 'required|string|max:255', @@ -40,62 +57,58 @@ new class extends Component { public function mount() { $this->deviceModels = DeviceModel::all(); + $this->devicePalettes = DevicePalette::all(); + return view('livewire.device-models.index'); } - public function createDeviceModel(): void - { - $this->validate(); - - DeviceModel::create([ - 'name' => $this->name, - 'label' => $this->label, - 'description' => $this->description, - 'width' => $this->width, - 'height' => $this->height, - 'colors' => $this->colors, - 'bit_depth' => $this->bit_depth, - 'scale_factor' => $this->scale_factor, - 'rotation' => $this->rotation, - 'mime_type' => $this->mime_type, - 'offset_x' => $this->offset_x, - 'offset_y' => $this->offset_y, - 'published_at' => $this->published_at, - ]); - - $this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at']); - \Flux::modal('create-device-model')->close(); - - $this->deviceModels = DeviceModel::all(); - session()->flash('message', 'Device model created successfully.'); - } - public $editingDeviceModelId; - public function editDeviceModel(DeviceModel $deviceModel): void + public $viewingDeviceModelId; + + public function openDeviceModelModal(?string $deviceModelId = null, bool $viewOnly = false): void { - $this->editingDeviceModelId = $deviceModel->id; - $this->name = $deviceModel->name; - $this->label = $deviceModel->label; - $this->description = $deviceModel->description; - $this->width = $deviceModel->width; - $this->height = $deviceModel->height; - $this->colors = $deviceModel->colors; - $this->bit_depth = $deviceModel->bit_depth; - $this->scale_factor = $deviceModel->scale_factor; - $this->rotation = $deviceModel->rotation; - $this->mime_type = $deviceModel->mime_type; - $this->offset_x = $deviceModel->offset_x; - $this->offset_y = $deviceModel->offset_y; - $this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i'); + if ($deviceModelId) { + $deviceModel = DeviceModel::findOrFail($deviceModelId); + + if ($viewOnly) { + $this->viewingDeviceModelId = $deviceModel->id; + $this->editingDeviceModelId = null; + } else { + $this->editingDeviceModelId = $deviceModel->id; + $this->viewingDeviceModelId = null; + } + + $this->name = $deviceModel->name; + $this->label = $deviceModel->label; + $this->description = $deviceModel->description; + $this->width = $deviceModel->width; + $this->height = $deviceModel->height; + $this->colors = $deviceModel->colors; + $this->bit_depth = $deviceModel->bit_depth; + $this->scale_factor = $deviceModel->scale_factor; + $this->rotation = $deviceModel->rotation; + $this->mime_type = $deviceModel->mime_type; + $this->offset_x = $deviceModel->offset_x; + $this->offset_y = $deviceModel->offset_y; + $this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i'); + $this->palette_id = $deviceModel->palette_id; + } else { + $this->editingDeviceModelId = null; + $this->viewingDeviceModelId = null; + $this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id']); + $this->mime_type = 'image/png'; + $this->scale_factor = 1.0; + $this->rotation = 0; + $this->offset_x = 0; + $this->offset_y = 0; + } } - public function updateDeviceModel(): void + public function saveDeviceModel(): void { - $deviceModel = DeviceModel::findOrFail($this->editingDeviceModelId); - - $this->validate([ - 'name' => 'required|string|max:255|unique:device_models,name,' . $deviceModel->id, + $rules = [ + 'name' => 'required|string|max:255', 'label' => 'required|string|max:255', 'description' => 'required|string', 'width' => 'required|integer|min:1', @@ -108,38 +121,96 @@ new class extends Component { 'offset_x' => 'required|integer', 'offset_y' => 'required|integer', 'published_at' => 'nullable|date', - ]); + 'palette_id' => 'nullable|exists:device_palettes,id', + ]; - $deviceModel->update([ - 'name' => $this->name, - 'label' => $this->label, - 'description' => $this->description, - 'width' => $this->width, - 'height' => $this->height, - 'colors' => $this->colors, - 'bit_depth' => $this->bit_depth, - 'scale_factor' => $this->scale_factor, - 'rotation' => $this->rotation, - 'mime_type' => $this->mime_type, - 'offset_x' => $this->offset_x, - 'offset_y' => $this->offset_y, - 'published_at' => $this->published_at, - ]); + if ($this->editingDeviceModelId) { + $rules['name'] = 'required|string|max:255|unique:device_models,name,'.$this->editingDeviceModelId; + } else { + $rules['name'] = 'required|string|max:255|unique:device_models,name'; + } - $this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'editingDeviceModelId']); - \Flux::modal('edit-device-model-' . $deviceModel->id)->close(); + $this->validate($rules); + + if ($this->editingDeviceModelId) { + $deviceModel = DeviceModel::findOrFail($this->editingDeviceModelId); + $deviceModel->update([ + 'name' => $this->name, + 'label' => $this->label, + 'description' => $this->description, + 'width' => $this->width, + 'height' => $this->height, + 'colors' => $this->colors, + 'bit_depth' => $this->bit_depth, + 'scale_factor' => $this->scale_factor, + 'rotation' => $this->rotation, + 'mime_type' => $this->mime_type, + 'offset_x' => $this->offset_x, + 'offset_y' => $this->offset_y, + 'published_at' => $this->published_at, + 'palette_id' => $this->palette_id ?: null, + ]); + $message = 'Device model updated successfully.'; + } else { + DeviceModel::create([ + 'name' => $this->name, + 'label' => $this->label, + 'description' => $this->description, + 'width' => $this->width, + 'height' => $this->height, + 'colors' => $this->colors, + 'bit_depth' => $this->bit_depth, + 'scale_factor' => $this->scale_factor, + 'rotation' => $this->rotation, + 'mime_type' => $this->mime_type, + 'offset_x' => $this->offset_x, + 'offset_y' => $this->offset_y, + 'published_at' => $this->published_at, + 'palette_id' => $this->palette_id ?: null, + 'source' => 'manual', + ]); + $message = 'Device model created successfully.'; + } + + $this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'editingDeviceModelId', 'viewingDeviceModelId']); + Flux::modal('device-model-modal')->close(); $this->deviceModels = DeviceModel::all(); - session()->flash('message', 'Device model updated successfully.'); + session()->flash('message', $message); } - public function deleteDeviceModel(DeviceModel $deviceModel): void + public function deleteDeviceModel(string $deviceModelId): void { + $deviceModel = DeviceModel::findOrFail($deviceModelId); $deviceModel->delete(); $this->deviceModels = DeviceModel::all(); session()->flash('message', 'Device model deleted successfully.'); } + + public function duplicateDeviceModel(string $deviceModelId): void + { + $deviceModel = DeviceModel::findOrFail($deviceModelId); + + $this->editingDeviceModelId = null; + $this->viewingDeviceModelId = null; + $this->name = $deviceModel->name.' (Copy)'; + $this->label = $deviceModel->label; + $this->description = $deviceModel->description; + $this->width = $deviceModel->width; + $this->height = $deviceModel->height; + $this->colors = $deviceModel->colors; + $this->bit_depth = $deviceModel->bit_depth; + $this->scale_factor = $deviceModel->scale_factor; + $this->rotation = $deviceModel->rotation; + $this->mime_type = $deviceModel->mime_type; + $this->offset_x = $deviceModel->offset_x; + $this->offset_y = $deviceModel->offset_y; + $this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i'); + $this->palette_id = $deviceModel->palette_id; + + $this->js('Flux.modal("device-model-modal").show()'); + } } ?> @@ -148,10 +219,19 @@ new class extends Component {
-

Device Models

- {{-- --}} - {{-- Add Device Model--}} - {{-- --}} +
+

Device Models

+ + + + Devices + Device Palettes + + +
+ + Add Device Model +
@if (session()->has('message'))
@@ -164,157 +244,104 @@ new class extends Component {
@endif - +
- Add Device Model + + @if ($viewingDeviceModelId) + View Device Model + @elseif ($editingDeviceModelId) + Edit Device Model + @else + Add Device Model + @endif +
-
+
+ name="name" autofocus :disabled="(bool) $viewingDeviceModelId"/>
+ name="label" :disabled="(bool) $viewingDeviceModelId"/>
+ class="block mt-1 w-full" name="description" :disabled="(bool) $viewingDeviceModelId"/>
+ name="width" :disabled="(bool) $viewingDeviceModelId"/> + name="height" :disabled="(bool) $viewingDeviceModelId"/>
+ name="colors" :disabled="(bool) $viewingDeviceModelId"/> + name="bit_depth" :disabled="(bool) $viewingDeviceModelId"/>
+ name="scale_factor" step="0.1" :disabled="(bool) $viewingDeviceModelId"/> + name="rotation" :disabled="(bool) $viewingDeviceModelId"/>
- + + image/png + image/bmp +
+ name="offset_x" :disabled="(bool) $viewingDeviceModelId"/> + name="offset_y" :disabled="(bool) $viewingDeviceModelId"/>
-
- - Create Device Model +
+ + None + @foreach ($devicePalettes as $palette) + {{ $palette->description ?? $palette->name }} ({{ $palette->name }}) + @endforeach +
+ + @if (!$viewingDeviceModelId) +
+ + {{ $editingDeviceModelId ? 'Update' : 'Create' }} Device Model +
+ @else +
+ + Duplicate +
+ @endif
- @foreach ($deviceModels as $deviceModel) - -
-
- Edit Device Model -
- -
-
- -
- -
- -
- -
- -
- -
- - -
- -
- - -
- -
- - -
- -
- - image/png - image/bmp - - -
- -
- - -
- -
- - Update Device Model -
-
-
-
- @endforeach - @@ -369,14 +396,25 @@ new class extends Component { >
- - source === 'api') + + + + + - - - + @else + + + + + + + @endif
diff --git a/resources/views/livewire/device-palettes/index.blade.php b/resources/views/livewire/device-palettes/index.blade.php new file mode 100644 index 0000000..28f99c9 --- /dev/null +++ b/resources/views/livewire/device-palettes/index.blade.php @@ -0,0 +1,384 @@ + 'required|string|max:255|unique:device_palettes,name', + 'description' => 'nullable|string|max:255', + 'grays' => 'required|integer|min:1|max:256', + 'colors' => 'nullable|array', + 'colors.*' => 'string|regex:/^#[0-9A-Fa-f]{6}$/', + 'framework_class' => 'nullable|string|max:255', + ]; + + public function mount() + { + $this->devicePalettes = DevicePalette::all(); + + return view('livewire.device-palettes.index'); + } + + public function addColor(): void + { + $this->validate(['colorInput' => 'required|string|regex:/^#[0-9A-Fa-f]{6}$/'], [ + 'colorInput.regex' => 'Color must be a valid hex color (e.g., #FF0000)', + ]); + + if (! in_array($this->colorInput, $this->colors)) { + $this->colors[] = $this->colorInput; + } + + $this->colorInput = ''; + } + + public function removeColor(int $index): void + { + unset($this->colors[$index]); + $this->colors = array_values($this->colors); + } + + public $editingDevicePaletteId; + + public $viewingDevicePaletteId; + + public function openDevicePaletteModal(?string $devicePaletteId = null, bool $viewOnly = false): void + { + if ($devicePaletteId) { + $devicePalette = DevicePalette::findOrFail($devicePaletteId); + + if ($viewOnly) { + $this->viewingDevicePaletteId = $devicePalette->id; + $this->editingDevicePaletteId = null; + } else { + $this->editingDevicePaletteId = $devicePalette->id; + $this->viewingDevicePaletteId = null; + } + + $this->name = $devicePalette->name; + $this->description = $devicePalette->description; + $this->grays = $devicePalette->grays; + + // Ensure colors is always an array and properly decoded + // The model cast should handle JSON decoding, but we'll be explicit + $colors = $devicePalette->getAttribute('colors'); + + if ($colors === null) { + $this->colors = []; + } elseif (is_string($colors)) { + $decoded = json_decode($colors, true); + $this->colors = is_array($decoded) ? array_values($decoded) : []; + } elseif (is_array($colors)) { + $this->colors = array_values($colors); // Re-index array + } else { + $this->colors = []; + } + + $this->framework_class = $devicePalette->framework_class; + } else { + $this->editingDevicePaletteId = null; + $this->viewingDevicePaletteId = null; + $this->reset(['name', 'description', 'grays', 'colors', 'framework_class']); + } + + $this->colorInput = ''; + } + + public function saveDevicePalette(): void + { + $rules = [ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string|max:255', + 'grays' => 'required|integer|min:1|max:256', + 'colors' => 'nullable|array', + 'colors.*' => 'string|regex:/^#[0-9A-Fa-f]{6}$/', + 'framework_class' => 'nullable|string|max:255', + ]; + + if ($this->editingDevicePaletteId) { + $rules['name'] = 'required|string|max:255|unique:device_palettes,name,'.$this->editingDevicePaletteId; + } else { + $rules['name'] = 'required|string|max:255|unique:device_palettes,name'; + } + + $this->validate($rules); + + if ($this->editingDevicePaletteId) { + $devicePalette = DevicePalette::findOrFail($this->editingDevicePaletteId); + $devicePalette->update([ + 'name' => $this->name, + 'description' => $this->description, + 'grays' => $this->grays, + 'colors' => ! empty($this->colors) ? $this->colors : null, + 'framework_class' => $this->framework_class, + ]); + $message = 'Device palette updated successfully.'; + } else { + DevicePalette::create([ + 'name' => $this->name, + 'description' => $this->description, + 'grays' => $this->grays, + 'colors' => ! empty($this->colors) ? $this->colors : null, + 'framework_class' => $this->framework_class, + 'source' => 'manual', + ]); + $message = 'Device palette created successfully.'; + } + + $this->reset(['name', 'description', 'grays', 'colors', 'framework_class', 'colorInput', 'editingDevicePaletteId', 'viewingDevicePaletteId']); + Flux::modal('device-palette-modal')->close(); + + $this->devicePalettes = DevicePalette::all(); + session()->flash('message', $message); + } + + public function deleteDevicePalette(string $devicePaletteId): void + { + $devicePalette = DevicePalette::findOrFail($devicePaletteId); + $devicePalette->delete(); + + $this->devicePalettes = DevicePalette::all(); + session()->flash('message', 'Device palette deleted successfully.'); + } + + public function duplicateDevicePalette(string $devicePaletteId): void + { + $devicePalette = DevicePalette::findOrFail($devicePaletteId); + + $this->editingDevicePaletteId = null; + $this->viewingDevicePaletteId = null; + $this->name = $devicePalette->name.' (Copy)'; + $this->description = $devicePalette->description; + $this->grays = $devicePalette->grays; + + $colors = $devicePalette->getAttribute('colors'); + if ($colors === null) { + $this->colors = []; + } elseif (is_string($colors)) { + $decoded = json_decode($colors, true); + $this->colors = is_array($decoded) ? array_values($decoded) : []; + } elseif (is_array($colors)) { + $this->colors = array_values($colors); + } else { + $this->colors = []; + } + + $this->framework_class = $devicePalette->framework_class; + $this->colorInput = ''; + + $this->js('Flux.modal("device-palette-modal").show()'); + } +} + +?> + +
+
+
+
+
+

Device Palettes

+ + + + Devices + Device Models + + +
+ + Add Device Palette + +
+ @if (session()->has('message')) +
+ + + + + +
+ @endif + + +
+
+ + @if ($viewingDevicePaletteId) + View Device Palette + @elseif ($editingDevicePaletteId) + Edit Device Palette + @else + Add Device Palette + @endif + +
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ Colors + @if (!$viewingDevicePaletteId) +
+ + Add +
+ @endif +
+ @if (!empty($colors) && is_array($colors) && count($colors) > 0) + @foreach ($colors as $index => $color) + @if (!empty($color)) +
+
+ {{ $color }} + @if (!$viewingDevicePaletteId) + + @endif +
+ @endif + @endforeach + @endif +
+ @if (!$viewingDevicePaletteId) +

Leave empty for grayscale-only palette

+ @endif +
+ + @if (!$viewingDevicePaletteId) +
+ + {{ $editingDevicePaletteId ? 'Update' : 'Create' }} Device Palette +
+ @else +
+ + Duplicate +
+ @endif + +
+
+ +
+ + + + + + + + + + + @foreach ($devicePalettes as $devicePalette) + + + + + + + @endforeach + +
+
Description
+
+
Grays
+
+
Colors
+
+
Actions
+
+
+
{{ $devicePalette->description ?? $devicePalette->name }}
+
{{ $devicePalette->name }}
+
+
+ {{ $devicePalette->grays }} + + @if ($devicePalette->colors) +
+ @foreach ($devicePalette->colors as $color) +
+ @endforeach + ({{ count($devicePalette->colors) }}) +
+ @else + Grayscale only + @endif +
+
+ + @if ($devicePalette->source === 'api') + + + + + + + @else + + + + + + + @endif + +
+
+
+
+
+ diff --git a/resources/views/livewire/devices/manage.blade.php b/resources/views/livewire/devices/manage.blade.php index d87bd1c..646adc0 100644 --- a/resources/views/livewire/devices/manage.blade.php +++ b/resources/views/livewire/devices/manage.blade.php @@ -121,7 +121,16 @@ new class extends Component { {{--@dump($devices)--}}
-

Devices

+
+

Devices

+ + + + Device Models + Device Palettes + + +
Add Device diff --git a/routes/web.php b/routes/web.php index e6afc1a..7b7868d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -24,6 +24,7 @@ Route::middleware(['auth'])->group(function () { Volt::route('/devices/{device}/logs', 'devices.logs')->name('devices.logs'); Volt::route('/device-models', 'device-models.index')->name('device-models.index'); + Volt::route('/device-palettes', 'device-palettes.index')->name('device-palettes.index'); Volt::route('plugins', 'plugins.index')->name('plugins.index'); diff --git a/tests/Feature/Jobs/FetchDeviceModelsJobTest.php b/tests/Feature/Jobs/FetchDeviceModelsJobTest.php index 1c131c4..7674d7f 100644 --- a/tests/Feature/Jobs/FetchDeviceModelsJobTest.php +++ b/tests/Feature/Jobs/FetchDeviceModelsJobTest.php @@ -12,6 +12,13 @@ uses(RefreshDatabase::class); beforeEach(function (): void { DeviceModel::truncate(); + + // Mock palettes API to return empty array by default + Http::fake([ + 'usetrmnl.com/api/palettes' => Http::response([ + 'data' => [], + ], 200), + ]); }); test('fetch device models job can be dispatched', function (): void { @@ -21,6 +28,7 @@ test('fetch device models job can be dispatched', function (): void { test('fetch device models job handles successful api response', function (): void { Http::fake([ + 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), 'usetrmnl.com/api/models' => Http::response([ 'data' => [ [ @@ -42,6 +50,10 @@ test('fetch device models job handles successful api response', function (): voi ], 200), ]); + Log::shouldReceive('info') + ->once() + ->with('Successfully fetched and updated palettes', ['count' => 0]); + Log::shouldReceive('info') ->once() ->with('Successfully fetched and updated device models', ['count' => 1]); @@ -67,6 +79,7 @@ test('fetch device models job handles successful api response', function (): voi test('fetch device models job handles multiple device models', function (): void { Http::fake([ + 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), 'usetrmnl.com/api/models' => Http::response([ 'data' => [ [ @@ -103,6 +116,10 @@ test('fetch device models job handles multiple device models', function (): void ], 200), ]); + Log::shouldReceive('info') + ->once() + ->with('Successfully fetched and updated palettes', ['count' => 0]); + Log::shouldReceive('info') ->once() ->with('Successfully fetched and updated device models', ['count' => 2]); @@ -116,11 +133,16 @@ test('fetch device models job handles multiple device models', function (): void test('fetch device models job handles empty data array', function (): void { Http::fake([ + 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), 'usetrmnl.com/api/models' => Http::response([ 'data' => [], ], 200), ]); + Log::shouldReceive('info') + ->once() + ->with('Successfully fetched and updated palettes', ['count' => 0]); + Log::shouldReceive('info') ->once() ->with('Successfully fetched and updated device models', ['count' => 0]); @@ -133,11 +155,16 @@ test('fetch device models job handles empty data array', function (): void { test('fetch device models job handles missing data field', function (): void { Http::fake([ + 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), 'usetrmnl.com/api/models' => Http::response([ 'message' => 'No data available', ], 200), ]); + Log::shouldReceive('info') + ->once() + ->with('Successfully fetched and updated palettes', ['count' => 0]); + Log::shouldReceive('info') ->once() ->with('Successfully fetched and updated device models', ['count' => 0]); @@ -150,11 +177,16 @@ test('fetch device models job handles missing data field', function (): void { test('fetch device models job handles non-array data', function (): void { Http::fake([ + 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), 'usetrmnl.com/api/models' => Http::response([ 'data' => 'invalid-data', ], 200), ]); + Log::shouldReceive('info') + ->once() + ->with('Successfully fetched and updated palettes', ['count' => 0]); + Log::shouldReceive('error') ->once() ->with('Invalid response format from device models API', Mockery::type('array')); @@ -167,11 +199,16 @@ test('fetch device models job handles non-array data', function (): void { test('fetch device models job handles api failure', function (): void { Http::fake([ + 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), 'usetrmnl.com/api/models' => Http::response([ 'error' => 'Internal Server Error', ], 500), ]); + Log::shouldReceive('info') + ->once() + ->with('Successfully fetched and updated palettes', ['count' => 0]); + Log::shouldReceive('error') ->once() ->with('Failed to fetch device models from API', [ @@ -187,11 +224,16 @@ test('fetch device models job handles api failure', function (): void { test('fetch device models job handles network exception', function (): void { Http::fake([ + 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), 'usetrmnl.com/api/models' => function (): void { throw new Exception('Network connection failed'); }, ]); + Log::shouldReceive('info') + ->once() + ->with('Successfully fetched and updated palettes', ['count' => 0]); + Log::shouldReceive('error') ->once() ->with('Exception occurred while fetching device models', Mockery::type('array')); @@ -204,6 +246,7 @@ test('fetch device models job handles network exception', function (): void { test('fetch device models job handles device model with missing name', function (): void { Http::fake([ + 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), 'usetrmnl.com/api/models' => Http::response([ 'data' => [ [ @@ -214,6 +257,10 @@ test('fetch device models job handles device model with missing name', function ], 200), ]); + Log::shouldReceive('info') + ->once() + ->with('Successfully fetched and updated palettes', ['count' => 0]); + Log::shouldReceive('warning') ->once() ->with('Device model data missing name field', Mockery::type('array')); @@ -230,6 +277,7 @@ test('fetch device models job handles device model with missing name', function test('fetch device models job handles device model with partial data', function (): void { Http::fake([ + 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), 'usetrmnl.com/api/models' => Http::response([ 'data' => [ [ @@ -240,6 +288,10 @@ test('fetch device models job handles device model with partial data', function ], 200), ]); + Log::shouldReceive('info') + ->once() + ->with('Successfully fetched and updated palettes', ['count' => 0]); + Log::shouldReceive('info') ->once() ->with('Successfully fetched and updated device models', ['count' => 1]); @@ -273,6 +325,7 @@ test('fetch device models job updates existing device model', function (): void ]); Http::fake([ + 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), 'usetrmnl.com/api/models' => Http::response([ 'data' => [ [ @@ -294,6 +347,10 @@ test('fetch device models job updates existing device model', function (): void ], 200), ]); + Log::shouldReceive('info') + ->once() + ->with('Successfully fetched and updated palettes', ['count' => 0]); + Log::shouldReceive('info') ->once() ->with('Successfully fetched and updated device models', ['count' => 1]); @@ -311,6 +368,7 @@ test('fetch device models job updates existing device model', function (): void test('fetch device models job handles processing exception for individual model', function (): void { Http::fake([ + 'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200), 'usetrmnl.com/api/models' => Http::response([ 'data' => [ [ @@ -327,6 +385,10 @@ test('fetch device models job handles processing exception for individual model' ], 200), ]); + Log::shouldReceive('info') + ->once() + ->with('Successfully fetched and updated palettes', ['count' => 0]); + Log::shouldReceive('warning') ->once() ->with('Device model data missing name field', Mockery::type('array')); diff --git a/tests/Feature/Volt/CatalogTrmnlTest.php b/tests/Feature/Volt/CatalogTrmnlTest.php index c3f6681..ba1b722 100644 --- a/tests/Feature/Volt/CatalogTrmnlTest.php +++ b/tests/Feature/Volt/CatalogTrmnlTest.php @@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Http; use Livewire\Livewire; use Livewire\Volt\Volt; -it('loads newest TRMNL recipes on mount', function () { +it('loads newest TRMNL recipes on mount', function (): void { Http::fake([ 'usetrmnl.com/recipes.json*' => Http::response([ 'data' => [ @@ -31,7 +31,7 @@ it('loads newest TRMNL recipes on mount', function () { ->assertSee('Installs: 10'); }); -it('searches TRMNL recipes when search term is provided', function () { +it('searches TRMNL recipes when search term is provided', function (): void { Http::fake([ // First call (mount -> newest) 'usetrmnl.com/recipes.json?*' => Http::sequence() @@ -71,7 +71,7 @@ it('searches TRMNL recipes when search term is provided', function () { ->assertSee('Install'); }); -it('installs plugin successfully when user is authenticated', function () { +it('installs plugin successfully when user is authenticated', function (): void { $user = User::factory()->create(); Http::fake([ @@ -100,7 +100,7 @@ it('installs plugin successfully when user is authenticated', function () { ->assertSee('Error installing plugin'); // This will fail because we don't have a real zip file }); -it('shows error when user is not authenticated', function () { +it('shows error when user is not authenticated', function (): void { Http::fake([ 'usetrmnl.com/recipes.json*' => Http::response([ 'data' => [ @@ -124,7 +124,7 @@ it('shows error when user is not authenticated', function () { ->assertStatus(403); // This will return 403 because user is not authenticated }); -it('shows error when plugin installation fails', function () { +it('shows error when plugin installation fails', function (): void { $user = User::factory()->create(); Http::fake([ diff --git a/tests/Feature/Volt/DevicePalettesTest.php b/tests/Feature/Volt/DevicePalettesTest.php new file mode 100644 index 0000000..376a4a6 --- /dev/null +++ b/tests/Feature/Volt/DevicePalettesTest.php @@ -0,0 +1,575 @@ +create(); + + $this->actingAs($user); + + $this->get(route('device-palettes.index'))->assertOk(); +}); + +test('component loads all device palettes on mount', function (): void { + $user = User::factory()->create(); + $initialCount = DevicePalette::count(); + DevicePalette::create(['name' => 'palette-1', 'grays' => 2, 'framework_class' => '']); + DevicePalette::create(['name' => 'palette-2', 'grays' => 4, 'framework_class' => '']); + DevicePalette::create(['name' => 'palette-3', 'grays' => 16, 'framework_class' => '']); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index'); + + $palettes = $component->get('devicePalettes'); + expect($palettes)->toHaveCount($initialCount + 3); +}); + +test('can open modal to create new device palette', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->call('openDevicePaletteModal'); + + $component + ->assertSet('editingDevicePaletteId', null) + ->assertSet('viewingDevicePaletteId', null) + ->assertSet('name', null) + ->assertSet('grays', 2); +}); + +test('can create a new device palette', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('name', 'test-palette') + ->set('description', 'Test Palette Description') + ->set('grays', 16) + ->set('colors', ['#FF0000', '#00FF00']) + ->set('framework_class', 'TestFramework') + ->call('saveDevicePalette'); + + $component->assertHasNoErrors(); + + expect(DevicePalette::where('name', 'test-palette')->exists())->toBeTrue(); + + $palette = DevicePalette::where('name', 'test-palette')->first(); + expect($palette->description)->toBe('Test Palette Description'); + expect($palette->grays)->toBe(16); + expect($palette->colors)->toBe(['#FF0000', '#00FF00']); + expect($palette->framework_class)->toBe('TestFramework'); + expect($palette->source)->toBe('manual'); +}); + +test('can create a grayscale-only palette without colors', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('name', 'grayscale-palette') + ->set('grays', 256) + ->set('colors', []) + ->call('saveDevicePalette'); + + $component->assertHasNoErrors(); + + $palette = DevicePalette::where('name', 'grayscale-palette')->first(); + expect($palette->colors)->toBeNull(); + expect($palette->grays)->toBe(256); +}); + +test('can open modal to edit existing device palette', function (): void { + $user = User::factory()->create(); + $palette = DevicePalette::create([ + 'name' => 'existing-palette', + 'description' => 'Existing Description', + 'grays' => 4, + 'colors' => ['#FF0000', '#00FF00'], + 'framework_class' => 'Framework', + 'source' => 'manual', + ]); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->call('openDevicePaletteModal', $palette->id); + + $component + ->assertSet('editingDevicePaletteId', $palette->id) + ->assertSet('name', 'existing-palette') + ->assertSet('description', 'Existing Description') + ->assertSet('grays', 4) + ->assertSet('colors', ['#FF0000', '#00FF00']) + ->assertSet('framework_class', 'Framework'); +}); + +test('can update an existing device palette', function (): void { + $user = User::factory()->create(); + $palette = DevicePalette::create([ + 'name' => 'original-palette', + 'grays' => 2, + 'framework_class' => '', + 'source' => 'manual', + ]); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->call('openDevicePaletteModal', $palette->id) + ->set('name', 'updated-palette') + ->set('description', 'Updated Description') + ->set('grays', 16) + ->set('colors', ['#0000FF']) + ->call('saveDevicePalette'); + + $component->assertHasNoErrors(); + + $palette->refresh(); + expect($palette->name)->toBe('updated-palette'); + expect($palette->description)->toBe('Updated Description'); + expect($palette->grays)->toBe(16); + expect($palette->colors)->toBe(['#0000FF']); +}); + +test('can delete a device palette', function (): void { + $user = User::factory()->create(); + $palette = DevicePalette::create([ + 'name' => 'to-delete', + 'grays' => 2, + 'framework_class' => '', + 'source' => 'manual', + ]); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->call('deleteDevicePalette', $palette->id); + + expect(DevicePalette::find($palette->id))->toBeNull(); + $component->assertSet('devicePalettes', function ($palettes) use ($palette) { + return $palettes->where('id', $palette->id)->isEmpty(); + }); +}); + +test('can duplicate a device palette', function (): void { + $user = User::factory()->create(); + $palette = DevicePalette::create([ + 'name' => 'original-palette', + 'description' => 'Original Description', + 'grays' => 4, + 'colors' => ['#FF0000', '#00FF00'], + 'framework_class' => 'Framework', + 'source' => 'manual', + ]); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->call('duplicateDevicePalette', $palette->id); + + $component + ->assertSet('editingDevicePaletteId', null) + ->assertSet('name', 'original-palette (Copy)') + ->assertSet('description', 'Original Description') + ->assertSet('grays', 4) + ->assertSet('colors', ['#FF0000', '#00FF00']) + ->assertSet('framework_class', 'Framework'); +}); + +test('can add a color to the colors array', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('colorInput', '#FF0000') + ->call('addColor'); + + $component + ->assertHasNoErrors() + ->assertSet('colors', ['#FF0000']) + ->assertSet('colorInput', ''); +}); + +test('cannot add duplicate colors', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('colors', ['#FF0000']) + ->set('colorInput', '#FF0000') + ->call('addColor'); + + $component + ->assertHasNoErrors() + ->assertSet('colors', ['#FF0000']); +}); + +test('can add multiple colors', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('colorInput', '#FF0000') + ->call('addColor') + ->set('colorInput', '#00FF00') + ->call('addColor') + ->set('colorInput', '#0000FF') + ->call('addColor'); + + $component + ->assertSet('colors', ['#FF0000', '#00FF00', '#0000FF']); +}); + +test('can remove a color from the colors array', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('colors', ['#FF0000', '#00FF00', '#0000FF']) + ->call('removeColor', 1); + + $component->assertSet('colors', ['#FF0000', '#0000FF']); +}); + +test('removing color reindexes array', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('colors', ['#FF0000', '#00FF00', '#0000FF']) + ->call('removeColor', 0); + + $colors = $component->get('colors'); + expect($colors)->toBe(['#00FF00', '#0000FF']); + expect(array_keys($colors))->toBe([0, 1]); +}); + +test('can open modal in view-only mode for api-sourced palette', function (): void { + $user = User::factory()->create(); + $palette = DevicePalette::create([ + 'name' => 'api-palette', + 'grays' => 2, + 'framework_class' => '', + 'source' => 'api', + ]); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->call('openDevicePaletteModal', $palette->id, true); + + $component + ->assertSet('viewingDevicePaletteId', $palette->id) + ->assertSet('editingDevicePaletteId', null); +}); + +test('name is required when creating device palette', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('grays', 16) + ->call('saveDevicePalette'); + + $component->assertHasErrors(['name']); +}); + +test('name must be unique when creating device palette', function (): void { + $user = User::factory()->create(); + DevicePalette::create([ + 'name' => 'existing-name', + 'grays' => 2, + 'framework_class' => '', + ]); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('name', 'existing-name') + ->set('grays', 16) + ->call('saveDevicePalette'); + + $component->assertHasErrors(['name']); +}); + +test('name can be same when updating device palette', function (): void { + $user = User::factory()->create(); + $palette = DevicePalette::create([ + 'name' => 'original-name', + 'grays' => 2, + 'framework_class' => '', + 'source' => 'manual', + ]); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->call('openDevicePaletteModal', $palette->id) + ->set('grays', 16) + ->call('saveDevicePalette'); + + $component->assertHasNoErrors(); +}); + +test('grays is required when creating device palette', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('name', 'test-palette') + ->set('grays', null) + ->call('saveDevicePalette'); + + $component->assertHasErrors(['grays']); +}); + +test('grays must be at least 1', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('name', 'test-palette') + ->set('grays', 0) + ->call('saveDevicePalette'); + + $component->assertHasErrors(['grays']); +}); + +test('grays must be at most 256', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('name', 'test-palette') + ->set('grays', 257) + ->call('saveDevicePalette'); + + $component->assertHasErrors(['grays']); +}); + +test('colors must be valid hex format', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('name', 'test-palette') + ->set('grays', 16) + ->set('colors', ['invalid-color', '#FF0000']) + ->call('saveDevicePalette'); + + $component->assertHasErrors(['colors.0']); +}); + +test('color input must be valid hex format when adding color', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('colorInput', 'invalid-color') + ->call('addColor'); + + $component->assertHasErrors(['colorInput']); +}); + +test('color input accepts valid hex format', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('colorInput', '#FF0000') + ->call('addColor'); + + $component->assertHasNoErrors(); +}); + +test('color input accepts lowercase hex format', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('colorInput', '#ff0000') + ->call('addColor'); + + $component->assertHasNoErrors(); +}); + +test('description can be null', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('name', 'test-palette') + ->set('grays', 16) + ->set('description', null) + ->call('saveDevicePalette'); + + $component->assertHasNoErrors(); + + $palette = DevicePalette::where('name', 'test-palette')->first(); + expect($palette->description)->toBeNull(); +}); + +test('framework class can be empty string', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('name', 'test-palette') + ->set('grays', 16) + ->set('framework_class', '') + ->call('saveDevicePalette'); + + $component->assertHasNoErrors(); + + $palette = DevicePalette::where('name', 'test-palette')->first(); + expect($palette->framework_class)->toBe(''); +}); + +test('empty colors array is saved as null', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('name', 'test-palette') + ->set('grays', 16) + ->set('colors', []) + ->call('saveDevicePalette'); + + $component->assertHasNoErrors(); + + $palette = DevicePalette::where('name', 'test-palette')->first(); + expect($palette->colors)->toBeNull(); +}); + +test('component resets form after saving', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('name', 'test-palette') + ->set('description', 'Test Description') + ->set('grays', 16) + ->set('colors', ['#FF0000']) + ->set('framework_class', 'TestFramework') + ->call('saveDevicePalette'); + + $component + ->assertSet('name', null) + ->assertSet('description', null) + ->assertSet('grays', 2) + ->assertSet('colors', []) + ->assertSet('framework_class', '') + ->assertSet('colorInput', '') + ->assertSet('editingDevicePaletteId', null) + ->assertSet('viewingDevicePaletteId', null); +}); + +test('component handles palette with null colors when editing', function (): void { + $user = User::factory()->create(); + $palette = DevicePalette::create([ + 'name' => 'grayscale-palette', + 'grays' => 2, + 'colors' => null, + 'framework_class' => '', + ]); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->call('openDevicePaletteModal', $palette->id); + + $component->assertSet('colors', []); +}); + +test('component handles palette with string colors when editing', function (): void { + $user = User::factory()->create(); + $palette = DevicePalette::create([ + 'name' => 'string-colors-palette', + 'grays' => 2, + 'framework_class' => '', + ]); + // Manually set colors as JSON string to simulate edge case + $palette->setRawAttributes(array_merge($palette->getAttributes(), [ + 'colors' => json_encode(['#FF0000', '#00FF00']), + ])); + $palette->save(); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->call('openDevicePaletteModal', $palette->id); + + $component->assertSet('colors', ['#FF0000', '#00FF00']); +}); + +test('component refreshes palette list after creating', function (): void { + $user = User::factory()->create(); + $initialCount = DevicePalette::count(); + DevicePalette::create(['name' => 'palette-1', 'grays' => 2, 'framework_class' => '']); + DevicePalette::create(['name' => 'palette-2', 'grays' => 4, 'framework_class' => '']); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->set('name', 'new-palette') + ->set('grays', 16) + ->call('saveDevicePalette'); + + $palettes = $component->get('devicePalettes'); + expect($palettes)->toHaveCount($initialCount + 3); + expect(DevicePalette::count())->toBe($initialCount + 3); +}); + +test('component refreshes palette list after deleting', function (): void { + $user = User::factory()->create(); + $initialCount = DevicePalette::count(); + $palette1 = DevicePalette::create([ + 'name' => 'palette-1', + 'grays' => 2, + 'framework_class' => '', + 'source' => 'manual', + ]); + $palette2 = DevicePalette::create([ + 'name' => 'palette-2', + 'grays' => 2, + 'framework_class' => '', + 'source' => 'manual', + ]); + + $this->actingAs($user); + + $component = Volt::test('device-palettes.index') + ->call('deleteDevicePalette', $palette1->id); + + $palettes = $component->get('devicePalettes'); + expect($palettes)->toHaveCount($initialCount + 1); + expect(DevicePalette::count())->toBe($initialCount + 1); +}); diff --git a/tests/Unit/Liquid/Filters/LocalizationTest.php b/tests/Unit/Liquid/Filters/LocalizationTest.php index a52623f..3129b1e 100644 --- a/tests/Unit/Liquid/Filters/LocalizationTest.php +++ b/tests/Unit/Liquid/Filters/LocalizationTest.php @@ -77,7 +77,7 @@ test('l_date handles null locale parameter', function (): void { $filter = new Localization(); $date = '2025-01-11'; - $result = $filter->l_date($date, 'Y-m-d', null); + $result = $filter->l_date($date, 'Y-m-d'); // Should work the same as default expect($result)->toContain('2025');