From 6e32919d36ff8529a02fecbf7e7e0b5fd7eb8e0e Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 2 Feb 2026 19:37:16 -0500 Subject: [PATCH 1/3] feat: color-coolify: store and set color on projects --- app/Livewire/Project/Edit.php | 12 ++++++- ..._03_001245_add_color_to_projects_table.php | 32 +++++++++++++++++ resources/views/livewire/dashboard.blade.php | 5 ++- .../views/livewire/project/edit.blade.php | 35 +++++++++++++++++++ .../views/livewire/project/index.blade.php | 5 ++- 5 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 database/migrations/2026_02_03_001245_add_color_to_projects_table.php diff --git a/app/Livewire/Project/Edit.php b/app/Livewire/Project/Edit.php index a2d73eb5f..eafc649cb 100644 --- a/app/Livewire/Project/Edit.php +++ b/app/Livewire/Project/Edit.php @@ -14,17 +14,25 @@ class Edit extends Component public ?string $description = null; + public ?string $color = null; + protected function rules(): array { return [ 'name' => ValidationPatterns::nameRules(), 'description' => ValidationPatterns::descriptionRules(), + 'color' => ['nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'], ]; } protected function messages(): array { - return ValidationPatterns::combinedMessages(); + return array_merge( + ValidationPatterns::combinedMessages(), + [ + 'color.regex' => 'The color must be a valid hex color code (e.g., #FF5733).', + ] + ); } public function mount(string $project_uuid) @@ -44,10 +52,12 @@ class Edit extends Component $this->project->update([ 'name' => $this->name, 'description' => $this->description, + 'color' => $this->color, ]); } else { $this->name = $this->project->name; $this->description = $this->project->description; + $this->color = $this->project->color; } } diff --git a/database/migrations/2026_02_03_001245_add_color_to_projects_table.php b/database/migrations/2026_02_03_001245_add_color_to_projects_table.php new file mode 100644 index 000000000..6ab3c480b --- /dev/null +++ b/database/migrations/2026_02_03_001245_add_color_to_projects_table.php @@ -0,0 +1,32 @@ +string('color', 7)->nullable()->after('description'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasColumn('projects', 'color')) { + Schema::table('projects', function (Blueprint $table) { + $table->dropColumn('color'); + }); + } + } +}; diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index a58ca0a00..c8c491ee9 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -35,7 +35,10 @@ @if ($projects->count() > 0)
@foreach ($projects as $project) -
+
$project->color, + ]) @if($project->color) style="border-left-color: {{ $project->color }}" @endif>
diff --git a/resources/views/livewire/project/edit.blade.php b/resources/views/livewire/project/edit.blade.php index dfaf20ed9..9375e9765 100644 --- a/resources/views/livewire/project/edit.blade.php +++ b/resources/views/livewire/project/edit.blade.php @@ -14,6 +14,41 @@
+
+ +
+ + + @if($color) + + @endif +
+
\ No newline at end of file diff --git a/resources/views/livewire/project/index.blade.php b/resources/views/livewire/project/index.blade.php index b9ee20326..b3496d175 100644 --- a/resources/views/livewire/project/index.blade.php +++ b/resources/views/livewire/project/index.blade.php @@ -13,7 +13,10 @@
All your projects are here.
@foreach ($projects as $project) -
+
$project->color, + ]) @if($project->color) style="border-left-color: {{ $project->color }}" @endif>
From c72c0168b45c1c9a8b90e76b6b2e7d368d8e6d03 Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 2 Feb 2026 19:48:59 -0500 Subject: [PATCH 2/3] feat: color-coolify: js to set button label color --- resources/js/app.js | 4 +++ resources/js/color-utils.js | 30 +++++++++++++++++++ .../views/livewire/project/edit.blade.php | 2 +- 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 resources/js/color-utils.js diff --git a/resources/js/app.js b/resources/js/app.js index 4dcae5f8e..2a3e8f3f2 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,4 +1,8 @@ import { initializeTerminalComponent } from './terminal.js'; +import { initializeColorUtils } from './color-utils.js'; + +// Initialize color utilities globally +initializeColorUtils(); ['livewire:navigated', 'alpine:init'].forEach((event) => { document.addEventListener(event, () => { diff --git a/resources/js/color-utils.js b/resources/js/color-utils.js new file mode 100644 index 000000000..ef792502d --- /dev/null +++ b/resources/js/color-utils.js @@ -0,0 +1,30 @@ +/** + * Color utility functions for the application + */ +export function initializeColorUtils() { + /** + * Determines the appropriate text color (black or white) based on background color luminance + * Uses WCAG relative luminance formula for accessibility + * + * @param {string} bgColor - Hex color code (e.g., '#FF5733') + * @returns {string} Tailwind CSS class: 'text-black' or 'text-white' + */ + function getContrastTextColor(bgColor) { + if (!bgColor) return ''; + + // Convert hex to RGB + const hex = bgColor.replace('#', ''); + const r = parseInt(hex.substr(0, 2), 16); + const g = parseInt(hex.substr(2, 2), 16); + const b = parseInt(hex.substr(4, 2), 16); + + // Calculate relative luminance using WCAG formula + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + + // Return dark text for light backgrounds, light text for dark backgrounds + return luminance > 0.5 ? 'text-black' : 'text-white'; + } + + // Make it globally available + window.getContrastTextColor = getContrastTextColor; +} diff --git a/resources/views/livewire/project/edit.blade.php b/resources/views/livewire/project/edit.blade.php index 9375e9765..e4be8e83e 100644 --- a/resources/views/livewire/project/edit.blade.php +++ b/resources/views/livewire/project/edit.blade.php @@ -32,7 +32,7 @@ @click="showPicker = true; $nextTick(() => document.getElementById('colorPicker').click())" class="flex items-center justify-center w-20 h-8 text-xs font-medium border rounded cursor-pointer border-coolgray-300 dark:border-coolgray-500 hover:border-coolgray-400 dark:hover:border-coolgray-400" :style="$wire.color ? 'background-color: ' + $wire.color : ''"> - + Select From 296b70445f4db3b0ba22ca3dccc6aebf05a0d8fa Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 2 Feb 2026 19:56:36 -0500 Subject: [PATCH 3/3] feat: color-coolify: added unit tests --- .../Project/ProjectColorValidationTest.php | 145 ++++++++++++++++++ tests/Unit/ProjectColorHexValidationTest.php | 111 ++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 tests/Unit/Livewire/Project/ProjectColorValidationTest.php create mode 100644 tests/Unit/ProjectColorHexValidationTest.php diff --git a/tests/Unit/Livewire/Project/ProjectColorValidationTest.php b/tests/Unit/Livewire/Project/ProjectColorValidationTest.php new file mode 100644 index 000000000..fb0687407 --- /dev/null +++ b/tests/Unit/Livewire/Project/ProjectColorValidationTest.php @@ -0,0 +1,145 @@ +makePartial(); + $component->color = '#FF5733'; + $component->name = 'Test Project'; + $component->description = 'Test Description'; + + $rules = $component->rules(); + + expect($rules)->toHaveKey('color') + ->and($rules['color'])->toContain('nullable') + ->and($rules['color'])->toContain('string'); +}); + +it('accepts null color value', function () { + $component = Mockery::mock(Edit::class)->makePartial(); + $component->color = null; + $component->name = 'Test Project'; + $component->description = 'Test Description'; + + $rules = $component->rules(); + + expect($rules['color'])->toContain('nullable'); +}); + +it('color validation rules include regex pattern', function () { + $component = Mockery::mock(Edit::class)->makePartial(); + + $rules = $component->rules(); + + expect($rules) + ->toHaveKey('color') + ->and($rules['color']) + ->toBeArray() + ->and(count($rules['color']))->toBe(3) + ->and($rules['color'][0])->toBe('nullable') + ->and($rules['color'][1])->toBe('string') + ->and($rules['color'][2])->toBeString() + ->and($rules['color'][2])->toContain('regex:/^#[0-9A-Fa-f]{6}$/'); +}); + +it('has custom validation message for invalid color format', function () { + $component = Mockery::mock(Edit::class)->makePartial(); + + $messages = $component->messages(); + + expect($messages) + ->toHaveKey('color.regex') + ->and($messages['color.regex']) + ->toBeString() + ->toContain('#FF5733'); +}); + +it('syncs color from model to component', function () { + $project = Mockery::mock(Project::class)->makePartial(); + $project->shouldReceive('getAttribute') + ->with('name') + ->andReturn('Test Project'); + $project->shouldReceive('getAttribute') + ->with('description') + ->andReturn('Test Description'); + $project->shouldReceive('getAttribute') + ->with('color') + ->andReturn('#FF5733'); + + $project->name = 'Test Project'; + $project->description = 'Test Description'; + $project->color = '#FF5733'; + + $component = Mockery::mock(Edit::class)->makePartial(); + $component->project = $project; + + $component->syncData(false); + + expect($component->color)->toBe('#FF5733'); +}); + +it('syncs null color from model to component', function () { + $project = Mockery::mock(Project::class)->makePartial(); + $project->shouldReceive('getAttribute') + ->with('name') + ->andReturn('Test Project'); + $project->shouldReceive('getAttribute') + ->with('description') + ->andReturn('Test Description'); + $project->shouldReceive('getAttribute') + ->with('color') + ->andReturn(null); + + $project->name = 'Test Project'; + $project->description = 'Test Description'; + $project->color = null; + + $component = Mockery::mock(Edit::class)->makePartial(); + $component->project = $project; + + $component->syncData(false); + + expect($component->color)->toBeNull(); +}); + +it('syncs color from component to model', function () { + $project = Mockery::mock(Project::class); + $project->shouldReceive('update') + ->once() + ->with(Mockery::on(function ($data) { + return $data['color'] === '#00FF00' + && $data['name'] === 'Test Project' + && $data['description'] === 'Test Description'; + })); + + $component = Mockery::mock(Edit::class)->makePartial(); + $component->project = $project; + $component->name = 'Test Project'; + $component->description = 'Test Description'; + $component->color = '#00FF00'; + + $component->shouldReceive('validate')->once(); + + $component->syncData(true); +}); + +it('syncs null color from component to model', function () { + $project = Mockery::mock(Project::class); + $project->shouldReceive('update') + ->once() + ->with(Mockery::on(function ($data) { + return $data['color'] === null + && array_key_exists('color', $data); + })); + + $component = Mockery::mock(Edit::class)->makePartial(); + $component->project = $project; + $component->name = 'Test Project'; + $component->description = 'Test Description'; + $component->color = null; + + $component->shouldReceive('validate')->once(); + + $component->syncData(true); +}); diff --git a/tests/Unit/ProjectColorHexValidationTest.php b/tests/Unit/ProjectColorHexValidationTest.php new file mode 100644 index 000000000..c2917c27a --- /dev/null +++ b/tests/Unit/ProjectColorHexValidationTest.php @@ -0,0 +1,111 @@ +toBe(1); + } +}); + +test('hex color regex rejects invalid hex codes', function () { + $invalidColors = [ + '#FFF', // too short (3 chars) + '#FFFFFFF', // too long (7 chars) + 'FF5733', // missing hash + '#GG5733', // invalid character G + '#FF57ZZ', // invalid characters Z + '#12 34 56', // spaces + '#12-34-56', // dashes + 'rgb(255,0,0)', // not hex format + '#', // just hash + '', // empty string + '#FF57', // too short (4 chars) + '#FF5', // too short (3 chars) + ]; + + $pattern = '/^#[0-9A-Fa-f]{6}$/'; + + foreach ($invalidColors as $color) { + expect(preg_match($pattern, $color))->toBe(0); + } +}); + +test('hex color regex pattern is correctly formatted', function () { + $pattern = '/^#[0-9A-Fa-f]{6}$/'; + + // Verify the pattern itself is valid + expect(@preg_match($pattern, ''))->not->toBeFalse(); +}); + +test('hex color regex accepts common colors', function () { + $commonColors = [ + '#FF0000', // Red + '#00FF00', // Green (Lime) + '#0000FF', // Blue + '#FFFF00', // Yellow + '#FF00FF', // Magenta + '#00FFFF', // Cyan + '#000000', // Black + '#FFFFFF', // White + '#808080', // Gray + '#FFA500', // Orange + '#800080', // Purple + '#008000', // Dark Green + '#FFC0CB', // Pink + '#A52A2A', // Brown + ]; + + $pattern = '/^#[0-9A-Fa-f]{6}$/'; + + foreach ($commonColors as $color) { + expect(preg_match($pattern, $color))->toBe(1); + } +}); + +test('hex color regex rejects 3-char shorthand hex codes', function () { + // HTML supports 3-char hex (#FFF), but we require 6-char format + $shorthandColors = [ + '#FFF', // white shorthand + '#000', // black shorthand + '#F00', // red shorthand + '#0F0', // green shorthand + '#00F', // blue shorthand + ]; + + $pattern = '/^#[0-9A-Fa-f]{6}$/'; + + foreach ($shorthandColors as $color) { + expect(preg_match($pattern, $color))->toBe(0); + } +}); + +test('hex color regex rejects color names', function () { + $colorNames = [ + 'red', + 'blue', + 'green', + 'black', + 'white', + 'transparent', + ]; + + $pattern = '/^#[0-9A-Fa-f]{6}$/'; + + foreach ($colorNames as $color) { + expect(preg_match($pattern, $color))->toBe(0); + } +});