Skip to content

Commit 3d1b490

Browse files
authored
feat: implement color values import (CT-74)
* feat(custom-properties): implement custom properties * chore: add proof of concept for inline translatable component * chore: improve UI for translatable fields * chore: custom properties improvements * chore: more custom property improvements * chore: custom properties improvements * chore: improve tabs * fix: resolve duplicate methods after merging main * feat: implement color input type * feat: implement color values import * chore: fix tests * chore: minor improvements
1 parent 2b7cd34 commit 3d1b490

6 files changed

Lines changed: 485 additions & 3 deletions

File tree

composer.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,11 @@
5353
"filament/spatie-laravel-media-library-plugin": "^3.2",
5454
"filament/spatie-laravel-translatable-plugin": "^3.2",
5555
"laravel/framework": "^11.0|^12.0",
56+
"nben/filament-record-nav": "^1.0",
57+
"phpoffice/phpspreadsheet": "^1.30",
5658
"solution-forest/filament-tree": "^2.1",
5759
"spatie/laravel-package-tools": "^1.19",
58-
"spatie/laravel-translatable": "^6.11",
59-
"nben/filament-record-nav": "^1.0"
60+
"spatie/laravel-translatable": "^6.11"
6061
},
6162
"require-dev": {
6263
"laravel/pint": "^1.21",

resources/lang/en/property-value.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
'info_url' => 'Info URL',
1010
'image' => 'Image',
1111
'sort' => 'Sort Order',
12+
'import_file' => 'Import File',
1213
],
1314

1415
'sections' => [
@@ -25,6 +26,7 @@
2526
'info_url' => 'Optional "read more" link',
2627
'image' => 'Optional image for this value (e.g., brand logo)',
2728
'sort' => 'Lower numbers appear first',
29+
'import_file' => 'Upload an Excel (.xlsx, .xls) or CSV file with two columns: <strong>name</strong> and <strong>hex</strong>. Example: Red, #FF0000',
2830
],
2931

3032
'table' => [
@@ -46,9 +48,14 @@
4648
],
4749
],
4850

51+
'actions' => [
52+
'import' => 'Import Colors',
53+
],
54+
4955
'modal' => [
5056
'create_heading' => 'Create Property Value',
5157
'edit_heading' => 'Edit Property Value',
58+
'import_heading' => 'Import Color Values',
5259
'merge_heading' => 'Merge Value',
5360
'merge_from_label' => 'Merge value…',
5461
'merge_to_label' => 'With value…*',
@@ -79,4 +86,19 @@
7986
'list' => 'List',
8087
],
8188
],
89+
90+
'notifications' => [
91+
'import_queued' => [
92+
'title' => 'Import Queued',
93+
'body' => 'Color import has been queued and will be processed in the background.',
94+
],
95+
'import_completed' => [
96+
'title' => 'Import Completed',
97+
'body' => 'Import completed: :inserted inserted, :skipped skipped, :errors errors.',
98+
'errors' => 'Errors',
99+
],
100+
'import_failed' => [
101+
'title' => 'Import Failed',
102+
],
103+
],
82104
];

resources/lang/sl/property-value.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
'info_url' => 'URL informacij',
1010
'image' => 'Slika',
1111
'sort' => 'Vrstni red',
12+
'import_file' => 'Uvoz datoteke',
1213
],
1314

1415
'sections' => [
@@ -25,6 +26,7 @@
2526
'info_url' => 'Neobvezna povezava "več informacij"',
2627
'image' => 'Neobvezna slika za to vrednost (npr. logotip blagovne znamke)',
2728
'sort' => 'Nižje številke se prikažejo prve',
29+
'import_file' => 'Naložite Excel (.xlsx, .xls) ali CSV datoteko z dvema stolpcema: <strong>name</strong> in <strong>hex</strong>. Primer: Rdeča, #FF0000',
2830
],
2931

3032
'table' => [
@@ -46,9 +48,14 @@
4648
],
4749
],
4850

51+
'actions' => [
52+
'import' => 'Uvozi barve',
53+
],
54+
4955
'modal' => [
5056
'create_heading' => 'Ustvari vrednost lastnosti',
5157
'edit_heading' => 'Uredi vrednost lastnosti',
58+
'import_heading' => 'Uvozi vrednosti barv',
5259
'merge_heading' => 'Združi vrednost',
5360
'merge_from_label' => 'Združi vrednost…',
5461
'merge_to_label' => 'Z vrednostjo…*',
@@ -79,4 +86,19 @@
7986
'list' => 'Seznam',
8087
],
8188
],
89+
90+
'notifications' => [
91+
'import_queued' => [
92+
'title' => 'Uvoz v čakalni vrsti',
93+
'body' => 'Uvoz barv je bil dodan v čakalno vrsto in bo obdelan v ozadju.',
94+
],
95+
'import_completed' => [
96+
'title' => 'Uvoz končan',
97+
'body' => 'Uvoz končan: :inserted dodano, :skipped preskočeno, :errors napak.',
98+
'errors' => 'Napake',
99+
],
100+
'import_failed' => [
101+
'title' => 'Uvoz ni uspel',
102+
],
103+
],
82104
];

src/Filament/Resources/PropertyValueResource/Pages/ListPropertyValues.php

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public function mount(): void
3636

3737
protected function getHeaderActions(): array
3838
{
39-
return [
39+
$actions = [
4040
LocaleSwitcher::make(),
4141
Actions\CreateAction::make()
4242
->modalWidth('lg')
@@ -79,6 +79,28 @@ protected function getHeaderActions(): array
7979
return $data;
8080
}),
8181
];
82+
83+
if ($this->property && $this->property->type === PropertyType::COLOR->value) {
84+
$actions[] = Actions\Action::make('import')
85+
->label(__('eclipse-catalogue::property-value.actions.import'))
86+
->icon('heroicon-o-arrow-up-tray')
87+
->modalWidth('lg')
88+
->modalHeading(__('eclipse-catalogue::property-value.modal.import_heading'))
89+
->form([
90+
\Filament\Forms\Components\FileUpload::make('file')
91+
->label(__('eclipse-catalogue::property-value.fields.import_file'))
92+
->helperText(new \Illuminate\Support\HtmlString(__('eclipse-catalogue::property-value.help_text.import_file')))
93+
->acceptedFileTypes(['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/csv'])
94+
->required()
95+
->disk('local')
96+
->directory('temp/color-imports'),
97+
])
98+
->action(function (array $data): void {
99+
\Eclipse\Catalogue\Jobs\ImportColorValues::dispatch($data['file'], $this->property->id);
100+
});
101+
}
102+
103+
return $actions;
82104
}
83105

84106
public function getTitle(): string

src/Jobs/ImportColorValues.php

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
<?php
2+
3+
namespace Eclipse\Catalogue\Jobs;
4+
5+
use Eclipse\Catalogue\Models\Property;
6+
use Eclipse\Catalogue\Models\PropertyValue;
7+
use Eclipse\Catalogue\Values\Background;
8+
use Eclipse\Common\Foundation\Jobs\QueueableJob;
9+
use Exception;
10+
use Illuminate\Support\Facades\Storage;
11+
use PhpOffice\PhpSpreadsheet\IOFactory;
12+
use PhpOffice\PhpSpreadsheet\Reader\Exception as PhpSpreadsheetException;
13+
14+
class ImportColorValues extends QueueableJob
15+
{
16+
/**
17+
* The timeout for the job.
18+
*/
19+
public int $timeout = 300;
20+
21+
/**
22+
* Whether to fail the job on timeout.
23+
*/
24+
public bool $failOnTimeout = true;
25+
26+
/**
27+
* The file path to import.
28+
*/
29+
private string $filePath;
30+
31+
/**
32+
* The property ID to import colors for.
33+
*/
34+
private int $propertyId;
35+
36+
/**
37+
* Import statistics.
38+
*/
39+
private array $stats = [
40+
'inserted' => 0,
41+
'skipped' => 0,
42+
'errors' => 0,
43+
];
44+
45+
/**
46+
* Error messages.
47+
*/
48+
private array $errors = [];
49+
50+
/**
51+
* Create a new job instance.
52+
*/
53+
public function __construct(string $filePath, int $propertyId)
54+
{
55+
parent::__construct();
56+
$this->filePath = $filePath;
57+
$this->propertyId = $propertyId;
58+
}
59+
60+
/**
61+
* Execute the job.
62+
*
63+
* @throws Exception
64+
*/
65+
protected function execute(): void
66+
{
67+
$property = Property::find($this->propertyId);
68+
if (! $property || $property->type !== \Eclipse\Catalogue\Enums\PropertyType::COLOR->value) {
69+
throw new Exception('Invalid property or property is not a color type.');
70+
}
71+
72+
if (! Storage::disk('local')->exists($this->filePath)) {
73+
throw new Exception('Import file not found.');
74+
}
75+
76+
$absolutePath = Storage::disk('local')->path($this->filePath);
77+
78+
try {
79+
$this->importFromSpreadsheet($absolutePath, $property);
80+
} finally {
81+
// Clean up the temporary file
82+
Storage::disk('local')->delete($this->filePath);
83+
}
84+
}
85+
86+
/**
87+
* Import colors from spreadsheet file (CSV or Excel).
88+
*/
89+
private function importFromSpreadsheet(string $filePath, Property $property): void
90+
{
91+
try {
92+
$reader = IOFactory::createReaderForFile($filePath);
93+
$spreadsheet = $reader->load($filePath);
94+
$worksheet = $spreadsheet->getActiveSheet();
95+
$highestRow = $worksheet->getHighestRow();
96+
97+
// Get headers from first row
98+
$headers = [];
99+
for ($col = 1; $col <= 2; $col++) {
100+
$headers[] = $worksheet->getCellByColumnAndRow($col, 1)->getValue();
101+
}
102+
$this->validateHeaders($headers);
103+
104+
// Process data rows
105+
for ($row = 2; $row <= $highestRow; $row++) {
106+
$rowData = [
107+
'name' => $worksheet->getCellByColumnAndRow(1, $row)->getValue(),
108+
'hex' => $worksheet->getCellByColumnAndRow(2, $row)->getValue(),
109+
];
110+
$this->processRow($rowData, $property);
111+
}
112+
} catch (PhpSpreadsheetException $e) {
113+
throw new Exception('Failed to read spreadsheet file: '.$e->getMessage());
114+
}
115+
}
116+
117+
/**
118+
* Validate file headers.
119+
*/
120+
private function validateHeaders(array $headers): void
121+
{
122+
$headers = array_map('strtolower', array_map('trim', $headers));
123+
124+
if (! in_array('name', $headers) || ! in_array('hex', $headers)) {
125+
throw new Exception('Invalid file format. Expected columns: name, hex');
126+
}
127+
}
128+
129+
/**
130+
* Process a single row of data.
131+
*/
132+
private function processRow(array $row, Property $property): void
133+
{
134+
$name = trim($row['name'] ?? '');
135+
$hex = trim($row['hex'] ?? '');
136+
137+
if (empty($name) || empty($hex)) {
138+
$this->stats['skipped']++;
139+
$this->errors[] = "Skipped row with empty name or hex: {$name}, {$hex}";
140+
141+
return;
142+
}
143+
144+
// Validate hex color
145+
if (! $this->isValidHexColor($hex)) {
146+
$this->stats['errors']++;
147+
$this->errors[] = "Invalid hex color '{$hex}' for name '{$name}'";
148+
149+
return;
150+
}
151+
152+
// Check for duplicates (by name)
153+
$existing = PropertyValue::where('property_id', $property->id)
154+
->where('value->'.app()->getLocale(), $name)
155+
->first();
156+
157+
if ($existing) {
158+
$this->stats['skipped']++;
159+
$this->errors[] = "Skipped duplicate name: {$name}";
160+
161+
return;
162+
}
163+
164+
try {
165+
// Create the color background object
166+
$color = Background::solid($hex);
167+
168+
// Create the property value
169+
PropertyValue::create([
170+
'property_id' => $property->id,
171+
'value' => [app()->getLocale() => $name],
172+
'color' => $color,
173+
'sort' => PropertyValue::where('property_id', $property->id)->max('sort') + 1,
174+
]);
175+
176+
$this->stats['inserted']++;
177+
} catch (Exception $e) {
178+
$this->stats['errors']++;
179+
$this->errors[] = "Failed to create property value for '{$name}': ".$e->getMessage();
180+
}
181+
}
182+
183+
/**
184+
* Validate hex color format.
185+
*/
186+
private function isValidHexColor(string $hex): bool
187+
{
188+
// Remove # if present
189+
$hex = ltrim($hex, '#');
190+
191+
// Check if it's a valid hex color (3 or 6 characters)
192+
return preg_match('/^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/', $hex) === 1;
193+
}
194+
195+
/**
196+
* Get the notification title based on the job status.
197+
*/
198+
protected function getNotificationTitle(): string
199+
{
200+
if ($this->status === \Eclipse\Common\Enums\JobStatus::COMPLETED) {
201+
return __('eclipse-catalogue::property-value.notifications.import_completed.title');
202+
}
203+
204+
if ($this->status === \Eclipse\Common\Enums\JobStatus::FAILED) {
205+
return __('eclipse-catalogue::property-value.notifications.import_failed.title');
206+
}
207+
208+
return parent::getNotificationTitle();
209+
}
210+
211+
/**
212+
* Get the notification body based on the job status.
213+
*/
214+
protected function getNotificationBody(): string
215+
{
216+
if ($this->status === \Eclipse\Common\Enums\JobStatus::COMPLETED) {
217+
$body = __('eclipse-catalogue::property-value.notifications.import_completed.body', [
218+
'inserted' => $this->stats['inserted'],
219+
'skipped' => $this->stats['skipped'],
220+
'errors' => $this->stats['errors'],
221+
]);
222+
223+
if (! empty($this->errors) && $this->stats['errors'] > 0) {
224+
$body .= "\n\n".__('eclipse-catalogue::property-value.notifications.import_completed.errors').":\n";
225+
$body .= implode("\n", array_slice($this->errors, 0, 5));
226+
if (count($this->errors) > 5) {
227+
$body .= "\n... and ".(count($this->errors) - 5).' more errors.';
228+
}
229+
}
230+
231+
return $body;
232+
}
233+
234+
if ($this->status === \Eclipse\Common\Enums\JobStatus::FAILED) {
235+
$message = $this->exception?->getMessage();
236+
237+
return $message ?: __('eclipse-catalogue::property-value.notifications.import_queued.body');
238+
}
239+
240+
return parent::getNotificationBody();
241+
}
242+
}

0 commit comments

Comments
 (0)