Generate table columns

This commit is contained in:
Dan Harrin
2024-11-22 09:44:10 +00:00
parent 6358560f63
commit b12b529bde
10 changed files with 488 additions and 52 deletions
@@ -24,7 +24,7 @@ class ClusterClassGenerator extends ClassGenerator
public function getImports(): array
{
return [
Cluster::class,
$this->getExtends(),
];
}
@@ -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);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Filament\Support;
class CliManager
{
/**
* @var array<string, array{path: string, viewNamespace: ?string}>
*/
protected array $componentLocations = [];
public function registerComponentLocation(string $path, string $namespace, ?string $viewNamespace): void
{
$this->componentLocations[$namespace] = [
'path' => $path,
'viewNamespace' => $viewNamespace,
];
}
/**
* @return array<string, array{path: string, viewNamespace: ?string}>
*/
public function getComponentLocations(): array
{
return $this->componentLocations;
}
}
@@ -0,0 +1,62 @@
<?php
namespace Filament\Support\Commands\Concerns;
use Filament\Support\Facades\FilamentCli;
use function Laravel\Prompts\select;
trait CanAskForComponentLocation
{
/**
* @return array{
* 0: string,
* 1: string,
* 3: ?string,
* }
*/
protected function askForComponentLocation(string $path, string $question = 'Where would you like to create the component?'): array
{
$pathNamespace = (string) str($path)->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,
];
}
}
@@ -0,0 +1,82 @@
<?php
namespace Filament\Support\Commands\Concerns;
use Illuminate\Contracts\View\Factory;
use Illuminate\Support\Arr;
use function Laravel\Prompts\select;
trait CanAskForViewLocation
{
/**
* @return array{
* 0: string,
* 1: string,
* }
*/
protected function askForViewLocation(string $view, string $question = 'Where would you like to create the Blade view?', ?string $defaultNamespace = null): array
{
$viewFactory = app(Factory::class);
$paths = [];
if (str(Arr::first(config('view.paths')))->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'),
];
}
}
@@ -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<class-string>
*/
public function getImplements(): array
{
return [];
}
/**
* @param class-string $name
*/
@@ -0,0 +1,20 @@
<?php
namespace Filament\Support\Facades;
use Filament\Support\CliManager;
use Illuminate\Support\Facades\Facade;
/**
* @method static void registerComponentLocation(string $directory, string $namespace, ?string $viewNamespace = null)
* @method static array<string, string> getComponentLocations()
*
* @see CliManager
*/
class FilamentCli extends Facade
{
protected static function getFacadeAccessor(): string
{
return CliManager::class;
}
}
@@ -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(),
@@ -0,0 +1,118 @@
<?php
namespace Filament\Tables\Commands\FileGenerators;
use Filament\Support\Commands\FileGenerators\ClassGenerator;
use Filament\Support\Components\Contracts\HasEmbeddedView;
use Filament\Tables\Columns\Column;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Method;
use Nette\PhpGenerator\Property;
class ColumnClassGenerator extends ClassGenerator
{
final public function __construct(
protected string $fqn,
protected bool $hasEmbeddedView,
protected ?string $view,
) {}
public function getNamespace(): string
{
return $this->extractNamespace($this->getFqn());
}
/**
* @return array<string>
*/
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<class-string>
*/
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(); ?>
<div>
<?= e($this->getState()) ?>
</div>
<?php return ob_get_clean();
PHP);
$this->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;
}
}
@@ -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<string>
@@ -31,59 +52,141 @@ class MakeColumnCommand extends Command
'make:table-column',
];
/**
* @return array<InputArgument>
*/
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<InputOption>
*/
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);
}
}