diff --git a/src/Filament/Integration/Components/Forms/ToggleButtonsComponent.php b/src/Filament/Integration/Components/Forms/ToggleButtonsComponent.php index 306449cf..05518297 100644 --- a/src/Filament/Integration/Components/Forms/ToggleButtonsComponent.php +++ b/src/Filament/Integration/Components/Forms/ToggleButtonsComponent.php @@ -4,7 +4,6 @@ namespace Relaticle\CustomFields\Filament\Integration\Components\Forms; -use Filament\Forms\Components\Field; use Filament\Forms\Components\ToggleButtons; use Relaticle\CustomFields\Filament\Integration\Base\AbstractFormComponent; use Relaticle\CustomFields\Filament\Integration\Concerns\Forms\ConfiguresColorOptions; diff --git a/src/Filament/Integration/Factories/AbstractComponentFactory.php b/src/Filament/Integration/Factories/AbstractComponentFactory.php index f710a4a8..cc71a683 100644 --- a/src/Filament/Integration/Factories/AbstractComponentFactory.php +++ b/src/Filament/Integration/Factories/AbstractComponentFactory.php @@ -4,7 +4,6 @@ namespace Relaticle\CustomFields\Filament\Integration\Factories; -use Closure; use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Container\Container; use InvalidArgumentException; diff --git a/src/Filament/Management/Forms/Components/VisibilityComponent.php b/src/Filament/Management/Forms/Components/VisibilityComponent.php index 6abcf322..73e43961 100644 --- a/src/Filament/Management/Forms/Components/VisibilityComponent.php +++ b/src/Filament/Management/Forms/Components/VisibilityComponent.php @@ -4,6 +4,7 @@ namespace Relaticle\CustomFields\Filament\Management\Forms\Components; +use Closure; use Exception; use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Repeater; @@ -25,6 +26,7 @@ use Relaticle\CustomFields\Facades\CustomFieldsType; use Relaticle\CustomFields\FeatureSystem\FeatureManager; use Relaticle\CustomFields\Models\CustomField; +use Relaticle\CustomFields\Models\CustomFieldSection; use Relaticle\CustomFields\Services\ModelAttributeDiscoveryService; use Relaticle\CustomFields\Services\RelationConditionResolver; use Relaticle\CustomFields\Services\Visibility\BackendVisibilityService; @@ -38,22 +40,49 @@ final class VisibilityComponent extends Component private ?string $sectionEntityType = null; + /** + * The section the conditioned field/section belongs to. When a consumer registers a + * scope resolver, this is handed to it so the "depends on" picker can be constrained + * to a subset (e.g. only fields in the same parent form). Null applies no scope. + */ + private ?CustomFieldSection $scopeSection = null; + + /** @var ?Closure(string, ?CustomFieldSection): ?Closure */ + private static ?Closure $availableFieldsScopeResolver = null; + public function __construct() { $this->schema([$this->buildFieldset()]); $this->columnSpanFull(); } - public static function make(): static + /** + * Register a resolver that constrains the conditional-visibility "depends on" field + * picker. The resolver receives the entity type and the section the conditioned + * field/section belongs to, and returns a query constraint closure, or null for no + * scope. Register once (e.g. from a service provider). + * + * @param ?Closure(string $entityType, ?CustomFieldSection $section): ?Closure $resolver + */ + public static function resolveAvailableFieldsScopeUsing(?Closure $resolver): void + { + self::$availableFieldsScopeResolver = $resolver; + } + + public static function make(?CustomFieldSection $scopeSection = null): static { - return new self; + $instance = new self; + $instance->scopeSection = $scopeSection; + + return $instance; } - public static function makeForSection(string $entityType): static + public static function makeForSection(string $entityType, ?CustomFieldSection $scopeSection = null): static { $instance = new self; $instance->forSection = true; $instance->sectionEntityType = $entityType; + $instance->scopeSection = $scopeSection; return $instance; } @@ -449,12 +478,23 @@ private function getAvailableFields(Get $get): array } $currentFieldCode = $this->forSection ? null : $get('../../../../code'); + $scopeSection = $this->scopeSection; + $scopeResolver = self::$availableFieldsScopeResolver; - return rescue(function () use ($entityType, $currentFieldCode) { - return CustomFields::customFieldModel()::query() + return rescue(function () use ($entityType, $currentFieldCode, $scopeSection, $scopeResolver) { + $query = CustomFields::customFieldModel()::query() ->forMorphEntity($entityType) - ->when($currentFieldCode, fn (mixed $query) => $query->where('code', '!=', $currentFieldCode)) - ->orderBy('name') + ->when($currentFieldCode, fn (mixed $query) => $query->where('code', '!=', $currentFieldCode)); + + if ($scopeResolver instanceof Closure) { + $constraint = $scopeResolver($entityType, $scopeSection); + + if ($constraint instanceof Closure) { + $query = $constraint($query) ?? $query; + } + } + + return $query->orderBy('name') ->pluck('name', 'code') ->toArray(); }, []); diff --git a/src/Filament/Management/Schemas/FieldForm.php b/src/Filament/Management/Schemas/FieldForm.php index 7d3e0f49..3460c8bf 100644 --- a/src/Filament/Management/Schemas/FieldForm.php +++ b/src/Filament/Management/Schemas/FieldForm.php @@ -34,10 +34,37 @@ use Relaticle\CustomFields\Filament\Management\Forms\Components\TypeField; use Relaticle\CustomFields\Filament\Management\Forms\Components\VisibilityComponent; use Relaticle\CustomFields\Models\CustomField; +use Relaticle\CustomFields\Models\CustomFieldSection; use Relaticle\CustomFields\Services\TenantContextService; class FieldForm implements FormInterface { + /** @var ?Closure(?CustomFieldSection): ?Closure */ + private static ?Closure $uniqueNameRuleModifierResolver = null; + + /** + * Register a resolver that scopes the field-name uniqueness rule beyond the default + * entity-type (+ tenant) scope. The resolver receives the section the field belongs to + * (the create target, or the edited field's section) and returns a rule modifier, or + * null for no extra scope. Lets a consumer allow the same field name across separate + * parent forms while still preventing duplicates within one form. Register once. + * + * @param ?Closure(?CustomFieldSection $section): ?Closure $resolver + */ + public static function resolveUniqueRuleModifierUsing(?Closure $resolver): void + { + self::$uniqueNameRuleModifierResolver = $resolver; + } + + private static function resolveUniqueNameRuleModifier(?CustomFieldSection $section): ?Closure + { + if (self::$uniqueNameRuleModifierResolver instanceof Closure) { + return (self::$uniqueNameRuleModifierResolver)($section); + } + + return null; + } + /** * Disable field when editing a system-defined custom field. */ @@ -107,8 +134,10 @@ private static function getValidationSchema(): array /** * @return array */ - public static function schema(bool $withOptionsRelationship = true): array + public static function schema(bool $withOptionsRelationship = true, ?CustomFieldSection $section = null): array { + $uniqueNameRuleModifier = self::resolveUniqueNameRuleModifier($section); + $optionsRepeater = Repeater::make('options') ->table([ TableColumn::make('Color')->width('150px')->hiddenHeaderLabel(), @@ -224,15 +253,23 @@ public static function schema(bool $withOptionsRelationship = true): array table: CustomFields::customFieldModel(), column: 'name', ignoreRecord: true, - modifyRuleUsing: fn (Unique $rule, Get $get) => $rule - ->where('entity_type', $get('entity_type')) - ->when( - FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY), - fn (Unique $rule) => $rule->where( - config('custom-fields.database.column_names.tenant_foreign_key'), - TenantContextService::getCurrentTenantId() - ) - ) + modifyRuleUsing: function (Unique $rule, Get $get) use ($uniqueNameRuleModifier): Unique { + $rule = $rule + ->where('entity_type', $get('entity_type')) + ->when( + FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY), + fn (Unique $rule) => $rule->where( + config('custom-fields.database.column_names.tenant_foreign_key'), + TenantContextService::getCurrentTenantId() + ) + ); + + if ($uniqueNameRuleModifier instanceof Closure) { + return $uniqueNameRuleModifier($rule, $get); + } + + return $rule; + } ) ->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state): void { $old ??= ''; @@ -505,7 +542,7 @@ public static function schema(bool $withOptionsRelationship = true): array if (FeatureManager::isEnabled(CustomFieldsFeature::FIELD_CONDITIONAL_VISIBILITY)) { $additionalTabs[] = Tab::make( __('custom-fields::custom-fields.field.form.visibility_settings') - )->schema([VisibilityComponent::make()]); + )->schema([VisibilityComponent::make($section)]); } // If no additional tabs, return schema directly without tabs wrapper diff --git a/src/Filament/Management/Schemas/SectionForm.php b/src/Filament/Management/Schemas/SectionForm.php index c6ab80b5..a9c2ad7f 100644 --- a/src/Filament/Management/Schemas/SectionForm.php +++ b/src/Filament/Management/Schemas/SectionForm.php @@ -29,6 +29,12 @@ class SectionForm implements FormInterface, SectionFormInterface /** @var ?Closure(Unique, Get):Unique */ private static ?Closure $modifyUniqueRuleUsing = null; + /** + * The section being edited, handed to the conditional-visibility picker so it can be + * scoped to the section's parent form. Null (e.g. on section create) applies no scope. + */ + private static ?CustomFieldSection $visibilityScopeSection = null; + /** @var array, string): array> */ private static array $schemaExtensions = []; @@ -36,6 +42,7 @@ public static function entityType(string $entityType): self { self::$entityType = $entityType; self::$modifyUniqueRuleUsing = null; + self::$visibilityScopeSection = null; return new self; } @@ -47,6 +54,13 @@ public function modifyUniqueRuleUsing(Closure $callback): self return $this; } + public function scopeVisibilityToSection(?CustomFieldSection $section): self + { + self::$visibilityScopeSection = $section; + + return $this; + } + /** * Register a callback that can append to or modify the section form schema. * Applies to both the create and edit section modals. The callback receives @@ -195,7 +209,7 @@ private static function visibilitySchema(): array } return [ - VisibilityComponent::makeForSection(self::$entityType), + VisibilityComponent::makeForSection(self::$entityType, self::$visibilityScopeSection), ]; } } diff --git a/src/Livewire/ManageCustomField.php b/src/Livewire/ManageCustomField.php index 6dd9b3af..093b33b5 100644 --- a/src/Livewire/ManageCustomField.php +++ b/src/Livewire/ManageCustomField.php @@ -47,7 +47,7 @@ public function editAction(): Action ->icon('heroicon-o-pencil-square') ->model(CustomFields::customFieldModel()) ->record($this->field) - ->schema(FieldForm::schema()) + ->schema(FieldForm::schema(section: $this->field->section)) ->fillForm(function (): array { $data = $this->field->toArray(); $data['options'] = $this->field->options->toArray(); diff --git a/src/Livewire/ManageCustomFieldSection.php b/src/Livewire/ManageCustomFieldSection.php index 0ffb3b9d..8d75c973 100644 --- a/src/Livewire/ManageCustomFieldSection.php +++ b/src/Livewire/ManageCustomFieldSection.php @@ -76,7 +76,8 @@ public function actions(): ?ActionGroup public function editAction(): Action { - $sectionForm = SectionForm::entityType($this->entityType); + $sectionForm = SectionForm::entityType($this->entityType) + ->scopeVisibilityToSection($this->section); if (self::$uniqueRuleModifierResolver instanceof Closure) { $modifier = (self::$uniqueRuleModifierResolver)($this->section); @@ -162,7 +163,7 @@ public function createFieldAction(): Action ->size(Size::ExtraSmall) ->label(__('custom-fields::custom-fields.field.form.add_field')) ->model(CustomFields::customFieldModel()) - ->schema(FieldForm::schema(withOptionsRelationship: false)) + ->schema(FieldForm::schema(withOptionsRelationship: false, section: $this->section)) ->fillForm(['entity_type' => $this->entityType]) ->mutateDataUsing(fn (array $data): array => $this->mutateFieldData($data, $this->entityType, $this->section->getKey())) ->action(fn (array $data) => $this->storeField($data)) diff --git a/tests/Feature/ConsumerScopeHooksTest.php b/tests/Feature/ConsumerScopeHooksTest.php new file mode 100644 index 00000000..af0c3cff --- /dev/null +++ b/tests/Feature/ConsumerScopeHooksTest.php @@ -0,0 +1,112 @@ +set('custom-fields.features', FeatureConfigurator::configure() + ->enable(CustomFieldsFeature::FIELD_CONDITIONAL_VISIBILITY) + ->enable(CustomFieldsFeature::SECTION_CONDITIONAL_VISIBILITY) + ); +}); + +afterEach(function (): void { + VisibilityComponent::resolveAvailableFieldsScopeUsing(null); + FieldForm::resolveUniqueRuleModifierUsing(null); +}); + +function nullGet(): Get +{ + return new class extends Get + { + public function __construct() {} + + public function __invoke(string|Component $path = '', bool $isAbsolute = false): mixed + { + return null; + } + }; +} + +function availableFields(VisibilityComponent $component): array +{ + $method = new ReflectionMethod($component, 'getAvailableFields'); + $method->setAccessible(true); + + return $method->invoke($component, nullGet()); +} + +describe('VisibilityComponent available-fields scope resolver', function (): void { + it('lists all entity fields when no resolver is registered (backward compatible)', function (): void { + $sectionA = CustomFieldSection::factory()->create(['entity_type' => Post::class, 'name' => 'A', 'code' => 'a']); + $sectionB = CustomFieldSection::factory()->create(['entity_type' => Post::class, 'name' => 'B', 'code' => 'b']); + CustomField::factory()->create(['custom_field_section_id' => $sectionA->id, 'entity_type' => Post::class, 'name' => 'Alpha', 'code' => 'alpha', 'type' => 'text']); + CustomField::factory()->create(['custom_field_section_id' => $sectionB->id, 'entity_type' => Post::class, 'name' => 'Beta', 'code' => 'beta', 'type' => 'text']); + + $options = availableFields(VisibilityComponent::makeForSection(Post::class, $sectionA)); + + expect($options)->toBe(['alpha' => 'Alpha', 'beta' => 'Beta']); + }); + + it('constrains the field list to the resolver-defined subset', function (): void { + $sectionA = CustomFieldSection::factory()->create(['entity_type' => Post::class, 'name' => 'A', 'code' => 'a']); + $sectionB = CustomFieldSection::factory()->create(['entity_type' => Post::class, 'name' => 'B', 'code' => 'b']); + CustomField::factory()->create(['custom_field_section_id' => $sectionA->id, 'entity_type' => Post::class, 'name' => 'Alpha', 'code' => 'alpha', 'type' => 'text']); + CustomField::factory()->create(['custom_field_section_id' => $sectionB->id, 'entity_type' => Post::class, 'name' => 'Beta', 'code' => 'beta', 'type' => 'text']); + + VisibilityComponent::resolveAvailableFieldsScopeUsing( + fn (string $entityType, ?CustomFieldSection $section): ?Closure => $section instanceof CustomFieldSection + ? fn ($query) => $query->where('custom_field_section_id', $section->id) + : null + ); + + $options = availableFields(VisibilityComponent::makeForSection(Post::class, $sectionA)); + + expect($options)->toBe(['alpha' => 'Alpha']); + }); + + it('falls back to all fields when the resolver returns null for the given section', function (): void { + $sectionA = CustomFieldSection::factory()->create(['entity_type' => Post::class, 'name' => 'A', 'code' => 'a']); + CustomField::factory()->create(['custom_field_section_id' => $sectionA->id, 'entity_type' => Post::class, 'name' => 'Alpha', 'code' => 'alpha', 'type' => 'text']); + + VisibilityComponent::resolveAvailableFieldsScopeUsing(fn (string $entityType, ?CustomFieldSection $section): null => null); + + $options = availableFields(VisibilityComponent::makeForSection(Post::class, $sectionA)); + + expect($options)->toBe(['alpha' => 'Alpha']); + }); +}); + +describe('FieldForm unique-name modifier resolver', function (): void { + it('builds the field schema unchanged when no resolver is registered (backward compatible)', function (): void { + $schema = FieldForm::schema(); + + expect($schema)->toBeArray()->not->toBeEmpty(); + }); + + it('accepts an optional section without altering the schema shape', function (): void { + $section = CustomFieldSection::factory()->create(['entity_type' => Post::class, 'name' => 'A', 'code' => 'a']); + + FieldForm::resolveUniqueRuleModifierUsing( + fn (?CustomFieldSection $s): ?Closure => $s instanceof CustomFieldSection + ? fn ($rule) => $rule->where('custom_field_section_id', $s->id) + : null + ); + + expect(FieldForm::schema(section: $section))->toBeArray()->not->toBeEmpty(); + }); +}); diff --git a/tests/helpers.php b/tests/helpers.php index 49e9a786..a623caf6 100644 --- a/tests/helpers.php +++ b/tests/helpers.php @@ -2,10 +2,6 @@ declare(strict_types=1); -use Livewire\Component; -use Livewire\Features\SupportTesting\Testable; -use Livewire\Livewire; - // // if (! function_exists('\Filament\Tests\livewire')) { // function livewire(string | Component $component, array $props = []): Testable