From e2b7a5efeda8e5ab1eed2403358748f0c9f3c7f4 Mon Sep 17 00:00:00 2001 From: Baspa Date: Wed, 10 Sep 2025 12:23:39 +0200 Subject: [PATCH 01/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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/36] 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; } } From 7862ad23c1c676283ade4582e2a4048df3397f3b Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 11 Dec 2025 11:34:08 +0100 Subject: [PATCH 25/36] fix: setting parent schemas --- .../SchemaRelationManager.php | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/Filament/RelationManagers/SchemaRelationManager.php b/src/Filament/RelationManagers/SchemaRelationManager.php index 85182c8..fa6d6c2 100644 --- a/src/Filament/RelationManagers/SchemaRelationManager.php +++ b/src/Filament/RelationManagers/SchemaRelationManager.php @@ -64,18 +64,20 @@ public function form(FilamentSchema $schema): FilamentSchema Select::make('parent_ulid') ->label(__('Parent Schema')) ->placeholder(__('Select a parent schema (optional)')) - ->relationship( - name: 'parent', - titleAttribute: 'name', - modifyQueryUsing: function ($query) { - $key = $this->ownerRecord->getKeyName(); - - return $query->where('model_key', $this->ownerRecord->{$key}) - ->where('model_type', get_class($this->ownerRecord)) - ->orderBy('position'); + ->options(function (?SchemaModel $record) { + $query = SchemaModel::query() + ->where('model_key', $this->ownerRecord->getKey()) + ->where('model_type', get_class($this->ownerRecord)) + ->orderBy('position'); + + if ($record) { + $query->where('ulid', '!=', $record->ulid); } - ) + + return $query->pluck('name', 'ulid')->toArray(); + }) ->searchable() + ->preload() ->helperText(__('Attach this schema to a parent schema for nested layouts')), Select::make('field_type') From 8f64f1aaf1fa46d1b9dcd9792e7389590b7cd597 Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 11 Dec 2025 11:36:40 +0100 Subject: [PATCH 26/36] feat: copy fields --- .../FieldsRelationManager.php | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/Filament/RelationManagers/FieldsRelationManager.php b/src/Filament/RelationManagers/FieldsRelationManager.php index de043dc..707dc73 100644 --- a/src/Filament/RelationManagers/FieldsRelationManager.php +++ b/src/Filament/RelationManagers/FieldsRelationManager.php @@ -20,6 +20,7 @@ use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; +use Filament\Actions\Action; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Model; @@ -192,6 +193,61 @@ public function table(Table $table): Table ->placeholder(__('All Schemas')), ]) ->headerActions([ + Action::make('copy_fields') + ->label(__('Copy Fields')) + ->icon('heroicon-o-document-duplicate') + ->slideOver() + ->schema([ + Select::make('source_record_id') + ->label(__('Source Record')) + ->options(function () { + return $this->ownerRecord::query() + ->whereKeyNot($this->ownerRecord->getKey()) + ->get() + ->pluck('name', $this->ownerRecord->getKeyName()) + ->toArray(); + }) + ->searchable() + ->required() + ->live() + ->afterStateUpdated(function (Set $set) { + $set('fields_to_copy', []); + }), + \Filament\Forms\Components\CheckboxList::make('fields_to_copy') + ->label(__('Fields to Copy')) + ->options(function (Get $get) { + $sourceRecordId = $get('source_record_id'); + if (! $sourceRecordId) { + return []; + } + + return Field::where('model_type', get_class($this->ownerRecord)) + ->where('model_key', $sourceRecordId) + ->pluck('name', 'ulid') + ->toArray(); + }) + ->required() + ->columns(2) + ->bulkToggleable() + ->visible(fn (Get $get) => filled($get('source_record_id'))), + ]) + ->action(function (array $data, Component $livewire) { + $fields = Field::whereIn('ulid', $data['fields_to_copy'])->get(); + + foreach ($fields as $field) { + $newField = $field->replicate(); + $newField->model_key = $this->ownerRecord->getKey(); + $newField->schema_id = null; + $newField->save(); + } + + $livewire->dispatch('refreshFields'); + + \Filament\Notifications\Notification::make() + ->title(__('Fields copied successfully')) + ->success() + ->send(); + }), CreateAction::make() ->slideOver() ->mutateDataUsing(function (array $data) { From cfce0778d4f3ef10b05dc82d49eceec7c075c384 Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:37:02 +0000 Subject: [PATCH 27/36] styles: fix styling issues --- src/Filament/RelationManagers/FieldsRelationManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Filament/RelationManagers/FieldsRelationManager.php b/src/Filament/RelationManagers/FieldsRelationManager.php index 707dc73..fe1c1d8 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 Filament\Actions\Action; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; @@ -20,7 +21,6 @@ use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; -use Filament\Actions\Action; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Model; From ab726f12b6a9c2037e9ad260efc5a810484dd91e Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 11 Dec 2025 12:19:06 +0100 Subject: [PATCH 28/36] dont use merge to prevent changing keys --- src/Concerns/HasSelectableValues.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Concerns/HasSelectableValues.php b/src/Concerns/HasSelectableValues.php index 97e7d86..492c221 100644 --- a/src/Concerns/HasSelectableValues.php +++ b/src/Concerns/HasSelectableValues.php @@ -15,7 +15,7 @@ trait HasSelectableValues { - protected static function resolveResourceModel(string $tableName): ?object + public static function resolveResourceModel(string $tableName): ?object { $resources = config('backstage.fields.selectable_resources'); @@ -158,10 +158,16 @@ protected static function mergeRelationshipOptions(array $allOptions, array $rel // If both types are selected, group relationship options by resource if (isset($field->config[$type]) && (is_array($field->config[$type]) && in_array('array', $field->config[$type]))) { - return array_merge($allOptions, $relationshipOptions); + return $allOptions + $relationshipOptions; } else { // For single relationship type, merge all options without grouping - return array_merge($allOptions, ...array_values($relationshipOptions)); + $flatOptions = []; + foreach ($relationshipOptions as $resourceOptions) { + foreach ($resourceOptions as $id => $label) { + $flatOptions[$id] = $label; + } + } + return $allOptions + $flatOptions; } } From cab0a929bd9b55782a6c6be2707c66c687e3bee1 Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 11 Dec 2025 12:57:16 +0100 Subject: [PATCH 29/36] feat: dynamic mode (wip) --- README.md | 23 ++++++ src/Fields/Base.php | 28 ++++++- src/Fields/Text.php | 182 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c06a46c..a6b679b 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,29 @@ Combine multiple conditions with logical operators: The visibility system works seamlessly with validation rules to create intelligent, user-friendly forms that adapt to your data and user interactions. +#### Dynamic Values (Text Fields) + +Text fields support dynamic value calculation, allowing them to automatically populate based on other fields in the form. + +##### Relation Prefill Mode + +Automatically prefill a text field based on a selection in another field (typically a Select field). + +- **Source Field**: The field to watch (e.g., a `building` select field). +- **Relation Column**: The column from the related model to fetch (e.g., `city`). + +*Example*: When a user selects a "Building", the "City" text field automatically updates to show the city associated with that building record. + +##### Calculation Mode + +Calculate the field's value using a mathematical formula based on other fields. + +- **Formula**: Enter a formula using field values. +- **Syntax**: Use field IDs or keys in curly braces, e.g., `{price} * {quantity}`. +- **Supported Operations**: Standard math operators (`+`, `-`, `*`, `/`, `(`, `)`). + +*Example*: A "Total" field can automatically calculate `{price} * {tax_rate}`. + ### Making a resource page configurable To make a resource page configurable, you need to add the `CanMapDynamicFields` trait to your page. For this example, we'll make a `EditContent` page configurable. diff --git a/src/Fields/Base.php b/src/Fields/Base.php index b0bda95..e41cb50 100644 --- a/src/Fields/Base.php +++ b/src/Fields/Base.php @@ -15,6 +15,7 @@ use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; use Filament\Support\Colors\Color; use ReflectionObject; @@ -157,7 +158,32 @@ public static function applyDefaultSettings($input, ?Field $field = null) ->helperText($field->config['helperText'] ?? self::getDefaultConfig()['helperText']) ->hint($field->config['hint'] ?? self::getDefaultConfig()['hint']) ->hintIcon($field->config['hintIcon'] ?? self::getDefaultConfig()['hintIcon']) - ->live(); + ->live() + ->afterStateUpdated(function ($state, Set $set, Get $get) use ($field) { + if (! $field) { + return; + } + + // Find fields that depend on this field + // We match fields where either the direct source matches this field ULID (relation mode) + // OR the formula contains this field ULID (calculation mode) + $dependents = \Backstage\Fields\Models\Field::where('model_type', $field->model_type) + ->where('model_key', $field->model_key) + ->where(function ($query) use ($field) { + $query->where('config->dynamic_source_field', $field->ulid) + ->orWhere('config->dynamic_formula', 'LIKE', "%{{$field->ulid}}%"); + }) + ->get(); + + foreach ($dependents as $dependent) { + if ($dependent->field_type === 'text') { + $newValue = \Backstage\Fields\Fields\Text::calculateDynamicValue($dependent, $state, $get); + if ($newValue !== null) { + $set("values.{$dependent->ulid}", $newValue); + } + } + } + }); if (isset($field->config['hintColor']) && $field->config['hintColor']) { $input->hintColor(Color::generateV3Palette($field->config['hintColor'])); diff --git a/src/Fields/Text.php b/src/Fields/Text.php index b934a18..4efb8f8 100644 --- a/src/Fields/Text.php +++ b/src/Fields/Text.php @@ -13,6 +13,7 @@ use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; class Text extends Base implements FieldContract { @@ -37,6 +38,10 @@ public static function getDefaultConfig(): array 'inputMode' => null, 'telRegex' => null, 'revealable' => false, + 'dynamic_mode' => 'none', + 'dynamic_source_field' => null, + 'dynamic_relation_column' => null, + 'dynamic_formula' => null, ]; } @@ -80,12 +85,97 @@ public static function make(string $name, ?Field $field = null): Input $input->integer(); } + $input = self::applyDynamicSettings($input, $field); $input = self::addAffixesToInput($input, $field); $input = self::addDatalistToInput($input, $field); return $input; } + public static function calculateDynamicValue(Field $field, $sourceValue, ?Get $get = null) + { + $mode = $field->config['dynamic_mode'] ?? 'none'; + + if ($mode === 'relation') { + if (empty($sourceValue)) return null; + + $sourceUlid = $field->config['dynamic_source_field'] ?? null; + if (! $sourceUlid) return null; + + $sourceField = \Backstage\Fields\Models\Field::find($sourceUlid); + if (! $sourceField) return null; + + $relations = $sourceField->config['relations'] ?? []; + $relationConfig = reset($relations); + if (! $relationConfig || empty($relationConfig['resource'])) return null; + + $modelInstance = \Backstage\Fields\Fields\Select::resolveResourceModel($relationConfig['resource']); + if (! $modelInstance) return null; + + $relatedRecord = $modelInstance::find($sourceValue); + if (! $relatedRecord) return null; + + $targetColumn = $field->config['dynamic_relation_column'] ?? null; + if ($targetColumn && isset($relatedRecord->$targetColumn)) { + return $relatedRecord->$targetColumn; + } + } + + if ($mode === 'calculation') { + if (! $get) return null; + + $formula = $field->config['dynamic_formula'] ?? null; + if (! $formula) return null; + + // Regex to find {ulid} patterns + $parsedFormula = preg_replace_callback('/\{([a-zA-Z0-9-]+)\}/', function ($matches) use ($get) { + $ulid = $matches[1]; + $val = $get("values.{$ulid}"); + // Ensure value is numeric for safety + return is_numeric($val) ? $val : 0; + }, $formula); + + // Safety: Only allow numbers and basic math operators + if (preg_match('/^[0-9\.\+\-\*\/\(\)\s]+$/', $parsedFormula)) { + try { + $result = @eval("return {$parsedFormula};"); + return $result; + } catch (\Throwable $e) { + return null; + } + } + } + + return null; + } + + protected static function applyDynamicSettings(Input $input, ?Field $field = null): Input + { + if (! $field || empty($field->config['dynamic_mode']) || $field->config['dynamic_mode'] === 'none') { + return $input; + } + + return $input + // We keep afterStateHydrated for initial load, + // but we remove the `key` hack as we use "Push" model for updates. + ->afterStateHydrated(function (Input $component, Get $get, Set $set) use ($field) { + $mode = $field->config['dynamic_mode'] ?? 'none'; + + // Use the shared calculation logic + // But we need to resolve sourceValue from $get + $sourceUlid = $field->config['dynamic_source_field'] ?? null; + if ($sourceUlid) { + $sourceValue = $get("values.{$sourceUlid}"); + $newValue = self::calculateDynamicValue($field, $sourceValue); // We need to update this sig for calc? + + if ($newValue !== null && $component->getState() !== $newValue) { + $component->state($newValue); + $set($component->getStatePath(), $newValue); + } + } + }); + } + public function getForm(): array { return [ @@ -167,6 +257,98 @@ public function getForm(): array ->visible(fn (Get $get): bool => $get('config.type') === 'tel'), ]), ]), + Tab::make('Dynamic Values') + ->label(__('Dynamic Values')) + ->schema([ + Grid::make(1) + ->schema([ + Select::make('config.dynamic_mode') + ->label(__('Mode')) + ->options([ + 'none' => __('None'), + 'relation' => __('Relation Prefill'), + 'calculation' => __('Calculation'), + ]) + ->live(), + Select::make('config.dynamic_source_field') + ->label(__('Source Field')) + ->helperText(__('Select the field to use as source.')) + ->options(function ($record, $component) { + $formSlug = null; + + if ($record && isset($record->model_key)) { + $formSlug = $record->model_key; + } + + if (! $formSlug) { + $routeParams = request()->route()?->parameters() ?? []; + $formSlug = $routeParams['record'] ?? $routeParams['form'] ?? $routeParams['id'] ?? null; + } + + if (! $formSlug && method_exists($component, 'getOwnerRecord')) { + $ownerRecord = $component->getOwnerRecord(); + if ($ownerRecord) { + $formSlug = $ownerRecord->getKey(); + } + } + + if (! $formSlug) { + return []; + } + + $fields = \Backstage\Fields\Models\Field::where('model_type', 'App\Models\Form') + ->where('model_key', $formSlug) + ->when($record && isset($record->ulid), function ($query) use ($record) { + return $query->where('ulid', '!=', $record->ulid); + }) + ->orderBy('name') + ->pluck('name', 'ulid') + ->toArray(); + + return $fields; + }) + ->searchable() + ->visible(fn (Get $get): bool => $get('config.dynamic_mode') === 'relation'), + \Filament\Forms\Components\Select::make('config.dynamic_relation_column') + ->label(__('Relation Column')) + ->helperText(__('The column to pluck from the related model.')) + ->visible(fn (Get $get): bool => $get('config.dynamic_mode') === 'relation') + ->searchable() + ->options(function (Get $get) { + $sourceUlid = $get('config.dynamic_source_field'); + if (! $sourceUlid) { + return []; + } + + $sourceField = \Backstage\Fields\Models\Field::find($sourceUlid); + if (! $sourceField) { + return []; + } + + $relations = $sourceField->config['relations'] ?? []; + $relationConfig = reset($relations); + + if (! $relationConfig || empty($relationConfig['resource'])) { + return []; + } + + $modelInstance = \Backstage\Fields\Fields\Select::resolveResourceModel($relationConfig['resource']); + if (! $modelInstance) { + return []; + } + + $columns = \Illuminate\Support\Facades\Schema::getColumnListing($modelInstance->getTable()); + + return collect($columns)->mapWithKeys(function ($column) { + return [$column => $column]; + })->toArray(); + }), + Input::make('config.dynamic_formula') + ->label(__('Formula')) + ->helperText(__('Use field names as variables. Example: "price * quantity". Use {field_ulid} for specific fields if needed.')) + ->visible(fn (Get $get): bool => $get('config.dynamic_mode') === 'calculation'), + ]), + ]), Tab::make('Rules') ->label(__('Rules')) ->schema([ From c6573994e1935fa1b10eb467dfce7974bb7c7bc6 Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:57:39 +0000 Subject: [PATCH 30/36] styles: fix styling issues --- src/Concerns/HasSelectableValues.php | 1 + src/Fields/Base.php | 2 +- src/Fields/Text.php | 102 ++++++++++++++++----------- 3 files changed, 62 insertions(+), 43 deletions(-) diff --git a/src/Concerns/HasSelectableValues.php b/src/Concerns/HasSelectableValues.php index 492c221..3e4fd07 100644 --- a/src/Concerns/HasSelectableValues.php +++ b/src/Concerns/HasSelectableValues.php @@ -167,6 +167,7 @@ protected static function mergeRelationshipOptions(array $allOptions, array $rel $flatOptions[$id] = $label; } } + return $allOptions + $flatOptions; } } diff --git a/src/Fields/Base.php b/src/Fields/Base.php index e41cb50..6b8b626 100644 --- a/src/Fields/Base.php +++ b/src/Fields/Base.php @@ -171,7 +171,7 @@ public static function applyDefaultSettings($input, ?Field $field = null) ->where('model_key', $field->model_key) ->where(function ($query) use ($field) { $query->where('config->dynamic_source_field', $field->ulid) - ->orWhere('config->dynamic_formula', 'LIKE', "%{{$field->ulid}}%"); + ->orWhere('config->dynamic_formula', 'LIKE', "%{{$field->ulid}}%"); }) ->get(); diff --git a/src/Fields/Text.php b/src/Fields/Text.php index 4efb8f8..7b620b0 100644 --- a/src/Fields/Text.php +++ b/src/Fields/Text.php @@ -95,55 +95,73 @@ public static function make(string $name, ?Field $field = null): Input public static function calculateDynamicValue(Field $field, $sourceValue, ?Get $get = null) { $mode = $field->config['dynamic_mode'] ?? 'none'; - + if ($mode === 'relation') { - if (empty($sourceValue)) return null; - + if (empty($sourceValue)) { + return null; + } + $sourceUlid = $field->config['dynamic_source_field'] ?? null; - if (! $sourceUlid) return null; + if (! $sourceUlid) { + return null; + } $sourceField = \Backstage\Fields\Models\Field::find($sourceUlid); - if (! $sourceField) return null; + if (! $sourceField) { + return null; + } $relations = $sourceField->config['relations'] ?? []; $relationConfig = reset($relations); - if (! $relationConfig || empty($relationConfig['resource'])) return null; + if (! $relationConfig || empty($relationConfig['resource'])) { + return null; + } $modelInstance = \Backstage\Fields\Fields\Select::resolveResourceModel($relationConfig['resource']); - if (! $modelInstance) return null; + if (! $modelInstance) { + return null; + } $relatedRecord = $modelInstance::find($sourceValue); - if (! $relatedRecord) return null; + if (! $relatedRecord) { + return null; + } $targetColumn = $field->config['dynamic_relation_column'] ?? null; if ($targetColumn && isset($relatedRecord->$targetColumn)) { return $relatedRecord->$targetColumn; } } - + if ($mode === 'calculation') { - if (! $get) return null; - - $formula = $field->config['dynamic_formula'] ?? null; - if (! $formula) return null; - - // Regex to find {ulid} patterns - $parsedFormula = preg_replace_callback('/\{([a-zA-Z0-9-]+)\}/', function ($matches) use ($get) { - $ulid = $matches[1]; - $val = $get("values.{$ulid}"); - // Ensure value is numeric for safety - return is_numeric($val) ? $val : 0; - }, $formula); - - // Safety: Only allow numbers and basic math operators - if (preg_match('/^[0-9\.\+\-\*\/\(\)\s]+$/', $parsedFormula)) { - try { - $result = @eval("return {$parsedFormula};"); - return $result; - } catch (\Throwable $e) { - return null; - } - } + if (! $get) { + return null; + } + + $formula = $field->config['dynamic_formula'] ?? null; + if (! $formula) { + return null; + } + + // Regex to find {ulid} patterns + $parsedFormula = preg_replace_callback('/\{([a-zA-Z0-9-]+)\}/', function ($matches) use ($get) { + $ulid = $matches[1]; + $val = $get("values.{$ulid}"); + + // Ensure value is numeric for safety + return is_numeric($val) ? $val : 0; + }, $formula); + + // Safety: Only allow numbers and basic math operators + if (preg_match('/^[0-9\.\+\-\*\/\(\)\s]+$/', $parsedFormula)) { + try { + $result = @eval("return {$parsedFormula};"); + + return $result; + } catch (\Throwable $e) { + return null; + } + } } return null; @@ -156,22 +174,22 @@ protected static function applyDynamicSettings(Input $input, ?Field $field = nul } return $input - // We keep afterStateHydrated for initial load, + // We keep afterStateHydrated for initial load, // but we remove the `key` hack as we use "Push" model for updates. ->afterStateHydrated(function (Input $component, Get $get, Set $set) use ($field) { $mode = $field->config['dynamic_mode'] ?? 'none'; - + // Use the shared calculation logic // But we need to resolve sourceValue from $get $sourceUlid = $field->config['dynamic_source_field'] ?? null; if ($sourceUlid) { - $sourceValue = $get("values.{$sourceUlid}"); - $newValue = self::calculateDynamicValue($field, $sourceValue); // We need to update this sig for calc? - - if ($newValue !== null && $component->getState() !== $newValue) { - $component->state($newValue); - $set($component->getStatePath(), $newValue); - } + $sourceValue = $get("values.{$sourceUlid}"); + $newValue = self::calculateDynamicValue($field, $sourceValue); // We need to update this sig for calc? + + if ($newValue !== null && $component->getState() !== $newValue) { + $component->state($newValue); + $set($component->getStatePath(), $newValue); + } } }); } @@ -327,7 +345,7 @@ public function getForm(): array $relations = $sourceField->config['relations'] ?? []; $relationConfig = reset($relations); - + if (! $relationConfig || empty($relationConfig['resource'])) { return []; } @@ -338,7 +356,7 @@ public function getForm(): array } $columns = \Illuminate\Support\Facades\Schema::getColumnListing($modelInstance->getTable()); - + return collect($columns)->mapWithKeys(function ($column) { return [$column => $column]; })->toArray(); From f7e20da772a7cc84ea2fa096dba8da18e4969c0c Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 11 Dec 2025 13:11:13 +0100 Subject: [PATCH 31/36] fix: wrong type hint --- src/Filament/RelationManagers/SchemaRelationManager.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Filament/RelationManagers/SchemaRelationManager.php b/src/Filament/RelationManagers/SchemaRelationManager.php index fa6d6c2..223ff26 100644 --- a/src/Filament/RelationManagers/SchemaRelationManager.php +++ b/src/Filament/RelationManagers/SchemaRelationManager.php @@ -5,7 +5,6 @@ use Backstage\Fields\Concerns\HasConfigurableFields; use Backstage\Fields\Concerns\HasFieldTypeResolver; use Backstage\Fields\Enums\Schema as SchemaEnum; -use Backstage\Fields\Models\Field; use Backstage\Fields\Models\Schema as SchemaModel; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; @@ -47,7 +46,7 @@ public function form(FilamentSchema $schema): FilamentSchema ->autocomplete(false) ->required() ->live(onBlur: true) - ->afterStateUpdated(function (Set $set, Get $get, ?string $state, ?string $old, ?Field $record) { + ->afterStateUpdated(function (Set $set, Get $get, ?string $state, ?string $old, ?SchemaModel $record) { if (! $record || blank($get('slug'))) { $set('slug', Str::slug($state)); } From a5d64e73ab836c8a0af73f97e192b168aceb057e Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 11 Dec 2025 13:29:52 +0100 Subject: [PATCH 32/36] feat: build schema tree --- .../RelationManagers/FieldsRelationManager.php | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Filament/RelationManagers/FieldsRelationManager.php b/src/Filament/RelationManagers/FieldsRelationManager.php index fe1c1d8..7a1edd8 100644 --- a/src/Filament/RelationManagers/FieldsRelationManager.php +++ b/src/Filament/RelationManagers/FieldsRelationManager.php @@ -326,11 +326,23 @@ public static function getPluralModelLabel(): string protected function getSchemaOptions(): array { - $options = \Backstage\Fields\Models\Schema::where('model_key', $this->ownerRecord->getKey()) + $schemas = \Backstage\Fields\Models\Schema::where('model_key', $this->ownerRecord->getKey()) ->where('model_type', get_class($this->ownerRecord)) ->orderBy('position') - ->pluck('name', 'ulid') - ->toArray(); + ->get(); + + return $this->buildSchemaTree($schemas); + } + + protected function buildSchemaTree($schemas, $parentId = null, $depth = 0): array + { + $options = []; + $children = $schemas->where('parent_ulid', $parentId); + + foreach ($children as $schema) { + $options[$schema->ulid] = str_repeat('— ', $depth) . $schema->name; + $options = $options + $this->buildSchemaTree($schemas, $schema->ulid, $depth + 1); + } return $options; } From 2809a64dca6ccc96604b6436ed582005df784688 Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 11 Dec 2025 14:46:48 +0100 Subject: [PATCH 33/36] replicate fields --- .../FieldsRelationManager.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Filament/RelationManagers/FieldsRelationManager.php b/src/Filament/RelationManagers/FieldsRelationManager.php index 7a1edd8..2364be7 100644 --- a/src/Filament/RelationManagers/FieldsRelationManager.php +++ b/src/Filament/RelationManagers/FieldsRelationManager.php @@ -269,6 +269,7 @@ public function table(Table $table): Table ->recordActions([ EditAction::make() ->slideOver() + ->hiddenLabel() ->mutateRecordDataUsing(function (array $data) { return [ @@ -278,9 +279,27 @@ public function table(Table $table): Table ]; }) ->after(function (Component $livewire) { + $livewire->dispatch('refreshFields'); + }), + Action::make('replicate') + ->tooltip(__('Duplicate')) + ->hiddenLabel() + ->icon('heroicon-o-document-duplicate') + ->action(function (Field $record, Component $livewire) { + $replica = $record->replicate(); + $replica->ulid = (string) Str::ulid(); + $replica->name = $record->name . ' (' . __('Copy') . ')'; + $replica->slug = Str::slug($replica->name); + $replica->position = Field::where('model_key', $this->ownerRecord->getKey()) + ->where('model_type', get_class($this->ownerRecord)) + ->max('position') + 1; + $replica->save(); + $livewire->dispatch('refreshFields'); }), DeleteAction::make() + ->hiddenLabel() + ->tooltip(__('Delete')) ->after(function (Component $livewire, array $data, Model $record, array $arguments) { if ( isset($record->valueColumn) && $this->ownerRecord->getConnection() From 2db69c0c8f3ec59a075694524c4e905986566b0b Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Thu, 11 Dec 2025 13:47:12 +0000 Subject: [PATCH 34/36] styles: fix styling issues --- src/Filament/RelationManagers/FieldsRelationManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Filament/RelationManagers/FieldsRelationManager.php b/src/Filament/RelationManagers/FieldsRelationManager.php index 2364be7..85b9a6a 100644 --- a/src/Filament/RelationManagers/FieldsRelationManager.php +++ b/src/Filament/RelationManagers/FieldsRelationManager.php @@ -283,7 +283,7 @@ public function table(Table $table): Table }), Action::make('replicate') ->tooltip(__('Duplicate')) - ->hiddenLabel() + ->hiddenLabel() ->icon('heroicon-o-document-duplicate') ->action(function (Field $record, Component $livewire) { $replica = $record->replicate(); From ac004f35e342ae2527151ca58390ef90e94629e6 Mon Sep 17 00:00:00 2001 From: Baspa Date: Thu, 11 Dec 2025 16:36:36 +0100 Subject: [PATCH 35/36] feat: advanced calculations and repeater default values --- README.md | 33 ++++++++++ src/Components/NormalizedRepeater.php | 49 +++++++++++++++ src/Concerns/HasFieldTypeResolver.php | 1 + src/Fields/Repeater.php | 91 +++++++++++++++++++++++++-- src/Fields/Text.php | 39 +++++++++--- 5 files changed, 201 insertions(+), 12 deletions(-) create mode 100644 src/Components/NormalizedRepeater.php diff --git a/README.md b/README.md index a6b679b..e6a788d 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,25 @@ Calculate the field's value using a mathematical formula based on other fields. *Example*: A "Total" field can automatically calculate `{price} * {tax_rate}`. +##### Advanced Use Cases + +**Using Slugs**: You can use the field slug instead of the ULID for better readability: `"{price} * {quantity}"`. + +**Conditional Logic**: The formula engine supports conditional operations similar to Excel or PHP ternary operators. This is useful for Repeater rows where you want calculations to apply only when specific criteria are met. + +*Syntax*: `condition ? value_if_true : value_if_false` + +*Example*: +``` +"{type}" == "Premium" ? {price} * 1.2 : {price} +``` + +*Nested Example* (Excel-like IF/ELSE): +``` +"{status}" == "Paid" ? 0 : ("{status}" == "Pending" ? {total} : null) +``` +**Note**: Returning `null` effectively skips the calculation, allowing the field to be manually edited or remain empty. + ### Making a resource page configurable To make a resource page configurable, you need to add the `CanMapDynamicFields` trait to your page. For this example, we'll make a `EditContent` page configurable. @@ -515,6 +534,20 @@ The package includes a powerful Rich Editor with custom plugins: - **[Visibility Rules](docs/visibility-rules.md)** - Comprehensive guide to controlling field visibility based on conditions and record properties + +## Fields + +### Repeater + +### Repeater + +The Repeater field includes a robust implementation to handle default value hydration. It uses a custom `NormalizedRepeater` component (extending `Filament\Forms\Components\Repeater`) to ensure that data hydration from JSON strings (common in database storage) is correctly decoded and normalized into a UUID-keyed array before the component renders. + +This prevents common "string given" errors in loops and ensures that fields are correctly populated even when the initial state is a flat list or single object. + +- **Default Values**: Can be entered as JSON in the field configuration. +- **JSON Editor**: Use the JSON editor to enter default values. + ## Testing ```bash diff --git a/src/Components/NormalizedRepeater.php b/src/Components/NormalizedRepeater.php new file mode 100644 index 0000000..af9d067 --- /dev/null +++ b/src/Components/NormalizedRepeater.php @@ -0,0 +1,49 @@ +toString()] = $item; + } + $state = $keyedState; + + // Persist the normalized state so keys don't rotate on every call + $this->rawState($state); + } + + return $state; + } + + return []; + } +} diff --git a/src/Concerns/HasFieldTypeResolver.php b/src/Concerns/HasFieldTypeResolver.php index de694b6..fb42367 100644 --- a/src/Concerns/HasFieldTypeResolver.php +++ b/src/Concerns/HasFieldTypeResolver.php @@ -26,6 +26,7 @@ protected function getFieldTypeFormSchema(?string $fieldType): array return app($className)->getForm(); } catch (Exception $e) { + \Illuminate\Support\Facades\Log::error("FieldTypeResolver failed for {$fieldType}: " . $e->getMessage(), ['trace' => $e->getTraceAsString()]); throw new Exception(message: "Failed to resolve field type class for '{$fieldType}'", code: 0, previous: $e); } } diff --git a/src/Fields/Repeater.php b/src/Fields/Repeater.php index 67c8e12..f67fa86 100644 --- a/src/Fields/Repeater.php +++ b/src/Fields/Repeater.php @@ -10,6 +10,8 @@ use Backstage\Fields\Facades\Fields; use Backstage\Fields\Models\Field; use Filament\Forms; +use Backstage\Fields\Components\NormalizedRepeater; +use Filament\Forms\Components\CodeEditor\Enums\Language; use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Repeater as Input; use Filament\Forms\Components\Repeater\TableColumn; @@ -59,7 +61,14 @@ public static function getDefaultConfig(): array public static function make(string $name, ?Field $field = null): Input { - $input = self::applyDefaultSettings(Input::make($name), $field); + // Create an anonymous class extending the Filament Repeater to intercept the state + // This is necessary because standard Filament hooks (like formatStateUsing) + // are bypassed by the Repeater's internal rendering logic. + // We use NormalizedRepeater (separate class) because standard anonymous classes + // cannot be serialized by Livewire. + $input = self::applyDefaultSettings(NormalizedRepeater::make($name), $field); + + $input->configure(); $isReorderable = $field->config['reorderable'] ?? self::getDefaultConfig()['reorderable']; $isReorderableWithButtons = $field->config['reorderableWithButtons'] ?? self::getDefaultConfig()['reorderableWithButtons']; @@ -245,6 +254,19 @@ function () { )) ->visible(fn (Get $get) => filled($get('field_type'))), ]), + Forms\Components\CodeEditor::make('config.defaultValue') + ->label(__('Default Items (JSON)')) + ->language(Language::Json) + ->formatStateUsing(function ($state) { + if (is_array($state)) { + return json_encode($state, JSON_PRETTY_PRINT); + } + + return $state; + }) + ->rules('json') + ->helperText(__('Array of objects for default rows. Example: [{"slug": "value"}]')) + ->columnSpanFull(), ])->columns(2), ])->columnSpanFull(), ]; @@ -258,19 +280,76 @@ protected function excludeFromBaseSchema(): array private static function generateSchemaFromChildren(Collection $children, bool $isTableMode = false): array { $schema = []; + $dependencyMap = []; // source_slug => [dependent_child_1, ... ] + $ulidToSlug = []; $children = $children->sortBy('position'); + // First pass: Build dependency map foreach ($children as $child) { - $fieldType = $child['field_type']; + $ulidToSlug[$child['ulid']] = $child['slug']; - $field = self::resolveFieldTypeClassName($fieldType); + $config = $child['config'] ?? []; + $mode = $config['dynamic_mode'] ?? 'none'; + + if ($mode === 'relation') { + $sourceUlid = $config['dynamic_source_field'] ?? null; + if ($sourceUlid) { + $dependencyMap[$sourceUlid][] = $child; + } + } elseif ($mode === 'calculation') { + $formula = $config['dynamic_formula'] ?? ''; + preg_match_all('/\{([a-zA-Z0-9-]+)\}/', $formula, $matches); + foreach ($matches[1] as $sourceUlid) { + $dependencyMap[$sourceUlid][] = $child; + } + } + } - if ($field === null) { + foreach ($children as $child) { + $fieldType = $child['field_type']; + $fieldClass = self::resolveFieldTypeClassName($fieldType); + + if ($fieldClass === null) { continue; } - $schema[] = $field::make($child['slug'], $child); + $component = $fieldClass::make($child['slug'], $child); + + // Check if this field is a source for others + if (isset($dependencyMap[$child['ulid']])) { + $dependents = $dependencyMap[$child['ulid']]; + + $component->live(onBlur: true) + ->afterStateUpdated(function (Get $get, Set $set, $state) use ($dependents, $ulidToSlug) { + foreach ($dependents as $dependent) { + $targetSlug = $dependent['slug']; + + // We need to pass the dependent Field model to calculateDynamicValue + // Since $dependent is likely the model instance itself (from $children collection) + // we can pass it directly. + + // Determine source value. + // For 'relation', the $state of the current field IS the source value. + + // Note: Text::calculateDynamicValue is static and stateless, + // it just needs the config from the field. + + $newValue = \Backstage\Fields\Fields\Text::calculateDynamicValue($dependent, $state, $get); + + if ($newValue !== null) { + // Relative path set + // Since we are inside a Repeater row, $set('slug', val) works for sibling fields + // BUT check if $get/set context is correct. + // In a Repeater item, Get/Set operate relative to the item. + // So $set($targetSlug, $newValue) should work. + $set($targetSlug, $newValue); + } + } + }); + } + + $schema[] = $component; } return $schema; @@ -322,4 +401,6 @@ private static function generateTableColumnsFromChildren(Collection $children, a return $tableColumns; } + + } diff --git a/src/Fields/Text.php b/src/Fields/Text.php index 7b620b0..f163f60 100644 --- a/src/Fields/Text.php +++ b/src/Fields/Text.php @@ -146,14 +146,38 @@ public static function calculateDynamicValue(Field $field, $sourceValue, ?Get $g // Regex to find {ulid} patterns $parsedFormula = preg_replace_callback('/\{([a-zA-Z0-9-]+)\}/', function ($matches) use ($get) { $ulid = $matches[1]; - $val = $get("values.{$ulid}"); + + // Try to find the field to get its slug for relative lookup + // This allows calculations to work inside Repeaters where fields are named by slug + $referencedField = \Backstage\Fields\Models\Field::find($ulid); + $val = null; + + if ($referencedField) { + // Try relative path first (e.g. inside Repeater) using slug + $val = $get($referencedField->slug); + } + + // Try direct slug/key access (simplifies Repeater usage) + if ($val === null) { + $val = $get($ulid); + } + + // Fallback to absolute path (e.g. top level) + if ($val === null) { + $val = $get("values.{$ulid}"); + } + + if (is_numeric($val)) { + return $val; + } - // Ensure value is numeric for safety - return is_numeric($val) ? $val : 0; + // If not numeric, encode as JSON to allow string comparisons in formula + // e.g. "My Value" + return json_encode($val); }, $formula); - // Safety: Only allow numbers and basic math operators - if (preg_match('/^[0-9\.\+\-\*\/\(\)\s]+$/', $parsedFormula)) { + // Safety: Allow numbers, math operators, comparisons, logic, and ternary + if (preg_match('/^[0-9\.\+\-\*\/\(\)\s\!\=\<\>\&\|\?\:\'\"\,]+$/', $parsedFormula)) { try { $result = @eval("return {$parsedFormula};"); @@ -361,9 +385,10 @@ public function getForm(): array return [$column => $column]; })->toArray(); }), - Input::make('config.dynamic_formula') + \Filament\Forms\Components\Textarea::make('config.dynamic_formula') ->label(__('Formula')) - ->helperText(__('Use field names as variables. Example: "price * quantity". Use {field_ulid} for specific fields if needed.')) + ->rows(3) + ->helperText(__('Use field names as variables. Example: "price * quantity". Use {field_slug} or {field_ulid} for specific fields.')) ->visible(fn (Get $get): bool => $get('config.dynamic_mode') === 'calculation'), ]), ]), From b60ae1287688da31d90cfa888075d42740e61807 Mon Sep 17 00:00:00 2001 From: Baspa <10845460+Baspa@users.noreply.github.com> Date: Thu, 11 Dec 2025 15:37:00 +0000 Subject: [PATCH 36/36] styles: fix styling issues --- src/Components/NormalizedRepeater.php | 3 +- src/Concerns/HasFieldTypeResolver.php | 1 + src/Fields/Repeater.php | 56 +++++++++++++-------------- src/Fields/Text.php | 10 ++--- 4 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/Components/NormalizedRepeater.php b/src/Components/NormalizedRepeater.php index af9d067..543be35 100644 --- a/src/Components/NormalizedRepeater.php +++ b/src/Components/NormalizedRepeater.php @@ -20,6 +20,7 @@ public function getRawState(): mixed foreach ($state as $item) { if (! is_array($item)) { $hasNonArrayItems = true; + break; } } @@ -36,7 +37,7 @@ public function getRawState(): mixed $keyedState[\Illuminate\Support\Str::uuid()->toString()] = $item; } $state = $keyedState; - + // Persist the normalized state so keys don't rotate on every call $this->rawState($state); } diff --git a/src/Concerns/HasFieldTypeResolver.php b/src/Concerns/HasFieldTypeResolver.php index fb42367..9b13794 100644 --- a/src/Concerns/HasFieldTypeResolver.php +++ b/src/Concerns/HasFieldTypeResolver.php @@ -27,6 +27,7 @@ protected function getFieldTypeFormSchema(?string $fieldType): array return app($className)->getForm(); } catch (Exception $e) { \Illuminate\Support\Facades\Log::error("FieldTypeResolver failed for {$fieldType}: " . $e->getMessage(), ['trace' => $e->getTraceAsString()]); + throw new Exception(message: "Failed to resolve field type class for '{$fieldType}'", code: 0, previous: $e); } } diff --git a/src/Fields/Repeater.php b/src/Fields/Repeater.php index f67fa86..c9f880f 100644 --- a/src/Fields/Repeater.php +++ b/src/Fields/Repeater.php @@ -2,6 +2,7 @@ namespace Backstage\Fields\Fields; +use Backstage\Fields\Components\NormalizedRepeater; use Backstage\Fields\Concerns\HasConfigurableFields; use Backstage\Fields\Concerns\HasFieldTypeResolver; use Backstage\Fields\Concerns\HasOptions; @@ -10,7 +11,6 @@ use Backstage\Fields\Facades\Fields; use Backstage\Fields\Models\Field; use Filament\Forms; -use Backstage\Fields\Components\NormalizedRepeater; use Filament\Forms\Components\CodeEditor\Enums\Language; use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Repeater as Input; @@ -254,7 +254,7 @@ function () { )) ->visible(fn (Get $get) => filled($get('field_type'))), ]), - Forms\Components\CodeEditor::make('config.defaultValue') + Forms\Components\CodeEditor::make('config.defaultValue') ->label(__('Default Items (JSON)')) ->language(Language::Json) ->formatStateUsing(function ($state) { @@ -315,36 +315,36 @@ private static function generateSchemaFromChildren(Collection $children, bool $i } $component = $fieldClass::make($child['slug'], $child); - + // Check if this field is a source for others if (isset($dependencyMap[$child['ulid']])) { $dependents = $dependencyMap[$child['ulid']]; - + $component->live(onBlur: true) - ->afterStateUpdated(function (Get $get, Set $set, $state) use ($dependents, $ulidToSlug) { + ->afterStateUpdated(function (Get $get, Set $set, $state) use ($dependents) { foreach ($dependents as $dependent) { - $targetSlug = $dependent['slug']; - - // We need to pass the dependent Field model to calculateDynamicValue - // Since $dependent is likely the model instance itself (from $children collection) - // we can pass it directly. - - // Determine source value. - // For 'relation', the $state of the current field IS the source value. - - // Note: Text::calculateDynamicValue is static and stateless, - // it just needs the config from the field. - - $newValue = \Backstage\Fields\Fields\Text::calculateDynamicValue($dependent, $state, $get); - - if ($newValue !== null) { - // Relative path set - // Since we are inside a Repeater row, $set('slug', val) works for sibling fields - // BUT check if $get/set context is correct. - // In a Repeater item, Get/Set operate relative to the item. - // So $set($targetSlug, $newValue) should work. - $set($targetSlug, $newValue); - } + $targetSlug = $dependent['slug']; + + // We need to pass the dependent Field model to calculateDynamicValue + // Since $dependent is likely the model instance itself (from $children collection) + // we can pass it directly. + + // Determine source value. + // For 'relation', the $state of the current field IS the source value. + + // Note: Text::calculateDynamicValue is static and stateless, + // it just needs the config from the field. + + $newValue = \Backstage\Fields\Fields\Text::calculateDynamicValue($dependent, $state, $get); + + if ($newValue !== null) { + // Relative path set + // Since we are inside a Repeater row, $set('slug', val) works for sibling fields + // BUT check if $get/set context is correct. + // In a Repeater item, Get/Set operate relative to the item. + // So $set($targetSlug, $newValue) should work. + $set($targetSlug, $newValue); + } } }); } @@ -401,6 +401,4 @@ private static function generateTableColumnsFromChildren(Collection $children, a return $tableColumns; } - - } diff --git a/src/Fields/Text.php b/src/Fields/Text.php index f163f60..a44a394 100644 --- a/src/Fields/Text.php +++ b/src/Fields/Text.php @@ -146,25 +146,25 @@ public static function calculateDynamicValue(Field $field, $sourceValue, ?Get $g // Regex to find {ulid} patterns $parsedFormula = preg_replace_callback('/\{([a-zA-Z0-9-]+)\}/', function ($matches) use ($get) { $ulid = $matches[1]; - + // Try to find the field to get its slug for relative lookup // This allows calculations to work inside Repeaters where fields are named by slug $referencedField = \Backstage\Fields\Models\Field::find($ulid); $val = null; - + if ($referencedField) { // Try relative path first (e.g. inside Repeater) using slug $val = $get($referencedField->slug); } - + // Try direct slug/key access (simplifies Repeater usage) if ($val === null) { - $val = $get($ulid); + $val = $get($ulid); } // Fallback to absolute path (e.g. top level) if ($val === null) { - $val = $get("values.{$ulid}"); + $val = $get("values.{$ulid}"); } if (is_numeric($val)) {