From d1cba4041361e768fc5fa111f02b6413431f45d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9ia=20Bohner?= Date: Mon, 17 Aug 2026 21:42:10 -0300 Subject: [PATCH] Add option for key value, layout tweaks --- CHANGELOG.md | 37 +--- README.md | 21 +++ config/filament-form-builder.php | 31 ++++ .../FilamentFormFieldsRelationManager.php | 139 ++++++++------- src/Models/Traits/BelongsToTenant.php | 20 ++- src/Support/OptionsEditor.php | 166 ++++++++++++++++++ tests/OptionsEditorTest.php | 71 ++++++++ 7 files changed, 377 insertions(+), 108 deletions(-) create mode 100644 src/Support/OptionsEditor.php create mode 100644 tests/OptionsEditorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index cae481f..d0dea9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -251,39 +251,4 @@ Fixes a typo in locking action visibility and adds a locked column to form resou Schema::table('filament_forms', function (Blueprint $table) { $table->boolean('locked')->default(false); }); - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -``` \ No newline at end of file +``` diff --git a/README.md b/README.md index 1e5e1d0..e1a738f 100644 --- a/README.md +++ b/README.md @@ -160,9 +160,30 @@ Key configuration options include: - **Custom Page Classes**: Override the default `ShowForm` and `ShowEntry` pages for guest and app panels - **Custom Middleware**: Override the default `SetFormPanel` middleware for panel context switching - **Route URIs**: Customize the form and entry route paths (`filament-form-uri`, `filament-form-user-uri`) +- **Options Editor**: Choose TagsInput (`tags`, default) or KeyValue (`key_value`) for Select / Radio / Checkbox List options. Supports a global string, a per-type map, or a Closure (`options_editor`) See `config/filament-form-builder.php` for all available configuration options. +#### Options editor examples + +```php +// Global TagsInput (default — unchanged for existing apps) +'options_editor' => 'tags', + +// Global KeyValue (value => label) +'options_editor' => 'key_value', + +// Per field type (enum case names) +'options_editor' => [ + 'RADIO' => 'key_value', + 'SELECT' => 'tags', + 'default' => 'tags', +], + +// Closure +'options_editor' => fn (?string $type): string => $type === 'RADIO' ? 'key_value' : 'tags', +``` + ### Private entries You can restrict who can view or export form entries on a per-form basis. When **Private entries** is enabled for a form, the package uses your application’s policy to decide visibility. diff --git a/config/filament-form-builder.php b/config/filament-form-builder.php index d49bb7c..1681ef5 100644 --- a/config/filament-form-builder.php +++ b/config/filament-form-builder.php @@ -151,4 +151,35 @@ | */ 'user_model' => 'App\Models\User', + + /* + |-------------------------------------------------------------------------- + | Options Editor + |-------------------------------------------------------------------------- + | + | How option lists are edited for Select, Select Multiple, Radio, and + | Checkbox List fields in the admin Fields relation manager. + | + | Default remains TagsInput (`tags`) so existing apps are unchanged. + | Use KeyValue (`key_value`) when options are stored as value => label + | maps (for example AI quizzes with keys a/b/c/d). + | + | Supported shapes: + | - string: 'tags' or 'key_value' for every options-capable type + | - array: per-type map (keys are FilamentFieldTypeEnum names), optional + | 'default' fallback + | - Closure: fn (?string $type): string + | + | Examples: + | 'options_editor' => 'tags', + | 'options_editor' => 'key_value', + | 'options_editor' => [ + | 'RADIO' => 'key_value', + | 'SELECT' => 'tags', + | 'default' => 'tags', + | ], + | 'options_editor' => fn (?string $type): string => $type === 'RADIO' ? 'key_value' : 'tags', + | + */ + 'options_editor' => 'tags', ]; diff --git a/src/Filament/Resources/FilamentFormResource/RelationManagers/FilamentFormFieldsRelationManager.php b/src/Filament/Resources/FilamentFormResource/RelationManagers/FilamentFormFieldsRelationManager.php index 2864edd..c5d536e 100644 --- a/src/Filament/Resources/FilamentFormResource/RelationManagers/FilamentFormFieldsRelationManager.php +++ b/src/Filament/Resources/FilamentFormResource/RelationManagers/FilamentFormFieldsRelationManager.php @@ -11,11 +11,11 @@ use Filament\Actions\EditAction; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; -use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Resources\RelationManagers\RelationManager; +use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; @@ -24,6 +24,7 @@ use Filament\Tables\Table; use Illuminate\Database\Eloquent\Model; use Tapp\FilamentFormBuilder\Enums\FilamentFieldTypeEnum; +use Tapp\FilamentFormBuilder\Support\OptionsEditor; class FilamentFormFieldsRelationManager extends RelationManager { @@ -37,42 +38,47 @@ public static function getTitle(Model $ownerRecord, string $pageClass): string public function form(Schema $schema): Schema { return $schema + ->columns(2) ->components([ - Select::make('type') - ->options(function () { - return collect(FilamentFieldTypeEnum::cases()) - ->mapWithKeys(fn ($type) => [$type->name => $type->fieldName()]) - ->sortBy(fn ($label, $key) => $label) - ->toArray(); - }) - ->columnSpan(function ($state) { - if (! empty($state) && FilamentFieldTypeEnum::fromString($state)->hasOptions()) { - return 1; - } - - return 2; - }) - ->required() - ->live(), + Grid::make(3) + ->schema([ + Select::make('type') + ->options(function () { + return collect(FilamentFieldTypeEnum::cases()) + ->mapWithKeys(fn ($type) => [$type->name => $type->fieldName()]) + ->sortBy(fn ($label, $key) => $label) + ->toArray(); + }) + ->required() + ->live() + ->columnSpan(1), + TextInput::make('order') + ->default(function () { + return $this->getOwnerRecord()->filamentFormFields()->count() + 1; + }) + ->numeric() + ->columnSpan(1), + Toggle::make('required') + ->inline(false) + ->visible(function (Get $get) { + return $get('type') !== FilamentFieldTypeEnum::REPEATER->name + && $get('type') !== FilamentFieldTypeEnum::HEADING->name; + }) + ->columnSpan(1), + ]) + ->columnSpanFull(), Textarea::make('label') ->required() ->label(function (Get $get) { return $get('type') === FilamentFieldTypeEnum::HEADING->name ? 'Heading' : 'Label'; - }), - TagsInput::make('options') - ->placeholder('Add options') - ->hint('Press enter after inputting each option') - ->visible(function (Get $get) { - if ($get('type')) { - return FilamentFieldTypeEnum::fromString($get('type'))->hasOptions(); - } - - return false; - }), + }) + ->columnSpanFull(), + self::optionsEditor(), Textarea::make('hint') ->label(function (Get $get) { return $get('type') === FilamentFieldTypeEnum::HEADING->name ? 'Subheading' : 'Hint'; - }), + }) + ->columnSpanFull(), // TagsInput::make('rules') // ->placeholder('Add rules') // ->hint('view list of available rules here, https://laravel.com/docs/11.x/validation#available-validation-rules') @@ -80,47 +86,37 @@ public function form(Schema $schema): Schema // return $get('type') !== FilamentFieldTypeEnum::REPEATER->name // && $get('type') !== FilamentFieldTypeEnum::HEADING->name; // }), - TextInput::make('order') - ->default(function () { - return $this->getOwnerRecord()->filamentFormFields()->count() + 1; - }) - ->numeric(), - Toggle::make('required') - ->visible(function (Get $get) { - return $get('type') !== FilamentFieldTypeEnum::REPEATER->name - && $get('type') !== FilamentFieldTypeEnum::HEADING->name; - }), Repeater::make('schema') ->label('Fields') ->schema([ - Textarea::make('label') - ->required(), - Select::make('type') - ->options(function () { - $options = collect(FilamentFieldTypeEnum::cases()) - ->filter(fn ($type) => $type !== FilamentFieldTypeEnum::REPEATER) - ->mapWithKeys(fn ($type) => [$type->name => $type->fieldName()]) - ->toArray(); + Grid::make(2) + ->schema([ + Select::make('type') + ->options(function () { + $options = collect(FilamentFieldTypeEnum::cases()) + ->filter(fn ($type) => $type !== FilamentFieldTypeEnum::REPEATER) + ->mapWithKeys(fn ($type) => [$type->name => $type->fieldName()]) + ->toArray(); - return $options; - }) + return $options; + }) + ->required() + ->live() + ->columnSpan(1), + Toggle::make('required') + ->inline(false) + ->columnSpan(1), + ]) + ->columnSpanFull(), + Textarea::make('label') ->required() - ->live(), - TagsInput::make('options') - ->placeholder('Add options') - ->hint('Press enter after inputting each option') - ->visible(function (Get $get) { - if ($get('type')) { - return FilamentFieldTypeEnum::fromString($get('type'))->hasOptions(); - } - - return false; - }), - Textarea::make('hint'), + ->columnSpanFull(), + self::optionsEditor(), + Textarea::make('hint') + ->columnSpanFull(), // TagsInput::make('rules') // ->placeholder('Add rules') // ->hint('view list of available rules here, https://laravel.com/docs/11.x/validation#available-validation-rules'), - Toggle::make('required'), ]) ->columns(2) ->columnSpanFull() @@ -140,7 +136,17 @@ public function table(Table $table): Table ->modelLabel(config('filament-form-builder.admin-panel-filament-form-field-name')) ->reorderable('order') ->columns([ - TextColumn::make('label'), + TextColumn::make('label') + ->limit(60) + ->tooltip(function (TextColumn $column): ?string { + $state = $column->getState(); + + if (! is_string($state) || strlen($state) <= $column->getCharacterLimit()) { + return null; + } + + return $state; + }), TextColumn::make('order') ->numeric() ->sortable(), @@ -205,4 +211,11 @@ public function table(Table $table): Table ]), ]); } + + private static function optionsEditor(): Grid + { + return Grid::make(1) + ->schema(fn (Get $get): array => OptionsEditor::components($get('type'))) + ->columnSpanFull(); + } } diff --git a/src/Models/Traits/BelongsToTenant.php b/src/Models/Traits/BelongsToTenant.php index 8d41cda..3697df7 100644 --- a/src/Models/Traits/BelongsToTenant.php +++ b/src/Models/Traits/BelongsToTenant.php @@ -17,11 +17,18 @@ public static function bootBelongsToTenant(): void return; } - // Register the dynamic relationship + // Register the dynamic relationship. The relation name must be passed explicitly: + // Eloquent otherwise guesses it from the backtrace, which resolves to this closure + // and breaks `associate()` and any later `refresh()` / `load()` on the model. static::resolveRelationUsing( static::getTenantRelationshipName(), function ($model) { - return $model->belongsTo(config('filament-form-builder.tenancy.model'), static::getTenantColumnName()); + return $model->belongsTo( + config('filament-form-builder.tenancy.model'), + static::getTenantColumnName(), + null, + static::getTenantRelationshipName(), + ); } ); @@ -34,14 +41,12 @@ function ($model) { return; } - $tenantRelationshipName = static::getTenantRelationshipName(); - // Try to get tenant from Filament context (Filament's standard method) // This handles top-level resources created outside Filament's Resource observers if (class_exists(Filament::class)) { $tenant = Filament::getTenant(); if ($tenant) { - $model->{$tenantRelationshipName}()->associate($tenant); + $model->{$tenantColumnName} = $tenant->getKey(); return; } @@ -55,10 +60,7 @@ function ($model) { $parentForm = $parentFormClass::find($parentFormId); if ($parentForm) { - $parentTenant = $parentForm->{$tenantRelationshipName}; - if ($parentTenant) { - $model->{$tenantRelationshipName}()->associate($parentTenant); - } + $model->{$tenantColumnName} = $parentForm->{$tenantColumnName}; } } }); diff --git a/src/Support/OptionsEditor.php b/src/Support/OptionsEditor.php new file mode 100644 index 0000000..04895a3 --- /dev/null +++ b/src/Support/OptionsEditor.php @@ -0,0 +1,166 @@ + 'key_value', 'default' => 'tags']` + * - Closure: `fn (?string $type): string => ...` + */ +final class OptionsEditor +{ + public const string TAGS = 'tags'; + + public const string KEY_VALUE = 'key_value'; + + public static function forType(null|string|FilamentFieldTypeEnum $type): string + { + $typeName = $type instanceof FilamentFieldTypeEnum ? $type->name : $type; + $config = config('filament-form-builder.options_editor', self::TAGS); + + if ($config instanceof Closure) { + $resolved = $config($typeName); + + return self::normalize(is_string($resolved) ? $resolved : self::TAGS); + } + + if (is_string($config)) { + return self::normalize($config); + } + + if (is_array($config)) { + if (is_string($typeName) && isset($config[$typeName]) && is_string($config[$typeName])) { + return self::normalize($config[$typeName]); + } + + if (isset($config['default']) && is_string($config['default'])) { + return self::normalize($config['default']); + } + } + + return self::TAGS; + } + + public static function isTags(null|string|FilamentFieldTypeEnum $type): bool + { + return self::forType($type) === self::TAGS; + } + + public static function isKeyValue(null|string|FilamentFieldTypeEnum $type): bool + { + return self::forType($type) === self::KEY_VALUE; + } + + public static function typeHasOptions(mixed $type): bool + { + if (! is_string($type) || $type === '') { + return false; + } + + $fieldType = FilamentFieldTypeEnum::fromString($type); + + return $fieldType instanceof FilamentFieldTypeEnum && $fieldType->hasOptions(); + } + + /** + * Only the editor matching the field type is built: two components sharing the + * `options` state path would hydrate in sequence and overwrite each other's state. + * + * @return list + */ + public static function components(mixed $type): array + { + if (! self::typeHasOptions($type)) { + return []; + } + + if (self::isKeyValue(is_string($type) ? $type : null)) { + return [ + KeyValue::make('options') + ->keyLabel('Key') + ->valueLabel('Label') + ->addActionLabel('Add option') + ->reorderable() + ->hint('Key is the stored value (for example a, b, c). Label is what respondents see.') + ->formatStateUsing(fn (mixed $state): array => self::normalizeForKeyValue($state)) + ->columnSpanFull(), + ]; + } + + return [ + TagsInput::make('options') + ->placeholder('Add options') + ->hint('Press enter after inputting each option') + ->formatStateUsing(fn (mixed $state): array => self::normalizeForTags($state)) + ->columnSpanFull(), + ]; + } + + /** + * TagsInput expects a list of strings. + * + * @return list + */ + public static function normalizeForTags(mixed $state): array + { + if (! is_array($state)) { + return []; + } + + return array_values(array_filter( + $state, + fn (mixed $value): bool => is_string($value) && $value !== '', + )); + } + + /** + * KeyValue expects an associative value => label map. + * + * @return array + */ + public static function normalizeForKeyValue(mixed $state): array + { + if (! is_array($state)) { + return []; + } + + if ($state === []) { + return []; + } + + if (array_is_list($state)) { + $labels = array_values(array_filter( + $state, + fn (mixed $value): bool => is_string($value) && $value !== '', + )); + + /** @var array */ + return array_combine($labels, $labels) ?: []; + } + + $normalized = []; + + foreach ($state as $key => $value) { + if (! is_string($value) || $value === '') { + continue; + } + + $normalized[(string) $key] = $value; + } + + return $normalized; + } + + private static function normalize(string $editor): string + { + return $editor === self::KEY_VALUE ? self::KEY_VALUE : self::TAGS; + } +} diff --git a/tests/OptionsEditorTest.php b/tests/OptionsEditorTest.php new file mode 100644 index 0000000..a3909be --- /dev/null +++ b/tests/OptionsEditorTest.php @@ -0,0 +1,71 @@ + 'tags']); + + expect(OptionsEditor::forType('RADIO'))->toBe(OptionsEditor::TAGS) + ->and(OptionsEditor::isTags('SELECT'))->toBeTrue() + ->and(OptionsEditor::isKeyValue(FilamentFieldTypeEnum::CHECKBOX_LIST))->toBeFalse(); +}); + +it('applies a global key_value editor', function (): void { + config(['filament-form-builder.options_editor' => 'key_value']); + + expect(OptionsEditor::forType('RADIO'))->toBe(OptionsEditor::KEY_VALUE) + ->and(OptionsEditor::isKeyValue('SELECT_MULTIPLE'))->toBeTrue(); +}); + +it('resolves a per-type map with default fallback', function (): void { + config(['filament-form-builder.options_editor' => [ + 'RADIO' => 'key_value', + 'SELECT' => 'tags', + 'default' => 'tags', + ]]); + + expect(OptionsEditor::forType('RADIO'))->toBe(OptionsEditor::KEY_VALUE) + ->and(OptionsEditor::forType('SELECT'))->toBe(OptionsEditor::TAGS) + ->and(OptionsEditor::forType('CHECKBOX_LIST'))->toBe(OptionsEditor::TAGS) + ->and(OptionsEditor::forType(null))->toBe(OptionsEditor::TAGS); +}); + +it('resolves a closure editor', function (): void { + config([ + 'filament-form-builder.options_editor' => fn (?string $type): string => $type === 'RADIO' + ? OptionsEditor::KEY_VALUE + : OptionsEditor::TAGS, + ]); + + expect(OptionsEditor::forType('RADIO'))->toBe(OptionsEditor::KEY_VALUE) + ->and(OptionsEditor::forType('SELECT'))->toBe(OptionsEditor::TAGS); +}); + +it('builds only the editor matching the field type', function (): void { + config(['filament-form-builder.options_editor' => ['RADIO' => 'key_value', 'default' => 'tags']]); + + expect(OptionsEditor::components('RADIO')) + ->toHaveCount(1) + ->sequence(fn ($component) => $component->toBeInstanceOf(KeyValue::class)); + + expect(OptionsEditor::components('SELECT')) + ->toHaveCount(1) + ->sequence(fn ($component) => $component->toBeInstanceOf(TagsInput::class)); + + expect(OptionsEditor::components('TEXT'))->toBe([]); + expect(OptionsEditor::components(null))->toBe([]); +}); + +it('normalizes list and keyed option payloads for each editor', function (): void { + expect(OptionsEditor::normalizeForTags(['a' => 'Alpha', 'b' => 'Beta'])) + ->toBe(['Alpha', 'Beta']) + ->and(OptionsEditor::normalizeForTags(['Alpha', 'Beta'])) + ->toBe(['Alpha', 'Beta']) + ->and(OptionsEditor::normalizeForKeyValue(['Alpha', 'Beta'])) + ->toBe(['Alpha' => 'Alpha', 'Beta' => 'Beta']) + ->and(OptionsEditor::normalizeForKeyValue(['a' => 'Alpha', 'b' => 'Beta'])) + ->toBe(['a' => 'Alpha', 'b' => 'Beta']); +});