Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

namespace Relaticle\CustomFields\Filament\Integration\Factories;

use Closure;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Contracts\Container\Container;
use InvalidArgumentException;
Expand Down
54 changes: 47 additions & 7 deletions src/Filament/Management/Forms/Components/VisibilityComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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();
}, []);
Expand Down
59 changes: 48 additions & 11 deletions src/Filament/Management/Schemas/FieldForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -107,8 +134,10 @@ private static function getValidationSchema(): array
/**
* @return array<int, Component>
*/
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(),
Expand Down Expand Up @@ -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 ??= '';
Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion src/Filament/Management/Schemas/SectionForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,20 @@ 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<int, Closure(array<int, Component>, string): array<int, Component>> */
private static array $schemaExtensions = [];

public static function entityType(string $entityType): self
{
self::$entityType = $entityType;
self::$modifyUniqueRuleUsing = null;
self::$visibilityScopeSection = null;

return new self;
}
Expand All @@ -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
Expand Down Expand Up @@ -195,7 +209,7 @@ private static function visibilitySchema(): array
}

return [
VisibilityComponent::makeForSection(self::$entityType),
VisibilityComponent::makeForSection(self::$entityType, self::$visibilityScopeSection),
];
}
}
2 changes: 1 addition & 1 deletion src/Livewire/ManageCustomField.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 3 additions & 2 deletions src/Livewire/ManageCustomFieldSection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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))
Expand Down
112 changes: 112 additions & 0 deletions tests/Feature/ConsumerScopeHooksTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

declare(strict_types=1);

use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Utilities\Get;
use Relaticle\CustomFields\Enums\CustomFieldsFeature;
use Relaticle\CustomFields\FeatureSystem\FeatureConfigurator;
use Relaticle\CustomFields\Filament\Management\Forms\Components\VisibilityComponent;
use Relaticle\CustomFields\Filament\Management\Schemas\FieldForm;
use Relaticle\CustomFields\Models\CustomField;
use Relaticle\CustomFields\Models\CustomFieldSection;
use Relaticle\CustomFields\Tests\Fixtures\Models\Post;

/**
* Consumer scoping hooks let an app (e.g. a versioned-form builder) constrain the
* "depends on" field picker and the field-name uniqueness rule to a subset of an
* entity's fields, so the same names can be reused across separate parent forms.
*/
beforeEach(function (): void {
config()->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();
});
});
Loading