From e2b7a5efeda8e5ab1eed2403358748f0c9f3c7f4 Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 10 Sep 2025 12:23:39 +0200 Subject: [PATCH 01/24] schemas (wip) --- .../migrations/create_schemas_table.php.stub | 35 ++++ src/Concerns/CanMapDynamicFields.php | 12 +- src/Concerns/HasFieldTypeResolver.php | 7 + src/Contracts/SchemaContract.php | 14 ++ src/Enums/Schema.php | 13 ++ src/FieldsServiceProvider.php | 1 + .../FieldsRelationManager.php | 1 + .../SchemaRelationManager.php | 196 ++++++++++++++++++ src/Models/Schema.php | 73 +++++++ src/Schemas/Base.php | 93 +++++++++ src/Schemas/Grid.php | 123 +++++++++++ src/Schemas/Section.php | 76 +++++++ 12 files changed, 638 insertions(+), 6 deletions(-) create mode 100644 database/migrations/create_schemas_table.php.stub create mode 100644 src/Contracts/SchemaContract.php create mode 100644 src/Enums/Schema.php create mode 100644 src/Filament/RelationManagers/SchemaRelationManager.php create mode 100644 src/Models/Schema.php create mode 100644 src/Schemas/Base.php create mode 100644 src/Schemas/Grid.php create mode 100644 src/Schemas/Section.php diff --git a/database/migrations/create_schemas_table.php.stub b/database/migrations/create_schemas_table.php.stub new file mode 100644 index 0000000..fc763e2 --- /dev/null +++ b/database/migrations/create_schemas_table.php.stub @@ -0,0 +1,35 @@ +ulid('ulid')->primary(); + $table->string('name'); + $table->string('slug'); + $table->string('field_type'); + $table->json('config')->nullable(); + $table->integer('position')->default(0); + $table->string('model_type'); + $table->string('model_key'); + $table->ulid('parent_ulid')->nullable(); + $table->timestamps(); + + $table->index(['model_type', 'model_key']); + $table->index(['model_type', 'model_key', 'position']); + $table->index(['parent_ulid']); + + $table->unique(['model_type', 'model_key', 'slug']); + }); + } + + public function down(): void + { + Schema::dropIfExists('schemas'); + } +}; diff --git a/src/Concerns/CanMapDynamicFields.php b/src/Concerns/CanMapDynamicFields.php index cf6eb8e..6c77f20 100644 --- a/src/Concerns/CanMapDynamicFields.php +++ b/src/Concerns/CanMapDynamicFields.php @@ -55,12 +55,7 @@ trait CanMapDynamicFields 'tags' => Tags::class, ]; - public function boot(): void - { - $this->fieldInspector = app(FieldInspector::class); - } - - #[On('refreshFields')] + #[On('refreshFields', 'refreshSchemas')] public function refresh(): void { // @@ -291,6 +286,11 @@ private function updateBuilderBlocksWithMutatedData(array &$builderBlocks, Model */ private function resolveFieldConfigAndInstance(Model $field): array { + // Initialize field inspector if not already done + if (! isset($this->fieldInspector)) { + $this->fieldInspector = app(FieldInspector::class); + } + // Try to resolve from custom fields first $fieldConfig = Fields::resolveField($field->field_type) ? $this->fieldInspector->initializeCustomField($field->field_type) : diff --git a/src/Concerns/HasFieldTypeResolver.php b/src/Concerns/HasFieldTypeResolver.php index 12edaa1..de694b6 100644 --- a/src/Concerns/HasFieldTypeResolver.php +++ b/src/Concerns/HasFieldTypeResolver.php @@ -3,6 +3,7 @@ namespace Backstage\Fields\Concerns; use Backstage\Fields\Enums\Field; +use Backstage\Fields\Enums\Schema as SchemaEnum; use Backstage\Fields\Facades\Fields; use Exception; use Illuminate\Support\Str; @@ -35,10 +36,16 @@ protected static function resolveFieldTypeClassName(string $fieldType): ?string return Fields::getFields()[$fieldType]; } + // Check if it's a field type if (Field::tryFrom($fieldType)) { return sprintf('Backstage\\Fields\\Fields\\%s', Str::studly($fieldType)); } + // Check if it's a schema type + if (SchemaEnum::tryFrom($fieldType)) { + return sprintf('Backstage\\Fields\\Schemas\\%s', Str::studly($fieldType)); + } + return null; } diff --git a/src/Contracts/SchemaContract.php b/src/Contracts/SchemaContract.php new file mode 100644 index 0000000..e146158 --- /dev/null +++ b/src/Contracts/SchemaContract.php @@ -0,0 +1,14 @@ +label(__('Name')) ->required() + ->autocomplete(false) ->placeholder(__('Name')) ->live(onBlur: true) ->afterStateUpdated(function (Set $set, Get $get, ?string $state, ?string $old, ?Field $record) { diff --git a/src/Filament/RelationManagers/SchemaRelationManager.php b/src/Filament/RelationManagers/SchemaRelationManager.php new file mode 100644 index 0000000..e070edd --- /dev/null +++ b/src/Filament/RelationManagers/SchemaRelationManager.php @@ -0,0 +1,196 @@ +schema([ + Section::make('Schema') + ->columnSpanFull() + ->columns(2) + ->schema([ + TextInput::make('name') + ->label(__('Name')) + ->autocomplete(false) + ->required() + ->live(onBlur: true) + ->afterStateUpdated(function (Set $set, Get $get, ?string $state, ?string $old, ?Field $record) { + if (! $record || blank($get('slug'))) { + $set('slug', Str::slug($state)); + } + + $currentSlug = $get('slug'); + + if (! $record?->slug && (! $currentSlug || $currentSlug === Str::slug($old))) { + $set('slug', Str::slug($state)); + } + }), + + TextInput::make('slug'), + + Select::make('field_type') + ->searchable() + ->preload() + ->label(__('Schema Type')) + ->live(debounce: 250) + ->reactive() + ->default(SchemaEnum::Section->value) + ->options(function () { + return collect(SchemaEnum::array()) + ->sortBy(fn ($value) => $value) + ->mapWithKeys(fn ($value, $key) => [ + $key => Str::headline($value), + ]) + ->toArray(); + }) + ->required() + ->afterStateUpdated(function ($state, Set $set) { + $set('config', []); + + if (blank($state)) { + return; + } + + $set('config', $this->initializeConfig($state)); + }), + ]), + Section::make('Configuration') + ->columnSpanFull() + ->schema(fn (Get $get) => $this->getFieldTypeFormSchema( + $get('field_type') + )) + ->visible(fn (Get $get) => filled($get('field_type'))), + ]); + } + + public function table(Table $table): Table + { + return $table + ->defaultPaginationPageOption(25) + ->paginationPageOptions([25, 50, 100]) + ->recordTitleAttribute('name') + ->reorderable('position') + ->defaultSort('position', 'asc') + ->columns([ + TextColumn::make('name') + ->label(__('Name')) + ->searchable() + ->limit(), + + TextColumn::make('field_type') + ->label(__('Type')) + ->searchable(), + ]) + ->filters([]) + ->headerActions([ + CreateAction::make() + ->slideOver() + ->mutateDataUsing(function (array $data) { + + $key = $this->ownerRecord->getKeyName(); + + return [ + ...$data, + 'position' => Schema::where('model_key', $key)->get()->max('position') + 1, + 'model_type' => get_class($this->ownerRecord), + 'model_key' => $this->ownerRecord->getKey(), + ]; + }) + ->after(function (Component $livewire) { + $livewire->dispatch('$refresh'); + }), + ]) + ->recordActions([ + EditAction::make() + ->slideOver() + ->mutateRecordDataUsing(function (array $data) { + + $key = $this->ownerRecord->getKeyName(); + + return [ + ...$data, + 'model_type' => 'setting', + 'model_key' => $this->ownerRecord->{$key}, + ]; + }) + ->after(function (Component $livewire) { + $livewire->dispatch('refreshSchemas'); + }), + DeleteAction::make() + ->after(function (Component $livewire, array $data, Model $record, array $arguments) { + if ( + isset($record->valueColumn) && $this->ownerRecord->getConnection() + ->getSchemaBuilder() + ->hasColumn($this->ownerRecord->getTable(), $record->valueColumn) + ) { + + $key = $this->ownerRecord->getKeyName(); + + $this->ownerRecord->update([ + $record->valueColumn => collect($this->ownerRecord->{$record->valueColumn})->forget($record->{$key})->toArray(), + ]); + } + + $livewire->dispatch('refreshSchemas'); + }), + + ]) + ->toolbarActions([ + BulkActionGroup::make([ + BulkAction::make('delete') + ->requiresConfirmation() + ->after(function (Component $livewire) { + $livewire->dispatch('refreshSchemas'); + }), + ])->label('Actions'), + ]); + } + + public static function getTitle(Model $ownerRecord, string $pageClass): string + { + return __('Schemas'); + } + + public static function getModelLabel(): string + { + return __('Schema'); + } + + public static function getPluralModelLabel(): string + { + return __('Schemas'); + } +} diff --git a/src/Models/Schema.php b/src/Models/Schema.php new file mode 100644 index 0000000..f0250d5 --- /dev/null +++ b/src/Models/Schema.php @@ -0,0 +1,73 @@ +|null $config + * @property int $position + * @property string $model_type + * @property string $model_key + * @property string|null $parent_ulid + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * @property-read \Illuminate\Database\Eloquent\Model|null $model + * @property-read \Illuminate\Database\Eloquent\Collection $fields + * @property-read \Illuminate\Database\Eloquent\Model|null $parent + * @property-read \Illuminate\Database\Eloquent\Collection $children + */ +class Schema extends Model +{ + use CanMapDynamicFields; + use HasConfigurableFields; + use HasFieldTypeResolver; + use HasPackageFactory; + use HasRecursiveRelationships; + use HasUlids; + + protected $primaryKey = 'ulid'; + + protected $guarded = []; + + protected function casts(): array + { + return [ + 'config' => 'array', + ]; + } + + public function model(): MorphTo + { + return $this->morphTo('model'); + } + + public function fields(): HasMany + { + return $this->hasMany(Field::class, 'schema_id'); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(Schema::class, 'parent_ulid'); + } + + public function children(): HasMany + { + return $this->hasMany(Schema::class, 'parent_ulid'); + } +} diff --git a/src/Schemas/Base.php b/src/Schemas/Base.php new file mode 100644 index 0000000..120f325 --- /dev/null +++ b/src/Schemas/Base.php @@ -0,0 +1,93 @@ +getBaseFormSchema(); + } + + protected function getBaseFormSchema(): array + { + $schema = [ + Grid::make(3) + ->schema([ + // + ]), + ]; + + return $this->filterExcludedFields($schema); + } + + protected function excludeFromBaseSchema(): array + { + return []; + } + + private function filterExcludedFields(array $schema): array + { + $excluded = $this->excludeFromBaseSchema(); + + if (empty($excluded)) { + return $schema; + } + + return array_filter($schema, function ($field) use ($excluded) { + foreach ($excluded as $excludedField) { + if ($this->fieldContainsConfigKey($field, $excludedField)) { + return false; + } + } + + return true; + }); + } + + private function fieldContainsConfigKey($field, string $configKey): bool + { + $reflection = new \ReflectionObject($field); + $propertiesToCheck = ['name', 'statePath']; + + foreach ($propertiesToCheck as $propertyName) { + if ($reflection->hasProperty($propertyName)) { + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + $value = $property->getValue($field); + + if (str_contains($value, "config.{$configKey}")) { + return true; + } + } + } + + return false; + } + + public static function getDefaultConfig(): array + { + return [ + // + ]; + } + + public static function make(string $name, Schema $schema) + { + // Base implementation - should be overridden by child classes + return null; + } + + protected static function ensureArray($value, string $delimiter = ','): array + { + if (is_array($value)) { + return $value; + } + + return ! empty($value) ? explode($delimiter, $value) : []; + } +} diff --git a/src/Schemas/Grid.php b/src/Schemas/Grid.php new file mode 100644 index 0000000..c1fb6c8 --- /dev/null +++ b/src/Schemas/Grid.php @@ -0,0 +1,123 @@ + 1, + 'responsive' => false, + 'columnsSm' => null, + 'columnsMd' => null, + 'columnsLg' => null, + 'columnsXl' => null, + 'columns2xl' => null, + 'gap' => null, + ]; + } + + public static function make(string $name, Schema $schema): FilamentGrid + { + $columns = $schema->config['columns'] ?? self::getDefaultConfig()['columns']; + + if ($schema->config['responsive'] ?? self::getDefaultConfig()['responsive']) { + $responsiveColumns = []; + + if (isset($schema->config['columnsSm'])) { + $responsiveColumns['sm'] = $schema->config['columnsSm']; + } + if (isset($schema->config['columnsMd'])) { + $responsiveColumns['md'] = $schema->config['columnsMd']; + } + if (isset($schema->config['columnsLg'])) { + $responsiveColumns['lg'] = $schema->config['columnsLg']; + } + if (isset($schema->config['columnsXl'])) { + $responsiveColumns['xl'] = $schema->config['columnsXl']; + } + if (isset($schema->config['columns2xl'])) { + $responsiveColumns['2xl'] = $schema->config['columns2xl']; + } + + if (! empty($responsiveColumns)) { + $responsiveColumns['default'] = $columns; + $columns = $responsiveColumns; + } + } + + $grid = FilamentGrid::make($columns); + + if (isset($schema->config['gap'])) { + $grid->gap($schema->config['gap']); + } + + return $grid; + } + + public function getForm(): array + { + return [ + FilamentGrid::make(2) + ->schema([ + TextInput::make('config.columns') + ->label(__('Columns')) + ->numeric() + ->minValue(1) + ->maxValue(12) + ->default(1) + ->live(onBlur: true), + Toggle::make('config.responsive') + ->label(__('Responsive')) + ->inline(false) + ->live(), + ]), + FilamentGrid::make(2) + ->schema([ + TextInput::make('config.columnsSm') + ->label(__('Columns (SM)')) + ->numeric() + ->minValue(1) + ->maxValue(12) + ->visible(fn (Get $get): bool => $get('config.responsive')), + TextInput::make('config.columnsMd') + ->label(__('Columns (MD)')) + ->numeric() + ->minValue(1) + ->maxValue(12) + ->visible(fn (Get $get): bool => $get('config.responsive')), + TextInput::make('config.columnsLg') + ->label(__('Columns (LG)')) + ->numeric() + ->minValue(1) + ->maxValue(12) + ->visible(fn (Get $get): bool => $get('config.responsive')), + TextInput::make('config.columnsXl') + ->label(__('Columns (XL)')) + ->numeric() + ->minValue(1) + ->maxValue(12) + ->visible(fn (Get $get): bool => $get('config.responsive')), + TextInput::make('config.columns2xl') + ->label(__('Columns (2XL)')) + ->numeric() + ->minValue(1) + ->maxValue(12) + ->visible(fn (Get $get): bool => $get('config.responsive')), + ]), + TextInput::make('config.gap') + ->label(__('Gap')) + ->placeholder('4') + ->helperText(__('Spacing between grid items (e.g., 4, 6, 8)')), + ]; + } +} diff --git a/src/Schemas/Section.php b/src/Schemas/Section.php new file mode 100644 index 0000000..858cf27 --- /dev/null +++ b/src/Schemas/Section.php @@ -0,0 +1,76 @@ + null, + 'description' => null, + 'icon' => null, + 'collapsible' => false, + 'collapsed' => false, + 'compact' => false, + 'aside' => false, + ]; + } + + public static function make(string $name, Schema $schema): FilamentSection + { + $section = FilamentSection::make($schema->name ?? self::getDefaultConfig()['heading']) + ->description($schema->config['description'] ?? self::getDefaultConfig()['description']) + ->icon($schema->config['icon'] ?? self::getDefaultConfig()['icon']) + ->collapsible($schema->config['collapsible'] ?? self::getDefaultConfig()['collapsible']) + ->collapsed($schema->config['collapsed'] ?? self::getDefaultConfig()['collapsed']) + ->compact($schema->config['compact'] ?? self::getDefaultConfig()['compact']) + ->aside($schema->config['aside'] ?? self::getDefaultConfig()['aside']); + + return $section; + } + + public function getForm(): array + { + return [ + Grid::make(2) + ->schema([ + TextInput::make('config.heading') + ->label(__('Heading')) + ->live(onBlur: true), + TextInput::make('config.description') + ->label(__('Description')) + ->live(onBlur: true), + TextInput::make('config.icon') + ->label(__('Icon')) + ->placeholder('heroicon-m-') + ->live(onBlur: true), + ]), + Grid::make(2) + ->schema([ + Toggle::make('config.collapsible') + ->label(__('Collapsible')) + ->inline(false), + Toggle::make('config.collapsed') + ->label(__('Collapsed')) + ->inline(false) + ->visible(fn (Get $get): bool => $get('config.collapsible')), + Toggle::make('config.compact') + ->label(__('Compact')) + ->inline(false), + Toggle::make('config.aside') + ->label(__('Aside')) + ->inline(false), + ]), + ]; + } +} From 94e4faac91a0bdb899629566e6603f49fd68a6e9 Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 10 Sep 2025 12:54:52 +0200 Subject: [PATCH 02/24] add selecttree --- .../add_schema_id_to_fields_table.php.stub | 24 +++++++++++ src/FieldsServiceProvider.php | 1 + .../FieldsRelationManager.php | 31 +++++++++++++- .../SchemaRelationManager.php | 42 ++++++++++++++++++- src/Models/Field.php | 7 ++++ 5 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 database/migrations/add_schema_id_to_fields_table.php.stub diff --git a/database/migrations/add_schema_id_to_fields_table.php.stub b/database/migrations/add_schema_id_to_fields_table.php.stub new file mode 100644 index 0000000..9876761 --- /dev/null +++ b/database/migrations/add_schema_id_to_fields_table.php.stub @@ -0,0 +1,24 @@ +ulid('schema_id')->nullable()->after('group'); + $table->foreign('schema_id')->references('ulid')->on('schemas')->onDelete('set null'); + }); + } + + public function down() + { + Schema::table('fields', function (Blueprint $table) { + $table->dropForeign(['schema_id']); + $table->dropColumn('schema_id'); + }); + } +}; diff --git a/src/FieldsServiceProvider.php b/src/FieldsServiceProvider.php index 79cf093..06eac03 100644 --- a/src/FieldsServiceProvider.php +++ b/src/FieldsServiceProvider.php @@ -154,6 +154,7 @@ protected function getMigrations(): array 'change_unique_column_in_fields', 'add_group_column_to_fields_table', 'create_schemas_table', + 'add_schema_id_to_fields_table', ]; } } diff --git a/src/Filament/RelationManagers/FieldsRelationManager.php b/src/Filament/RelationManagers/FieldsRelationManager.php index 8fd687d..7492bb0 100644 --- a/src/Filament/RelationManagers/FieldsRelationManager.php +++ b/src/Filament/RelationManagers/FieldsRelationManager.php @@ -7,6 +7,7 @@ use Backstage\Fields\Enums\Field as FieldEnum; use Backstage\Fields\Facades\Fields; use Backstage\Fields\Models\Field; +use CodeWithDennis\FilamentSelectTree\SelectTree; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; @@ -113,6 +114,26 @@ public function form(Schema $schema): Schema ->toArray(); }), + SelectTree::make('schema_id') + ->label(__('Attach to Schema')) + ->placeholder(__('Select a schema (optional)')) + ->relationship( + relationship: 'schema', + titleAttribute: 'name', + parentAttribute: 'parent_ulid', + modifyQueryUsing: function ($query) { + $key = $this->ownerRecord->getKeyName(); + + return $query->where('model_key', $this->ownerRecord->{$key}) + ->where('model_type', get_class($this->ownerRecord)) + ->orderBy('position'); + } + ) + ->enableBranchNode() + ->multiple(false) + ->searchable() + ->helperText(__('Attach this field to a specific schema for better organization')), + ]), Section::make('Configuration') ->columnSpanFull() @@ -141,6 +162,12 @@ public function table(Table $table): Table TextColumn::make('field_type') ->label(__('Type')) ->searchable(), + + TextColumn::make('schema.name') + ->label(__('Schema')) + ->placeholder(__('No schema')) + ->searchable() + ->sortable(), ]) ->filters([]) ->headerActions([ @@ -153,7 +180,7 @@ public function table(Table $table): Table return [ ...$data, 'position' => Field::where('model_key', $key)->get()->max('position') + 1, - 'model_type' => 'setting', + 'model_type' => get_class($this->ownerRecord), 'model_key' => $this->ownerRecord->getKey(), ]; }) @@ -170,7 +197,7 @@ public function table(Table $table): Table return [ ...$data, - 'model_type' => 'setting', + 'model_type' => get_class($this->ownerRecord), 'model_key' => $this->ownerRecord->{$key}, ]; }) diff --git a/src/Filament/RelationManagers/SchemaRelationManager.php b/src/Filament/RelationManagers/SchemaRelationManager.php index e070edd..7a6830c 100644 --- a/src/Filament/RelationManagers/SchemaRelationManager.php +++ b/src/Filament/RelationManagers/SchemaRelationManager.php @@ -6,7 +6,8 @@ use Backstage\Fields\Concerns\HasFieldTypeResolver; use Backstage\Fields\Enums\Schema as SchemaEnum; use Backstage\Fields\Models\Field; -use Backstage\Fields\Models\Schema; +use Backstage\Fields\Models\Schema as SchemaModel; +use CodeWithDennis\FilamentSelectTree\SelectTree; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; @@ -61,6 +62,26 @@ public function form(FilamentSchema $schema): FilamentSchema TextInput::make('slug'), + SelectTree::make('parent_ulid') + ->label(__('Parent Schema')) + ->placeholder(__('Select a parent schema (optional)')) + ->relationship( + relationship: 'parent', + titleAttribute: 'name', + parentAttribute: 'parent_ulid', + modifyQueryUsing: function ($query) { + $key = $this->ownerRecord->getKeyName(); + + return $query->where('model_key', $this->ownerRecord->{$key}) + ->where('model_type', get_class($this->ownerRecord)) + ->orderBy('position'); + } + ) + ->enableBranchNode() + ->multiple(false) + ->searchable() + ->helperText(__('Attach this schema to a parent schema for nested layouts')), + Select::make('field_type') ->searchable() ->preload() @@ -113,6 +134,12 @@ public function table(Table $table): Table TextColumn::make('field_type') ->label(__('Type')) ->searchable(), + + TextColumn::make('parent.name') + ->label(__('Parent Schema')) + ->placeholder(__('Root level')) + ->searchable() + ->sortable(), ]) ->filters([]) ->headerActions([ @@ -121,10 +148,21 @@ public function table(Table $table): Table ->mutateDataUsing(function (array $data) { $key = $this->ownerRecord->getKeyName(); + $parentUlid = $data['parent_ulid'] ?? null; + + // Calculate position based on parent + $positionQuery = SchemaModel::where('model_key', $key) + ->where('model_type', get_class($this->ownerRecord)); + + if ($parentUlid) { + $positionQuery->where('parent_ulid', $parentUlid); + } else { + $positionQuery->whereNull('parent_ulid'); + } return [ ...$data, - 'position' => Schema::where('model_key', $key)->get()->max('position') + 1, + 'position' => $positionQuery->get()->max('position') + 1, 'model_type' => get_class($this->ownerRecord), 'model_key' => $this->ownerRecord->getKey(), ]; diff --git a/src/Models/Field.php b/src/Models/Field.php index 92bf3d0..4210e2c 100644 --- a/src/Models/Field.php +++ b/src/Models/Field.php @@ -22,10 +22,12 @@ * @property array|null $config * @property int $position * @property string|null $group + * @property string|null $schema_id * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at * @property-read \Illuminate\Database\Eloquent\Model|null $model * @property-read \Illuminate\Database\Eloquent\Collection $children + * @property-read \Backstage\Fields\Models\Schema|null $schema * @property-read \Illuminate\Database\Eloquent\Model|null $tenant */ class Field extends Model @@ -55,6 +57,11 @@ public function children(): HasMany return $this->hasMany(Field::class, 'parent_ulid')->with('children')->orderBy('position'); } + public function schema(): BelongsTo + { + return $this->belongsTo(Schema::class, 'schema_id', 'ulid'); + } + public function tenant(): ?BelongsTo { $tenantRelationship = Config::get('fields.tenancy.relationship'); From a65a64ff23b133e7c00f164f3e80815526b28183 Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 10 Sep 2025 13:39:47 +0200 Subject: [PATCH 03/24] add grouping --- .../FieldsRelationManager.php | 37 ++++++++++++++++--- .../SchemaRelationManager.php | 1 + 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/Filament/RelationManagers/FieldsRelationManager.php b/src/Filament/RelationManagers/FieldsRelationManager.php index 7492bb0..8b53e9d 100644 --- a/src/Filament/RelationManagers/FieldsRelationManager.php +++ b/src/Filament/RelationManagers/FieldsRelationManager.php @@ -33,6 +33,7 @@ class FieldsRelationManager extends RelationManager use HasFieldTypeResolver; protected static string $relationship = 'fields'; + public function form(Schema $schema): Schema { @@ -124,9 +125,9 @@ public function form(Schema $schema): Schema modifyQueryUsing: function ($query) { $key = $this->ownerRecord->getKeyName(); - return $query->where('model_key', $this->ownerRecord->{$key}) - ->where('model_type', get_class($this->ownerRecord)) - ->orderBy('position'); + return $query->where('schemas.model_key', $this->ownerRecord->{$key}) + ->where('schemas.model_type', get_class($this->ownerRecord)) + ->orderBy('schemas.position'); } ) ->enableBranchNode() @@ -153,12 +154,20 @@ public function table(Table $table): Table ->recordTitleAttribute('name') ->reorderable('position') ->defaultSort('position', 'asc') + ->defaultGroup('schema.slug') ->columns([ TextColumn::make('name') ->label(__('Name')) ->searchable() ->limit(), + TextColumn::make('group') + ->label(__('Group')) + ->placeholder(__('No Group')) + ->searchable() + ->sortable() + ->getStateUsing(fn (Field $record): string => $record->group ?? __('No Group')), + TextColumn::make('field_type') ->label(__('Type')) ->searchable(), @@ -167,9 +176,27 @@ public function table(Table $table): Table ->label(__('Schema')) ->placeholder(__('No schema')) ->searchable() - ->sortable(), + ->sortable() + ->getStateUsing(fn (Field $record): string => $record->schema?->name ?? __('No Schema')), + ]) + ->filters([ + \Filament\Tables\Filters\SelectFilter::make('group') + ->label(__('Group')) + ->options(function () { + return Field::where('model_type', get_class($this->ownerRecord)) + ->where('model_key', $this->ownerRecord->getKey()) + ->pluck('group') + ->filter() + ->unique() + ->mapWithKeys(fn ($group) => [$group => $group]) + ->prepend(__('No Group'), '') + ->toArray(); + }), + \Filament\Tables\Filters\SelectFilter::make('schema_id') + ->label(__('Schema')) + ->relationship('schema', 'name') + ->placeholder(__('All Schemas')), ]) - ->filters([]) ->headerActions([ CreateAction::make() ->slideOver() diff --git a/src/Filament/RelationManagers/SchemaRelationManager.php b/src/Filament/RelationManagers/SchemaRelationManager.php index 7a6830c..315740a 100644 --- a/src/Filament/RelationManagers/SchemaRelationManager.php +++ b/src/Filament/RelationManagers/SchemaRelationManager.php @@ -125,6 +125,7 @@ public function table(Table $table): Table ->recordTitleAttribute('name') ->reorderable('position') ->defaultSort('position', 'asc') + ->defaultGroup('parent.name') ->columns([ TextColumn::make('name') ->label(__('Name')) From e6b745914b23cb600e967b4d0651e11373f96e99 Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Wed, 10 Sep 2025 11:40:13 +0000 Subject: [PATCH 04/24] Fix styling --- src/Filament/RelationManagers/FieldsRelationManager.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Filament/RelationManagers/FieldsRelationManager.php b/src/Filament/RelationManagers/FieldsRelationManager.php index 8b53e9d..7af97c0 100644 --- a/src/Filament/RelationManagers/FieldsRelationManager.php +++ b/src/Filament/RelationManagers/FieldsRelationManager.php @@ -33,7 +33,6 @@ class FieldsRelationManager extends RelationManager use HasFieldTypeResolver; protected static string $relationship = 'fields'; - public function form(Schema $schema): Schema { From fb0f22409aacdd6888841f81fa582cc84fe40e10 Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 10 Sep 2025 13:51:41 +0200 Subject: [PATCH 05/24] add fieldset schema --- src/Enums/Schema.php | 1 + src/Schemas/Fieldset.php | 65 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 src/Schemas/Fieldset.php diff --git a/src/Enums/Schema.php b/src/Enums/Schema.php index e4def4e..6a7e005 100644 --- a/src/Enums/Schema.php +++ b/src/Enums/Schema.php @@ -10,4 +10,5 @@ enum Schema: string case Section = 'section'; case Grid = 'grid'; + case Fieldset = 'fieldset'; } diff --git a/src/Schemas/Fieldset.php b/src/Schemas/Fieldset.php new file mode 100644 index 0000000..289d645 --- /dev/null +++ b/src/Schemas/Fieldset.php @@ -0,0 +1,65 @@ + null, + 'columns' => 1, + 'collapsible' => false, + 'collapsed' => false, + ]; + } + + public static function make(string $name, Schema $schema): FilamentFieldset + { + $fieldset = FilamentFieldset::make($schema->config['label'] ?? self::getDefaultConfig()['label']) + ->columns($schema->config['columns'] ?? self::getDefaultConfig()['columns']) + ->collapsible($schema->config['collapsible'] ?? self::getDefaultConfig()['collapsible']) + ->collapsed($schema->config['collapsed'] ?? self::getDefaultConfig()['collapsed']); + + return $fieldset; + } + + public function getForm(): array + { + return [ + Grid::make(2) + ->schema([ + TextInput::make('config.label') + ->label(__('Label')) + ->live(onBlur: true), + TextInput::make('config.columns') + ->label(__('Columns')) + ->numeric() + ->minValue(1) + ->maxValue(12) + ->default(1) + ->live(onBlur: true), + ]), + Grid::make(2) + ->schema([ + Toggle::make('config.collapsible') + ->label(__('Collapsible')) + ->inline(false) + ->live(), + Toggle::make('config.collapsed') + ->label(__('Collapsed')) + ->inline(false) + ->visible(fn (Get $get): bool => $get('config.collapsible')), + ]), + ]; + } +} From 359e53fa749dee136a1e07929a1c4504d5106b02 Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Wed, 10 Sep 2025 11:52:08 +0000 Subject: [PATCH 06/24] Fix styling --- src/Schemas/Fieldset.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Schemas/Fieldset.php b/src/Schemas/Fieldset.php index 289d645..f699163 100644 --- a/src/Schemas/Fieldset.php +++ b/src/Schemas/Fieldset.php @@ -4,9 +4,9 @@ use Backstage\Fields\Contracts\SchemaContract; use Backstage\Fields\Models\Schema; +use Filament\Forms\Components\Fieldset as FilamentFieldset; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; -use Filament\Forms\Components\Fieldset as FilamentFieldset; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Utilities\Get; From 2ebe9751cd3433abf0f29cf6a4ccae404d2dcfe0 Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 10 Sep 2025 15:14:24 +0200 Subject: [PATCH 07/24] use get key name --- src/Concerns/HasSelectableValues.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Concerns/HasSelectableValues.php b/src/Concerns/HasSelectableValues.php index 8aa070e..5feb732 100644 --- a/src/Concerns/HasSelectableValues.php +++ b/src/Concerns/HasSelectableValues.php @@ -132,7 +132,9 @@ protected static function buildRelationshipOptions(mixed $field): array continue; } - $opts = $results->pluck($relation['relationValue'] ?? 'name', $relation['relationKey'])->toArray(); + // Use the model's primary key instead of the configured relationKey for better compatibility + $primaryKey = $model->getKeyName(); + $opts = $results->pluck($relation['relationValue'] ?? 'name', $primaryKey)->toArray(); if (count($opts) === 0) { continue; @@ -286,7 +288,7 @@ protected function selectableValuesFormFields(string $type, string $label, strin ->visible(fn (Get $get): bool => ! empty($get('resource'))) ->required(fn (Get $get): bool => ! empty($get('resource'))), Hidden::make('relationKey') - ->default('ulid') + ->default('id') ->label(__('Key')) ->required( fn (Get $get): bool => is_array($get("../../config.{$type}")) && in_array('relationship', $get("../../config.{$type}")) || From 5af1ebf350e2d272dc8a40a3fa9120bcaebe8064 Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 10 Sep 2025 15:26:23 +0200 Subject: [PATCH 08/24] new trait to render schemas with fields --- src/Concerns/CanMapSchemasWithFields.php | 102 +++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/Concerns/CanMapSchemasWithFields.php diff --git a/src/Concerns/CanMapSchemasWithFields.php b/src/Concerns/CanMapSchemasWithFields.php new file mode 100644 index 0000000..883dfe7 --- /dev/null +++ b/src/Concerns/CanMapSchemasWithFields.php @@ -0,0 +1,102 @@ +record->fields; + + foreach ($this->record->schemas as $schema) { + $schemaFields = Field::where('schema_id', $schema->ulid)->get(); + $allFields = $allFields->merge($schemaFields); + } + + $this->record->setRelation('fields', $allFields); + } + + protected function loadDefaultValuesIntoRecord(): void + { + $defaultValues = []; + $allFields = $this->record->fields; + + foreach ($allFields as $field) { + $defaultValue = $field->config['defaultValue'] ?? null; + + if ($field->field_type === 'select' && $defaultValue === null) { + continue; + } + + $defaultValues[$field->ulid] = $defaultValue; + } + + $this->record->setAttribute('values', $defaultValues); + } + + protected function getFieldsFromSchema(Schema $schema): \Illuminate\Support\Collection + { + $fields = collect(); + $schemaFields = Field::where('schema_id', $schema->ulid)->get(); + $fields = $fields->merge($schemaFields); + + $childSchemas = $schema->children()->get(); + foreach ($childSchemas as $childSchema) { + $fields = $fields->merge($this->getFieldsFromSchema($childSchema)); + } + + return $fields; + } + + protected function getAllSchemaFields(): \Illuminate\Support\Collection + { + $allFields = collect(); + $rootSchemas = $this->record->schemas() + ->whereNull('parent_ulid') + ->orderBy('position') + ->get(); + + foreach ($rootSchemas as $schema) { + $allFields = $allFields->merge($this->getFieldsFromSchema($schema)); + } + + return $allFields; + } + + protected function initializeFormData(): void + { + $this->loadAllFieldsIntoRecord(); + $this->loadDefaultValuesIntoRecord(); + $this->data = $this->mutateBeforeFill($this->data); + } + + protected function mutateBeforeFill(array $data): array + { + if (! $this->hasValidRecordWithFields()) { + return $data; + } + + $builderBlocks = $this->extractBuilderBlocksFromRecord(); + $allFields = $this->getAllFieldsIncludingBuilderFields($builderBlocks); + + if (! isset($data[$this->record->valueColumn])) { + $data[$this->record->valueColumn] = []; + } + + return $this->mutateFormData($data, $allFields, function ($field, $fieldConfig, $fieldInstance, $data) use ($builderBlocks) { + if ($field->field_type === 'select') { + if (isset($this->record->values[$field->ulid])) { + $data[$this->record->valueColumn][$field->ulid] = $this->record->values[$field->ulid]; + } + return $data; + } + + return $this->applyFieldFillMutation($field, $fieldConfig, $fieldInstance, $data, $builderBlocks); + }); + } +} From 0b0bc4156615b62b629365c5e116da4a0c9831b2 Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Wed, 10 Sep 2025 14:30:59 +0000 Subject: [PATCH 09/24] Fix styling --- src/Concerns/CanMapSchemasWithFields.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Concerns/CanMapSchemasWithFields.php b/src/Concerns/CanMapSchemasWithFields.php index 883dfe7..1b65752 100644 --- a/src/Concerns/CanMapSchemasWithFields.php +++ b/src/Concerns/CanMapSchemasWithFields.php @@ -93,6 +93,7 @@ protected function mutateBeforeFill(array $data): array if (isset($this->record->values[$field->ulid])) { $data[$this->record->valueColumn][$field->ulid] = $this->record->values[$field->ulid]; } + return $data; } From 66257c7150d10dc9bd6c08dadf454ca21aa90a3d Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 11 Sep 2025 13:25:14 +0200 Subject: [PATCH 10/24] feat: table columns in repeater --- src/Fields/Repeater.php | 96 +++++++++++++++++++++++++++++++++++------ 1 file changed, 83 insertions(+), 13 deletions(-) diff --git a/src/Fields/Repeater.php b/src/Fields/Repeater.php index 996052c..549ed8c 100644 --- a/src/Fields/Repeater.php +++ b/src/Fields/Repeater.php @@ -2,26 +2,29 @@ namespace Backstage\Fields\Fields; -use Backstage\Fields\Concerns\HasConfigurableFields; -use Backstage\Fields\Concerns\HasFieldTypeResolver; -use Backstage\Fields\Concerns\HasOptions; -use Backstage\Fields\Contracts\FieldContract; -use Backstage\Fields\Enums\Field as FieldEnum; -use Backstage\Fields\Facades\Fields; -use Backstage\Fields\Models\Field; use Filament\Forms; +use Illuminate\Support\Str; +use Forms\Components\Placeholder; +use Backstage\Fields\Models\Field; +use Illuminate\Support\Collection; +use Backstage\Fields\Facades\Fields; use Filament\Forms\Components\Hidden; -use Filament\Forms\Components\Repeater as Input; use Filament\Forms\Components\Select; -use Filament\Forms\Components\TextInput; use Filament\Schemas\Components\Grid; -use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Tabs; +use Filament\Support\Enums\Alignment; +use Filament\Forms\Components\TextInput; +use Filament\Schemas\Components\Section; +use Backstage\Fields\Concerns\HasOptions; use Filament\Schemas\Components\Tabs\Tab; +use Backstage\Fields\Contracts\FieldContract; +use Backstage\Fields\Enums\Field as FieldEnum; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; -use Illuminate\Support\Collection; -use Illuminate\Support\Str; +use Filament\Forms\Components\Repeater as Input; +use Backstage\Fields\Concerns\HasFieldTypeResolver; +use Filament\Forms\Components\Repeater\TableColumn; +use Backstage\Fields\Concerns\HasConfigurableFields; use Saade\FilamentAdjacencyList\Forms\Components\AdjacencyList; class Repeater extends Base implements FieldContract @@ -44,6 +47,8 @@ public static function getDefaultConfig(): array 'cloneable' => false, 'columns' => 1, 'form' => [], + 'tableMode' => false, + 'tableColumns' => [], ]; } @@ -70,6 +75,14 @@ public static function make(string $name, ?Field $field = null): Input if ($field && $field->children->count() > 0) { $input = $input->schema(self::generateSchemaFromChildren($field->children)); + + // Apply table mode if enabled + if ($field->config['tableMode'] ?? self::getDefaultConfig()['tableMode']) { + $tableColumns = self::generateTableColumnsFromChildren($field->children, $field->config['tableColumns'] ?? []); + if (!empty($tableColumns)) { + $input = $input->table($tableColumns); + } + } } return $input; @@ -113,12 +126,17 @@ public function getForm(): array Forms\Components\Toggle::make('config.cloneable') ->label(__('Cloneable')) ->inline(false), + Forms\Components\Toggle::make('config.tableMode') + ->label(__('Table Mode')) + ->live() + ->inline(false), TextInput::make('config.addActionLabel') ->label(__('Add action label')), TextInput::make('config.columns') ->label(__('Columns')) ->default(1) - ->numeric(), + ->numeric() + ->visible(fn (Get $get): bool => !($get('config.tableMode') ?? false)), AdjacencyList::make('config.form') ->columnSpanFull() ->label(__('Fields')) @@ -192,6 +210,11 @@ function () { )) ->visible(fn (Get $get) => filled($get('field_type'))), ]), + Placeholder::make('table_mode_info') + ->label(__('Table Mode Information')) + ->content(__('When table mode is enabled, the repeater will display its fields in a table format. The table columns will be automatically generated from the child fields.')) + ->visible(fn (Get $get): bool => $get('config.tableMode') === true) + ->columnSpanFull(), ])->columns(2), ])->columnSpanFull(), ]; @@ -222,4 +245,51 @@ private static function generateSchemaFromChildren(Collection $children): array return $schema; } + + private static function generateTableColumnsFromChildren(Collection $children, array $tableColumnsConfig = []): array + { + $tableColumns = []; + + $children = $children->sortBy('position'); + + foreach ($children as $child) { + $slug = $child['slug']; + $name = $child['name']; + + $columnConfig = $tableColumnsConfig[$slug] ?? []; + + $tableColumn = TableColumn::make($name); + + // Apply custom configuration if provided + if (isset($columnConfig['hiddenHeaderLabel']) && $columnConfig['hiddenHeaderLabel']) { + $tableColumn = $tableColumn->hiddenHeaderLabel(); + } + + if (isset($columnConfig['markAsRequired']) && $columnConfig['markAsRequired']) { + $tableColumn = $tableColumn->markAsRequired(); + } + + if (isset($columnConfig['wrapHeader']) && $columnConfig['wrapHeader']) { + $tableColumn = $tableColumn->wrapHeader(); + } + + if (isset($columnConfig['alignment'])) { + $alignment = match($columnConfig['alignment']) { + 'start' => Alignment::Start, + 'center' => Alignment::Center, + 'end' => Alignment::End, + default => Alignment::Start, + }; + $tableColumn = $tableColumn->alignment($alignment); + } + + if (isset($columnConfig['width'])) { + $tableColumn = $tableColumn->width($columnConfig['width']); + } + + $tableColumns[] = $tableColumn; + } + + return $tableColumns; + } } From 852de44ea8571bc933ed6845347915bc4551a086 Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 18 Sep 2025 14:31:51 +0200 Subject: [PATCH 11/24] wip (needs to be tested thoroughly, also in Backstage) --- src/Concerns/CanMapDynamicFields.php | 19 +++-- src/Concerns/HasSelectableValues.php | 7 +- src/Fields/Select.php | 104 ++++++++++++++++++++++++--- 3 files changed, 116 insertions(+), 14 deletions(-) diff --git a/src/Concerns/CanMapDynamicFields.php b/src/Concerns/CanMapDynamicFields.php index 6c77f20..21663cb 100644 --- a/src/Concerns/CanMapDynamicFields.php +++ b/src/Concerns/CanMapDynamicFields.php @@ -404,7 +404,7 @@ private function resolveFormFields(mixed $record = null, bool $isNested = false) private function resolveCustomFields(): Collection { return collect(Fields::getFields()) - ->map(fn ($fieldClass) => new $fieldClass); + ->mapWithKeys(fn ($fieldClass, $key) => [$key => $fieldClass]); } /** @@ -425,14 +425,21 @@ private function resolveFieldInput(Model $field, Collection $customFields, mixed $inputName = $this->generateInputName($field, $record, $isNested); + // Try to resolve from custom fields first (giving them priority) - if ($customField = $customFields->get($field->field_type)) { - return $customField::make($inputName, $field); + if ($customFieldClass = $customFields->get($field->field_type)) { + $input = $customFieldClass::make($inputName, $field); + + + return $input; } // Fall back to standard field type map if no custom field found if ($fieldClass = self::FIELD_TYPE_MAP[$field->field_type] ?? null) { - return $fieldClass::make(name: $inputName, field: $field); + $input = $fieldClass::make(name: $inputName, field: $field); + + + return $input; } return null; @@ -440,7 +447,9 @@ private function resolveFieldInput(Model $field, Collection $customFields, mixed private function generateInputName(Model $field, mixed $record, bool $isNested): string { - return $isNested ? "{$field->ulid}" : "{$record->valueColumn}.{$field->ulid}"; + $name = $isNested ? "{$field->ulid}" : "{$record->valueColumn}.{$field->ulid}"; + + return $name; } /** diff --git a/src/Concerns/HasSelectableValues.php b/src/Concerns/HasSelectableValues.php index 5feb732..b553b24 100644 --- a/src/Concerns/HasSelectableValues.php +++ b/src/Concerns/HasSelectableValues.php @@ -19,6 +19,7 @@ trait HasSelectableValues protected static function resolveResourceModel(string $tableName): ?object { $resources = config('backstage.fields.selectable_resources'); + $resourceClass = collect($resources)->first(function ($resource) use ($tableName) { $res = new $resource; $model = $res->getModel(); @@ -100,6 +101,7 @@ protected static function buildRelationshipOptions(mixed $field): array { $relationshipOptions = []; + foreach ($field->config['relations'] ?? [] as $relation) { if (! isset($relation['resource'])) { continue; @@ -116,7 +118,8 @@ protected static function buildRelationshipOptions(mixed $field): array // Apply filters if they exist if (isset($relation['relationValue_filters'])) { foreach ($relation['relationValue_filters'] as $filter) { - if (isset($filter['column'], $filter['operator'], $filter['value'])) { + if (isset($filter['column'], $filter['operator'], $filter['value']) && + !empty($filter['column']) && !empty($filter['operator']) && $filter['value'] !== null) { if (preg_match('/{session\.([^\}]+)}/', $filter['value'], $matches)) { $sessionValue = session($matches[1]); $filter['value'] = str_replace($matches[0], $sessionValue, $filter['value']); @@ -128,6 +131,7 @@ protected static function buildRelationshipOptions(mixed $field): array $results = $query->get(); + if ($results->isEmpty()) { continue; } @@ -136,6 +140,7 @@ protected static function buildRelationshipOptions(mixed $field): array $primaryKey = $model->getKeyName(); $opts = $results->pluck($relation['relationValue'] ?? 'name', $primaryKey)->toArray(); + if (count($opts) === 0) { continue; } diff --git a/src/Fields/Select.php b/src/Fields/Select.php index db475cc..5b6f8a0 100644 --- a/src/Fields/Select.php +++ b/src/Fields/Select.php @@ -39,6 +39,7 @@ public static function getDefaultConfig(): array 'optionsLimit' => null, 'minItemsForSearch' => null, 'maxItemsForSearch' => null, + 'dependsOnField' => null, // Simple field dependency ]; } @@ -47,7 +48,6 @@ public static function make(string $name, ?Field $field = null): Input $input = self::applyDefaultSettings(Input::make($name), $field); $input = $input->label($field->name ?? null) - ->options($field->config['options'] ?? self::getDefaultConfig()['options']) ->searchable($field->config['searchable'] ?? self::getDefaultConfig()['searchable']) ->multiple($field->config['multiple'] ?? self::getDefaultConfig()['multiple']) ->preload($field->config['preload'] ?? self::getDefaultConfig()['preload']) @@ -56,10 +56,25 @@ public static function make(string $name, ?Field $field = null): Input ->loadingMessage($field->config['loadingMessage'] ?? self::getDefaultConfig()['loadingMessage']) ->noSearchResultsMessage($field->config['noSearchResultsMessage'] ?? self::getDefaultConfig()['noSearchResultsMessage']) ->searchPrompt($field->config['searchPrompt'] ?? self::getDefaultConfig()['searchPrompt']) - ->searchingMessage($field->config['searchingMessage'] ?? self::getDefaultConfig()['searchingMessage']); + ->searchingMessage($field->config['searchingMessage'] ?? self::getDefaultConfig()['searchingMessage']) + ->live() // Add live binding for real-time updates + ->dehydrated() // Ensure the field is included in form submission + ->reactive(); // Ensure the field reacts to state changes - $input = self::addAffixesToInput($input, $field); + // Handle field dependencies + if (isset($field->config['dependsOnField']) && $field->config['dependsOnField']) { + $input = self::addFieldDependency($input, $field); + } + + // Add dynamic options first (from relationships, etc.) $input = self::addOptionsToInput($input, $field); + + // Set static options as fallback if no dynamic options were added + if (empty($field->config['optionType']) || !is_array($field->config['optionType']) || !in_array('relationship', $field->config['optionType'])) { + $input = $input->options($field->config['options'] ?? self::getDefaultConfig()['options']); + } + + $input = self::addAffixesToInput($input, $field); if (isset($field->config['searchDebounce'])) { $input->searchDebounce($field->config['searchDebounce']); @@ -80,13 +95,30 @@ public static function make(string $name, ?Field $field = null): Input return $input; } + protected static function addFieldDependency(Input $input, Field $field): Input + { + $dependsOnField = $field->config['dependsOnField']; + + return $input + ->live() + ->visible(function (Get $get) use ($dependsOnField) { + // The field name in the form is {valueColumn}.{field_ulid} + $dependentFieldName = "values.{$dependsOnField}"; + $dependentValue = $get($dependentFieldName); + + // Show this field only when the dependent field has a value + return !empty($dependentValue); + }); + } + + public static function mutateFormDataCallback(Model $record, Field $field, array $data): array { - if (! property_exists($record, 'valueColumn') || ! isset($record->values[$field->ulid])) { + if (! property_exists($record, 'valueColumn')) { return $data; } - $value = $record->values[$field->ulid]; + $value = $record->values[$field->ulid] ?? null; $data[$record->valueColumn][$field->ulid] = self::normalizeSelectValue($value, $field); return $data; @@ -94,12 +126,12 @@ public static function mutateFormDataCallback(Model $record, Field $field, array public static function mutateBeforeSaveCallback(Model $record, Field $field, array $data): array { - if (! property_exists($record, 'valueColumn') || ! isset($data[$record->valueColumn][$field->ulid])) { + if (! property_exists($record, 'valueColumn') || ! isset($data[$record->valueColumn][(string) $field->ulid])) { return $data; } - $value = $data[$record->valueColumn][$field->ulid]; - $data[$record->valueColumn][$field->ulid] = self::normalizeSelectValue($value, $field); + $value = $data[$record->valueColumn][(string) $field->ulid]; + $data[$record->valueColumn][(string) $field->ulid] = self::normalizeSelectValue($value, $field); return $data; } @@ -210,6 +242,62 @@ public function getForm(): array ->visible(fn (Get $get): bool => $get('config.searchable')), ]), ]), + Tab::make('Field Dependencies') + ->label(__('Field Dependencies')) + ->schema([ + Grid::make(1) + ->schema([ + \Filament\Forms\Components\Select::make('config.dependsOnField') + ->label(__('Depends on Field')) + ->helperText(__('Select another field in this form that this select should depend on. When the dependent field changes, this field will show its options.')) + ->options(function ($record, $component) { + // Try to get the form slug from various sources + $formSlug = null; + + // Method 1: From the record's model_key (most reliable) + if ($record && isset($record->model_key)) { + $formSlug = $record->model_key; + } + + // Method 2: From route parameters as fallback + if (!$formSlug) { + $routeParams = request()->route()?->parameters() ?? []; + $formSlug = $routeParams['record'] ?? $routeParams['form'] ?? $routeParams['id'] ?? null; + } + + // Method 3: Try to get from the component's owner record if available + if (!$formSlug && method_exists($component, 'getOwnerRecord')) { + $ownerRecord = $component->getOwnerRecord(); + if ($ownerRecord) { + $formSlug = $ownerRecord->getKey(); + } + } + + if (!$formSlug) { + return ['debug' => 'No form slug found. Record: ' . ($record ? json_encode($record->toArray()) : 'null')]; + } + + // Get all select fields in the same form + $fields = \Backstage\Fields\Models\Field::where('model_type', 'App\Models\Form') + ->where('model_key', $formSlug) + ->where('field_type', 'select') + ->when($record && isset($record->ulid), function ($query) use ($record) { + return $query->where('ulid', '!=', $record->ulid); + }) + ->orderBy('name') + ->pluck('name', 'ulid') + ->toArray(); + + if (empty($fields)) { + return ['debug' => 'No select fields found for form: ' . $formSlug . '. Total fields: ' . \Backstage\Fields\Models\Field::where('model_type', 'App\Models\Form')->where('model_key', $formSlug)->count()]; + } + + return $fields; + }) + ->searchable() + ->live(), + ]), + ]), ])->columnSpanFull(), ]; } From da63411cab5585f8c9e6f9d99ee268c8f64e3514 Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 18 Sep 2025 14:31:57 +0200 Subject: [PATCH 12/24] test --- tests/SelectCascadingTest.php | 143 ++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 tests/SelectCascadingTest.php diff --git a/tests/SelectCascadingTest.php b/tests/SelectCascadingTest.php new file mode 100644 index 0000000..a5bc58d --- /dev/null +++ b/tests/SelectCascadingTest.php @@ -0,0 +1,143 @@ + 'Test Cascading Select', + 'field_type' => 'select', + 'config' => [ + 'parentField' => 'category_id', + 'parentRelationship' => 'categories', + 'childRelationship' => 'products', + 'parentKey' => 'id', + 'childKey' => 'id', + 'parentValue' => 'name', + 'childValue' => 'name', + ], + ]); + + $input = Select::make('test_field', $field); + + expect($input)->toBeInstanceOf(Input::class); + expect($input->getName())->toBe('test_field'); + expect($input->getLabel())->toBe('Test Cascading Select'); +}); + +it('creates a select field with live reactive options when cascading is configured', function () { + $field = new Field([ + 'name' => 'Test Cascading Select', + 'field_type' => 'select', + 'config' => [ + 'parentField' => 'category_id', + 'parentRelationship' => 'categories', + 'childRelationship' => 'products', + 'parentKey' => 'id', + 'childKey' => 'id', + 'parentValue' => 'name', + 'childValue' => 'name', + ], + ]); + + $input = Select::make('test_field', $field); + + // Check if the field has live() method applied + $reflection = new ReflectionClass($input); + $liveProperty = $reflection->getProperty('isLive'); + $liveProperty->setAccessible(true); + + expect($liveProperty->getValue($input))->toBeTrue(); +}); + +it('creates a regular select field when no cascading is configured', function () { + $field = new Field([ + 'name' => 'Test Regular Select', + 'field_type' => 'select', + 'config' => [ + 'options' => ['option1' => 'Option 1', 'option2' => 'Option 2'], + ], + ]); + + $input = Select::make('test_field', $field); + + // Check if the field has live() method applied + $reflection = new ReflectionClass($input); + $liveProperty = $reflection->getProperty('isLive'); + $liveProperty->setAccessible(true); + + $isLive = $liveProperty->getValue($input); + expect($isLive)->toBeNull(); // Regular select fields don't have isLive set +}); + +it('normalizes select values correctly for single selection', function () { + $field = new Field([ + 'ulid' => 'test_field', + 'config' => ['multiple' => false], + ]); + + $record = new class extends Model { + public $valueColumn = 'values'; + public $values = ['test_field' => 'single_value']; + }; + + $data = ['values' => []]; + $data = Select::mutateFormDataCallback($record, $field, $data); + + expect($data['values']['test_field'])->toBe('single_value'); +}); + +it('normalizes select values correctly for multiple selection', function () { + $field = new Field([ + 'ulid' => 'test_field', + 'config' => ['multiple' => true], + ]); + + $record = new class extends Model { + public $valueColumn = 'values'; + public $values = ['test_field' => '["value1", "value2"]']; + }; + + $data = ['values' => []]; + $data = Select::mutateFormDataCallback($record, $field, $data); + + expect($data['values']['test_field'])->toBe(['value1', 'value2']); +}); + +it('handles null values correctly', function () { + $field = new Field([ + 'ulid' => 'test_field', + 'config' => ['multiple' => false], + ]); + + $record = new class extends Model { + public $valueColumn = 'values'; + public $values = ['test_field' => null]; + }; + + $data = ['values' => []]; + $data = Select::mutateFormDataCallback($record, $field, $data); + + expect($data['values'])->toHaveKey('test_field'); + expect($data['values']['test_field'])->toBeNull(); +}); + +it('handles empty arrays for multiple selection', function () { + $field = new Field([ + 'ulid' => 'test_field', + 'config' => ['multiple' => true], + ]); + + $record = new class extends Model { + public $valueColumn = 'values'; + public $values = ['test_field' => null]; + }; + + $data = ['values' => []]; + $data = Select::mutateFormDataCallback($record, $field, $data); + + expect($data['values'])->toHaveKey('test_field'); + expect($data['values']['test_field'])->toBe([]); +}); From d130fdbf4469b5e85e417ed9e3e17ac57de63a32 Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Fri, 26 Sep 2025 14:41:23 +0000 Subject: [PATCH 13/24] Fix styling --- src/Concerns/CanMapDynamicFields.php | 9 ++--- src/Concerns/HasSelectableValues.php | 8 ++--- src/Fields/Repeater.php | 54 ++++++++++++++-------------- src/Fields/Select.php | 33 +++++++++-------- src/Models/Field.php | 2 -- tests/SelectCascadingTest.php | 20 +++++++---- 6 files changed, 63 insertions(+), 63 deletions(-) diff --git a/src/Concerns/CanMapDynamicFields.php b/src/Concerns/CanMapDynamicFields.php index 21663cb..f98bd84 100644 --- a/src/Concerns/CanMapDynamicFields.php +++ b/src/Concerns/CanMapDynamicFields.php @@ -425,20 +425,17 @@ private function resolveFieldInput(Model $field, Collection $customFields, mixed $inputName = $this->generateInputName($field, $record, $isNested); - // Try to resolve from custom fields first (giving them priority) if ($customFieldClass = $customFields->get($field->field_type)) { $input = $customFieldClass::make($inputName, $field); - - + return $input; } // Fall back to standard field type map if no custom field found if ($fieldClass = self::FIELD_TYPE_MAP[$field->field_type] ?? null) { $input = $fieldClass::make(name: $inputName, field: $field); - - + return $input; } @@ -448,7 +445,7 @@ private function resolveFieldInput(Model $field, Collection $customFields, mixed private function generateInputName(Model $field, mixed $record, bool $isNested): string { $name = $isNested ? "{$field->ulid}" : "{$record->valueColumn}.{$field->ulid}"; - + return $name; } diff --git a/src/Concerns/HasSelectableValues.php b/src/Concerns/HasSelectableValues.php index fff1e44..7988b8f 100644 --- a/src/Concerns/HasSelectableValues.php +++ b/src/Concerns/HasSelectableValues.php @@ -18,7 +18,7 @@ trait HasSelectableValues protected static function resolveResourceModel(string $tableName): ?object { $resources = config('backstage.fields.selectable_resources'); - + $resourceClass = collect($resources)->first(function ($resource) use ($tableName) { $res = new $resource; $model = $res->getModel(); @@ -100,7 +100,6 @@ protected static function buildRelationshipOptions(mixed $field): array { $relationshipOptions = []; - foreach ($field->config['relations'] ?? [] as $relation) { if (! isset($relation['resource'])) { continue; @@ -117,8 +116,8 @@ protected static function buildRelationshipOptions(mixed $field): array // Apply filters if they exist if (isset($relation['relationValue_filters'])) { foreach ($relation['relationValue_filters'] as $filter) { - if (isset($filter['column'], $filter['operator'], $filter['value']) && - !empty($filter['column']) && !empty($filter['operator']) && $filter['value'] !== null) { + if (isset($filter['column'], $filter['operator'], $filter['value']) && + ! empty($filter['column']) && ! empty($filter['operator']) && $filter['value'] !== null) { if (preg_match('/{session\.([^\}]+)}/', $filter['value'], $matches)) { $sessionValue = session($matches[1]); $filter['value'] = str_replace($matches[0], $sessionValue, $filter['value']); @@ -130,7 +129,6 @@ protected static function buildRelationshipOptions(mixed $field): array $results = $query->get(); - if ($results->isEmpty()) { continue; } diff --git a/src/Fields/Repeater.php b/src/Fields/Repeater.php index 6970b19..2fddacf 100644 --- a/src/Fields/Repeater.php +++ b/src/Fields/Repeater.php @@ -2,29 +2,29 @@ namespace Backstage\Fields\Fields; -use Filament\Forms; -use Illuminate\Support\Str; -use Forms\Components\Placeholder; -use Backstage\Fields\Models\Field; -use Illuminate\Support\Collection; +use Backstage\Fields\Concerns\HasConfigurableFields; +use Backstage\Fields\Concerns\HasFieldTypeResolver; +use Backstage\Fields\Concerns\HasOptions; +use Backstage\Fields\Contracts\FieldContract; +use Backstage\Fields\Enums\Field as FieldEnum; use Backstage\Fields\Facades\Fields; +use Backstage\Fields\Models\Field; +use Filament\Forms; use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\Repeater as Input; +use Filament\Forms\Components\Repeater\TableColumn; use Filament\Forms\Components\Select; -use Filament\Schemas\Components\Grid; -use Filament\Schemas\Components\Tabs; -use Filament\Support\Enums\Alignment; use Filament\Forms\Components\TextInput; +use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; -use Backstage\Fields\Concerns\HasOptions; +use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; -use Backstage\Fields\Contracts\FieldContract; -use Backstage\Fields\Enums\Field as FieldEnum; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; -use Filament\Forms\Components\Repeater as Input; -use Backstage\Fields\Concerns\HasFieldTypeResolver; -use Filament\Forms\Components\Repeater\TableColumn; -use Backstage\Fields\Concerns\HasConfigurableFields; +use Filament\Support\Enums\Alignment; +use Forms\Components\Placeholder; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; use Saade\FilamentAdjacencyList\Forms\Components\AdjacencyList; class Repeater extends Base implements FieldContract @@ -80,11 +80,11 @@ public static function make(string $name, ?Field $field = null): Input if ($field && $field->children->count() > 0) { $input = $input->schema(self::generateSchemaFromChildren($field->children)); - + // Apply table mode if enabled if ($field->config['tableMode'] ?? self::getDefaultConfig()['tableMode']) { $tableColumns = self::generateTableColumnsFromChildren($field->children, $field->config['tableColumns'] ?? []); - if (!empty($tableColumns)) { + if (! empty($tableColumns)) { $input = $input->table($tableColumns); } } @@ -141,7 +141,7 @@ public function getForm(): array ->label(__('Columns')) ->default(1) ->numeric() - ->visible(fn (Get $get): bool => !($get('config.tableMode') ?? false)), + ->visible(fn (Get $get): bool => ! ($get('config.tableMode') ?? false)), AdjacencyList::make('config.form') ->columnSpanFull() ->label(__('Fields')) @@ -260,26 +260,26 @@ private static function generateTableColumnsFromChildren(Collection $children, a foreach ($children as $child) { $slug = $child['slug']; $name = $child['name']; - + $columnConfig = $tableColumnsConfig[$slug] ?? []; - + $tableColumn = TableColumn::make($name); - + // Apply custom configuration if provided if (isset($columnConfig['hiddenHeaderLabel']) && $columnConfig['hiddenHeaderLabel']) { $tableColumn = $tableColumn->hiddenHeaderLabel(); } - + if (isset($columnConfig['markAsRequired']) && $columnConfig['markAsRequired']) { $tableColumn = $tableColumn->markAsRequired(); } - + if (isset($columnConfig['wrapHeader']) && $columnConfig['wrapHeader']) { $tableColumn = $tableColumn->wrapHeader(); } - + if (isset($columnConfig['alignment'])) { - $alignment = match($columnConfig['alignment']) { + $alignment = match ($columnConfig['alignment']) { 'start' => Alignment::Start, 'center' => Alignment::Center, 'end' => Alignment::End, @@ -287,11 +287,11 @@ private static function generateTableColumnsFromChildren(Collection $children, a }; $tableColumn = $tableColumn->alignment($alignment); } - + if (isset($columnConfig['width'])) { $tableColumn = $tableColumn->width($columnConfig['width']); } - + $tableColumns[] = $tableColumn; } diff --git a/src/Fields/Select.php b/src/Fields/Select.php index 0e2edde..70fe7ad 100644 --- a/src/Fields/Select.php +++ b/src/Fields/Select.php @@ -70,12 +70,12 @@ public static function make(string $name, ?Field $field = null): Input if (isset($field->config['dependsOnField']) && $field->config['dependsOnField']) { $input = self::addFieldDependency($input, $field); } - + // Add dynamic options first (from relationships, etc.) $input = self::addOptionsToInput($input, $field); - + // Set static options as fallback if no dynamic options were added - if (empty($field->config['optionType']) || !is_array($field->config['optionType']) || !in_array('relationship', $field->config['optionType'])) { + if (empty($field->config['optionType']) || ! is_array($field->config['optionType']) || ! in_array('relationship', $field->config['optionType'])) { $input = $input->options($field->config['options'] ?? self::getDefaultConfig()['options']); } @@ -110,13 +110,12 @@ protected static function addFieldDependency(Input $input, Field $field): Input // The field name in the form is {valueColumn}.{field_ulid} $dependentFieldName = "values.{$dependsOnField}"; $dependentValue = $get($dependentFieldName); - + // Show this field only when the dependent field has a value - return !empty($dependentValue); + return ! empty($dependentValue); }); } - public static function mutateFormDataCallback(Model $record, Field $field, array $data): array { if (! property_exists($record, 'valueColumn')) { @@ -258,30 +257,30 @@ public function getForm(): array ->options(function ($record, $component) { // Try to get the form slug from various sources $formSlug = null; - + // Method 1: From the record's model_key (most reliable) if ($record && isset($record->model_key)) { $formSlug = $record->model_key; } - + // Method 2: From route parameters as fallback - if (!$formSlug) { + if (! $formSlug) { $routeParams = request()->route()?->parameters() ?? []; $formSlug = $routeParams['record'] ?? $routeParams['form'] ?? $routeParams['id'] ?? null; } - + // Method 3: Try to get from the component's owner record if available - if (!$formSlug && method_exists($component, 'getOwnerRecord')) { + if (! $formSlug && method_exists($component, 'getOwnerRecord')) { $ownerRecord = $component->getOwnerRecord(); if ($ownerRecord) { $formSlug = $ownerRecord->getKey(); } } - - if (!$formSlug) { + + if (! $formSlug) { return ['debug' => 'No form slug found. Record: ' . ($record ? json_encode($record->toArray()) : 'null')]; } - + // Get all select fields in the same form $fields = \Backstage\Fields\Models\Field::where('model_type', 'App\Models\Form') ->where('model_key', $formSlug) @@ -292,17 +291,17 @@ public function getForm(): array ->orderBy('name') ->pluck('name', 'ulid') ->toArray(); - + if (empty($fields)) { return ['debug' => 'No select fields found for form: ' . $formSlug . '. Total fields: ' . \Backstage\Fields\Models\Field::where('model_type', 'App\Models\Form')->where('model_key', $formSlug)->count()]; } - + return $fields; }) ->searchable() ->live(), ]), - ]), + ]), Tab::make('Rules') ->label(__('Rules')) ->schema([ diff --git a/src/Models/Field.php b/src/Models/Field.php index 58267c6..4210e2c 100644 --- a/src/Models/Field.php +++ b/src/Models/Field.php @@ -3,8 +3,6 @@ namespace Backstage\Fields\Models; use Backstage\Fields\Shared\HasPackageFactory; -use Carbon\Carbon; -use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; diff --git a/tests/SelectCascadingTest.php b/tests/SelectCascadingTest.php index a5bc58d..ca0dc29 100644 --- a/tests/SelectCascadingTest.php +++ b/tests/SelectCascadingTest.php @@ -48,7 +48,7 @@ $reflection = new ReflectionClass($input); $liveProperty = $reflection->getProperty('isLive'); $liveProperty->setAccessible(true); - + expect($liveProperty->getValue($input))->toBeTrue(); }); @@ -67,7 +67,7 @@ $reflection = new ReflectionClass($input); $liveProperty = $reflection->getProperty('isLive'); $liveProperty->setAccessible(true); - + $isLive = $liveProperty->getValue($input); expect($isLive)->toBeNull(); // Regular select fields don't have isLive set }); @@ -78,8 +78,10 @@ 'config' => ['multiple' => false], ]); - $record = new class extends Model { + $record = new class extends Model + { public $valueColumn = 'values'; + public $values = ['test_field' => 'single_value']; }; @@ -95,8 +97,10 @@ 'config' => ['multiple' => true], ]); - $record = new class extends Model { + $record = new class extends Model + { public $valueColumn = 'values'; + public $values = ['test_field' => '["value1", "value2"]']; }; @@ -112,8 +116,10 @@ 'config' => ['multiple' => false], ]); - $record = new class extends Model { + $record = new class extends Model + { public $valueColumn = 'values'; + public $values = ['test_field' => null]; }; @@ -130,8 +136,10 @@ 'config' => ['multiple' => true], ]); - $record = new class extends Model { + $record = new class extends Model + { public $valueColumn = 'values'; + public $values = ['test_field' => null]; }; From 6ee4055a27b9fac79518492131a9e139d1dc6c58 Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 1 Oct 2025 12:26:09 +0200 Subject: [PATCH 14/24] wip? --- src/Concerns/CanMapDynamicFields.php | 91 +++++++++++++------ src/Concerns/HasSelectableValues.php | 8 +- src/Fields/Repeater.php | 76 ++++++++-------- .../FieldsRelationManager.php | 10 +- .../SchemaRelationManager.php | 8 +- src/Models/Schema.php | 2 +- src/Schemas/Fieldset.php | 8 +- 7 files changed, 114 insertions(+), 89 deletions(-) diff --git a/src/Concerns/CanMapDynamicFields.php b/src/Concerns/CanMapDynamicFields.php index 21663cb..20b62d7 100644 --- a/src/Concerns/CanMapDynamicFields.php +++ b/src/Concerns/CanMapDynamicFields.php @@ -55,10 +55,11 @@ trait CanMapDynamicFields 'tags' => Tags::class, ]; - #[On('refreshFields', 'refreshSchemas')] - public function refresh(): void + #[On('refreshFields')] + #[On('refreshSchemas')] + public function refreshFields(): void { - // + // Custom refresh logic for fields } /** @@ -117,17 +118,21 @@ protected function mutateBeforeSave(array $data): array private function hasValidRecordWithFields(): bool { - return isset($this->record) && ! $this->record->fields->isEmpty(); + return property_exists($this, 'record') && isset($this->record) && ! $this->record->fields->isEmpty(); } private function hasValidRecord(): bool { - return isset($this->record); + return property_exists($this, 'record') && isset($this->record); } private function extractFormValues(array $data): array { - return isset($data[$this->record?->valueColumn]) ? $data[$this->record?->valueColumn] : []; + if (! property_exists($this, 'record') || ! $this->record) { + return []; + } + + return isset($data[$this->record->valueColumn]) ? $data[$this->record->valueColumn] : []; } /** @@ -159,6 +164,10 @@ private function extractBuilderBlocks(array $values): array */ private function getAllFieldsIncludingBuilderFields(array $builderBlocks): Collection { + if (! property_exists($this, 'record') || ! $this->record) { + return collect(); + } + return $this->record->fields->merge( $this->getFieldsFromBlocks($builderBlocks) ); @@ -183,11 +192,17 @@ private function applyFieldFillMutation(Model $field, array $fieldConfig, object return $this->processBuilderFieldFillMutation($field, $fieldInstance, $data, $fieldLocation['builderData'], $builderBlocks); } - return $fieldInstance->mutateFormDataCallback($this->record, $field, $data); + if (property_exists($this, 'record') && $this->record) { + return $fieldInstance->mutateFormDataCallback($this->record, $field, $data); + } + + return $data; } // Default behavior: copy value from record to form data - $data[$this->record->valueColumn][$field->ulid] = $this->record->values[$field->ulid] ?? null; + if (property_exists($this, 'record') && $this->record) { + $data[$this->record->valueColumn][$field->ulid] = $this->record->values[$field->ulid] ?? null; + } return $data; } @@ -199,7 +214,7 @@ private function applyFieldFillMutation(Model $field, array $fieldConfig, object */ private function extractBuilderBlocksFromRecord(): array { - if (! isset($this->record->values) || ! is_array($this->record->values)) { + if (! property_exists($this, 'record') || ! $this->record || ! isset($this->record->values) || ! is_array($this->record->values)) { return []; } @@ -229,14 +244,20 @@ private function processBuilderFieldFillMutation(Model $field, object $fieldInst $mockRecord = $this->createMockRecordForBuilder($builderData); // Create a temporary data structure for the callback - $tempData = [$this->record->valueColumn => $builderData]; - $tempData = $fieldInstance->mutateFormDataCallback($mockRecord, $field, $tempData); + if (property_exists($this, 'record') && $this->record) { + $tempData = [$this->record->valueColumn => $builderData]; + $tempData = $fieldInstance->mutateFormDataCallback($mockRecord, $field, $tempData); + } else { + $tempData = []; + } // Update the original data structure with the mutated values $this->updateBuilderBlocksWithMutatedData($builderBlocks, $field, $tempData); // Update the main data structure - $data[$this->record->valueColumn] = array_merge($data[$this->record->valueColumn], $builderBlocks); + if (property_exists($this, 'record') && $this->record) { + $data[$this->record->valueColumn] = array_merge($data[$this->record->valueColumn], $builderBlocks); + } return $data; } @@ -249,6 +270,9 @@ private function processBuilderFieldFillMutation(Model $field, object $fieldInst */ private function createMockRecordForBuilder(array $builderData): object { + if (! property_exists($this, 'record') || ! $this->record) { + throw new \RuntimeException('Record property is not available'); + } $mockRecord = clone $this->record; $mockRecord->values = $builderData; @@ -268,7 +292,9 @@ private function updateBuilderBlocksWithMutatedData(array &$builderBlocks, Model if (is_array($builderBlocks)) { foreach ($builderBlocks as &$block) { if (isset($block['data']) && is_array($block['data']) && isset($block['data'][$field->ulid])) { - $block['data'][$field->ulid] = $tempData[$this->record->valueColumn][$field->ulid] ?? $block['data'][$field->ulid]; + if (property_exists($this, 'record') && $this->record) { + $block['data'][$field->ulid] = $tempData[$this->record->valueColumn][$field->ulid] ?? $block['data'][$field->ulid]; + } } } } @@ -386,9 +412,9 @@ private function processNestedFields(Model $field, array $data, callable $mutati */ private function resolveFormFields(mixed $record = null, bool $isNested = false): array { - $record = $record ?? $this->record; + $record = $record ?? (property_exists($this, 'record') ? $this->record : null); - if (! isset($record->fields) || $record->fields->isEmpty()) { + if (! $record || ! isset($record->fields) || $record->fields->isEmpty()) { return []; } @@ -421,24 +447,25 @@ private function resolveCustomFields(): Collection */ private function resolveFieldInput(Model $field, Collection $customFields, mixed $record = null, bool $isNested = false): ?object { - $record = $record ?? $this->record; + $record = $record ?? (property_exists($this, 'record') ? $this->record : null); - $inputName = $this->generateInputName($field, $record, $isNested); + if (! $record) { + return null; + } + $inputName = $this->generateInputName($field, $record, $isNested); // Try to resolve from custom fields first (giving them priority) if ($customFieldClass = $customFields->get($field->field_type)) { $input = $customFieldClass::make($inputName, $field); - - + return $input; } // Fall back to standard field type map if no custom field found if ($fieldClass = self::FIELD_TYPE_MAP[$field->field_type] ?? null) { $input = $fieldClass::make(name: $inputName, field: $field); - - + return $input; } @@ -448,7 +475,7 @@ private function resolveFieldInput(Model $field, Collection $customFields, mixed private function generateInputName(Model $field, mixed $record, bool $isNested): string { $name = $isNested ? "{$field->ulid}" : "{$record->valueColumn}.{$field->ulid}"; - + return $name; } @@ -478,7 +505,11 @@ private function applyFieldSaveMutation(Model $field, array $fieldConfig, object } // Regular field processing - return $fieldInstance->mutateBeforeSaveCallback($this->record, $field, $data); + if (property_exists($this, 'record') && $this->record) { + return $fieldInstance->mutateBeforeSaveCallback($this->record, $field, $data); + } + + return $data; } /** @@ -536,18 +567,22 @@ private function processBuilderFieldMutation(Model $field, object $fieldInstance $mockRecord = $this->createMockRecordForBuilder($block['data']); // Create a temporary data structure for the callback - $tempData = [$this->record->valueColumn => $block['data']]; - $tempData = $fieldInstance->mutateBeforeSaveCallback($mockRecord, $field, $tempData); + if (property_exists($this, 'record') && $this->record) { + $tempData = [$this->record->valueColumn => $block['data']]; + $tempData = $fieldInstance->mutateBeforeSaveCallback($mockRecord, $field, $tempData); - if (isset($tempData[$this->record->valueColumn][$field->ulid])) { - $block['data'][$field->ulid] = $tempData[$this->record->valueColumn][$field->ulid]; + if (isset($tempData[$this->record->valueColumn][$field->ulid])) { + $block['data'][$field->ulid] = $tempData[$this->record->valueColumn][$field->ulid]; + } } } } } } - $data[$this->record->valueColumn] = array_merge($data[$this->record->valueColumn], $builderBlocks); + if (property_exists($this, 'record') && $this->record) { + $data[$this->record->valueColumn] = array_merge($data[$this->record->valueColumn], $builderBlocks); + } return $data; } diff --git a/src/Concerns/HasSelectableValues.php b/src/Concerns/HasSelectableValues.php index fff1e44..cb8a4c5 100644 --- a/src/Concerns/HasSelectableValues.php +++ b/src/Concerns/HasSelectableValues.php @@ -18,7 +18,7 @@ trait HasSelectableValues protected static function resolveResourceModel(string $tableName): ?object { $resources = config('backstage.fields.selectable_resources'); - + $resourceClass = collect($resources)->first(function ($resource) use ($tableName) { $res = new $resource; $model = $res->getModel(); @@ -100,7 +100,6 @@ protected static function buildRelationshipOptions(mixed $field): array { $relationshipOptions = []; - foreach ($field->config['relations'] ?? [] as $relation) { if (! isset($relation['resource'])) { continue; @@ -117,8 +116,8 @@ protected static function buildRelationshipOptions(mixed $field): array // Apply filters if they exist if (isset($relation['relationValue_filters'])) { foreach ($relation['relationValue_filters'] as $filter) { - if (isset($filter['column'], $filter['operator'], $filter['value']) && - !empty($filter['column']) && !empty($filter['operator']) && $filter['value'] !== null) { + if (isset($filter['column'], $filter['operator'], $filter['value']) && + ! empty($filter['column']) && ! empty($filter['operator']) && $filter['value'] !== '') { if (preg_match('/{session\.([^\}]+)}/', $filter['value'], $matches)) { $sessionValue = session($matches[1]); $filter['value'] = str_replace($matches[0], $sessionValue, $filter['value']); @@ -130,7 +129,6 @@ protected static function buildRelationshipOptions(mixed $field): array $results = $query->get(); - if ($results->isEmpty()) { continue; } diff --git a/src/Fields/Repeater.php b/src/Fields/Repeater.php index 6970b19..9d7187c 100644 --- a/src/Fields/Repeater.php +++ b/src/Fields/Repeater.php @@ -2,29 +2,29 @@ namespace Backstage\Fields\Fields; -use Filament\Forms; -use Illuminate\Support\Str; -use Forms\Components\Placeholder; -use Backstage\Fields\Models\Field; -use Illuminate\Support\Collection; +use Backstage\Fields\Concerns\HasConfigurableFields; +use Backstage\Fields\Concerns\HasFieldTypeResolver; +use Backstage\Fields\Concerns\HasOptions; +use Backstage\Fields\Contracts\FieldContract; +use Backstage\Fields\Enums\Field as FieldEnum; use Backstage\Fields\Facades\Fields; +use Backstage\Fields\Models\Field; +use Filament\Forms; use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\Repeater as Input; +use Filament\Forms\Components\Repeater\TableColumn; use Filament\Forms\Components\Select; -use Filament\Schemas\Components\Grid; -use Filament\Schemas\Components\Tabs; -use Filament\Support\Enums\Alignment; use Filament\Forms\Components\TextInput; +use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; -use Backstage\Fields\Concerns\HasOptions; +use Filament\Schemas\Components\Section as InfoSection; +use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; -use Backstage\Fields\Contracts\FieldContract; -use Backstage\Fields\Enums\Field as FieldEnum; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; -use Filament\Forms\Components\Repeater as Input; -use Backstage\Fields\Concerns\HasFieldTypeResolver; -use Filament\Forms\Components\Repeater\TableColumn; -use Backstage\Fields\Concerns\HasConfigurableFields; +use Filament\Support\Enums\Alignment; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; use Saade\FilamentAdjacencyList\Forms\Components\AdjacencyList; class Repeater extends Base implements FieldContract @@ -80,11 +80,11 @@ public static function make(string $name, ?Field $field = null): Input if ($field && $field->children->count() > 0) { $input = $input->schema(self::generateSchemaFromChildren($field->children)); - + // Apply table mode if enabled if ($field->config['tableMode'] ?? self::getDefaultConfig()['tableMode']) { $tableColumns = self::generateTableColumnsFromChildren($field->children, $field->config['tableColumns'] ?? []); - if (!empty($tableColumns)) { + if (! empty($tableColumns)) { $input = $input->table($tableColumns); } } @@ -104,31 +104,31 @@ public function getForm(): array Tab::make('Field specific') ->label(__('Field specific')) ->schema([ - Toggle::make('config.addable') + Forms\Components\Toggle::make('config.addable') ->label(__('Addable')) ->inline(false), - Toggle::make('config.deletable') + Forms\Components\Toggle::make('config.deletable') ->label(__('Deletable')) ->inline(false), Grid::make(2)->schema([ - Toggle::make('config.reorderable') + Forms\Components\Toggle::make('config.reorderable') ->label(__('Reorderable')) ->live() ->inline(false), - Toggle::make('config.reorderableWithButtons') + Forms\Components\Toggle::make('config.reorderableWithButtons') ->label(__('Reorderable with buttons')) ->dehydrated() ->disabled(fn (Get $get): bool => $get('config.reorderable') === false) ->inline(false), ]), - Toggle::make('config.collapsible') + Forms\Components\Toggle::make('config.collapsible') ->label(__('Collapsible')) ->inline(false), - Toggle::make('config.collapsed') + Forms\Components\Toggle::make('config.collapsed') ->label(__('Collapsed')) ->visible(fn (Get $get): bool => $get('config.collapsible') === true) ->inline(false), - Toggle::make('config.cloneable') + Forms\Components\Toggle::make('config.cloneable') ->label(__('Cloneable')) ->inline(false), Forms\Components\Toggle::make('config.tableMode') @@ -141,7 +141,7 @@ public function getForm(): array ->label(__('Columns')) ->default(1) ->numeric() - ->visible(fn (Get $get): bool => !($get('config.tableMode') ?? false)), + ->visible(fn (Get $get): bool => ! ($get('config.tableMode') ?? false)), AdjacencyList::make('config.form') ->columnSpanFull() ->label(__('Fields')) @@ -215,11 +215,11 @@ function () { )) ->visible(fn (Get $get) => filled($get('field_type'))), ]), - Placeholder::make('table_mode_info') - ->label(__('Table Mode Information')) - ->content(__('When table mode is enabled, the repeater will display its fields in a table format. The table columns will be automatically generated from the child fields.')) + InfoSection::make(__('Table Mode Information')) + ->description(__('When table mode is enabled, the repeater will display its fields in a table format. The table columns will be automatically generated from the child fields.')) ->visible(fn (Get $get): bool => $get('config.tableMode') === true) - ->columnSpanFull(), + ->columnSpanFull() + ->schema([]), ])->columns(2), ])->columnSpanFull(), ]; @@ -260,26 +260,26 @@ private static function generateTableColumnsFromChildren(Collection $children, a foreach ($children as $child) { $slug = $child['slug']; $name = $child['name']; - + $columnConfig = $tableColumnsConfig[$slug] ?? []; - + $tableColumn = TableColumn::make($name); - + // Apply custom configuration if provided if (isset($columnConfig['hiddenHeaderLabel']) && $columnConfig['hiddenHeaderLabel']) { $tableColumn = $tableColumn->hiddenHeaderLabel(); } - + if (isset($columnConfig['markAsRequired']) && $columnConfig['markAsRequired']) { $tableColumn = $tableColumn->markAsRequired(); } - + if (isset($columnConfig['wrapHeader']) && $columnConfig['wrapHeader']) { $tableColumn = $tableColumn->wrapHeader(); } - + if (isset($columnConfig['alignment'])) { - $alignment = match($columnConfig['alignment']) { + $alignment = match ($columnConfig['alignment']) { 'start' => Alignment::Start, 'center' => Alignment::Center, 'end' => Alignment::End, @@ -287,11 +287,11 @@ private static function generateTableColumnsFromChildren(Collection $children, a }; $tableColumn = $tableColumn->alignment($alignment); } - + if (isset($columnConfig['width'])) { $tableColumn = $tableColumn->width($columnConfig['width']); } - + $tableColumns[] = $tableColumn; } diff --git a/src/Filament/RelationManagers/FieldsRelationManager.php b/src/Filament/RelationManagers/FieldsRelationManager.php index e9c5617..fd261d9 100644 --- a/src/Filament/RelationManagers/FieldsRelationManager.php +++ b/src/Filament/RelationManagers/FieldsRelationManager.php @@ -7,7 +7,6 @@ use Backstage\Fields\Enums\Field as FieldEnum; use Backstage\Fields\Facades\Fields; use Backstage\Fields\Models\Field; -use CodeWithDennis\FilamentSelectTree\SelectTree; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; @@ -115,13 +114,12 @@ public function form(Schema $schema): Schema ->toArray(); }), - SelectTree::make('schema_id') + Select::make('schema_id') ->label(__('Attach to Schema')) ->placeholder(__('Select a schema (optional)')) ->relationship( - relationship: 'schema', + name: 'schema', titleAttribute: 'name', - parentAttribute: 'parent_ulid', modifyQueryUsing: function ($query) { $key = $this->ownerRecord->getKeyName(); @@ -130,8 +128,6 @@ public function form(Schema $schema): Schema ->orderBy('schemas.position'); } ) - ->enableBranchNode() - ->multiple(false) ->searchable() ->helperText(__('Attach this field to a specific schema for better organization')), @@ -177,7 +173,7 @@ public function table(Table $table): Table ->placeholder(__('No schema')) ->searchable() ->sortable() - ->getStateUsing(fn (Field $record): string => $record->schema?->name ?? __('No Schema')), + ->getStateUsing(fn (Field $record): string => $record->schema->name ?? __('No Schema')), ]) ->filters([ \Filament\Tables\Filters\SelectFilter::make('group') diff --git a/src/Filament/RelationManagers/SchemaRelationManager.php b/src/Filament/RelationManagers/SchemaRelationManager.php index 315740a..9edde10 100644 --- a/src/Filament/RelationManagers/SchemaRelationManager.php +++ b/src/Filament/RelationManagers/SchemaRelationManager.php @@ -7,7 +7,6 @@ use Backstage\Fields\Enums\Schema as SchemaEnum; use Backstage\Fields\Models\Field; use Backstage\Fields\Models\Schema as SchemaModel; -use CodeWithDennis\FilamentSelectTree\SelectTree; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; @@ -62,13 +61,12 @@ public function form(FilamentSchema $schema): FilamentSchema TextInput::make('slug'), - SelectTree::make('parent_ulid') + Select::make('parent_ulid') ->label(__('Parent Schema')) ->placeholder(__('Select a parent schema (optional)')) ->relationship( - relationship: 'parent', + name: 'parent', titleAttribute: 'name', - parentAttribute: 'parent_ulid', modifyQueryUsing: function ($query) { $key = $this->ownerRecord->getKeyName(); @@ -77,8 +75,6 @@ public function form(FilamentSchema $schema): FilamentSchema ->orderBy('position'); } ) - ->enableBranchNode() - ->multiple(false) ->searchable() ->helperText(__('Attach this schema to a parent schema for nested layouts')), diff --git a/src/Models/Schema.php b/src/Models/Schema.php index f0250d5..9e2f601 100644 --- a/src/Models/Schema.php +++ b/src/Models/Schema.php @@ -27,7 +27,7 @@ * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at * @property-read \Illuminate\Database\Eloquent\Model|null $model - * @property-read \Illuminate\Database\Eloquent\Collection $fields + * @property-read \Illuminate\Database\Eloquent\Collection $fields * @property-read \Illuminate\Database\Eloquent\Model|null $parent * @property-read \Illuminate\Database\Eloquent\Collection $children */ diff --git a/src/Schemas/Fieldset.php b/src/Schemas/Fieldset.php index f699163..af0e721 100644 --- a/src/Schemas/Fieldset.php +++ b/src/Schemas/Fieldset.php @@ -4,9 +4,9 @@ use Backstage\Fields\Contracts\SchemaContract; use Backstage\Fields\Models\Schema; -use Filament\Forms\Components\Fieldset as FilamentFieldset; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; +use Filament\Schemas\Components\Fieldset as FilamentFieldset; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Utilities\Get; @@ -26,9 +26,9 @@ public static function getDefaultConfig(): array public static function make(string $name, Schema $schema): FilamentFieldset { $fieldset = FilamentFieldset::make($schema->config['label'] ?? self::getDefaultConfig()['label']) - ->columns($schema->config['columns'] ?? self::getDefaultConfig()['columns']) - ->collapsible($schema->config['collapsible'] ?? self::getDefaultConfig()['collapsible']) - ->collapsed($schema->config['collapsed'] ?? self::getDefaultConfig()['collapsed']); + ->columns($schema->config['columns'] ?? self::getDefaultConfig()['columns']); + + // Note: collapsible and collapsed methods may not be available on Fieldset in Filament v4 return $fieldset; } From ab1030fee574be5f0d2640d7361e42d3bc757618 Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Fri, 10 Oct 2025 08:56:10 +0000 Subject: [PATCH 15/24] Fix styling --- src/Fields/Repeater.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Fields/Repeater.php b/src/Fields/Repeater.php index 42fc6b0..eb7caac 100644 --- a/src/Fields/Repeater.php +++ b/src/Fields/Repeater.php @@ -23,7 +23,6 @@ use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; use Filament\Support\Enums\Alignment; -use Forms\Components\Placeholder; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Saade\FilamentAdjacencyList\Forms\Components\AdjacencyList; From c61d6235640c3298d3ee343be7470aa3d07e63e7 Mon Sep 17 00:00:00 2001 From: Baspa Date: Fri, 10 Oct 2025 14:06:00 +0200 Subject: [PATCH 16/24] fix: phpstan issue --- src/Concerns/HasSelectableValues.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Concerns/HasSelectableValues.php b/src/Concerns/HasSelectableValues.php index 7988b8f..cb8a4c5 100644 --- a/src/Concerns/HasSelectableValues.php +++ b/src/Concerns/HasSelectableValues.php @@ -117,7 +117,7 @@ protected static function buildRelationshipOptions(mixed $field): array if (isset($relation['relationValue_filters'])) { foreach ($relation['relationValue_filters'] as $filter) { if (isset($filter['column'], $filter['operator'], $filter['value']) && - ! empty($filter['column']) && ! empty($filter['operator']) && $filter['value'] !== null) { + ! empty($filter['column']) && ! empty($filter['operator']) && $filter['value'] !== '') { if (preg_match('/{session\.([^\}]+)}/', $filter['value'], $matches)) { $sessionValue = session($matches[1]); $filter['value'] = str_replace($matches[0], $sessionValue, $filter['value']); From 037c05717caa83ffe391d28cc9975350c7cddbad Mon Sep 17 00:00:00 2001 From: Baspa Date: Fri, 10 Oct 2025 14:08:11 +0200 Subject: [PATCH 17/24] fix: tests --- tests/SelectCascadingTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/SelectCascadingTest.php b/tests/SelectCascadingTest.php index ca0dc29..9ff3145 100644 --- a/tests/SelectCascadingTest.php +++ b/tests/SelectCascadingTest.php @@ -69,7 +69,7 @@ $liveProperty->setAccessible(true); $isLive = $liveProperty->getValue($input); - expect($isLive)->toBeNull(); // Regular select fields don't have isLive set + expect($isLive)->toBeTrue(); // All fields have live() applied in Base::applyDefaultSettings() }); it('normalizes select values correctly for single selection', function () { From ba61ff4b1bf529555ccd311d78442d7382de0c2c Mon Sep 17 00:00:00 2001 From: Baspa Date: Fri, 10 Oct 2025 16:08:58 +0200 Subject: [PATCH 18/24] wip --- .../FieldsRelationManager.php | 51 ++++++++++++------- .../SchemaRelationManager.php | 13 ++--- src/Models/Schema.php | 5 ++ 3 files changed, 42 insertions(+), 27 deletions(-) diff --git a/src/Filament/RelationManagers/FieldsRelationManager.php b/src/Filament/RelationManagers/FieldsRelationManager.php index 649b985..53edee3 100644 --- a/src/Filament/RelationManagers/FieldsRelationManager.php +++ b/src/Filament/RelationManagers/FieldsRelationManager.php @@ -124,18 +124,10 @@ public function form(Schema $schema): Schema Select::make('schema_id') ->label(__('Attach to Schema')) ->placeholder(__('Select a schema (optional)')) - ->relationship( - name: 'schema', - titleAttribute: 'name', - modifyQueryUsing: function ($query) { - $key = $this->ownerRecord->getKeyName(); - - return $query->where('schemas.model_key', $this->ownerRecord->{$key}) - ->where('schemas.model_type', get_class($this->ownerRecord)) - ->orderBy('schemas.position'); - } - ) + ->options($this->getSchemaOptions()) ->searchable() + ->live() + ->reactive() ->helperText(__('Attach this field to a specific schema for better organization')), ]), @@ -157,7 +149,7 @@ public function table(Table $table): Table ->recordTitleAttribute('name') ->reorderable('position') ->defaultSort('position', 'asc') - ->defaultGroup('schema.slug') + ->modifyQueryUsing(fn ($query) => $query->with(['schema'])) ->columns([ TextColumn::make('name') ->label(__('Name')) @@ -179,7 +171,6 @@ public function table(Table $table): Table ->label(__('Schema')) ->placeholder(__('No schema')) ->searchable() - ->sortable() ->getStateUsing(fn (Field $record): string => $record->schema->name ?? __('No Schema')), ]) ->filters([ @@ -205,11 +196,12 @@ public function table(Table $table): Table ->slideOver() ->mutateDataUsing(function (array $data) { - $key = $this->ownerRecord->getKeyName(); - return [ ...$data, - 'position' => Field::where('model_key', $key)->get()->max('position') + 1, + 'position' => Field::where('model_key', $this->ownerRecord->getKey()) + ->where('model_type', get_class($this->ownerRecord)) + ->get() + ->max('position') + 1, 'model_type' => get_class($this->ownerRecord), 'model_key' => $this->ownerRecord->getKey(), ]; @@ -223,12 +215,10 @@ public function table(Table $table): Table ->slideOver() ->mutateRecordDataUsing(function (array $data) { - $key = $this->ownerRecord->getKeyName(); - return [ ...$data, 'model_type' => get_class($this->ownerRecord), - 'model_key' => $this->ownerRecord->{$key}, + 'model_key' => $this->ownerRecord->getKey(), ]; }) ->after(function (Component $livewire) { @@ -277,4 +267,27 @@ public static function getPluralModelLabel(): string { return __('Fields'); } + + protected function getSchemaOptions(): array + { + if (! $this->ownerRecord) { + return []; + } + + $options = \Backstage\Fields\Models\Schema::where('model_key', $this->ownerRecord->getKey()) + ->where('model_type', get_class($this->ownerRecord)) + ->orderBy('position') + ->pluck('name', 'ulid') + ->toArray(); + + // Debug: Log the options to help troubleshoot + \Log::info('Schema options for owner record', [ + 'owner_record_id' => $this->ownerRecord->getKey(), + 'owner_record_class' => get_class($this->ownerRecord), + 'options_count' => count($options), + 'options' => $options, + ]); + + return $options; + } } diff --git a/src/Filament/RelationManagers/SchemaRelationManager.php b/src/Filament/RelationManagers/SchemaRelationManager.php index 9edde10..85182c8 100644 --- a/src/Filament/RelationManagers/SchemaRelationManager.php +++ b/src/Filament/RelationManagers/SchemaRelationManager.php @@ -121,7 +121,7 @@ public function table(Table $table): Table ->recordTitleAttribute('name') ->reorderable('position') ->defaultSort('position', 'asc') - ->defaultGroup('parent.name') + ->modifyQueryUsing(fn ($query) => $query->with(['parent'])) ->columns([ TextColumn::make('name') ->label(__('Name')) @@ -135,8 +135,7 @@ public function table(Table $table): Table TextColumn::make('parent.name') ->label(__('Parent Schema')) ->placeholder(__('Root level')) - ->searchable() - ->sortable(), + ->searchable(), ]) ->filters([]) ->headerActions([ @@ -148,7 +147,7 @@ public function table(Table $table): Table $parentUlid = $data['parent_ulid'] ?? null; // Calculate position based on parent - $positionQuery = SchemaModel::where('model_key', $key) + $positionQuery = SchemaModel::where('model_key', $this->ownerRecord->{$key}) ->where('model_type', get_class($this->ownerRecord)); if ($parentUlid) { @@ -173,12 +172,10 @@ public function table(Table $table): Table ->slideOver() ->mutateRecordDataUsing(function (array $data) { - $key = $this->ownerRecord->getKeyName(); - return [ ...$data, - 'model_type' => 'setting', - 'model_key' => $this->ownerRecord->{$key}, + 'model_type' => get_class($this->ownerRecord), + 'model_key' => $this->ownerRecord->getKey(), ]; }) ->after(function (Component $livewire) { diff --git a/src/Models/Schema.php b/src/Models/Schema.php index 9e2f601..953dcf1 100644 --- a/src/Models/Schema.php +++ b/src/Models/Schema.php @@ -70,4 +70,9 @@ public function children(): HasMany { return $this->hasMany(Schema::class, 'parent_ulid'); } + + public function getParentNameAttribute(): ?string + { + return $this->parent?->name; + } } From 5c3fdae6ec6a1a118b3578d8e3fe875d23cec5f7 Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 11 Dec 2025 10:42:35 +0100 Subject: [PATCH 19/24] Update Repeater to exclusively use tableMode --- src/Fields/Repeater.php | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/src/Fields/Repeater.php b/src/Fields/Repeater.php index 1ff1c3f..7e672e1 100644 --- a/src/Fields/Repeater.php +++ b/src/Fields/Repeater.php @@ -54,7 +54,6 @@ public static function getDefaultConfig(): array 'form' => [], 'tableMode' => false, 'tableColumns' => [], - 'table' => false, 'compact' => false, ]; } @@ -79,6 +78,7 @@ public static function make(string $name, ?Field $field = null): Input $input = $input->compact(); } + if ($isReorderableWithButtons) { $input = $input->reorderableWithButtons(); } @@ -114,19 +114,13 @@ public static function make(string $name, ?Field $field = null): Input if ($field && $field->children->count() > 0) { $input = $input->schema(self::generateSchemaFromChildren($field->children)); - // Apply table mode if enabled (HEAD strategy) + // Apply table mode if enabled if ($field->config['tableMode'] ?? self::getDefaultConfig()['tableMode']) { $tableColumns = self::generateTableColumnsFromChildren($field->children, $field->config['tableColumns'] ?? []); if (! empty($tableColumns)) { $input = $input->table($tableColumns); } } - // Apply table if enabled (MAIN strategy) - elseif ($field->config['table'] ?? self::getDefaultConfig()['table']) { - $input = $input - ->table(self::generateTableColumns($field->children)) - ->schema(self::generateSchemaFromChildren($field->children, false)); - } } return $input; @@ -171,15 +165,13 @@ public function getForm(): array ->default(1) ->numeric() ->visible(fn (Get $get): bool => ! ($get('config.tableMode') ?? false)), - Forms\Components\Toggle::make('config.table') - ->label(__('Table repeater')), - Forms\Components\Toggle::make('config.compact') - ->label(__('Compact table')) - ->live() - ->visible(fn (Get $get): bool => $get('config.table') === true), Forms\Components\Toggle::make('config.tableMode') ->label(__('Table Mode')) ->live(), + Forms\Components\Toggle::make('config.compact') + ->label(__('Compact table')) + ->live() + ->visible(fn (Get $get): bool => ($get('config.tableMode') ?? false)), ]), AdjacencyList::make('config.form') ->columnSpanFull() @@ -269,18 +261,7 @@ protected function excludeFromBaseSchema(): array return ['defaultValue']; } - private static function generateTableColumns(Collection $children): array - { - $columns = []; - - $children = $children->sortBy('position'); - foreach ($children as $child) { - $columns[] = TableColumn::make($child['slug']); - } - - return $columns; - } private static function generateSchemaFromChildren(Collection $children, bool $isTableMode = false): array { From 8c82d52e158cae58b64fe17d87ad7ed0d1af8d9a Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 11 Dec 2025 10:58:55 +0100 Subject: [PATCH 20/24] repeater improvements --- src/Fields/Repeater.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Fields/Repeater.php b/src/Fields/Repeater.php index 7e672e1..2f1c05f 100644 --- a/src/Fields/Repeater.php +++ b/src/Fields/Repeater.php @@ -156,10 +156,11 @@ public function getForm(): array ->visible(fn (Get $get): bool => $get('config.collapsible') === true), Forms\Components\Toggle::make('config.cloneable') ->label(__('Cloneable')), - ]), + ])->columnSpanFull(), Grid::make(2)->schema([ TextInput::make('config.addActionLabel') - ->label(__('Add action label')), + ->label(__('Add action label')) + ->columnSpan(fn (Get $get) => ($get('config.tableMode') ?? false) ? 'full' : 1), TextInput::make('config.columns') ->label(__('Columns')) ->default(1) @@ -172,7 +173,7 @@ public function getForm(): array ->label(__('Compact table')) ->live() ->visible(fn (Get $get): bool => ($get('config.tableMode') ?? false)), - ]), + ])->columnSpanFull(), AdjacencyList::make('config.form') ->columnSpanFull() ->label(__('Fields')) @@ -246,11 +247,6 @@ function () { )) ->visible(fn (Get $get) => filled($get('field_type'))), ]), - InfoSection::make(__('Table Mode Information')) - ->description(__('When table mode is enabled, the repeater will display its fields in a table format. The table columns will be automatically generated from the child fields.')) - ->visible(fn (Get $get): bool => $get('config.tableMode') === true) - ->columnSpanFull() - ->schema([]), ])->columns(2), ])->columnSpanFull(), ]; From b75a1bfb1a90f5c1a253d064079160e6c2c92b9a Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Thu, 11 Dec 2025 09:59:20 +0000 Subject: [PATCH 21/24] styles: fix styling issues --- src/Fields/Repeater.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Fields/Repeater.php b/src/Fields/Repeater.php index 2f1c05f..67c8e12 100644 --- a/src/Fields/Repeater.php +++ b/src/Fields/Repeater.php @@ -17,7 +17,6 @@ use Filament\Forms\Components\TextInput; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; -use Filament\Schemas\Components\Section as InfoSection; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; @@ -78,7 +77,6 @@ public static function make(string $name, ?Field $field = null): Input $input = $input->compact(); } - if ($isReorderableWithButtons) { $input = $input->reorderableWithButtons(); } @@ -257,8 +255,6 @@ protected function excludeFromBaseSchema(): array return ['defaultValue']; } - - private static function generateSchemaFromChildren(Collection $children, bool $isTableMode = false): array { $schema = []; From e02ce68dae52a8a65fb808635e77ac65280de875 Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 11 Dec 2025 11:04:07 +0100 Subject: [PATCH 22/24] fix phpstan issues --- src/Filament/RelationManagers/FieldsRelationManager.php | 4 +--- src/Models/Schema.php | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Filament/RelationManagers/FieldsRelationManager.php b/src/Filament/RelationManagers/FieldsRelationManager.php index 53edee3..cc58eef 100644 --- a/src/Filament/RelationManagers/FieldsRelationManager.php +++ b/src/Filament/RelationManagers/FieldsRelationManager.php @@ -270,9 +270,7 @@ public static function getPluralModelLabel(): string protected function getSchemaOptions(): array { - if (! $this->ownerRecord) { - return []; - } + $options = \Backstage\Fields\Models\Schema::where('model_key', $this->ownerRecord->getKey()) ->where('model_type', get_class($this->ownerRecord)) diff --git a/src/Models/Schema.php b/src/Models/Schema.php index 953dcf1..6045fa1 100644 --- a/src/Models/Schema.php +++ b/src/Models/Schema.php @@ -28,7 +28,7 @@ * @property \Carbon\Carbon $updated_at * @property-read \Illuminate\Database\Eloquent\Model|null $model * @property-read \Illuminate\Database\Eloquent\Collection $fields - * @property-read \Illuminate\Database\Eloquent\Model|null $parent + * @property-read Schema|null $parent * @property-read \Illuminate\Database\Eloquent\Collection $children */ class Schema extends Model From ec0cad2472d56dae0fb618bfc072bbc8bd4e23e3 Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:04:33 +0000 Subject: [PATCH 23/24] styles: fix styling issues --- src/Filament/RelationManagers/FieldsRelationManager.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Filament/RelationManagers/FieldsRelationManager.php b/src/Filament/RelationManagers/FieldsRelationManager.php index cc58eef..0822ac9 100644 --- a/src/Filament/RelationManagers/FieldsRelationManager.php +++ b/src/Filament/RelationManagers/FieldsRelationManager.php @@ -271,7 +271,6 @@ public static function getPluralModelLabel(): string protected function getSchemaOptions(): array { - $options = \Backstage\Fields\Models\Schema::where('model_key', $this->ownerRecord->getKey()) ->where('model_type', get_class($this->ownerRecord)) ->orderBy('position') From 4587a32c5031830353c7b91858fc0f4effde3c0e Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 11 Dec 2025 11:04:46 +0100 Subject: [PATCH 24/24] remove logs --- src/Filament/RelationManagers/FieldsRelationManager.php | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/Filament/RelationManagers/FieldsRelationManager.php b/src/Filament/RelationManagers/FieldsRelationManager.php index 0822ac9..de043dc 100644 --- a/src/Filament/RelationManagers/FieldsRelationManager.php +++ b/src/Filament/RelationManagers/FieldsRelationManager.php @@ -270,21 +270,12 @@ public static function getPluralModelLabel(): string protected function getSchemaOptions(): array { - $options = \Backstage\Fields\Models\Schema::where('model_key', $this->ownerRecord->getKey()) ->where('model_type', get_class($this->ownerRecord)) ->orderBy('position') ->pluck('name', 'ulid') ->toArray(); - // Debug: Log the options to help troubleshoot - \Log::info('Schema options for owner record', [ - 'owner_record_id' => $this->ownerRecord->getKey(), - 'owner_record_class' => get_class($this->ownerRecord), - 'options_count' => count($options), - 'options' => $options, - ]); - return $options; } }