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

luttje / filament-user-attributes / 24601465173

18 Apr 2026 09:10AM UTC coverage: 76.343% (-2.1%) from 78.478%
24601465173

Pull #29

github

web-flow
Merge 340e263f0 into da87d6f02
Pull Request #29: Support Filament v5

11 of 100 new or added lines in 2 files covered. (11.0%)

3 existing lines in 1 file now uncovered.

2104 of 2756 relevant lines covered (76.34%)

26.82 hits per line

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

5.32
/src/Commands/WizardStepResources.php
1
<?php
2

3
namespace Luttje\FilamentUserAttributes\Commands;
4

5
use Illuminate\Console\Command;
6
use Illuminate\Support\Collection;
7
use Luttje\FilamentUserAttributes\CodeGeneration\CodeEditor;
8
use Luttje\FilamentUserAttributes\Contracts\ConfiguresUserAttributesContract;
9
use Luttje\FilamentUserAttributes\Contracts\UserAttributesConfigContract;
10
use Luttje\FilamentUserAttributes\Facades\FilamentUserAttributes;
11
use Luttje\FilamentUserAttributes\Traits\UserAttributesResource;
12

13
class WizardStepResources extends Command
14
{
15
    public function __construct()
3✔
16
    {
17
        $this->signature = 'filament-user-attributes:wizard-resources';
3✔
18
        $this->description = 'Wizard to help setup your resources with Filament User Attributes';
3✔
19

20
        parent::__construct();
3✔
21
    }
22

23
    public function handle()
3✔
24
    {
25
        if (!$this->promptForResourcesSetup()) {
3✔
26
            return;
3✔
27
        }
28

29
        $this->finalizeResourcesSetup();
×
30
    }
31

32
    protected function promptForResourcesSetup(): bool
3✔
33
    {
34
        $models = $this->getModelsImplementingConfiguresUserAttributesContract();
3✔
35

36
        if ($models->isEmpty()) {
3✔
37
            $this->info('(Failing) No models found to have been setup to configure user attributes.');
×
38
            return false;
×
39
        }
40

41
        return $this->confirm('Do you want to setup any resources to display and edit user attributes?', true);
3✔
42
    }
43

44
    protected function getModelsImplementingConfiguresUserAttributesContract(): Collection
3✔
45
    {
46
        return collect(FilamentUserAttributes::getConfigurableModels(configuredOnly: false))
3✔
47
            ->filter(fn ($model) => in_array(ConfiguresUserAttributesContract::class, class_implements($model)));
3✔
48
    }
49

50
    protected function getChosenResources(array $resources): array
×
51
    {
52
        return $this->choice(
×
53
            'Which resources should display and edit user attributes?',
×
54
            $resources,
×
55
            null,
×
56
            null,
×
57
            true
×
58
        );
×
59
    }
60

61
    protected function finalizeResourcesSetup()
×
62
    {
63
        $resources = FilamentUserAttributes::getConfigurableResources(configuredOnly: false);
×
64

NEW
65
        if (empty($resources)) {
×
NEW
66
            $this->warn('(Failing) No resources found to setup for user attributes.');
×
NEW
67
            return;
×
68
        }
69

70
        $resources = array_keys($resources);
×
71
        $chosenResources = $this->getChosenResources($resources);
×
72

73
        if (empty($chosenResources)) {
×
74
            return;
×
75
        }
76

77
        $this->setupResources($chosenResources);
×
78

79
        return;
×
80
    }
81

82
    protected function setupResources(array $resources)
×
83
    {
84
        foreach ($resources as $resource) {
×
85
            $this->setupResource($resource);
×
86
        }
87
    }
88

89
    protected function setupResource(string $resource)
×
90
    {
91
        $file = FilamentUserAttributes::findResourceFilePath($resource);
×
NEW
92
        $originalCode = file_get_contents($file);
×
93

94
        // Detect Filament 5 delegation patterns before modifying the resource
NEW
95
        $formDelegation = self::detectDelegation($originalCode, 'form');
×
NEW
96
        $tableDelegation = self::detectDelegation($originalCode, 'table');
×
97

98
        $editor = CodeEditor::make();
×
NEW
99
        $editor->editFileWithBackup($file, function ($code) use ($editor, $resource, $formDelegation, $tableDelegation) {
×
100
            $code = $editor->addTrait($code, UserAttributesResource::class);
×
101
            $code = $editor->addInterface($code, UserAttributesConfigContract::class);
×
102
            $code = $editor->addMethod($code, 'getUserAttributesConfig', function () use ($resource) {
×
103
                $method = new \PhpParser\Node\Stmt\ClassMethod('getUserAttributesConfig', [
×
104
                    'flags' => \PhpParser\Node\Stmt\Class_::MODIFIER_PUBLIC | \PhpParser\Node\Stmt\Class_::MODIFIER_STATIC,
×
105
                    'returnType' => new \PhpParser\Node\NullableType(
×
106
                        new \PhpParser\Node\Name\FullyQualified(ConfiguresUserAttributesContract::class)
×
107
                    ),
×
108
                ]);
×
109
                $method->stmts = $this->guessTemplate($resource, $this->getModelsImplementingConfiguresUserAttributesContract());
×
110
                return $method;
×
111
            });
×
112

113
            // Only apply inline wrapping if the method doesn't delegate to another class
NEW
114
            if ($formDelegation === null) {
×
NEW
115
                $code = self::applyWrapperMethod($editor, $code, 'form', 'schema', 'withUserAttributeFields');
×
116
            }
NEW
117
            if ($tableDelegation === null) {
×
NEW
118
                $code = self::applyWrapperMethod($editor, $code, 'table', 'columns', 'withUserAttributeColumns');
×
119
            }
120

121
            return $code;
×
122
        });
×
123

124
        // Handle Filament 5 delegated form/table classes
NEW
125
        if ($formDelegation !== null) {
×
NEW
126
            $this->applyWrapperToDelegatedClass($editor, $formDelegation, $resource, 'components', 'withUserAttributeFields');
×
127
        }
NEW
128
        if ($tableDelegation !== null) {
×
NEW
129
            $this->applyWrapperToDelegatedClass($editor, $tableDelegation, $resource, 'columns', 'withUserAttributeColumns');
×
130
        }
131
    }
132

133
    /**
134
     * Detects if a method delegates to another class via SomeClass::configure($param),
135
     * as is common in Filament 5 resources.
136
     *
137
     * @return string|null The FQCN of the delegated class, or null if no delegation detected.
138
     */
NEW
139
    private static function detectDelegation(string $code, string $methodName): ?string
×
140
    {
NEW
141
        $parser = (new \PhpParser\ParserFactory())->createForNewestSupportedVersion();
×
NEW
142
        $ast = $parser->parse($code);
×
143

NEW
144
        $nodeFinder = new \PhpParser\NodeFinder();
×
145

146
        // Collect use statements for name resolution
NEW
147
        $useMap = [];
×
NEW
148
        $namespace = '';
×
149

NEW
150
        $namespaceNode = $nodeFinder->findFirstInstanceOf($ast, \PhpParser\Node\Stmt\Namespace_::class);
×
NEW
151
        if ($namespaceNode) {
×
NEW
152
            $namespace = $namespaceNode->name->toString();
×
153

NEW
154
            foreach ($namespaceNode->stmts as $stmt) {
×
NEW
155
                if ($stmt instanceof \PhpParser\Node\Stmt\Use_) {
×
NEW
156
                    foreach ($stmt->uses as $use) {
×
NEW
157
                        $alias = $use->alias ? $use->alias->name : $use->name->getLast();
×
NEW
158
                        $useMap[$alias] = $use->name->toString();
×
159
                    }
160
                }
161
            }
162
        }
163

164
        // Find the method
NEW
165
        $method = $nodeFinder->findFirst($ast, function ($node) use ($methodName) {
×
NEW
166
            return $node instanceof \PhpParser\Node\Stmt\ClassMethod
×
NEW
167
                && $node->name->name === $methodName;
×
NEW
168
        });
×
169

NEW
170
        if ($method === null || empty($method->stmts)) {
×
NEW
171
            return null;
×
172
        }
173

174
        // Look for a return statement with a static call to ::configure()
NEW
175
        $returnStmt = $nodeFinder->findFirst($method->stmts, function ($node) {
×
NEW
176
            return $node instanceof \PhpParser\Node\Stmt\Return_
×
NEW
177
                && $node->expr instanceof \PhpParser\Node\Expr\StaticCall
×
NEW
178
                && $node->expr->name instanceof \PhpParser\Node\Identifier
×
NEW
179
                && $node->expr->name->name === 'configure';
×
NEW
180
        });
×
181

NEW
182
        if ($returnStmt === null) {
×
NEW
183
            return null;
×
184
        }
185

NEW
186
        $staticCall = $returnStmt->expr;
×
187

NEW
188
        if (!($staticCall->class instanceof \PhpParser\Node\Name)) {
×
NEW
189
            return null;
×
190
        }
191

NEW
192
        $className = $staticCall->class->toString();
×
193

194
        // Resolve using use statements
NEW
195
        $parts = explode('\\', $className);
×
NEW
196
        $firstPart = $parts[0];
×
197

NEW
198
        if (isset($useMap[$firstPart])) {
×
NEW
199
            if (count($parts) > 1) {
×
NEW
200
                array_shift($parts);
×
NEW
201
                return $useMap[$firstPart] . '\\' . implode('\\', $parts);
×
202
            }
NEW
203
            return $useMap[$firstPart];
×
204
        }
205

206
        // If not in use statements, assume same namespace
NEW
207
        if ($namespace) {
×
NEW
208
            return $namespace . '\\' . $className;
×
209
        }
210

NEW
211
        return $className;
×
212
    }
213

214
    /**
215
     * Applies the wrapper method in a delegated class file (Filament 5 pattern).
216
     */
NEW
217
    private function applyWrapperToDelegatedClass(
×
218
        CodeEditor $editor,
219
        string $delegatedClass,
220
        string $resourceClass,
221
        string $methodNameToWrapInside,
222
        string $methodNameToCall
223
    ): void {
NEW
224
        if (!class_exists($delegatedClass)) {
×
NEW
225
            $this->warn("Could not find delegated class $delegatedClass");
×
NEW
226
            return;
×
227
        }
228

NEW
229
        $refClass = new \ReflectionClass($delegatedClass);
×
NEW
230
        $file = $refClass->getFileName();
×
231

NEW
232
        if (!$file) {
×
NEW
233
            $this->warn("Could not determine file path for $delegatedClass");
×
NEW
234
            return;
×
235
        }
236

NEW
237
        $editor->editFileWithBackup($file, function ($code) use ($editor, $resourceClass, $methodNameToWrapInside, $methodNameToCall) {
×
NEW
238
            return self::applyWrapperMethod(
×
NEW
239
                $editor,
×
NEW
240
                $code,
×
NEW
241
                'configure',
×
NEW
242
                $methodNameToWrapInside,
×
NEW
243
                $methodNameToCall,
×
NEW
244
                $resourceClass
×
NEW
245
            );
×
NEW
246
        });
×
247
    }
248

NEW
249
    private static function applyWrapperMethod($editor, $contents, $parentMethodName, $methodNameToWrapInside, $methodNameToCall, ?string $callerClass = null)
×
250
    {
251
        return $editor->modifyMethod(
×
252
            $contents,
×
253
            $parentMethodName,
×
NEW
254
            function ($method) use ($editor, $methodNameToWrapInside, $methodNameToCall, $callerClass) {
×
255
                /** @var \PhpParser\Node\Stmt\ClassMethod */
256
                $method = $method;
×
257
                $firstParameter = $method->params[0];
×
258
                $schema = $editor->findCall(
×
259
                    $method->stmts,
×
260
                    $firstParameter->var->name,
×
261
                    $methodNameToWrapInside
×
262
                );
×
263

264
                if (
265
                    $schema->args[0]->value instanceof \PhpParser\Node\Expr\StaticCall
×
266
                    && $schema->args[0]->value->name->name === $methodNameToCall
×
267
                ) {
268
                    return $method;
×
269
                }
270

NEW
271
                $callerName = $callerClass !== null
×
NEW
272
                    ? new \PhpParser\Node\Name\FullyQualified($callerClass)
×
NEW
273
                    : new \PhpParser\Node\Name('self');
×
274

275
                $schema->args = [
×
276
                    new \PhpParser\Node\Arg(
×
277
                        new \PhpParser\Node\Expr\StaticCall(
×
NEW
278
                            $callerName,
×
279
                            $methodNameToCall,
×
280
                            $schema->args
×
281
                        )
×
282
                    ),
×
283
                ];
×
284

285
                return $method;
×
286
            }
×
287
        );
×
288
    }
289

290
    protected function guessTemplate(string $resource, Collection $models)
×
291
    {
292
        $this->warn("\nWe will prepare the getUserAttributesConfig method for you, but you'll have to finish it for $resource.");
×
293

294
        $model = $models->first();
×
295

296
        if (!$model) {
×
297
            return $this->guessTemplateNoModels($resource);
×
298
        }
299

300
        if (is_subclass_of($model, \Illuminate\Foundation\Auth\User::class)) {
×
301
            return $this->guessTemplateAuthUser();
×
302
        }
303

304
        if (strstr($model, 'Tenant') !== false) {
×
305
            return $this->guessTemplateTenant();
×
306
        }
307

308
        return $this->guessTemplateNoKnownModels($resource, $models);
×
309
    }
310

311
    protected function guessTemplateAuthUser()
×
312
    {
313
        $this->line("\nGuessing that your configuration model may be a user model...");
×
314

315
        return [
×
316
            new \PhpParser\Node\Stmt\Expression(
×
317
                new \PhpParser\Node\Expr\Assign(
×
318
                    new \PhpParser\Node\Expr\Variable('user'),
×
NEW
319
                    new \PhpParser\Node\Expr\StaticCall(
×
NEW
320
                        new \PhpParser\Node\Name\FullyQualified(\Illuminate\Support\Facades\Auth::class),
×
NEW
321
                        'user'
×
NEW
322
                    ),
×
323
                ),
×
324
                [
×
325
                    'comments' => [
×
326
                        new \PhpParser\Comment\Doc(
×
327
                            <<<PHPDOC
×
328
/** @var \App\Models\User */
329
PHPDOC
×
330
                        ),
×
331
                    ],
×
332
                ]
×
333
            ),
×
334
            new \PhpParser\Node\Stmt\Return_(
×
335
                new \PhpParser\Node\Expr\Variable('user')
×
336
            ),
×
337
        ];
×
338
    }
339

340
    protected function guessTemplateTenant()
×
341
    {
342
        $this->line("\nGuessing that your configuration model may be a multi-tenancy model...");
×
343

344
        return [
×
345
            new \PhpParser\Node\Stmt\Expression(
×
346
                new \PhpParser\Node\Expr\Assign(
×
347
                    new \PhpParser\Node\Expr\Variable('tenant'),
×
348
                    new \PhpParser\Node\Expr\StaticCall(
×
349
                        new \PhpParser\Node\Name\FullyQualified(\Filament\Facades\Filament::class),
×
350
                        'getTenant'
×
351
                    ),
×
352
                ),
×
353
                [
×
354
                    'comments' => [
×
355
                        new \PhpParser\Comment\Doc(
×
356
                            <<<PHPDOC
×
357
// TODO: Double-check that this is the correct configuration model for your app.
358
PHPDOC
×
359
                        ),
×
360
                    ],
×
361
                ]
×
362
            ),
×
363
            new \PhpParser\Node\Stmt\Return_(
×
364
                new \PhpParser\Node\Expr\Variable('tenant')
×
365
            ),
×
366
        ];
×
367
    }
368

369
    protected function guessTemplateNoModels(string $resource)
×
370
    {
371
        $this->warn("\nWe didn't find any models that implement the correct interface, so you'll have to do this yourself.");
×
372

373
        return [
×
374
            new \PhpParser\Node\Stmt\Throw_(
×
375
                new \PhpParser\Node\Expr\New_(
×
376
                    new \PhpParser\Node\Name\FullyQualified(\Exception::class),
×
377
                    [
×
378
                        new \PhpParser\Node\Arg(
×
379
                            new \PhpParser\Node\Scalar\String_('You have to implement the getUserAttributesConfig method in ' . $resource . '.')
×
380
                        ),
×
381
                    ]
×
382
                ),
×
383
                [
×
384
                    'comments' => [
×
385
                        new \PhpParser\Comment\Doc(
×
386
                            <<<PHPDOC
×
387
// TODO: You should finish this method and return the model that configures the user attributes.
388
// TODO: We didn't find any models that implement the correct interface, so you'll have to do this yourself.
389
PHPDOC
×
390
                        ),
×
391
                    ],
×
392
                ]
×
393
            ),
×
394
        ];
×
395
    }
396

397
    protected function guessTemplateNoKnownModels(string $resource, Collection $models)
×
398
    {
399
        $this->warn("\nWe didn't recognize any of the models that implement the correct interface, so you'll have to finish this implementation yourself.");
×
400

401
        return [
×
402
            new \PhpParser\Node\Stmt\Throw_(
×
403
                new \PhpParser\Node\Expr\New_(
×
404
                    new \PhpParser\Node\Name\FullyQualified(\Exception::class),
×
405
                    [
×
406
                        new \PhpParser\Node\Arg(
×
407
                            new \PhpParser\Node\Scalar\String_('You have to implement the getUserAttributesConfig method in ' . $resource . '.')
×
408
                        ),
×
409
                    ]
×
410
                ),
×
411
                [
×
412
                    'comments' => [
×
413
                        new \PhpParser\Comment\Doc(
×
414
                            '// TODO: You should finish this method and return the model that configures the user attributes.' .
×
415
                            $models->map(fn ($model) => '\\' . $model . '::class')
×
416
                                ->values()
×
417
                                ->reduce(fn ($carry, $model) => $carry . ($carry ? "\n" : '') . "\t\t// $model", "\t\t// These are the models that implement the correct interface:")
×
418
                        ),
×
419
                    ],
×
420
                ]
×
421
            ),
×
422
        ];
×
423
    }
424
}
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