From b12b529bdedd5c8ff84eb47c712081623aaff8ce Mon Sep 17 00:00:00 2001 From: Dan Harrin Date: Fri, 22 Nov 2024 09:44:10 +0000 Subject: [PATCH] Generate table columns --- .../FileGenerators/ClusterClassGenerator.php | 2 +- .../panels/src/Commands/MakePageCommand.php | 37 ++-- packages/support/src/CliManager.php | 27 +++ .../Concerns/CanAskForComponentLocation.php | 62 +++++++ .../Concerns/CanAskForViewLocation.php | 82 ++++++++ .../FileGenerators/ClassGenerator.php | 12 ++ packages/support/src/Facades/FilamentCli.php | 20 ++ .../support/src/SupportServiceProvider.php | 5 + .../FileGenerators/ColumnClassGenerator.php | 118 ++++++++++++ .../tables/src/Commands/MakeColumnCommand.php | 175 ++++++++++++++---- 10 files changed, 488 insertions(+), 52 deletions(-) create mode 100644 packages/support/src/CliManager.php create mode 100644 packages/support/src/Commands/Concerns/CanAskForComponentLocation.php create mode 100644 packages/support/src/Commands/Concerns/CanAskForViewLocation.php create mode 100644 packages/support/src/Facades/FilamentCli.php create mode 100644 packages/tables/src/Commands/FileGenerators/ColumnClassGenerator.php diff --git a/packages/panels/src/Commands/FileGenerators/ClusterClassGenerator.php b/packages/panels/src/Commands/FileGenerators/ClusterClassGenerator.php index a0910bcb13..3ab085ec46 100644 --- a/packages/panels/src/Commands/FileGenerators/ClusterClassGenerator.php +++ b/packages/panels/src/Commands/FileGenerators/ClusterClassGenerator.php @@ -24,7 +24,7 @@ class ClusterClassGenerator extends ClassGenerator public function getImports(): array { return [ - Cluster::class, + $this->getExtends(), ]; } diff --git a/packages/panels/src/Commands/MakePageCommand.php b/packages/panels/src/Commands/MakePageCommand.php index 3216480b77..c3f67fe885 100644 --- a/packages/panels/src/Commands/MakePageCommand.php +++ b/packages/panels/src/Commands/MakePageCommand.php @@ -21,6 +21,7 @@ use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\ManageRelatedRecords; use Filament\Resources\Pages\Page as ResourcePage; use Filament\Resources\Pages\ViewRecord; +use Filament\Support\Commands\Concerns\CanAskForViewLocation; use Filament\Support\Commands\Concerns\CanManipulateFiles; use Filament\Support\Commands\Exceptions\InvalidCommandOutput; use Filament\Support\Commands\FileGenerators\Concerns\CanCheckFileGenerationFlags; @@ -34,6 +35,7 @@ use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use Illuminate\Support\Stringable; use ReflectionClass; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputArgument; @@ -54,6 +56,7 @@ class MakePageCommand extends Command use CanAskForRelatedResource; use CanAskForResource; use CanAskForSchema; + use CanAskForViewLocation; use CanCheckFileGenerationFlags; use CanManipulateFiles; use HasCluster; @@ -74,6 +77,8 @@ class MakePageCommand extends Command protected ?string $view = null; + protected ?string $viewPath = null; + protected bool $hasResource; /** @@ -349,12 +354,21 @@ class MakePageCommand extends Command $this->fqn = $this->pagesNamespace . '\\' . $this->fqnEnd; if ((! $this->hasResource) || ($this->resourcePageType === ResourcePage::class)) { - $this->view = str($this->fqn) - ->replaceFirst('App\\', '') - ->replace('\\', '/') - ->explode('/') - ->map(Str::kebab(...)) - ->implode('.'); + [ + $this->view, + $this->viewPath, + ] = $this->askForViewLocation( + str($this->fqn) + ->whenContains( + 'Filament\\', + fn (Stringable $fqn) => $fqn->after('Filament\\')->prepend('Filament\\'), + fn (Stringable $fqn) => $fqn->replaceFirst('App\\', ''), + ) + ->replace('\\', '/') + ->explode('/') + ->map(Str::kebab(...)) + ->implode('.'), + ); } } @@ -635,17 +649,10 @@ class MakePageCommand extends Command return; } - $path = resource_path( - (string) str($this->view) - ->replace('.', '/') - ->prepend('views/') - ->append('.blade.php'), - ); - - if (! $this->option('force') && $this->checkForCollision($path)) { + if (! $this->option('force') && $this->checkForCollision($this->viewPath)) { throw new InvalidCommandOutput; } - $this->copyStubToApp('PageView', $path); + $this->copyStubToApp('PageView', $this->viewPath); } } diff --git a/packages/support/src/CliManager.php b/packages/support/src/CliManager.php new file mode 100644 index 0000000000..b0a797198f --- /dev/null +++ b/packages/support/src/CliManager.php @@ -0,0 +1,27 @@ + + */ + protected array $componentLocations = []; + + public function registerComponentLocation(string $path, string $namespace, ?string $viewNamespace): void + { + $this->componentLocations[$namespace] = [ + 'path' => $path, + 'viewNamespace' => $viewNamespace, + ]; + } + + /** + * @return array + */ + public function getComponentLocations(): array + { + return $this->componentLocations; + } +} diff --git a/packages/support/src/Commands/Concerns/CanAskForComponentLocation.php b/packages/support/src/Commands/Concerns/CanAskForComponentLocation.php new file mode 100644 index 0000000000..85985bc852 --- /dev/null +++ b/packages/support/src/Commands/Concerns/CanAskForComponentLocation.php @@ -0,0 +1,62 @@ +replace('/', '\\'); + + $locations = FilamentCli::getComponentLocations(); + + if (blank($locations)) { + return [ + "App\\{$pathNamespace}", + app_path($path), + null, + ]; + } + + $options = [ + null => "App\\{$pathNamespace}", + ...array_map( + fn (string $namespace): string => "{$namespace}\\{$pathNamespace}", + array_combine( + array_keys($locations), + array_keys($locations), + ), + ), + ]; + + $namespace = select( + label: $question, + options: $options, + ); + + if (blank($namespace)) { + return [ + "App\\{$pathNamespace}", + app_path($path), + '', + ]; + } + + return [ + "{$namespace}\\{$pathNamespace}", + $locations[$namespace]['path'] . '/' . $path, + $locations[$namespace]['viewNamespace'] ?? null, + ]; + } +} diff --git a/packages/support/src/Commands/Concerns/CanAskForViewLocation.php b/packages/support/src/Commands/Concerns/CanAskForViewLocation.php new file mode 100644 index 0000000000..b1f4421f04 --- /dev/null +++ b/packages/support/src/Commands/Concerns/CanAskForViewLocation.php @@ -0,0 +1,82 @@ +startsWith(base_path())) { + $paths[''] = Arr::first(config('view.paths')); + } + + foreach ($viewFactory->getFinder()->getHints() as $namespace => $hintPaths) { + foreach ($hintPaths as $path) { + if (! str($path)->startsWith(base_path())) { + continue; + } + + if (str($path)->startsWith(base_path('vendor'))) { + continue; + } + + if ($path === resource_path('views/vendor/livewire')) { + continue; + } + + $paths[$namespace] = $path; + + break; + } + } + + $options = array_map( + fn (string $path): string => (string) str($path)->after(base_path()), + $paths, + ); + + $namespace = ($defaultNamespace !== null) && array_key_exists($defaultNamespace, $paths) + ? $defaultNamespace + : (count($options) > 1 + ? select( + label: $question, + options: $options, + ) + : array_key_first($options)); + + if (blank($namespace)) { + return [ + $view, + resource_path( + (string) str($view) + ->replace('.', '/') + ->prepend('views/') + ->append('.blade.php'), + ), + ]; + } + + return [ + "{$namespace}::{$view}", + (string) str($view) + ->replace('.', '/') + ->prepend("{$paths[$namespace]}/") + ->append('.blade.php'), + ]; + } +} diff --git a/packages/support/src/Commands/FileGenerators/ClassGenerator.php b/packages/support/src/Commands/FileGenerators/ClassGenerator.php index 26c19e83ba..91b23f5f86 100644 --- a/packages/support/src/Commands/FileGenerators/ClassGenerator.php +++ b/packages/support/src/Commands/FileGenerators/ClassGenerator.php @@ -31,6 +31,10 @@ abstract class ClassGenerator implements FileGenerator $class->setExtends($extends); } + foreach ($this->getImplements() as $implement) { + $class->addImplement($implement); + } + $this->addTraitsToClass($class); $this->addPropertiesToClass($class); $this->addMethodsToClass($class); @@ -59,6 +63,14 @@ abstract class ClassGenerator implements FileGenerator return null; } + /** + * @return array + */ + public function getImplements(): array + { + return []; + } + /** * @param class-string $name */ diff --git a/packages/support/src/Facades/FilamentCli.php b/packages/support/src/Facades/FilamentCli.php new file mode 100644 index 0000000000..15debdae3e --- /dev/null +++ b/packages/support/src/Facades/FilamentCli.php @@ -0,0 +1,20 @@ + getComponentLocations() + * + * @see CliManager + */ +class FilamentCli extends Facade +{ + protected static function getFacadeAccessor(): string + { + return CliManager::class; + } +} diff --git a/packages/support/src/SupportServiceProvider.php b/packages/support/src/SupportServiceProvider.php index fe4459f446..55e79818b0 100644 --- a/packages/support/src/SupportServiceProvider.php +++ b/packages/support/src/SupportServiceProvider.php @@ -69,6 +69,11 @@ class SupportServiceProvider extends PackageServiceProvider fn () => new AssetManager, ); + $this->app->singleton( + CliManager::class, + fn () => new CliManager, + ); + $this->app->scoped( ScopedComponentManager::class, fn () => $this->app->make(ComponentManager::class)->clone(), diff --git a/packages/tables/src/Commands/FileGenerators/ColumnClassGenerator.php b/packages/tables/src/Commands/FileGenerators/ColumnClassGenerator.php new file mode 100644 index 0000000000..0b505211e1 --- /dev/null +++ b/packages/tables/src/Commands/FileGenerators/ColumnClassGenerator.php @@ -0,0 +1,118 @@ +extractNamespace($this->getFqn()); + } + + /** + * @return array + */ + public function getImports(): array + { + return [ + $this->getExtends(), + ...($this->hasEmbeddedView() ? [HasEmbeddedView::class] : []), + ]; + } + + public function getBasename(): string + { + return class_basename($this->getFqn()); + } + + public function getExtends(): string + { + return Column::class; + } + + /** + * @return array + */ + public function getImplements(): array + { + return [ + ...($this->hasEmbeddedView() ? [HasEmbeddedView::class] : []), + ]; + } + + protected function addPropertiesToClass(ClassType $class): void + { + $this->addViewPropertyToClass($class); + } + + protected function addMethodsToClass(ClassType $class): void + { + $this->addToEmbeddedHtmlMethodToClass($class); + } + + protected function addViewPropertyToClass(ClassType $class): void + { + if ($this->hasEmbeddedView()) { + return; + } + + $property = $class->addProperty('view', $this->getView()) + ->setProtected() + ->setType('string'); + $this->configureViewProperty($property); + } + + protected function configureViewProperty(Property $property): void {} + + protected function addToEmbeddedHtmlMethodToClass(ClassType $class): void + { + if (! $this->hasEmbeddedView()) { + return; + } + + $method = $class->addMethod('toEmbeddedHtml') + ->setPublic() + ->setReturnType('string') + ->setBody(<<<'PHP' + ob_start(); ?> + +
+ getState()) ?> +
+ + configureToEmbeddedHtmlMethod($method); + } + + protected function configureToEmbeddedHtmlMethod(Method $method): void {} + + public function getFqn(): string + { + return $this->fqn; + } + + public function hasEmbeddedView(): bool + { + return $this->hasEmbeddedView; + } + + public function getView(): ?string + { + return $this->view; + } +} diff --git a/packages/tables/src/Commands/MakeColumnCommand.php b/packages/tables/src/Commands/MakeColumnCommand.php index 0df80d36c0..84bb922154 100644 --- a/packages/tables/src/Commands/MakeColumnCommand.php +++ b/packages/tables/src/Commands/MakeColumnCommand.php @@ -2,11 +2,18 @@ namespace Filament\Tables\Commands; +use Filament\Support\Commands\Concerns\CanAskForComponentLocation; +use Filament\Support\Commands\Concerns\CanAskForViewLocation; use Filament\Support\Commands\Concerns\CanManipulateFiles; +use Filament\Support\Commands\Exceptions\InvalidCommandOutput; +use Filament\Tables\Commands\FileGenerators\ColumnClassGenerator; use Illuminate\Console\Command; use Illuminate\Support\Str; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; +use function Laravel\Prompts\confirm; use function Laravel\Prompts\text; #[AsCommand(name: 'make:filament-table-column', aliases: [ @@ -16,11 +23,25 @@ use function Laravel\Prompts\text; ])] class MakeColumnCommand extends Command { + use CanAskForComponentLocation; + use CanAskForViewLocation; use CanManipulateFiles; protected $description = 'Create a new table column class and cell view'; - protected $signature = 'make:filament-table-column {name?} {--F|force}'; + protected $name = 'make:filament-table-column'; + + protected string $fqnEnd; + + protected string $fqn; + + protected string $path; + + protected bool $hasEmbeddedView; + + protected ?string $view = null; + + protected ?string $viewPath = null; /** * @var array @@ -31,59 +52,141 @@ class MakeColumnCommand extends Command 'make:table-column', ]; + /** + * @return array + */ + protected function getArguments(): array + { + return [ + new InputArgument( + name: 'name', + mode: InputArgument::OPTIONAL, + description: 'The name of the column to generate, optionally prefixed with directories', + ), + ]; + } + + /** + * @return array + */ + protected function getOptions(): array + { + return [ + new InputOption( + name: 'embedded-view', + shortcut: 'E', + mode: InputOption::VALUE_NONE, + description: 'Define embedded HTML inside the class instead of using a separate Blade view file', + ), + new InputOption( + name: 'force', + shortcut: 'F', + mode: InputOption::VALUE_NONE, + description: 'Overwrite the contents of the files if they already exist', + ), + ]; + } + public function handle(): int { - $column = (string) str($this->argument('name') ?? text( + try { + $this->configureFqnEnd(); + $this->configureHasEmbeddedView(); + + $this->configureLocation(); + + $this->createColumn(); + $this->createView(); + } catch (InvalidCommandOutput) { + return static::FAILURE; + } + + $this->components->info("Filament table column [{$this->fqn}] created successfully."); + + return static::SUCCESS; + } + + protected function configureFqnEnd(): void + { + $this->fqnEnd = (string) str($this->argument('name') ?? text( label: 'What is the column name?', - placeholder: 'StatusSwitcher', + placeholder: 'StatusSwitcherColumn', required: true, )) ->trim('/') ->trim('\\') ->trim(' ') + ->studly() ->replace('/', '\\'); - $columnClass = (string) str($column)->classBasename(); - $columnNamespace = str($column)->contains('\\') ? - (string) str($column)->beforeLast('\\') : - ''; + } - $view = str($column) - ->prepend('filament\\tables\\columns\\') - ->explode('\\') - ->map(fn ($segment) => Str::kebab($segment)) - ->implode('.'); - - $path = app_path( - (string) str($column) - ->prepend('Filament\\Tables\\Columns\\') - ->replace('\\', '/') - ->append('.php'), - ); - $viewPath = resource_path( - (string) str($view) - ->replace('.', '/') - ->prepend('views/') - ->append('.blade.php'), + protected function configureHasEmbeddedView(): void + { + $this->hasEmbeddedView = $this->option('embedded-view') || confirm( + label: 'Do you want to embed the HTML of the view in the column class?', + default: false, + hint: 'Defining the HTML of the column in the class instead of in a Blade view file improves the performance of the column, but doesn\'t allow you to use Blade syntax.', ); + } - if (! $this->option('force') && $this->checkForCollision([ + protected function configureLocation(): void + { + [ + $namespace, $path, - ])) { - return static::INVALID; + $viewNamespace, + ] = $this->askForComponentLocation( + path: 'Filament/Tables/Columns', + question: 'Where would you like to create the column?', + ); + + $this->fqn = "{$namespace}\\{$this->fqnEnd}"; + $this->path = (string) str("{$path}\\{$this->fqnEnd}.php") + ->replace('\\', '/') + ->replace('//', '/'); + + if ($this->hasEmbeddedView) { + return; } - $this->copyStubToApp('Column', $path, [ - 'class' => $columnClass, - 'namespace' => 'App\\Filament\\Tables\\Columns' . ($columnNamespace !== '' ? "\\{$columnNamespace}" : ''), - 'view' => $view, - ]); + [ + $this->view, + $this->viewPath, + ] = $this->askForViewLocation( + str($this->fqn) + ->afterLast('Filament\\Tables\\Columns') + ->prepend('Filament\\Tables\\Columns') + ->replace('\\', '/') + ->explode('/') + ->map(Str::kebab(...)) + ->implode('.'), + defaultNamespace: $viewNamespace, + ); + } - if (! $this->fileExists($viewPath)) { - $this->copyStubToApp('ColumnView', $viewPath); + protected function createColumn(): void + { + if (! $this->option('force') && $this->checkForCollision($this->path)) { + throw new InvalidCommandOutput; } - $this->components->info("Filament table column [{$path}] created successfully."); + $this->writeFile($this->path, app(ColumnClassGenerator::class, [ + 'fqn' => $this->fqn, + 'hasEmbeddedView' => $this->hasEmbeddedView, + 'view' => $this->view, + ])); + } - return static::SUCCESS; + protected function createView(): void + { + if (blank($this->view)) { + return; + } + + if (! $this->option('force') && $this->checkForCollision($this->viewPath)) { + throw new InvalidCommandOutput; + } + + $this->copyStubToApp('ColumnView', $this->viewPath); } }