• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

luttje / filament-user-attributes / 6917010354

18 Nov 2023 11:58PM UTC coverage: 76.475% (+13.3%) from 63.165%
6917010354

push

github

luttje
Merge branch 'main' of https://github.com/luttje/filament-user-attributes

1141 of 1492 relevant lines covered (76.47%)

28.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.7
/src/FilamentUserAttributes.php
1
<?php
2

3
namespace Luttje\FilamentUserAttributes;
4

5
use Closure;
6
use Filament\Forms\Components\Component;
7
use Filament\Forms\Components\Section;
8
use Filament\Forms\Components\Tabs;
9
use Filament\Forms\Components\Tabs\Tab;
10
use Filament\Tables\Columns\Column;
11
use Illuminate\Support\Facades\File;
12
use Luttje\FilamentUserAttributes\Contracts\ConfiguresUserAttributesContract;
13
use Luttje\FilamentUserAttributes\Contracts\UserAttributesConfigContract;
14
use Luttje\FilamentUserAttributes\Filament\UserAttributeComponentFactoryRegistry;
15
use Luttje\FilamentUserAttributes\Traits\ConfiguresUserAttributes;
16

17
/**
18
 * Class FilamentUserAttributes
19
 *
20
 * This class provides functionality for managing user attributes in Filament admin
21
 * panels. It handles the registration and discovery of resources, user attribute
22
 * components, and facilitates the integration of custom fields and columns in
23
 * Filament resources.
24
 */
25
class FilamentUserAttributes
26
{
27
    /**
28
     * @var array|Closure List of registered resources or a closure that returns resources.
29
     */
30
    protected array | Closure $registeredResources = [];
31

32
    /**
33
     * @var array|null Cached list of discovered resources.
34
     */
35
    protected ?array $cachedDiscoveredResources = null;
36

37
    /**
38
     * @var string Path to the application directory.
39
     */
40
    protected string $appPath;
41

42
    /**
43
     * @var string Namespace of the application.
44
     */
45
    protected string $appNamespace;
46

47
    /**
48
     * Constructor for FilamentUserAttributes.
49
     *
50
     * @param string|null $appPath       Optional path to the application directory.
51
     * @param string|null $appNamespace  Optional namespace of the application.
52
     */
53
    public function __construct(string $appPath = null, string $appNamespace = null)
272✔
54
    {
55
        $this->appNamespace = $appNamespace ?? app()->getNamespace();
272✔
56
        $this->appPath = $appPath ?? app_path();
272✔
57

58
        $this->appNamespace = rtrim($this->appNamespace, '\\') . '\\';
272✔
59
        $this->appPath = rtrim($this->appPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
272✔
60
    }
61

62
    /**
63
     * Register resources that can be configured with user attributes.
64
     * You can provide an associative array of resources, where the key
65
     * is the resource class and the value is the resource label to show
66
     * to users.
67
     *
68
     * You can also provide a closure that returns an array of resources
69
     * in the same format.
70
     *
71
     * Call this in your AppServiceProvider's boot function.
72
     */
73
    public function registerResources(array | Closure $resources): void
8✔
74
    {
75
        if (config('filament-user-attributes.discover_resources') !== false) {
8✔
76
            throw new \Exception("You cannot register resources when the 'filament-user-attributes.discover_resources' config option is enabled. Set it to false.");
4✔
77
        }
78

79
        if (is_array($resources)) {
4✔
80
            $this->registeredResources = array_merge($this->registeredResources, $resources);
4✔
81
        } elseif ($resources instanceof Closure) {
×
82
            $this->registeredResources = $resources;
×
83
        }
84
    }
85

86
    /**
87
     * Registers all types of user attribute field factories.
88
     */
89
    public function registerDefaultUserAttributeComponentFactories(): void
272✔
90
    {
91
        UserAttributeComponentFactoryRegistry::register('text', \Luttje\FilamentUserAttributes\Filament\Factories\TextComponentFactory::class);
272✔
92
        UserAttributeComponentFactoryRegistry::register('number', \Luttje\FilamentUserAttributes\Filament\Factories\NumberInputComponentFactory::class);
272✔
93
        UserAttributeComponentFactoryRegistry::register('textarea', \Luttje\FilamentUserAttributes\Filament\Factories\TextareaComponentFactory::class);
272✔
94
        UserAttributeComponentFactoryRegistry::register('richeditor', \Luttje\FilamentUserAttributes\Filament\Factories\RichEditorComponentFactory::class);
272✔
95
        UserAttributeComponentFactoryRegistry::register('tags', \Luttje\FilamentUserAttributes\Filament\Factories\TagsInputComponentFactory::class);
272✔
96

97
        UserAttributeComponentFactoryRegistry::register('select', \Luttje\FilamentUserAttributes\Filament\Factories\SelectComponentFactory::class);
272✔
98
        UserAttributeComponentFactoryRegistry::register('checkbox', \Luttje\FilamentUserAttributes\Filament\Factories\CheckboxComponentFactory::class);
272✔
99
        UserAttributeComponentFactoryRegistry::register('radio', \Luttje\FilamentUserAttributes\Filament\Factories\RadioComponentFactory::class);
272✔
100

101
        UserAttributeComponentFactoryRegistry::register('datetime', \Luttje\FilamentUserAttributes\Filament\Factories\DateTimeComponentFactory::class);
272✔
102
    }
103

104
    /**
105
     * Returns the user attribute columns.
106
     */
107
    public function getUserAttributeColumns(string $resource): array
20✔
108
    {
109
        $config = $this->getUserAttributeConfig($resource);
20✔
110

111
        if (!in_array(ConfiguresUserAttributes::class, class_uses_recursive($config))) {
20✔
112
            throw new \Exception("The resource '$resource' does not correctly use the ConfiguresUserAttributes trait");
×
113
        }
114

115
        return $config->getUserAttributeColumns($resource);
20✔
116
    }
117

118
    /**
119
     * Returns the user attribute fields.
120
     */
121
    public function getUserAttributeFields(string $resource): array
8✔
122
    {
123
        $config = $this->getUserAttributeConfig($resource);
8✔
124

125
        if (!in_array(ConfiguresUserAttributes::class, class_uses_recursive($config))) {
8✔
126
            throw new \Exception("The resource '$resource' does not use the ConfiguresUserAttributes trait");
×
127
        }
128

129
        return $config->getUserAttributeFields($resource);
8✔
130
    }
131

132
    /**
133
     * Returns the user attribute configuration model.
134
     */
135
    public function getUserAttributeConfig(string $resource): ConfiguresUserAttributesContract
24✔
136
    {
137
        if (!in_array(UserAttributesConfigContract::class, class_implements($resource))) {
24✔
138
            throw new \Exception("The resource '$resource' does not implement the UserAttributesConfigContract interface.");
×
139
        }
140

141
        /** @var ?UserAttributesConfigContract */
142
        $resource = $resource;
24✔
143
        $config = $resource::getUserAttributesConfig();
24✔
144

145
        if ($config === null) {
24✔
146
            throw new \Exception("The resource '" . strval($resource) . "' did not return a configuration model from the getUserAttributesConfig function (or it was null).");
×
147
        }
148

149
        return $config;
24✔
150
    }
151

152
    /**
153
     * Returns all Resource discover paths, normalized
154
     */
155
    public function getResourceDiscoverPaths(): array|false
16✔
156
    {
157
        $discoverPaths = config('filament-user-attributes.discover_resources');
16✔
158

159
        if ($discoverPaths === false) {
16✔
160
            return false;
4✔
161
        }
162

163
        return array_map(function ($path) {
12✔
164
            return str_replace('/', DIRECTORY_SEPARATOR, $path);
12✔
165
        }, $discoverPaths);
12✔
166
    }
167

168
    /**
169
     * Finds all resources that have the HasUserAttributesContract interface
170
     */
171
    public function getConfigurableResources($configuredOnly = true)
16✔
172
    {
173
        $discoverPaths = $this->getResourceDiscoverPaths();
16✔
174

175
        if ($discoverPaths === false) {
16✔
176
            $resources = $this->registeredResources;
4✔
177

178
            if ($resources instanceof Closure) {
4✔
179
                return $resources();
×
180
            }
181

182
            return $resources;
4✔
183
        }
184

185
        if ($this->cachedDiscoveredResources === null) {
12✔
186
            $this->cachedDiscoveredResources = $this->discoverConfigurableResources($discoverPaths, $configuredOnly);
12✔
187
        }
188

189
        return $this->cachedDiscoveredResources;
12✔
190
    }
191

192
    /**
193
     * Discovers all resources that have the HasUserAttributesContract interface
194
     */
195
    public function discoverConfigurableResources(array $paths, bool $configuredOnly): array
12✔
196
    {
197
        $resources = [];
12✔
198

199
        foreach ($paths as $targetPath) {
12✔
200
            $path = $this->appPath . $targetPath;
12✔
201

202
            if (!File::exists($path)) {
12✔
203
                continue;
4✔
204
            }
205

206
            $nameTransformer = config('filament-user-attributes.discovery_resource_name_transformer');
8✔
207

208
            $resourcesForPath = collect(File::files($path))
8✔
209
                ->map(function ($file) use ($targetPath) {
8✔
210
                    $type = $this->appNamespace . $targetPath . '\\' . $file->getRelativePathName();
8✔
211
                    $type = substr($type, 0, -strlen('.php'));
8✔
212

213
                    return $type;
8✔
214
                });
8✔
215

216
            // Note: this will autoload the models if $configured = true
217
            if ($configuredOnly) {
8✔
218
                $resourcesForPath = $resourcesForPath->filter(function ($type) {
8✔
219
                    if (!class_exists($type)) {
8✔
220
                        return false;
×
221
                    }
222

223
                    if (!in_array(\Luttje\FilamentUserAttributes\Contracts\UserAttributesConfigContract::class, class_implements($type))) {
8✔
224
                        return false;
8✔
225
                    }
226

227
                    return true;
8✔
228
                });
8✔
229
            }
230

231
            $resourcesForPath = $resourcesForPath->mapWithKeys(function ($type) use ($nameTransformer) {
8✔
232
                return [$type => $nameTransformer($type)];
8✔
233
            })
8✔
234
                ->toArray();
8✔
235

236
            $resources = array_merge($resources, $resourcesForPath);
8✔
237
        }
238

239
        return $resources;
12✔
240
    }
241

242
    /**
243
     * Discovers all models that could possibly be configured with user attributes.
244
     */
245
    public function getConfigurableModels($configuredOnly = true)
4✔
246
    {
247
        $discoverPaths = config('filament-user-attributes.discover_models');
4✔
248

249
        if ($discoverPaths === false) {
4✔
250
            return [];
×
251
        }
252

253
        return $this->discoverConfigurableModels($discoverPaths, $configuredOnly);
4✔
254
    }
255

256
    /**
257
     * Discovers all models that could possibly be configured with user attributes.
258
     */
259
    public function discoverConfigurableModels(array $paths, bool $configuredOnly): array
4✔
260
    {
261
        $models = [];
4✔
262

263
        foreach ($paths as $targetPath) {
4✔
264
            $path = $this->appPath . $targetPath;
4✔
265

266
            if (!File::exists($path)) {
4✔
267
                continue;
×
268
            }
269

270
            $modelsForPath = collect(File::allFiles($path))
4✔
271
                ->map(function ($file) use ($targetPath) {
4✔
272
                    $type = $this->appNamespace . $targetPath . '\\' . $file->getRelativePathName();
4✔
273
                    $type = substr($type, 0, -strlen('.php'));
4✔
274

275
                    return $type;
4✔
276
                });
4✔
277

278
            // Note: this will autoload the models if $configured = true
279
            if ($configuredOnly) {
4✔
280
                $modelsForPath = $modelsForPath->filter(function ($type) {
×
281
                    if (!class_exists($type)) {
×
282
                        return false;
×
283
                    }
284

285
                    if (!in_array(\Luttje\FilamentUserAttributes\Contracts\HasUserAttributesContract::class, class_implements($type))) {
×
286
                        return false;
×
287
                    }
288

289
                    return true;
×
290
                });
×
291
            }
292

293
            $models = array_merge($models, $modelsForPath->toArray());
4✔
294
        }
295

296
        return $models;
4✔
297
    }
298

299
    /**
300
     * Uses configured path discovery information to find the path for the given
301
     * resource class
302
     */
303
    public function findResourceFilePath(string $resource): string
×
304
    {
305
        $discoverPaths = $this->getResourceDiscoverPaths();
×
306

307
        foreach ($discoverPaths as $targetPath) {
×
308
            $path = $this->appPath . $targetPath;
×
309

310
            if (!File::exists($path)) {
×
311
                continue;
×
312
            }
313

314
            $file = $path . DIRECTORY_SEPARATOR . class_basename($resource) . '.php';
×
315

316
            if (File::exists($file)) {
×
317
                return $file;
×
318
            }
319
        }
320

321
        throw new \Exception("Could not find the file for resource '$resource'.");
×
322
    }
323

324
    /**
325
     * Uses configured path discovery information to find the path for the given
326
     * model class
327
     */
328
    public function findModelFilePath(string $model): string
4✔
329
    {
330
        $discoverPaths = config('filament-user-attributes.discover_models');
4✔
331

332
        foreach ($discoverPaths as $targetPath) {
4✔
333
            $path = $this->appPath . $targetPath;
4✔
334

335
            if (!File::exists($path)) {
4✔
336
                continue;
×
337
            }
338

339
            $file = $path . DIRECTORY_SEPARATOR . class_basename($model) . '.php';
4✔
340

341
            if (File::exists($file)) {
4✔
342
                return $file;
4✔
343
            }
344
        }
345

346
        throw new \Exception("Could not find the file for model '$model'.");
×
347
    }
348

349
    /**
350
     * Helper function to get label for a component.
351
     */
352
    private function getComponentLabel($component, ?string $parentLabel = null): string
64✔
353
    {
354
        $label = $component->getLabel();
64✔
355

356
        if (!empty($label)) {
64✔
357
            return $parentLabel ? ($parentLabel . ' > ' . $label) : $label;
64✔
358
        }
359

360
        return $parentLabel ?? '';
36✔
361
    }
362

363
    /**
364
     * Gets all components and child components as a flat array of names with labels
365
     */
366
    public function getAllFieldComponents(array $components, ?string $parentLabel = null): array
32✔
367
    {
368
        $namesWithLabels = [];
32✔
369

370
        foreach ($components as $component) {
32✔
371
            $label = $this->getComponentLabel($component, $parentLabel);
32✔
372

373
            if ($component instanceof \Filament\Forms\Components\Field) {
32✔
374
                $namesWithLabels[] = [
32✔
375
                    'name' => $component->getName(),
32✔
376
                    'label' => $label,
32✔
377
                ];
32✔
378
            }
379

380
            if ($component instanceof Tabs
32✔
381
                || $component instanceof Tab
32✔
382
                || $component instanceof Section
32✔
383
            ) {
384
                $namesWithLabels = array_merge(
32✔
385
                    $namesWithLabels,
32✔
386
                    $this->getAllFieldComponents(
32✔
387
                        $component->getChildComponents(),
32✔
388
                        $label
32✔
389
                    )
32✔
390
                );
32✔
391
            }
392
        }
393

394
        return $namesWithLabels;
32✔
395
    }
396

397
    /**
398
     * Gets all columns as a flat array of names with labels
399
     */
400
    public function getAllTableColumns(array $columns): array
32✔
401
    {
402
        $namesWithLabels = [];
32✔
403

404
        foreach ($columns as $column) {
32✔
405
            $label = $this->getComponentLabel($column);
32✔
406
            $namesWithLabels[] = [
32✔
407
                'name' => $column->getName(),
32✔
408
                'label' => $label,
32✔
409
            ];
32✔
410
        }
411

412
        return $namesWithLabels;
32✔
413
    }
414

415
    /**
416
     * Search the components and child components until the component with the given name is found,
417
     * then add the given component after it.
418
     */
419
    public function addFieldBesidesField(
20✔
420
        array $components,
421
        string $siblingComponentName,
422
        string $position,
423
        Component $componentToAdd,
424
        ?string $parentLabel = null,
425
        &$siblingFound = false
426
    ): array {
427
        $newComponents = [];
20✔
428

429
        foreach ($components as $component) {
20✔
430
            $label = $this->getComponentLabel($component, $parentLabel);
20✔
431

432
            $newComponents[] = $component;
20✔
433

434
            if ($component instanceof \Filament\Forms\Components\Field
20✔
435
            && $label === $siblingComponentName) {
20✔
436
                $siblingFound = true;
16✔
437
                if ($position === 'before') {
16✔
438
                    array_splice($newComponents, count($newComponents) - 1, 0, [$componentToAdd]);
12✔
439
                } elseif($position === 'after') {
4✔
440
                    $newComponents[] = $componentToAdd;
4✔
441
                }
442
            }
443

444
            if ($component instanceof Tabs
20✔
445
                || $component instanceof Tab
20✔
446
                || $component instanceof Section
20✔
447
            ) {
448
                $containerChildComponents = $component->getChildComponents();
12✔
449
                $childComponents = $this->addFieldBesidesField(
12✔
450
                    $containerChildComponents,
12✔
451
                    $siblingComponentName,
12✔
452
                    $position,
12✔
453
                    $componentToAdd,
12✔
454
                    $label,
12✔
455
                    $siblingFound
12✔
456
                );
12✔
457

458
                $component->childComponents($childComponents);
12✔
459
            }
460
        }
461

462
        if (!$siblingFound) {
20✔
463
            $newComponents[] = $componentToAdd;
4✔
464
        }
465

466
        return $newComponents;
20✔
467
    }
468

469
    /**
470
     * Search the columns and child columns until the column with the given name is found,
471
     * unlike with forms, tables simply have columns in a flat array next to each other.
472
     */
473
    public function addColumnBesidesColumn(array $columns, string $siblingColumnName, string $position, Column $columnToAdd): array
12✔
474
    {
475
        $newColumns = [];
12✔
476

477
        foreach ($columns as $column) {
12✔
478
            $label = $this->getComponentLabel($column);
12✔
479
            $newColumns[] = $column;
12✔
480

481
            if ($label === $siblingColumnName) {
12✔
482
                if ($position === 'before') {
12✔
483
                    array_splice($newColumns, count($newColumns) - 1, 0, [$columnToAdd]);
4✔
484
                } elseif ($position === 'after') {
8✔
485
                    $newColumns[] = $columnToAdd;
8✔
486
                } else {
487
                    throw new \Exception("Invalid position '$position' given.");
×
488
                }
489
            }
490
        }
491

492
        return $newColumns;
12✔
493
    }
494

495
    /**
496
     * Merges the custom fields into the given form schema.
497
     */
498
    public function mergeCustomFormFields(array $fields, string $resource): array
8✔
499
    {
500
        $customFields = collect(FilamentUserAttributes::getUserAttributeFields($resource));
8✔
501

502
        for ($i = 0; $i < $customFields->count(); $i++) {
8✔
503
            $customField = $customFields->pop();
8✔
504

505
            if (!isset($customField['ordering'])
8✔
506
                || $customField['ordering']['sibling'] === null) {
8✔
507
                $customFields->prepend($customField);
8✔
508
                continue;
8✔
509
            }
510

511
            $fields = $this->addFieldBesidesField(
4✔
512
                $fields,
4✔
513
                $customField['ordering']['sibling'],
4✔
514
                $customField['ordering']['position'],
4✔
515
                $customField['field']
4✔
516
            );
4✔
517
        }
518

519
        return array_merge($fields, $customFields->pluck('field')->toArray());
8✔
520
    }
521

522
    /**
523
     * Merges the custom columns into the given table schema.
524
     */
525
    public function mergeCustomTableColumns(array $columns, $resource): array
20✔
526
    {
527
        $customColumns = collect(FilamentUserAttributes::getUserAttributeColumns($resource));
20✔
528

529
        for ($i = 0; $i < $customColumns->count(); $i++) {
20✔
530
            $customColumn = $customColumns->pop();
12✔
531

532
            if (!isset($customColumn['ordering'])
12✔
533
                || $customColumn['ordering']['sibling'] === null) {
12✔
534
                $customColumns->prepend($customColumn);
8✔
535
                continue;
8✔
536
            }
537

538
            $columns = $this->addColumnBesidesColumn(
4✔
539
                $columns,
4✔
540
                $customColumn['ordering']['sibling'],
4✔
541
                $customColumn['ordering']['position'],
4✔
542
                $customColumn['column']
4✔
543
            );
4✔
544
        }
545

546
        return array_merge($columns, $customColumns->pluck('column')->toArray());
20✔
547
    }
548

549
    /**
550
     * Converts a class name to a human readable label by getting
551
     * the last part of the name and adding spaces between words.
552
     */
553
    public function classNameToLabel(string $className): string
12✔
554
    {
555
        if (method_exists($className, 'getModelLabel')) {
12✔
556
            $label = $className::getModelLabel();
8✔
557

558
            if (!empty($label)) {
8✔
559
                return $label . ucfirst(__('filament-user-attributes::user-attributes.suffix_page'));
8✔
560
            }
561
        }
562

563
        $className = class_basename($className);
4✔
564
        $className = preg_replace('/(?<!^)[A-Z]/', ' $0', $className);
4✔
565
        $className = preg_replace('/Resource$/', ucfirst(__('filament-user-attributes::user-attributes.suffix_page')), $className);
4✔
566

567
        return $className;
4✔
568
    }
569
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc