Make widget command

This commit is contained in:
Dan Harrin
2024-11-25 13:28:16 +00:00
parent f4b76ed5ac
commit a394e9ef04
30 changed files with 934 additions and 360 deletions
@@ -3,9 +3,9 @@
namespace Filament\Commands\FileGenerators;
use Filament\Clusters\Cluster;
use Filament\Commands\FileGenerators\Concerns\CanGenerateViewProperty;
use Filament\Pages\Page;
use Filament\Support\Commands\FileGenerators\ClassGenerator;
use Filament\Support\Commands\FileGenerators\Concerns\CanGenerateViewProperty;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Literal;
use Nette\PhpGenerator\Property;
@@ -2,11 +2,11 @@
namespace Filament\Commands\FileGenerators\Resources\Pages;
use Filament\Commands\FileGenerators\Concerns\CanGenerateViewProperty;
use Filament\Commands\FileGenerators\Resources\Pages\Concerns\CanGenerateResourceProperty;
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use Filament\Resources\Pages\Page;
use Filament\Support\Commands\FileGenerators\ClassGenerator;
use Filament\Support\Commands\FileGenerators\Concerns\CanGenerateViewProperty;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Method;
use Nette\PhpGenerator\TraitUse;
@@ -2,9 +2,9 @@
namespace Filament\Commands;
use Filament\Commands\Concerns\HasPanel;
use Filament\Commands\FileGenerators\ClusterClassGenerator;
use Filament\Support\Commands\Concerns\CanManipulateFiles;
use Filament\Support\Commands\Concerns\HasPanel;
use Filament\Support\Commands\Exceptions\InvalidCommandOutput;
use Filament\Support\Commands\FileGenerators\Concerns\CanCheckFileGenerationFlags;
use Filament\Support\Commands\FileGenerators\FileGenerationFlag;
@@ -2,14 +2,6 @@
namespace Filament\Commands;
use Filament\Commands\Concerns\CanAskForRelatedModel;
use Filament\Commands\Concerns\CanAskForRelatedResource;
use Filament\Commands\Concerns\CanAskForResource;
use Filament\Commands\Concerns\CanAskForSchema;
use Filament\Commands\Concerns\HasCluster;
use Filament\Commands\Concerns\HasClusterPagesLocation;
use Filament\Commands\Concerns\HasPanel;
use Filament\Commands\Concerns\HasResourcesLocation;
use Filament\Commands\FileGenerators\CustomPageClassGenerator;
use Filament\Commands\FileGenerators\Resources\Pages\ResourceCreateRecordPageClassGenerator;
use Filament\Commands\FileGenerators\Resources\Pages\ResourceCustomPageClassGenerator;
@@ -21,8 +13,16 @@ 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\CanAskForRelatedModel;
use Filament\Support\Commands\Concerns\CanAskForRelatedResource;
use Filament\Support\Commands\Concerns\CanAskForResource;
use Filament\Support\Commands\Concerns\CanAskForSchema;
use Filament\Support\Commands\Concerns\CanAskForViewLocation;
use Filament\Support\Commands\Concerns\CanManipulateFiles;
use Filament\Support\Commands\Concerns\HasCluster;
use Filament\Support\Commands\Concerns\HasClusterPagesLocation;
use Filament\Support\Commands\Concerns\HasPanel;
use Filament\Support\Commands\Concerns\HasResourcesLocation;
use Filament\Support\Commands\Exceptions\InvalidCommandOutput;
use Filament\Support\Commands\FileGenerators\Concerns\CanCheckFileGenerationFlags;
use Filament\Support\Commands\FileGenerators\FileGenerationFlag;
@@ -136,7 +136,7 @@ class MakePageCommand extends Command
name: 'panel',
shortcut: null,
mode: InputOption::VALUE_REQUIRED,
description: 'The panel to create the resource in',
description: 'The panel to create the page in',
),
new InputOption(
name: 'resource',
@@ -2,15 +2,15 @@
namespace Filament\Commands;
use Filament\Commands\Concerns\CanAskForRelatedModel;
use Filament\Commands\Concerns\CanAskForRelatedResource;
use Filament\Commands\Concerns\CanAskForResource;
use Filament\Commands\Concerns\CanAskForSchema;
use Filament\Commands\Concerns\HasCluster;
use Filament\Commands\Concerns\HasPanel;
use Filament\Commands\Concerns\HasResourcesLocation;
use Filament\Commands\FileGenerators\Resources\RelationManagerClassGenerator;
use Filament\Support\Commands\Concerns\CanAskForRelatedModel;
use Filament\Support\Commands\Concerns\CanAskForRelatedResource;
use Filament\Support\Commands\Concerns\CanAskForResource;
use Filament\Support\Commands\Concerns\CanAskForSchema;
use Filament\Support\Commands\Concerns\CanManipulateFiles;
use Filament\Support\Commands\Concerns\HasCluster;
use Filament\Support\Commands\Concerns\HasPanel;
use Filament\Support\Commands\Concerns\HasResourcesLocation;
use Filament\Support\Commands\Exceptions\InvalidCommandOutput;
use Filament\Support\Commands\FileGenerators\Concerns\CanCheckFileGenerationFlags;
use Filament\Support\Commands\FileGenerators\FileGenerationFlag;
@@ -2,10 +2,6 @@
namespace Filament\Commands;
use Filament\Commands\Concerns\CanAskForResource;
use Filament\Commands\Concerns\HasCluster;
use Filament\Commands\Concerns\HasPanel;
use Filament\Commands\Concerns\HasResourcesLocation;
use Filament\Commands\FileGenerators\Resources\Pages\ResourceCreateRecordPageClassGenerator;
use Filament\Commands\FileGenerators\Resources\Pages\ResourceEditRecordPageClassGenerator;
use Filament\Commands\FileGenerators\Resources\Pages\ResourceListRecordsPageClassGenerator;
@@ -16,7 +12,11 @@ use Filament\Commands\FileGenerators\Resources\Schemas\ResourceFormSchemaClassGe
use Filament\Commands\FileGenerators\Resources\Schemas\ResourceInfolistSchemaClassGenerator;
use Filament\Commands\FileGenerators\Resources\Schemas\ResourceTableClassGenerator;
use Filament\Resources\Pages\Page;
use Filament\Support\Commands\Concerns\CanAskForResource;
use Filament\Support\Commands\Concerns\CanManipulateFiles;
use Filament\Support\Commands\Concerns\HasCluster;
use Filament\Support\Commands\Concerns\HasPanel;
use Filament\Support\Commands\Concerns\HasResourcesLocation;
use Filament\Support\Commands\Exceptions\InvalidCommandOutput;
use Filament\Support\Commands\FileGenerators\Concerns\CanCheckFileGenerationFlags;
use Filament\Support\Commands\FileGenerators\FileGenerationFlag;
@@ -267,47 +267,52 @@ class MakeResourceCommand extends Command
protected function configureModel(): void
{
$modelNamespace = $this->option('model-namespace') ?? 'App\\Models';
if ($this->argument('model')) {
$this->modelFqnEnd = (string) str($this->argument('model'))
->trim('/')
->trim('\\')
->trim(' ')
->when(
fn (Stringable $model): bool => str($model)->endsWith('Resource'),
fn (Stringable $model): Stringable => str($model)->beforeLast('Resource'),
)
->studly()
->replace('/', '\\');
$modelFqns = collect(get_declared_classes())
->filter(fn (string $class): bool => is_subclass_of($class, Model::class) &&
str($class)->startsWith("{$modelNamespace}\\"))
->map(fn (string $class): string => str($class)->after("{$modelNamespace}\\"))
->all();
if (blank($this->modelFqnEnd)) {
$this->modelFqnEnd = 'Resource';
}
$this->modelFqnEnd = (string) str($this->argument('model') ?? suggest(
label: 'What is the model name?',
options: function (string $search) use ($modelFqns): array {
$search = str($search)->trim()->replace(['\\', '/'], '');
$modelNamespace = $this->option('model-namespace') ?? 'App\\Models';
if (blank($search)) {
return $modelFqns;
}
$this->modelFqn = "{$modelNamespace}\\{$this->modelFqnEnd}";
} else {
$modelFqns = collect(get_declared_classes())
->filter(fn (string $class): bool => is_subclass_of($class, Model::class) &&
(! str((new ReflectionClass($class))->getFileName())->startsWith(base_path('vendor'))))
->all();
return array_filter(
$modelFqns,
fn (string $class): bool => str($class)->replace(['\\', '/'], '')->contains($search, ignoreCase: true),
);
},
placeholder: 'BlogPost',
required: true,
))
->trim('/')
->trim('\\')
->trim(' ')
->when(
fn (Stringable $model): bool => str($model)->endsWith('Resource'),
fn (Stringable $model): Stringable => str($model)->beforeLast('Resource'),
)
->studly()
->replace('/', '\\');
$this->modelFqn = suggest(
label: 'What is the model?',
options: function (string $search) use ($modelFqns): array {
$search = str($search)->trim()->replace(['\\', '/'], '');
if (blank($this->modelFqnEnd)) {
$this->modelFqnEnd = 'Resource';
if (blank($search)) {
return $modelFqns;
}
return array_filter(
$modelFqns,
fn (string $class): bool => str($class)->replace(['\\', '/'], '')->contains($search, ignoreCase: true),
);
},
placeholder: 'App\\Models\\BlogPost',
required: true,
);
$this->modelFqnEnd = class_basename($this->modelFqn);
}
$this->modelFqn = "{$modelNamespace}\\{$this->modelFqnEnd}";
if ($this->option('model')) {
$this->callSilently('make:model', [
'name' => $this->modelFqn,
@@ -2,8 +2,8 @@
namespace Filament\Commands;
use Filament\Commands\Concerns\HasPanel;
use Filament\Support\Commands\Concerns\CanManipulateFiles;
use Filament\Support\Commands\Concerns\HasPanel;
use Filament\Support\Commands\Exceptions\InvalidCommandOutput;
use Filament\Support\Commands\Exceptions\SuccessCommandOutput;
use Illuminate\Console\Command;
+1 -1
View File
@@ -1,3 +1,3 @@
<x-filament-panels::page>
{{-- Page content --}}
</x-filament-panels::page>
@@ -2,11 +2,11 @@
namespace Filament\Commands;
use Filament\Commands\Concerns\HasCluster;
use Filament\Commands\Concerns\HasClusterPagesLocation;
use Filament\Commands\Concerns\HasPanel;
use Filament\Commands\FileGenerators\SettingsPageClassGenerator;
use Filament\Support\Commands\Concerns\CanManipulateFiles;
use Filament\Support\Commands\Concerns\HasCluster;
use Filament\Support\Commands\Concerns\HasClusterPagesLocation;
use Filament\Support\Commands\Concerns\HasPanel;
use Filament\Support\Commands\Exceptions\InvalidCommandOutput;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
@@ -170,7 +170,7 @@ class MakeSettingsPageCommand extends Command
$settingsFqns = array_filter(
get_declared_classes(),
fn (string $modelFqn): bool => is_subclass_of($modelFqn, Settings::class),
fn (string $class): bool => is_subclass_of($class, Settings::class),
);
$this->settingsFqn = search(
@@ -1,6 +1,6 @@
<?php
namespace Filament\Commands\Concerns;
namespace Filament\Support\Commands\Concerns;
use Illuminate\Database\Eloquent\Model;
@@ -15,7 +15,7 @@ trait CanAskForRelatedModel
{
$modelFqns = array_filter(
get_declared_classes(),
fn (string $modelFqn): bool => is_subclass_of($modelFqn, Model::class),
fn (string $class): bool => is_subclass_of($class, Model::class),
);
return search(
@@ -1,6 +1,6 @@
<?php
namespace Filament\Commands\Concerns;
namespace Filament\Support\Commands\Concerns;
use function Laravel\Prompts\confirm;
@@ -1,6 +1,6 @@
<?php
namespace Filament\Commands\Concerns;
namespace Filament\Support\Commands\Concerns;
use Filament\Resources\Resource;
use Illuminate\Support\Collection;
@@ -1,6 +1,6 @@
<?php
namespace Filament\Commands\Concerns;
namespace Filament\Support\Commands\Concerns;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\suggest;
@@ -1,6 +1,6 @@
<?php
namespace Filament\Commands\Concerns;
namespace Filament\Support\Commands\Concerns;
use Filament\Clusters\Cluster;
use ReflectionClass;
@@ -1,6 +1,6 @@
<?php
namespace Filament\Commands\Concerns;
namespace Filament\Support\Commands\Concerns;
use ReflectionClass;
@@ -1,19 +1,26 @@
<?php
namespace Filament\Commands\Concerns;
namespace Filament\Support\Commands\Concerns;
use Filament\Facades\Filament;
use Filament\Panel;
use Illuminate\Support\Arr;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\select;
trait HasPanel
{
protected ?Panel $panel;
protected function configurePanel(string $question): void
protected function configurePanel(string $question, ?string $initialQuestion = null): void
{
if (! class_exists(Panel::class)) {
$this->panel = null;
return;
}
$panelName = $this->option('panel');
$this->panel = filled($panelName) ? Filament::getPanel($panelName, isStrict: false) : null;
@@ -22,6 +29,12 @@ trait HasPanel
return;
}
if (filled($initialQuestion) && (! confirm(label: $initialQuestion))) {
$this->panel = null;
return;
}
$panels = Filament::getPanels();
/** @var Panel $panel */
@@ -1,6 +1,6 @@
<?php
namespace Filament\Commands\Concerns;
namespace Filament\Support\Commands\Concerns;
use Illuminate\Support\Arr;
@@ -1,6 +1,6 @@
<?php
namespace Filament\Commands\FileGenerators\Concerns;
namespace Filament\Support\Commands\FileGenerators\Concerns;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Property;
+2 -1
View File
@@ -47,7 +47,8 @@ if (! function_exists('Filament\Support\get_model_label')) {
*/
function get_model_label(string $model): string
{
return (string) str(class_basename($model))
return (string) str($model)
->classBasename()
->kebab()
->replace('-', ' ');
}
@@ -10,6 +10,7 @@ use Filament\Tables\Commands\FileGenerators\LivewireTableComponentClassGenerator
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use ReflectionClass;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
@@ -144,16 +145,28 @@ class MakeLivewireTableCommand extends Command
protected function configureModel(): void
{
$modelNamespace = $this->option('model-namespace') ?? 'App\\Models';
if ($this->argument('model')) {
$this->modelFqnEnd = (string) str($this->argument('model'))
->trim('/')
->trim('\\')
->trim(' ')
->studly()
->replace('/', '\\');
$modelNamespace = $this->option('model-namespace') ?? 'App\\Models';
$this->modelFqn = "{$modelNamespace}\\{$this->modelFqnEnd}";
return;
}
$modelFqns = collect(get_declared_classes())
->filter(fn (string $class): bool => is_subclass_of($class, Model::class) &&
str($class)->startsWith("{$modelNamespace}\\"))
->map(fn (string $class): string => str($class)->after("{$modelNamespace}\\"))
(! str((new ReflectionClass($class))->getFileName())->startsWith(base_path('vendor'))))
->all();
$this->modelFqnEnd = (string) str($this->argument('model') ?? suggest(
label: 'What is the model name?',
$this->modelFqn = suggest(
label: 'What is the model?',
options: function (string $search) use ($modelFqns): array {
$search = str($search)->trim()->replace(['\\', '/'], '');
@@ -166,15 +179,11 @@ class MakeLivewireTableCommand extends Command
fn (string $class): bool => str($class)->replace(['\\', '/'], '')->contains($search, ignoreCase: true),
);
},
placeholder: 'BlogPost',
))
->trim('/')
->trim('\\')
->trim(' ')
->studly()
->replace('/', '\\');
placeholder: 'App\\Models\\BlogPost',
required: true,
);
$this->modelFqn = "{$modelNamespace}\\{$this->modelFqnEnd}";
$this->modelFqnEnd = class_basename($this->modelFqn);
}
protected function configureIsGenerated(): void
@@ -8,6 +8,7 @@ use Filament\Support\Commands\Exceptions\InvalidCommandOutput;
use Filament\Tables\Commands\FileGenerators\TableClassGenerator;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use ReflectionClass;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
@@ -134,16 +135,28 @@ class MakeTableCommand extends Command
protected function configureModel(): void
{
$modelNamespace = $this->option('model-namespace') ?? 'App\\Models';
if ($this->argument('model')) {
$this->modelFqnEnd = (string) str($this->argument('model'))
->trim('/')
->trim('\\')
->trim(' ')
->studly()
->replace('/', '\\');
$modelNamespace = $this->option('model-namespace') ?? 'App\\Models';
$this->modelFqn = "{$modelNamespace}\\{$this->modelFqnEnd}";
return;
}
$modelFqns = collect(get_declared_classes())
->filter(fn (string $class): bool => is_subclass_of($class, Model::class) &&
str($class)->startsWith("{$modelNamespace}\\"))
->map(fn (string $class): string => str($class)->after("{$modelNamespace}\\"))
(! str((new ReflectionClass($class))->getFileName())->startsWith(base_path('vendor'))))
->all();
$this->modelFqnEnd = (string) str($this->argument('model') ?? suggest(
label: 'What is the model name?',
$this->modelFqn = suggest(
label: 'What is the model?',
options: function (string $search) use ($modelFqns): array {
$search = str($search)->trim()->replace(['\\', '/'], '');
@@ -156,15 +169,11 @@ class MakeTableCommand extends Command
fn (string $class): bool => str($class)->replace(['\\', '/'], '')->contains($search, ignoreCase: true),
);
},
placeholder: 'BlogPost',
))
->trim('/')
->trim('\\')
->trim(' ')
->studly()
->replace('/', '\\');
placeholder: 'App\\Models\\BlogPost',
required: true,
);
$this->modelFqn = "{$modelNamespace}\\{$this->modelFqnEnd}";
$this->modelFqnEnd = class_basename($this->modelFqn);
}
protected function configureIsGenerated(): void
@@ -0,0 +1,115 @@
<?php
namespace Filament\Widgets\Commands\FileGenerators;
use Filament\Support\Commands\FileGenerators\ClassGenerator;
use Filament\Widgets\ChartWidget;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Literal;
use Nette\PhpGenerator\Method;
use Nette\PhpGenerator\Property;
class ChartWidgetClassGenerator extends ClassGenerator
{
final public function __construct(
protected string $fqn,
protected string $type,
) {}
public function getNamespace(): string
{
return $this->extractNamespace($this->getFqn());
}
/**
* @return array<string>
*/
public function getImports(): array
{
$extends = $this->getExtends();
$extendsBasename = class_basename($extends);
return [
...(($extendsBasename === class_basename($this->getFqn())) ? [$extends => "Base{$extendsBasename}"] : [$extends]),
];
}
public function getBasename(): string
{
return class_basename($this->getFqn());
}
public function getExtends(): string
{
return ChartWidget::class;
}
protected function addPropertiesToClass(ClassType $class): void
{
$this->addHeadingPropertyToClass($class);
}
protected function addMethodsToClass(ClassType $class): void
{
$this->addGetDataMethodToClass($class);
$this->addGetTypeMethodToClass($class);
}
protected function addHeadingPropertyToClass(ClassType $class): void
{
$property = $class->addProperty(
'heading',
(string) str($this->getBasename())
->classBasename()
->kebab()
->replace('-', ' ')
->title(),
)
->setProtected()
->setStatic()
->setType('?string');
$this->configureHeadingProperty($property);
}
protected function configureHeadingProperty(Property $property): void {}
protected function addGetDataMethodToClass(ClassType $class): void
{
$method = $class->addMethod('getData')
->setProtected()
->setReturnType('array')
->setBody(<<<'PHP'
return [
//
];
PHP);
$this->configureGetDataMethod($method);
}
protected function configureGetDataMethod(Method $method): void {}
protected function addGetTypeMethodToClass(ClassType $class): void
{
$method = $class->addMethod('getType')
->setProtected()
->setReturnType('string')
->setBody(new Literal(<<<'PHP'
return ?;
PHP, [$this->getType()]));
$this->configureGetTypeMethod($method);
}
protected function configureGetTypeMethod(Method $method): void {}
public function getFqn(): string
{
return $this->fqn;
}
public function getType(): string
{
return $this->type;
}
}
@@ -0,0 +1,61 @@
<?php
namespace Filament\Widgets\Commands\FileGenerators;
use Filament\Support\Commands\FileGenerators\ClassGenerator;
use Filament\Support\Commands\FileGenerators\Concerns\CanGenerateViewProperty;
use Filament\Widgets\Widget;
use Nette\PhpGenerator\ClassType;
class CustomWidgetClassGenerator extends ClassGenerator
{
use CanGenerateViewProperty;
final public function __construct(
protected string $fqn,
protected string $view,
) {}
public function getNamespace(): string
{
return $this->extractNamespace($this->getFqn());
}
/**
* @return array<string>
*/
public function getImports(): array
{
$extends = $this->getExtends();
$extendsBasename = class_basename($extends);
return [
...(($extendsBasename === class_basename($this->getFqn())) ? [$extends => "Base{$extendsBasename}"] : [$extends]),
];
}
public function getBasename(): string
{
return class_basename($this->getFqn());
}
public function getExtends(): string
{
return Widget::class;
}
protected function addPropertiesToClass(ClassType $class): void
{
$this->addViewPropertyToClass($class);
}
public function getFqn(): string
{
return $this->fqn;
}
public function getView(): string
{
return $this->view;
}
}
@@ -0,0 +1,71 @@
<?php
namespace Filament\Widgets\Commands\FileGenerators;
use Filament\Support\Commands\FileGenerators\ClassGenerator;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Method;
class StatsOverviewWidgetClassGenerator extends ClassGenerator
{
final public function __construct(
protected string $fqn,
) {}
public function getNamespace(): string
{
return $this->extractNamespace($this->getFqn());
}
/**
* @return array<string>
*/
public function getImports(): array
{
$extends = $this->getExtends();
$extendsBasename = class_basename($extends);
return [
...(($extendsBasename === class_basename($this->getFqn())) ? [$extends => "Base{$extendsBasename}"] : [$extends]),
Stat::class,
];
}
public function getBasename(): string
{
return class_basename($this->getFqn());
}
public function getExtends(): string
{
return StatsOverviewWidget::class;
}
protected function addMethodsToClass(ClassType $class): void
{
$this->addGetStatsMethodToClass($class);
}
protected function addGetStatsMethodToClass(ClassType $class): void
{
$method = $class->addMethod('getStats')
->setProtected()
->setReturnType('array')
->setBody(<<<'PHP'
return [
//
];
PHP);
$this->configureGetStatsMethod($method);
}
protected function configureGetStatsMethod(Method $method): void {}
public function getFqn(): string
{
return $this->fqn;
}
}
@@ -0,0 +1,97 @@
<?php
namespace Filament\Widgets\Commands\FileGenerators;
use Filament\Support\Commands\Concerns\CanReadModelSchemas;
use Filament\Support\Commands\FileGenerators\ClassGenerator;
use Filament\Tables\Commands\FileGenerators\Concerns\CanGenerateTables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Method;
class TableWidgetClassGenerator extends ClassGenerator
{
use CanGenerateTables;
use CanReadModelSchemas;
/**
* @param class-string<Model> $modelFqn
*/
final public function __construct(
protected string $fqn,
protected string $modelFqn,
protected bool $isGenerated,
) {}
public function getNamespace(): string
{
return $this->extractNamespace($this->getFqn());
}
/**
* @return array<string>
*/
public function getImports(): array
{
$extends = $this->getExtends();
$extendsBasename = class_basename($extends);
return [
...(($extendsBasename === class_basename($this->getFqn())) ? [$extends => "Base{$extendsBasename}"] : [$extends]),
Table::class,
...($this->hasPartialImports() ? ['Filament\Tables'] : []),
Builder::class,
$this->getModelFqn(),
];
}
public function getBasename(): string
{
return class_basename($this->getFqn());
}
public function getExtends(): string
{
return TableWidget::class;
}
protected function addMethodsToClass(ClassType $class): void
{
$this->addTableMethodToClass($class);
}
protected function addTableMethodToClass(ClassType $class): void
{
$method = $class->addMethod('table')
->setPublic()
->setReturnType(Table::class)
->setBody($this->generateTableMethodBody($this->getModelFqn()));
$method->addParameter('table')
->setType(Table::class);
$this->configureTableMethod($method);
}
protected function configureTableMethod(Method $method): void {}
public function getFqn(): string
{
return $this->fqn;
}
/**
* @return ?class-string<Model>
*/
public function getModelFqn(): ?string
{
return $this->modelFqn;
}
public function isGenerated(): bool
{
return $this->isGenerated;
}
}
@@ -2,16 +2,38 @@
namespace Filament\Widgets\Commands;
use Filament\Facades\Filament;
use Filament\Panel;
use Filament\Resources\Resource;
use Filament\Support\Commands\Concerns\CanAskForLivewireComponentLocation;
use Filament\Support\Commands\Concerns\CanAskForResource;
use Filament\Support\Commands\Concerns\CanAskForViewLocation;
use Filament\Support\Commands\Concerns\CanManipulateFiles;
use Filament\Support\Commands\Concerns\HasCluster;
use Filament\Support\Commands\Concerns\HasPanel;
use Filament\Support\Commands\Concerns\HasResourcesLocation;
use Filament\Support\Commands\Exceptions\InvalidCommandOutput;
use Filament\Support\Commands\FileGenerators\Concerns\CanCheckFileGenerationFlags;
use Filament\Support\Facades\FilamentCli;
use Filament\Widgets\ChartWidget;
use Filament\Widgets\Commands\FileGenerators\ChartWidgetClassGenerator;
use Filament\Widgets\Commands\FileGenerators\CustomWidgetClassGenerator;
use Filament\Widgets\Commands\FileGenerators\StatsOverviewWidgetClassGenerator;
use Filament\Widgets\Commands\FileGenerators\TableWidgetClassGenerator;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\TableWidget;
use Filament\Widgets\Widget;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
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;
use Symfony\Component\Console\Input\InputOption;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\search;
use function Laravel\Prompts\select;
use function Laravel\Prompts\suggest;
use function Laravel\Prompts\text;
#[AsCommand(name: 'make:filament-widget', aliases: [
@@ -20,11 +42,18 @@ use function Laravel\Prompts\text;
])]
class MakeWidgetCommand extends Command
{
use CanAskForLivewireComponentLocation;
use CanAskForResource;
use CanAskForViewLocation;
use CanCheckFileGenerationFlags;
use CanManipulateFiles;
use HasCluster;
use HasPanel;
use HasResourcesLocation;
protected $description = 'Create a new Filament widget class';
protected $signature = 'make:filament-widget {name?} {--R|resource=} {--C|chart} {--T|table} {--S|stats-overview} {--panel=} {--F|force}';
protected $name = 'make:filament-widget';
/**
* @var array<string>
@@ -34,9 +63,137 @@ class MakeWidgetCommand extends Command
'filament:widget',
];
/**
* @var class-string
*/
protected string $fqn;
protected string $fqnEnd;
protected ?string $view = null;
protected ?string $viewPath = null;
protected bool $hasResource;
/**
* @var ?class-string
*/
protected ?string $resourceFqn = null;
/**
* @var class-string<Widget> | null
*/
protected ?string $type = null;
protected string $widgetsNamespace;
protected string $widgetsDirectory;
/**
* @return array<InputArgument>
*/
protected function getArguments(): array
{
return [
new InputArgument(
name: 'name',
mode: InputArgument::OPTIONAL,
description: 'The name of the widget to generate, optionally prefixed with directories',
),
];
}
/**
* @return array<InputOption>
*/
protected function getOptions(): array
{
return [
new InputOption(
name: 'chart',
shortcut: 'C',
mode: InputOption::VALUE_NONE,
description: 'Create a chart widget',
),
new InputOption(
name: 'cluster',
shortcut: null,
mode: InputOption::VALUE_OPTIONAL,
description: 'The cluster that the resource belongs to',
),
new InputOption(
name: 'panel',
shortcut: null,
mode: InputOption::VALUE_REQUIRED,
description: 'The panel to create the widget in',
),
new InputOption(
name: 'resource',
shortcut: 'R',
mode: InputOption::VALUE_OPTIONAL,
description: 'The resource to create the widget in',
),
new InputOption(
name: 'stats-overview',
shortcut: 'S',
mode: InputOption::VALUE_NONE,
description: 'Create a stats overview widget',
),
new InputOption(
name: 'table',
shortcut: 'T',
mode: InputOption::VALUE_NONE,
description: 'Create a table widget',
),
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
{
$widget = (string) str($this->argument('name') ?? text(
try {
$this->configureFqnEnd();
$this->configureType();
$this->configurePanel(
question: 'Which panel would you like to create this widget in?',
initialQuestion: 'Would you like to create this widget in a panel?',
);
$this->configureHasResource();
$this->configureCluster();
$this->configureResource();
$this->configureWidgetsLocation();
$this->configureLocation();
$this->createCustomWidget();
$this->createChartWidget();
$this->createStatsOverviewWidget();
$this->createTableWidget();
$this->createView();
} catch (InvalidCommandOutput) {
return static::INVALID;
}
$this->components->info("Filament widget [{$this->fqn}] created successfully.");
if (filled($this->resourceFqn)) {
$this->components->info("Make sure to register the widget in [{$this->resourceFqn}::getWidgets()], and add it to a page in the resource.");
} elseif ($this->panel && empty($this->panel->getWidgetNamespaces())) {
$this->components->info('Make sure to register the widget with [widgets()] or discover it with [discoverWidgets()] in the panel service provider.');
}
return static::SUCCESS;
}
protected function configureFqnEnd(): void
{
$this->fqnEnd = (string) str($this->argument('name') ?? text(
label: 'What is the widget name?',
placeholder: 'BlogPostsChart',
required: true,
@@ -44,210 +201,315 @@ class MakeWidgetCommand extends Command
->trim('/')
->trim('\\')
->trim(' ')
->studly()
->replace('/', '\\');
$widgetClass = (string) str($widget)->classBasename();
$widgetNamespace = str($widget)->contains('\\') ?
(string) str($widget)->beforeLast('\\') :
'';
}
$resource = null;
$resourceClass = null;
protected function configureType(): void
{
$this->type = match (true) {
boolval($this->option('chart')) => ChartWidget::class,
boolval($this->option('stats-overview')) => StatsOverviewWidget::class,
boolval($this->option('table')) => TableWidget::class,
default => null,
} ?? select(
label: 'Which type of widget would you like to create?',
options: [
Widget::class => 'Custom',
ChartWidget::class => 'Chart',
StatsOverviewWidget::class => 'Stats overview',
TableWidget::class => 'Table',
],
);
}
$type = match (true) {
$this->option('chart') => 'Chart',
$this->option('stats-overview') => 'Stats overview',
$this->option('table') => 'Table',
default => select(
label: 'What type of widget do you want to create?',
options: ['Chart', 'Stats overview', 'Table', 'Custom'],
),
};
protected function configureHasResource(): void
{
if (! $this->panel) {
$this->hasResource = false;
if (class_exists(Resource::class)) {
$resourceInput = $this->option('resource') ?? text(
label: 'What is the resource you would like to create this in?',
placeholder: '[Optional] BlogPostResource',
);
if (filled($resourceInput)) {
$resource = (string) str($resourceInput)
->studly()
->trim('/')
->trim('\\')
->trim(' ')
->replace('/', '\\');
if (! str($resource)->endsWith('Resource')) {
$resource .= 'Resource';
}
$resourceClass = (string) str($resource)
->classBasename();
}
return;
}
$panel = null;
$this->hasResource = $this->option('resource') || confirm(
label: 'Would you like to create this widget in a resource?',
default: false,
);
}
if (class_exists(Panel::class)) {
$panel = $this->option('panel');
if ($panel) {
$panel = Filament::getPanel($panel, isStrict: false);
}
if (! $panel) {
$panels = Filament::getPanels();
$namespace = config('livewire.class_namespace');
/** @var ?Panel $panel */
$panel = $panels[select(
label: 'Where would you like to create this?',
options: array_unique([
...array_map(
fn (Panel $panel): string => "The [{$panel->getId()}] panel",
$panels,
),
$namespace => "[{$namespace}] alongside other Livewire components",
])
)] ?? null;
}
protected function configureCluster(): void
{
if (! $this->hasResource) {
return;
}
$path = null;
$namespace = null;
$resourcePath = null;
$resourceNamespace = null;
if (! $panel) {
$namespace = config('livewire.class_namespace');
$path = app_path((string) str($namespace)->after('App\\')->replace('\\', '/'));
} elseif ($resource === null) {
$widgetDirectories = $panel->getWidgetDirectories();
$widgetNamespaces = $panel->getWidgetNamespaces();
foreach ($widgetDirectories as $widgetIndex => $widgetDirectory) {
if (str($widgetDirectory)->startsWith(base_path('vendor'))) {
unset($widgetDirectories[$widgetIndex]);
unset($widgetNamespaces[$widgetIndex]);
}
}
$namespace = (count($widgetNamespaces) > 1) ?
select(
label: 'Which namespace would you like to create this in?',
options: $widgetNamespaces,
) :
(Arr::first($widgetNamespaces) ?? 'App\\Filament\\Widgets');
$path = (count($widgetDirectories) > 1) ?
$widgetDirectories[array_search($namespace, $widgetNamespaces)] :
(Arr::first($widgetDirectories) ?? app_path('Filament/Widgets/'));
} else {
$resourceDirectories = $panel->getResourceDirectories();
$resourceNamespaces = $panel->getResourceNamespaces();
foreach ($resourceDirectories as $resourceIndex => $resourceDirectory) {
if (str($resourceDirectory)->startsWith(base_path('vendor'))) {
unset($resourceDirectories[$resourceIndex]);
unset($resourceNamespaces[$resourceIndex]);
}
}
$resourceNamespace = (count($resourceNamespaces) > 1) ?
select(
label: 'Which namespace would you like to create this in?',
options: $resourceNamespaces,
) :
(Arr::first($resourceNamespaces) ?? 'App\\Filament\\Resources');
$resourcePath = (count($resourceDirectories) > 1) ?
$resourceDirectories[array_search($resourceNamespace, $resourceNamespaces)] :
(Arr::first($resourceDirectories) ?? app_path('Filament/Resources/'));
}
$view = str($widget)->prepend(
(string) str($resource === null ? ($panel ? "{$namespace}\\" : 'livewire\\') : "{$resourceNamespace}\\{$resource}\\widgets\\")
->replaceFirst('App\\', '')
)
->replace('\\', '/')
->explode('/')
->map(fn ($segment) => Str::lower(Str::kebab($segment)))
->implode('.');
$path = (string) str($widget)
->prepend('/')
->prepend($resource === null ? $path : "{$resourcePath}\\{$resource}\\Widgets\\")
->replace('\\', '/')
->replace('//', '/')
->append('.php');
$viewPath = resource_path(
(string) str($view)
->replace('.', '/')
->prepend('views/')
->append('.blade.php'),
$this->configureClusterFqn(
initialQuestion: 'Is the resource in a cluster?',
question: 'Which cluster is the resource in?',
);
if (! $this->option('force') && $this->checkForCollision([
$path,
...($this->option('stats-overview') || $this->option('chart')) ? [] : [$viewPath],
])) {
return static::INVALID;
if (blank($this->clusterFqn)) {
return;
}
if ($type === 'Chart') {
$chartType = select(
label: 'Which type of chart would you like to create?',
options: [
'Bar chart',
'Bubble chart',
'Doughnut chart',
'Line chart',
'Pie chart',
'Polar area chart',
'Radar chart',
'Scatter chart',
],
$this->configureClusterResourcesLocation();
}
protected function configureResource(): void
{
if (! $this->hasResource) {
return;
}
$this->configureResourcesLocation(question: 'Which namespace would you like to search for resources in?');
$this->resourceFqn = $this->askForResource(
question: 'Which resource would you like to create this widget in?',
initialResource: $this->option('resource'),
);
$pluralResourceBasenameBeforeResource = (string) str($this->resourceFqn)
->classBasename()
->beforeLast('Resource')
->plural();
$resourceNamespacePartBeforeBasename = (string) str($this->resourceFqn)
->beforeLast('\\')
->classBasename();
if ($pluralResourceBasenameBeforeResource === $resourceNamespacePartBeforeBasename) {
$this->widgetsNamespace = (string) str($this->resourceFqn)
->beforeLast('\\')
->append('\\Widgets');
$this->widgetsDirectory = (string) str((new ReflectionClass($this->resourceFqn))->getFileName())
->beforeLast(DIRECTORY_SEPARATOR)
->append('/Widgets');
return;
}
$this->widgetsNamespace = "{$this->resourceFqn}\\Widgets";
$this->widgetsDirectory = (string) str((new ReflectionClass($this->resourceFqn))->getFileName())
->beforeLast('.')
->append('/Widgets');
}
protected function configureWidgetsLocation(): void
{
if (filled($this->resourceFqn)) {
return;
}
if (! $this->panel) {
[
$this->widgetsNamespace,
$this->widgetsDirectory,
] = $this->askForLivewireComponentLocation(
question: 'Where would you like to create the widget?',
);
$this->copyStubToApp('ChartWidget', $path, [
'class' => $widgetClass,
'namespace' => filled($resource) ? "{$resourceNamespace}\\{$resource}\\Widgets" . ($widgetNamespace !== '' ? "\\{$widgetNamespace}" : '') : $namespace . ($widgetNamespace !== '' ? "\\{$widgetNamespace}" : ''),
'type' => match ($chartType) {
'Bar chart' => 'bar',
'Bubble chart' => 'bubble',
'Doughnut chart' => 'doughnut',
'Pie chart' => 'pie',
'Polar area chart' => 'polarArea',
'Radar chart' => 'radar',
'Scatter chart' => 'scatter',
default => 'line',
},
]);
} elseif ($type === 'Stats overview') {
$this->copyStubToApp('StatsOverviewWidget', $path, [
'class' => $widgetClass,
'namespace' => filled($resource) ? "{$resourceNamespace}\\{$resource}\\Widgets" . ($widgetNamespace !== '' ? "\\{$widgetNamespace}" : '') : $namespace . ($widgetNamespace !== '' ? "\\{$widgetNamespace}" : ''),
]);
} elseif ($type === 'Table') {
$this->copyStubToApp('TableWidget', $path, [
'class' => $widgetClass,
'namespace' => filled($resource) ? "{$resourceNamespace}\\{$resource}\\Widgets" . ($widgetNamespace !== '' ? "\\{$widgetNamespace}" : '') : $namespace . ($widgetNamespace !== '' ? "\\{$widgetNamespace}" : ''),
]);
} else {
$this->copyStubToApp('Widget', $path, [
'class' => $widgetClass,
'namespace' => filled($resource) ? "{$resourceNamespace}\\{$resource}\\Widgets" . ($widgetNamespace !== '' ? "\\{$widgetNamespace}" : '') : $namespace . ($widgetNamespace !== '' ? "\\{$widgetNamespace}" : ''),
'view' => $view,
]);
$this->copyStubToApp('WidgetView', $viewPath);
return;
}
$this->components->info("Filament widget [{$path}] created successfully.");
$directories = $this->panel->getWidgetDirectories();
$namespaces = $this->panel->getWidgetNamespaces();
if ($resource !== null) {
$this->components->info("Make sure to register the widget in `{$resourceClass}::getWidgets()`, and then again in `getHeaderWidgets()` or `getFooterWidgets()` of any `{$resourceClass}` page.");
foreach ($directories as $index => $directory) {
if (str($directory)->startsWith(base_path('vendor'))) {
unset($directories[$index]);
unset($namespaces[$index]);
}
}
return static::SUCCESS;
if (count($namespaces) < 2) {
$this->widgetsNamespace = (Arr::first($namespaces) ?? 'App\\Filament\\Widgets');
$this->widgetsDirectory = (Arr::first($directories) ?? app_path('Filament/Widgets/'));
return;
}
$this->widgetsNamespace = search(
label: 'Which namespace would you like to create this widget in?',
options: function (?string $search) use ($namespaces): array {
if (blank($search)) {
return $namespaces;
}
$search = str($search)->trim()->replace(['\\', '/'], '');
return array_filter($namespaces, fn (string $namespace): bool => str($namespace)->replace(['\\', '/'], '')->contains($search, ignoreCase: true));
},
);
$this->widgetsDirectory = $directories[array_search($this->widgetsNamespace, $namespaces)];
}
protected function configureLocation(): void
{
$this->fqn = $this->widgetsNamespace . '\\' . $this->fqnEnd;
if ($this->type === Widget::class) {
$componentLocations = FilamentCli::getLivewireComponentLocations();
$matchingComponentLocationNamespaces = collect($componentLocations)
->keys()
->filter(fn (string $namespace): bool => str($this->fqn)->startsWith($namespace));
[
$this->view,
$this->viewPath,
] = $this->askForViewLocation(
view: str($this->fqn)
->whenContains(
'Filament\\',
fn (Stringable $fqn) => $fqn->after('Filament\\')->prepend('Filament\\'),
fn (Stringable $fqn) => $fqn
->afterLast('\\Livewire\\')
->prepend('Livewire\\'),
)
->replace('\\', '/')
->explode('/')
->map(Str::kebab(...))
->implode('.'),
question: 'Where would you like to create the Blade view for the widget?',
defaultNamespace: (count($matchingComponentLocationNamespaces) === 1)
? $componentLocations[Arr::first($matchingComponentLocationNamespaces)]['viewNamespace'] ?? null
: null,
);
}
}
protected function createCustomWidget(): void
{
if ($this->type !== Widget::class) {
return;
}
$path = (string) str("{$this->widgetsDirectory}\\{$this->fqnEnd}.php")
->replace('\\', '/')
->replace('//', '/');
if (! $this->option('force') && $this->checkForCollision($path)) {
throw new InvalidCommandOutput;
}
$this->writeFile($path, app(CustomWidgetClassGenerator::class, [
'fqn' => $this->fqn,
'view' => $this->view,
]));
}
protected function createChartWidget(): void
{
if ($this->type !== ChartWidget::class) {
return;
}
$type = select(
label: 'Which type of chart would you like to create?',
options: [
'bar' => 'Bar chart',
'bubble' => 'Bubble chart',
'doughnut' => 'Doughnut chart',
'line' => 'Line chart',
'pie' => 'Pie chart',
'polarArea' => 'Polar area chart',
'radar' => 'Radar chart',
'scatter' => 'Scatter chart',
],
default: 'line',
);
$path = (string) str("{$this->widgetsDirectory}\\{$this->fqnEnd}.php")
->replace('\\', '/')
->replace('//', '/');
if (! $this->option('force') && $this->checkForCollision($path)) {
throw new InvalidCommandOutput;
}
$this->writeFile($path, app(ChartWidgetClassGenerator::class, [
'fqn' => $this->fqn,
'type' => $type,
]));
}
protected function createStatsOverviewWidget(): void
{
if ($this->type !== StatsOverviewWidget::class) {
return;
}
$path = (string) str("{$this->widgetsDirectory}\\{$this->fqnEnd}.php")
->replace('\\', '/')
->replace('//', '/');
if (! $this->option('force') && $this->checkForCollision($path)) {
throw new InvalidCommandOutput;
}
$this->writeFile($path, app(StatsOverviewWidgetClassGenerator::class, [
'fqn' => $this->fqn,
]));
}
protected function createTableWidget(): void
{
if ($this->type !== TableWidget::class) {
return;
}
$modelFqns = collect(get_declared_classes())
->filter(fn (string $class): bool => is_subclass_of($class, Model::class) &&
(! str((new ReflectionClass($class))->getFileName())->startsWith(base_path('vendor'))))
->all();
$modelFqn = suggest(
label: 'What is the model?',
options: function (string $search) use ($modelFqns): array {
$search = str($search)->trim()->replace(['\\', '/'], '');
if (blank($search)) {
return $modelFqns;
}
return array_filter(
$modelFqns,
fn (string $class): bool => str($class)->replace(['\\', '/'], '')->contains($search, ignoreCase: true),
);
},
placeholder: 'App\\Models\\BlogPost',
);
$isGenerated = confirm(
label: 'Would you like to generate the table columns based on the attributes of the model?',
default: false,
);
$path = (string) str("{$this->widgetsDirectory}\\{$this->fqnEnd}.php")
->replace('\\', '/')
->replace('//', '/');
if (! $this->option('force') && $this->checkForCollision($path)) {
throw new InvalidCommandOutput;
}
$this->writeFile($path, app(TableWidgetClassGenerator::class, [
'fqn' => $this->fqn,
'modelFqn' => $modelFqn,
'isGenerated' => $isGenerated,
]));
}
protected function createView(): void
{
if (blank($this->view)) {
return;
}
if (! $this->option('force') && $this->checkForCollision($this->viewPath)) {
throw new InvalidCommandOutput;
}
$this->copyStubToApp('WidgetView', $this->viewPath);
}
}
-22
View File
@@ -1,22 +0,0 @@
<?php
namespace {{ namespace }};
use Filament\Widgets\ChartWidget;
class {{ class }} extends ChartWidget
{
protected static ?string $heading = 'Chart';
protected function getData(): array
{
return [
//
];
}
protected function getType(): string
{
return '{{ type }}';
}
}
@@ -1,16 +0,0 @@
<?php
namespace {{ namespace }};
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class {{ class }} extends BaseWidget
{
protected function getStats(): array
{
return [
//
];
}
}
-21
View File
@@ -1,21 +0,0 @@
<?php
namespace {{ namespace }};
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;
class {{ class }} extends BaseWidget
{
public function table(Table $table): Table
{
return $table
->query(
// ...
)
->columns([
// ...
]);
}
}
-10
View File
@@ -1,10 +0,0 @@
<?php
namespace {{ namespace }};
use Filament\Widgets\Widget;
class {{ class }} extends Widget
{
protected static string $view = '{{ view }}';
}