From 86782449c5c035e7c5ccd04accb1782f165ff01b Mon Sep 17 00:00:00 2001 From: manukminasyan Date: Thu, 12 Feb 2026 23:25:52 +0400 Subject: [PATCH 01/11] feat: add model attribute conditions and section conditional visibility Add two new feature-flagged capabilities: 1. Model attribute conditions: visibility conditions can now reference the parent model's database columns (e.g., show field when product.type === 'physical'), not just other custom field values. 2. Section-level visibility: sections support the same conditional visibility system as fields, with full parity for both custom field and model attribute conditions. Key changes: - New ConditionSource enum (CustomField / ModelAttribute) - ModelAttributeDiscoveryService for introspecting model columns - VisibilityData and CoreVisibilityLogicService accept optional Model - FrontendVisibilityService generates $get() paths per source type - SectionComponentFactory applies visibleJs for reactive visibility - VisibilityComponent admin UI with source selector and dynamic inputs - Fix visibleJs/visible() conflict that prevented reactive visibility --- src/Data/CustomFieldSectionSettingsData.php | 9 +- src/Data/VisibilityConditionData.php | 12 + src/Data/VisibilityData.php | 42 +- src/Enums/ConditionSource.php | 21 + src/Enums/CustomFieldsFeature.php | 4 + .../Base/AbstractFormComponent.php | 15 +- .../Integration/Builders/FormBuilder.php | 9 +- .../Components/Forms/ClosureFormAdapter.php | 2 +- .../Factories/SectionComponentFactory.php | 70 +- .../Forms/Components/VisibilityComponent.php | 146 ++- .../Pages/CustomFieldsManagementPage.php | 3 +- .../Management/Schemas/SectionForm.php | 14 + src/Livewire/ManageCustomFieldSection.php | 6 +- .../ModelAttributeDiscoveryService.php | 224 ++++ .../Visibility/BackendVisibilityService.php | 28 +- .../Visibility/CoreVisibilityLogicService.php | 77 +- .../Visibility/FrontendVisibilityService.php | 136 ++- .../Feature/ModelAttributeConditionsTest.php | 583 +++++++++++ .../SectionVisibilityIntegrationTest.php | 973 ++++++++++++++++++ 19 files changed, 2292 insertions(+), 82 deletions(-) create mode 100644 src/Enums/ConditionSource.php create mode 100644 src/Services/ModelAttributeDiscoveryService.php create mode 100644 tests/Feature/ModelAttributeConditionsTest.php create mode 100644 tests/Feature/SectionVisibilityIntegrationTest.php diff --git a/src/Data/CustomFieldSectionSettingsData.php b/src/Data/CustomFieldSectionSettingsData.php index 5e016cc2..83d12ae4 100644 --- a/src/Data/CustomFieldSectionSettingsData.php +++ b/src/Data/CustomFieldSectionSettingsData.php @@ -1,5 +1,7 @@ source === ConditionSource::ModelAttribute; + } + + public function isCustomField(): bool + { + return $this->source === ConditionSource::CustomField; + } } diff --git a/src/Data/VisibilityData.php b/src/Data/VisibilityData.php index 347fec1e..4b79ab83 100644 --- a/src/Data/VisibilityData.php +++ b/src/Data/VisibilityData.php @@ -4,6 +4,8 @@ namespace Relaticle\CustomFields\Data; +use Illuminate\Database\Eloquent\Model; +use Relaticle\CustomFields\Enums\ConditionSource; use Relaticle\CustomFields\Enums\VisibilityLogic; use Relaticle\CustomFields\Enums\VisibilityMode; use Spatie\LaravelData\Attributes\DataCollectionOf; @@ -34,7 +36,7 @@ public function requiresConditions(): bool /** * @param array $fieldValues */ - public function evaluate(array $fieldValues): bool + public function evaluate(array $fieldValues, ?Model $record = null): bool { if (! $this->requiresConditions() || ! $this->conditions instanceof DataCollection) { return $this->mode === VisibilityMode::ALWAYS_VISIBLE; @@ -43,7 +45,7 @@ public function evaluate(array $fieldValues): bool $results = []; foreach ($this->conditions as $condition) { - $result = $this->evaluateCondition($condition, $fieldValues); + $result = $this->evaluateCondition($condition, $fieldValues, $record); $results[] = $result; } @@ -55,14 +57,22 @@ public function evaluate(array $fieldValues): bool /** * @param array $fieldValues */ - private function evaluateCondition(VisibilityConditionData $condition, array $fieldValues): bool - { - $fieldValue = $fieldValues[$condition->field_code] ?? null; + private function evaluateCondition( + VisibilityConditionData $condition, + array $fieldValues, + ?Model $record = null + ): bool { + $fieldValue = match ($condition->source) { + ConditionSource::CustomField => $fieldValues[$condition->field_code] ?? null, + ConditionSource::ModelAttribute => $record?->getAttribute($condition->field_code), + }; return $condition->operator->evaluate($fieldValue, $condition->value); } /** + * Get dependent custom field codes (excludes model attribute conditions). + * * @return array */ public function getDependentFields(): array @@ -74,9 +84,29 @@ public function getDependentFields(): array $fields = []; foreach ($this->conditions as $condition) { - $fields[] = $condition->field_code; + if ($condition->isCustomField()) { + $fields[] = $condition->field_code; + } } return array_unique($fields); } + + /** + * Check if any conditions reference model attributes. + */ + public function hasModelAttributeConditions(): bool + { + if (! $this->conditions instanceof DataCollection) { + return false; + } + + foreach ($this->conditions as $condition) { + if ($condition->isModelAttribute()) { + return true; + } + } + + return false; + } } diff --git a/src/Enums/ConditionSource.php b/src/Enums/ConditionSource.php new file mode 100644 index 00000000..967d5d11 --- /dev/null +++ b/src/Enums/ConditionSource.php @@ -0,0 +1,21 @@ + 'Custom Field', + self::ModelAttribute => 'Model Attribute', + }; + } +} diff --git a/src/Enums/CustomFieldsFeature.php b/src/Enums/CustomFieldsFeature.php index 30c903fc..dc4548a3 100644 --- a/src/Enums/CustomFieldsFeature.php +++ b/src/Enums/CustomFieldsFeature.php @@ -19,6 +19,10 @@ enum CustomFieldsFeature: string case FIELD_UNIQUE_VALUE = 'field_unique_value'; case FIELD_VALIDATION_RULES = 'field_validation_rules'; + // Visibility features + case MODEL_ATTRIBUTE_CONDITIONS = 'model_attribute_conditions'; + case SECTION_CONDITIONAL_VISIBILITY = 'section_conditional_visibility'; + // Table/UI integration features case UI_TABLE_COLUMNS = 'ui_table_columns'; case UI_TABLE_FILTERS = 'ui_table_filters'; diff --git a/src/Filament/Integration/Base/AbstractFormComponent.php b/src/Filament/Integration/Base/AbstractFormComponent.php index 3506a8a1..41d06682 100644 --- a/src/Filament/Integration/Base/AbstractFormComponent.php +++ b/src/Filament/Integration/Base/AbstractFormComponent.php @@ -29,7 +29,7 @@ public function __construct( protected ValidationService $validationService, protected CoreVisibilityLogicService $coreVisibilityLogic, - protected FrontendVisibilityService $frontendVisibilityService + protected FrontendVisibilityService $frontendVisibilityService, ) {} /** @@ -141,9 +141,16 @@ private function applyVisibility( $allFields ); - return in_array($jsExpression, [null, '', '0'], true) - ? $field - : $field->live()->visibleJs($jsExpression); + if (in_array($jsExpression, [null, '', '0'], true)) { + return $field; + } + + // visibleJs alone handles both initial state (via x-cloak) and reactivity. + // Do NOT combine with visible() — server-side visible(false) prevents the + // component from rendering entirely, which blocks visibleJs from ever executing. + $field->live()->visibleJs($jsExpression); + + return $field; } /** diff --git a/src/Filament/Integration/Builders/FormBuilder.php b/src/Filament/Integration/Builders/FormBuilder.php index e282664d..12b77e59 100644 --- a/src/Filament/Integration/Builders/FormBuilder.php +++ b/src/Filament/Integration/Builders/FormBuilder.php @@ -6,6 +6,7 @@ namespace Relaticle\CustomFields\Filament\Integration\Builders; use Filament\Schemas\Components\Grid; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Relaticle\CustomFields\Enums\CustomFieldsFeature; use Relaticle\CustomFields\FeatureSystem\FeatureManager; @@ -78,13 +79,17 @@ public function values(): Collection return $allFields->map($createField); } + $record = $this->explicitModel instanceof Model && $this->explicitModel->exists + ? $this->explicitModel + : null; + return $this->getFilteredSections() - ->map(function (CustomFieldSection $section) use ($sectionComponentFactory, $createField) { + ->map(function (CustomFieldSection $section) use ($sectionComponentFactory, $createField, $allFields, $record) { $fields = $section->fields->map($createField); return $fields->isEmpty() ? null - : $sectionComponentFactory->create($section)->schema($fields->toArray()); + : $sectionComponentFactory->create($section, $allFields, $record)->schema($fields->toArray()); }) ->filter(); } diff --git a/src/Filament/Integration/Components/Forms/ClosureFormAdapter.php b/src/Filament/Integration/Components/Forms/ClosureFormAdapter.php index 1e86508a..3955d8ed 100644 --- a/src/Filament/Integration/Components/Forms/ClosureFormAdapter.php +++ b/src/Filament/Integration/Components/Forms/ClosureFormAdapter.php @@ -31,7 +31,7 @@ public function __construct( private Closure $closure, ValidationService $validationService, CoreVisibilityLogicService $coreVisibilityLogic, - FrontendVisibilityService $frontendVisibilityService + FrontendVisibilityService $frontendVisibilityService, ) { parent::__construct($validationService, $coreVisibilityLogic, $frontendVisibilityService); } diff --git a/src/Filament/Integration/Factories/SectionComponentFactory.php b/src/Filament/Integration/Factories/SectionComponentFactory.php index efa86352..a73efbd5 100644 --- a/src/Filament/Integration/Factories/SectionComponentFactory.php +++ b/src/Filament/Integration/Factories/SectionComponentFactory.php @@ -7,14 +7,32 @@ use Filament\Schemas\Components\Fieldset; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; use Relaticle\CustomFields\Enums\CustomFieldSectionType; +use Relaticle\CustomFields\Enums\CustomFieldsFeature; +use Relaticle\CustomFields\FeatureSystem\FeatureManager; +use Relaticle\CustomFields\Models\CustomField; use Relaticle\CustomFields\Models\CustomFieldSection; +use Relaticle\CustomFields\Services\Visibility\BackendVisibilityService; +use Relaticle\CustomFields\Services\Visibility\FrontendVisibilityService; -final class SectionComponentFactory +final readonly class SectionComponentFactory { - public function create(CustomFieldSection $customFieldSection): Section|Fieldset|Grid - { - return match ($customFieldSection->type) { + public function __construct( + private FrontendVisibilityService $frontendVisibilityService, + private BackendVisibilityService $backendVisibilityService, + ) {} + + /** + * @param Collection|null $allFields + */ + public function create( + CustomFieldSection $customFieldSection, + ?Collection $allFields = null, + ?Model $record = null + ): Section|Fieldset|Grid { + $component = match ($customFieldSection->type) { CustomFieldSectionType::SECTION => Section::make($customFieldSection->name) ->columnSpanFull() ->description($customFieldSection->description) @@ -25,5 +43,49 @@ public function create(CustomFieldSection $customFieldSection): Section|Fieldset ->columns(12), CustomFieldSectionType::HEADLESS => Grid::make(12)->columnSpanFull(), }; + + if ($this->shouldApplySectionVisibility($customFieldSection)) { + $this->applySectionVisibility($component, $customFieldSection, $allFields, $record); + } + + return $component; + } + + private function shouldApplySectionVisibility(CustomFieldSection $customFieldSection): bool + { + if (! FeatureManager::isEnabled(CustomFieldsFeature::SECTION_CONDITIONAL_VISIBILITY)) { + return false; + } + + $visibility = $customFieldSection->settings->visibility ?? null; + + return $visibility?->requiresConditions() ?? false; + } + + private function applySectionVisibility( + Section|Fieldset|Grid $component, + CustomFieldSection $section, + ?Collection $allFields, + ?Model $record + ): void { + $jsExpression = $this->frontendVisibilityService->buildSectionVisibilityExpression($section, $allFields); + + if (filled($jsExpression)) { + // visibleJs alone handles both initial state (via x-cloak) and reactivity. + // Do NOT combine with visible() — server-side visible(false) prevents the + // component from rendering entirely, which blocks visibleJs from ever executing. + $component->visibleJs($jsExpression); + + return; + } + + // Fallback to server-side only when no JS expression is available + $visibility = $section->settings->visibility ?? null; + + if ($record instanceof Model && $visibility?->hasModelAttributeConditions()) { + $component->visible( + fn () => $this->backendVisibilityService->isSectionVisible($record, $section, $allFields ?? collect()) + ); + } } } diff --git a/src/Filament/Management/Forms/Components/VisibilityComponent.php b/src/Filament/Management/Forms/Components/VisibilityComponent.php index ca4cd478..68aa4173 100644 --- a/src/Filament/Management/Forms/Components/VisibilityComponent.php +++ b/src/Filament/Management/Forms/Components/VisibilityComponent.php @@ -15,12 +15,16 @@ use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; use Relaticle\CustomFields\CustomFields; +use Relaticle\CustomFields\Enums\ConditionSource; +use Relaticle\CustomFields\Enums\CustomFieldsFeature; use Relaticle\CustomFields\Enums\FieldDataType; use Relaticle\CustomFields\Enums\VisibilityLogic; use Relaticle\CustomFields\Enums\VisibilityMode; use Relaticle\CustomFields\Enums\VisibilityOperator; use Relaticle\CustomFields\Facades\CustomFieldsType; +use Relaticle\CustomFields\FeatureSystem\FeatureManager; use Relaticle\CustomFields\Models\CustomField; +use Relaticle\CustomFields\Services\ModelAttributeDiscoveryService; use Relaticle\CustomFields\Services\Visibility\BackendVisibilityService; /** @@ -31,6 +35,12 @@ final class VisibilityComponent extends Component { protected string $view = 'filament-schemas::components.grid'; + private string $statePrefix = 'settings.visibility'; + + private bool $forSection = false; + + private ?string $sectionEntityType = null; + public function __construct() { $this->schema([$this->buildFieldset()]); @@ -42,10 +52,20 @@ public static function make(): static return new self; } + public static function makeForSection(string $entityType): static + { + $instance = new self; + $instance->forSection = true; + $instance->sectionEntityType = $entityType; + $instance->schema([$instance->buildFieldset()]); + + return $instance; + } + private function buildFieldset(): Fieldset { return Fieldset::make('Conditional Visibility')->schema([ - Select::make('settings.visibility.mode') + Select::make("{$this->statePrefix}.mode") ->label('Visibility') ->options(VisibilityMode::class) ->default(VisibilityMode::ALWAYS_VISIBLE) @@ -58,14 +78,14 @@ private function buildFieldset(): Fieldset }) ->live(), - Select::make('settings.visibility.logic') - ->label('Condition VisibilityLogic') + Select::make("{$this->statePrefix}.logic") + ->label('Condition Logic') ->options(VisibilityLogic::class) ->default(VisibilityLogic::ALL) ->required() ->visible(fn (Get $get): bool => $this->modeRequiresConditions($get)), - Repeater::make('settings.visibility.conditions') + Repeater::make("{$this->statePrefix}.conditions") ->label('Conditions') ->schema($this->buildConditionSchema()) ->visible(fn (Get $get): bool => $this->modeRequiresConditions($get)) @@ -85,22 +105,34 @@ private function buildFieldset(): Fieldset */ private function buildConditionSchema(): array { + $modelAttributeConditionsEnabled = FeatureManager::isEnabled(CustomFieldsFeature::MODEL_ATTRIBUTE_CONDITIONS); + return [ + // Source selector: only shown when model attribute conditions feature is enabled + Select::make('source') + ->label('Source') + ->options(ConditionSource::class) + ->default(ConditionSource::CustomField) + ->live() + ->afterStateUpdated(fn (Get $get, Set $set) => $this->resetConditionValues($get, $set)) + ->columnSpan(2) + ->visible(fn (): bool => $modelAttributeConditionsEnabled), + Select::make('field_code') ->label('Field') ->options(fn (Get $get): array => $this->getAvailableFields($get)) ->required() ->live() ->afterStateUpdated(fn (Get $get, Set $set) => $this->resetConditionValues($get, $set)) - ->columnSpan(4), + ->columnSpan(fn (): int => $modelAttributeConditionsEnabled ? 3 : 4), Select::make('operator') - ->label('VisibilityOperator') + ->label('Operator') ->options(fn (Get $get): array => $this->getCompatibleOperators($get)) ->required() ->live() ->afterStateUpdated(fn (Set $set) => $this->clearAllValueFields($set)) - ->columnSpan(3), + ->columnSpan(fn (): int => $modelAttributeConditionsEnabled ? 2 : 3), ...$this->getValueInputComponents(), @@ -161,12 +193,37 @@ private function getValueInputComponents(): array ]; } + private function getConditionSource(Get $get): ConditionSource + { + $source = $get('source'); + + if ($source instanceof ConditionSource) { + return $source; + } + + if (is_string($source)) { + return ConditionSource::tryFrom($source) ?? ConditionSource::CustomField; + } + + return ConditionSource::CustomField; + } + + private function isModelAttributeSource(Get $get): bool + { + return $this->getConditionSource($get) === ConditionSource::ModelAttribute; + } + private function shouldShowSingleSelect(Get $get): bool { if (! $this->operatorRequiresValue($get)) { return false; } + // Model attributes never show select (no predefined options) + if ($this->isModelAttributeSource($get)) { + return false; + } + $fieldData = $this->getFieldTypeData($get); if ($fieldData === null) { return false; @@ -187,6 +244,11 @@ private function shouldShowMultipleSelect(Get $get): bool return false; } + // Model attributes never show multi-select + if ($this->isModelAttributeSource($get)) { + return false; + } + $fieldData = $this->getFieldTypeData($get); if ($fieldData === null) { return false; @@ -202,6 +264,12 @@ private function shouldShowToggle(Get $get): bool return false; } + if ($this->isModelAttributeSource($get)) { + $dataType = $this->getModelAttributeDataType($get); + + return $dataType === FieldDataType::BOOLEAN; + } + $fieldData = $this->getFieldTypeData($get); return $fieldData && $fieldData->dataType === FieldDataType::BOOLEAN; @@ -213,6 +281,13 @@ private function shouldShowTextInput(Get $get): bool return false; } + if ($this->isModelAttributeSource($get)) { + $dataType = $this->getModelAttributeDataType($get); + + // Show text input for all model attribute types except boolean + return $dataType !== FieldDataType::BOOLEAN; + } + $fieldData = $this->getFieldTypeData($get); if ($fieldData === null) { return true; // Default to text input @@ -232,6 +307,11 @@ private function getFieldOptions(Get $get): array return []; } + // Model attributes don't have predefined options + if ($this->isModelAttributeSource($get)) { + return []; + } + $entityType = $this->getEntityType($get); if (blank($entityType)) { return []; @@ -253,6 +333,17 @@ private function getPlaceholder(Get $get): string return 'Select an operator first'; } + if ($this->isModelAttributeSource($get)) { + $dataType = $this->getModelAttributeDataType($get); + + return match ($dataType) { + FieldDataType::NUMERIC, FieldDataType::FLOAT => 'Enter a number', + FieldDataType::DATE, FieldDataType::DATE_TIME => 'Enter a date (YYYY-MM-DD)', + FieldDataType::BOOLEAN => 'Toggle value', + default => 'Enter comparison value', + }; + } + $fieldData = $this->getFieldTypeData($get); if ($fieldData === null) { return 'Enter comparison value'; @@ -274,7 +365,7 @@ private function getPlaceholder(Get $get): string private function modeRequiresConditions(Get $get): bool { - $mode = $get('settings.visibility.mode'); + $mode = $get("{$this->statePrefix}.mode"); return $mode instanceof VisibilityMode && $mode->requiresConditions(); } @@ -302,7 +393,14 @@ private function getAvailableFields(Get $get): array return []; } - $currentFieldCode = $get('../../../../code'); + if ($this->isModelAttributeSource($get)) { + return rescue(function () use ($entityType) { + return app(ModelAttributeDiscoveryService::class) + ->getAttributeOptions($entityType); + }, []); + } + + $currentFieldCode = $this->forSection ? null : $get('../../../../code'); return rescue(function () use ($entityType, $currentFieldCode) { return CustomFields::customFieldModel()::query() @@ -319,6 +417,14 @@ private function getAvailableFields(Get $get): array */ private function getCompatibleOperators(Get $get): array { + if ($this->isModelAttributeSource($get)) { + $dataType = $this->getModelAttributeDataType($get); + + return $dataType + ? $dataType->getCompatibleOperatorOptions() + : VisibilityOperator::options(); + } + $fieldData = $this->getFieldTypeData($get); return $fieldData @@ -326,6 +432,24 @@ private function getCompatibleOperators(Get $get): array : VisibilityOperator::options(); } + private function getModelAttributeDataType(Get $get): ?FieldDataType + { + $fieldCode = $get('field_code'); + if (blank($fieldCode)) { + return null; + } + + $entityType = $this->getEntityType($get); + if (blank($entityType)) { + return null; + } + + return rescue(function () use ($entityType, $fieldCode) { + return app(ModelAttributeDiscoveryService::class) + ->getAttributeDataType($entityType, $fieldCode); + }); + } + private function getFieldTypeData(Get $get): ?object { $fieldCode = $get('field_code'); @@ -360,6 +484,10 @@ private function getCustomField(string $fieldCode, Get $get): ?CustomField private function getEntityType(Get $get): ?string { + if ($this->forSection) { + return $this->sectionEntityType; + } + return $get('../../../../entity_type') ?? request('entityType') ?? request()->route('entityType'); diff --git a/src/Filament/Management/Pages/CustomFieldsManagementPage.php b/src/Filament/Management/Pages/CustomFieldsManagementPage.php index 3c47fac7..eecaa53d 100644 --- a/src/Filament/Management/Pages/CustomFieldsManagementPage.php +++ b/src/Filament/Management/Pages/CustomFieldsManagementPage.php @@ -155,7 +155,8 @@ public function createSectionAction(): Action ]) ->schema(SectionForm::entityType($this->currentEntityType)->schema()) ->action(fn (array $data): CustomFieldSection => $this->storeSection($data)) - ->modalWidth(Width::TwoExtraLarge); + ->modalWidth(FeatureManager::isEnabled(CustomFieldsFeature::SECTION_CONDITIONAL_VISIBILITY) ? Width::ScreenLarge : Width::TwoExtraLarge) + ->slideOver(FeatureManager::isEnabled(CustomFieldsFeature::SECTION_CONDITIONAL_VISIBILITY)); } /** diff --git a/src/Filament/Management/Schemas/SectionForm.php b/src/Filament/Management/Schemas/SectionForm.php index b40036f2..d8b8c873 100644 --- a/src/Filament/Management/Schemas/SectionForm.php +++ b/src/Filament/Management/Schemas/SectionForm.php @@ -17,6 +17,7 @@ use Relaticle\CustomFields\Enums\CustomFieldSectionType; use Relaticle\CustomFields\Enums\CustomFieldsFeature; use Relaticle\CustomFields\FeatureSystem\FeatureManager; +use Relaticle\CustomFields\Filament\Management\Forms\Components\VisibilityComponent; use Relaticle\CustomFields\Services\TenantContextService; class SectionForm implements FormInterface, SectionFormInterface @@ -30,6 +31,18 @@ public static function entityType(string $entityType): self return new self; } + /** + * @return array + */ + private static function visibilitySchema(): array + { + if (! FeatureManager::isEnabled(CustomFieldsFeature::SECTION_CONDITIONAL_VISIBILITY)) { + return []; + } + + return [VisibilityComponent::makeForSection(self::$entityType)]; + } + /** * @return array */ @@ -139,6 +152,7 @@ public static function schema(): array ->maxLength(255) ->nullable() ->columnSpan(12), + ...self::visibilitySchema(), ]), ]; } diff --git a/src/Livewire/ManageCustomFieldSection.php b/src/Livewire/ManageCustomFieldSection.php index 36044255..e0c24b8e 100644 --- a/src/Livewire/ManageCustomFieldSection.php +++ b/src/Livewire/ManageCustomFieldSection.php @@ -15,6 +15,8 @@ use Illuminate\Contracts\View\View; use Livewire\Component; use Relaticle\CustomFields\CustomFields; +use Relaticle\CustomFields\Enums\CustomFieldsFeature; +use Relaticle\CustomFields\FeatureSystem\FeatureManager; use Relaticle\CustomFields\Filament\Management\Schemas\FieldForm; use Relaticle\CustomFields\Filament\Management\Schemas\SectionForm; use Relaticle\CustomFields\Livewire\Concerns\CreatesCustomFields; @@ -69,13 +71,13 @@ public function editAction(): Action return Action::make('edit') ->icon('heroicon-o-pencil-square') ->model(CustomFields::sectionModel()) - ->slideOver(false) + ->slideOver(FeatureManager::isEnabled(CustomFieldsFeature::SECTION_CONDITIONAL_VISIBILITY)) ->record($this->section) ->schema(SectionForm::entityType($this->entityType)->schema()) ->fillForm($this->section->toArray()) ->action(fn (array $data): bool => ! $this->section->hasSystemDefinedFields() && $this->section->update($data)) ->visible(fn (CustomFieldSection $record): bool => ! $record->hasSystemDefinedFields()) - ->modalWidth(Width::TwoExtraLarge); + ->modalWidth(FeatureManager::isEnabled(CustomFieldsFeature::SECTION_CONDITIONAL_VISIBILITY) ? Width::ScreenLarge : Width::TwoExtraLarge); } public function activateAction(): Action diff --git a/src/Services/ModelAttributeDiscoveryService.php b/src/Services/ModelAttributeDiscoveryService.php new file mode 100644 index 00000000..27a21e1c --- /dev/null +++ b/src/Services/ModelAttributeDiscoveryService.php @@ -0,0 +1,224 @@ +> + */ + private static array $cache = []; + + private const EXCLUDED_CASTS = [ + 'array', + 'json', + 'collection', + 'encrypted:array', + 'encrypted:collection', + 'encrypted:json', + 'object', + 'hashed', + ]; + + /** + * Get discoverable attributes for an entity type. + * + * @return Collection + */ + public function getAttributes(string $entityType): Collection + { + if (isset(self::$cache[$entityType])) { + return self::$cache[$entityType]; + } + + $model = $this->resolveModel($entityType); + + if (! $model instanceof Model) { + return self::$cache[$entityType] = collect(); + } + + $tableName = $model->getTable(); + + if (! Schema::hasTable($tableName)) { + return self::$cache[$entityType] = collect(); + } + + $columns = Schema::getColumns($tableName); + $castExcludedColumns = $this->getCastExcludedColumns($model); + + $attributes = collect($columns) + ->filter(fn (array $column): bool => $this->shouldIncludeColumn($column, $castExcludedColumns)) + ->map(fn (array $column): array => [ + 'name' => (string) $column['name'], + 'label' => $this->generateLabel((string) $column['name']), + 'data_type' => $this->mapColumnType($column), + ]) + ->values(); + + return self::$cache[$entityType] = $attributes; + } + + /** + * Get attributes formatted as select options. + * + * @return array + */ + public function getAttributeOptions(string $entityType): array + { + return $this->getAttributes($entityType) + ->mapWithKeys(fn (array $attr): array => [$attr['name'] => $attr['label']]) + ->toArray(); + } + + /** + * Get the data type for a specific model attribute. + */ + public function getAttributeDataType(string $entityType, string $attributeName): ?FieldDataType + { + return $this->getAttributes($entityType) + ->firstWhere('name', $attributeName)['data_type'] ?? null; + } + + public static function clearCache(?string $entityType = null): void + { + if ($entityType === null) { + self::$cache = []; + } else { + unset(self::$cache[$entityType]); + } + } + + private function resolveModel(string $entityType): ?Model + { + $entity = Entities::getEntity($entityType); + + if ($entity) { + $modelClass = $entity->getModelClass(); + + return new $modelClass; + } + + if (class_exists($entityType) && is_subclass_of($entityType, Model::class)) { + return new $entityType; + } + + return null; + } + + /** + * Get columns excluded by their Eloquent cast type (e.g. array, json, collection). + * This catches JSON columns that SQLite reports as 'text'. + * + * @return array + */ + private function getCastExcludedColumns(Model $model): array + { + $excluded = []; + + foreach ($model->getCasts() as $column => $cast) { + $baseCast = strtolower(strtok($cast, ':') ?: $cast); + + if (in_array($baseCast, self::EXCLUDED_CASTS, true)) { + $excluded[] = $column; + } + } + + return $excluded; + } + + /** + * @param array{name: string, type_name: string, type: string, nullable: bool} $column + * @param array $castExcludedColumns + */ + private function shouldIncludeColumn(array $column, array $castExcludedColumns = []): bool + { + if (in_array($column['name'], self::EXCLUDED_COLUMNS, true)) { + return false; + } + + if (in_array($column['name'], $castExcludedColumns, true)) { + return false; + } + + $typeName = strtolower($column['type_name']); + + foreach (self::EXCLUDED_TYPES as $excludedType) { + if (str_contains($typeName, $excludedType)) { + return false; + } + } + + return true; + } + + private function generateLabel(string $columnName): string + { + return Str::of($columnName) + ->replace('_', ' ') + ->title() + ->toString(); + } + + /** + * @param array{name: string, type_name: string, type: string, nullable: bool} $column + */ + private function mapColumnType(array $column): FieldDataType + { + $typeName = strtolower($column['type_name']); + $type = strtolower($column['type'] ?? ''); + + if ($this->isBooleanColumn($typeName, $type)) { + return FieldDataType::BOOLEAN; + } + + return match (true) { + in_array($typeName, ['varchar', 'char', 'string'], true) => FieldDataType::STRING, + in_array($typeName, ['text', 'longtext', 'mediumtext', 'tinytext'], true) => FieldDataType::TEXT, + in_array($typeName, ['integer', 'int', 'bigint', 'smallint', 'tinyint', 'mediumint'], true) => FieldDataType::NUMERIC, + in_array($typeName, ['decimal', 'float', 'double', 'real', 'numeric'], true) => FieldDataType::FLOAT, + $typeName === 'date' => FieldDataType::DATE, + in_array($typeName, ['datetime', 'timestamp'], true) => FieldDataType::DATE_TIME, + default => FieldDataType::STRING, + }; + } + + private function isBooleanColumn(string $typeName, string $type): bool + { + if ($typeName === 'boolean' || $typeName === 'bool') { + return true; + } + + if ($typeName === 'tinyint' && str_contains($type, '(1)')) { + return true; + } + + return false; + } +} diff --git a/src/Services/Visibility/BackendVisibilityService.php b/src/Services/Visibility/BackendVisibilityService.php index 522f0594..9043adc7 100644 --- a/src/Services/Visibility/BackendVisibilityService.php +++ b/src/Services/Visibility/BackendVisibilityService.php @@ -11,6 +11,7 @@ use Relaticle\CustomFields\Facades\Entities; use Relaticle\CustomFields\Models\Contracts\HasCustomFields; use Relaticle\CustomFields\Models\CustomField; +use Relaticle\CustomFields\Models\CustomFieldSection; use Relaticle\CustomFields\Services\Options\ComponentOptionsExtractor; use Throwable; @@ -111,7 +112,8 @@ public function isFieldVisible( return $this->coreLogic->evaluateVisibilityWithCascading( $field, $fieldValues, - $allFields + $allFields, + $record ); } @@ -133,18 +135,34 @@ public function getVisibleFields( ): bool => $this->coreLogic->evaluateVisibilityWithCascading( $field, $fieldValues, - $fields + $fields, + $record ) ); } /** - * Get field values normalized for visibility evaluation. + * Check if a section should be visible for the given record. * - * @param Collection $fields - * @return array + * @param Collection $allFields */ + public function isSectionVisible( + Model $record, + CustomFieldSection $section, + Collection $allFields + ): bool { + $fieldValues = $this->extractFieldValues($record, $allFields); + + return $this->coreLogic->evaluateSectionVisibility( + $section, + $fieldValues, + $record + ); + } + /** + * Get field values normalized for visibility evaluation. + * * @param Collection $fields * @return array */ diff --git a/src/Services/Visibility/CoreVisibilityLogicService.php b/src/Services/Visibility/CoreVisibilityLogicService.php index 7fd7de93..f8212fd6 100644 --- a/src/Services/Visibility/CoreVisibilityLogicService.php +++ b/src/Services/Visibility/CoreVisibilityLogicService.php @@ -4,6 +4,7 @@ namespace Relaticle\CustomFields\Services\Visibility; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Relaticle\CustomFields\Data\VisibilityConditionData; use Relaticle\CustomFields\Data\VisibilityData; @@ -11,6 +12,7 @@ use Relaticle\CustomFields\Enums\VisibilityMode; use Relaticle\CustomFields\Enums\VisibilityOperator; use Relaticle\CustomFields\Models\CustomField; +use Relaticle\CustomFields\Models\CustomFieldSection; use Spatie\LaravelData\DataCollection; /** @@ -36,6 +38,14 @@ public function getVisibilityData(CustomField $field): ?VisibilityData return $settings->visibility ?? null; } + /** + * Extract visibility data from a section. + */ + public function getVisibilityDataFromSection(CustomFieldSection $section): ?VisibilityData + { + return $section->settings->visibility ?? null; + } + /** * Determine if a field has visibility conditions. * Single source of truth for visibility requirement checking. @@ -47,9 +57,20 @@ public function hasVisibilityConditions(CustomField $field): bool return $visibility?->requiresConditions() ?? false; } + /** + * Determine if a section has visibility conditions. + */ + public function hasSectionVisibilityConditions(CustomFieldSection $section): bool + { + $visibility = $this->getVisibilityDataFromSection($section); + + return $visibility?->requiresConditions() ?? false; + } + /** * Get dependent field codes for a given field. - * This determines which fields this field depends on for visibility. + * This determines which custom fields this field depends on for visibility. + * Model attribute dependencies are excluded (they don't trigger ->live()). * * @return array */ @@ -66,11 +87,26 @@ public function getDependentFields(CustomField $field): array * * @param array $fieldValues */ - public function evaluateVisibility(CustomField $field, array $fieldValues): bool + public function evaluateVisibility(CustomField $field, array $fieldValues, ?Model $record = null): bool { $visibility = $this->getVisibilityData($field); - return $visibility?->evaluate($fieldValues) ?? true; + return $visibility?->evaluate($fieldValues, $record) ?? true; + } + + /** + * Evaluate whether a section should be visible. + * + * @param array $fieldValues + */ + public function evaluateSectionVisibility( + CustomFieldSection $section, + array $fieldValues, + ?Model $record = null + ): bool { + $visibility = $this->getVisibilityDataFromSection($section); + + return $visibility?->evaluate($fieldValues, $record) ?? true; } /** @@ -80,12 +116,16 @@ public function evaluateVisibility(CustomField $field, array $fieldValues): bool * @param array $fieldValues * @param Collection $allFields */ - public function evaluateVisibilityWithCascading(CustomField $field, array $fieldValues, Collection $allFields): bool - { + public function evaluateVisibilityWithCascading( + CustomField $field, + array $fieldValues, + Collection $allFields, + ?Model $record = null + ): bool { // Create keyed collection once for O(1) lookups during recursion $fieldsByCode = $allFields->keyBy('code'); - return $this->evaluateVisibilityWithCascadingInternal($field, $fieldValues, $fieldsByCode); + return $this->evaluateVisibilityWithCascadingInternal($field, $fieldValues, $fieldsByCode, $record); } /** @@ -97,10 +137,11 @@ public function evaluateVisibilityWithCascading(CustomField $field, array $field private function evaluateVisibilityWithCascadingInternal( CustomField $field, array $fieldValues, - Collection $fieldsByCode + Collection $fieldsByCode, + ?Model $record = null ): bool { // First check if the field itself should be visible - if (! $this->evaluateVisibility($field, $fieldValues)) { + if (! $this->evaluateVisibility($field, $fieldValues, $record)) { return false; } @@ -109,7 +150,7 @@ private function evaluateVisibilityWithCascadingInternal( return true; } - // Check if all parent fields are visible (cascading) + // Check if all parent custom fields are visible (cascading) $dependentFields = $this->getDependentFields($field); foreach ($dependentFields as $dependentFieldCode) { @@ -120,7 +161,7 @@ private function evaluateVisibilityWithCascadingInternal( } // Recursively check parent visibility - if (! $this->evaluateVisibilityWithCascadingInternal($parentField, $fieldValues, $fieldsByCode)) { + if (! $this->evaluateVisibilityWithCascadingInternal($parentField, $fieldValues, $fieldsByCode, $record)) { return false; } } @@ -167,6 +208,22 @@ public function getVisibilityConditions(CustomField $field): array return $visibility->conditions->all(); } + /** + * Get visibility conditions for a section. + * + * @return array + */ + public function getSectionVisibilityConditions(CustomFieldSection $section): array + { + $visibility = $this->getVisibilityDataFromSection($section); + + if (! $visibility instanceof VisibilityData || ! $visibility->conditions instanceof DataCollection) { + return []; + } + + return $visibility->conditions->all(); + } + /** * Check if field should always save regardless of visibility. */ diff --git a/src/Services/Visibility/FrontendVisibilityService.php b/src/Services/Visibility/FrontendVisibilityService.php index 6c1f3a2a..231e6857 100644 --- a/src/Services/Visibility/FrontendVisibilityService.php +++ b/src/Services/Visibility/FrontendVisibilityService.php @@ -7,10 +7,13 @@ use Illuminate\Support\Collection; use Illuminate\Support\Str; use Relaticle\CustomFields\Data\VisibilityConditionData; +use Relaticle\CustomFields\Data\VisibilityData; use Relaticle\CustomFields\Enums\VisibilityLogic; use Relaticle\CustomFields\Enums\VisibilityMode; use Relaticle\CustomFields\Enums\VisibilityOperator; use Relaticle\CustomFields\Models\CustomField; +use Relaticle\CustomFields\Models\CustomFieldSection; +use Spatie\LaravelData\DataCollection; /** * Frontend Visibility Service @@ -55,6 +58,49 @@ public function buildVisibilityExpression( return $conditions->isNotEmpty() ? $conditions->implode(' && ') : null; } + /** + * Build visibility expression for a section. + * + * @param Collection|null $allFields + */ + public function buildSectionVisibilityExpression( + CustomFieldSection $section, + ?Collection $allFields + ): ?string { + if (! $this->coreLogic->hasSectionVisibilityConditions($section)) { + return null; + } + + $visibility = $this->coreLogic->getVisibilityDataFromSection($section); + + if (! $visibility instanceof VisibilityData || ! $visibility->conditions instanceof DataCollection) { + return null; + } + + $conditions = $visibility->conditions->all(); + + if ($conditions === []) { + return null; + } + + $mode = $visibility->mode; + $logic = $visibility->logic; + + $jsConditions = collect($conditions) + ->filter(fn (VisibilityConditionData $condition): bool => $this->shouldIncludeCondition($condition, $allFields)) + ->map(fn (VisibilityConditionData $condition): ?string => $this->buildCondition($condition, $mode, $allFields)) + ->filter() + ->values(); + + if ($jsConditions->isEmpty()) { + return null; + } + + $operator = $logic === VisibilityLogic::ALL ? ' && ' : ' || '; + + return $jsConditions->implode($operator); + } + /** * Build field conditions using core visibility logic. * @@ -74,12 +120,7 @@ private function buildFieldConditions( $logic = $this->coreLogic->getVisibilityLogic($field); $jsConditions = collect($conditions) - ->filter( - fn (VisibilityConditionData $condition): bool => $allFields->contains( - 'code', - $condition->field_code - ) - ) + ->filter(fn (VisibilityConditionData $condition): bool => $this->shouldIncludeCondition($condition, $allFields)) ->map( fn (VisibilityConditionData $condition): ?string => $this->buildCondition( $condition, @@ -99,6 +140,22 @@ private function buildFieldConditions( return $jsConditions->implode($operator); } + /** + * Check if a condition should be included in JS expression generation. + * Custom field conditions must reference an existing field. + * Model attribute conditions are always included. + * + * @param Collection|null $allFields + */ + private function shouldIncludeCondition(VisibilityConditionData $condition, ?Collection $allFields): bool + { + if ($condition->isModelAttribute()) { + return true; + } + + return $allFields instanceof Collection && $allFields->contains('code', $condition->field_code); + } + /** * Build parent conditions for cascading visibility. * @@ -138,16 +195,24 @@ private function buildParentConditions( /** * Build a single condition using core logic rules. * - * @param Collection $allFields + * @param Collection|null $allFields */ private function buildCondition( VisibilityConditionData $condition, VisibilityMode $mode, - Collection $allFields + ?Collection $allFields ): ?string { + $isModelAttribute = $condition->isModelAttribute(); - $targetField = $allFields->firstWhere('code', $condition->field_code); - $fieldValue = sprintf("\$get('custom_fields.%s')", $condition->field_code); + // Resolve the $get() path based on source + $fieldValue = $isModelAttribute + ? sprintf("\$get('%s')", $condition->field_code) + : sprintf("\$get('custom_fields.%s')", $condition->field_code); + + // For model attributes, skip option resolution and always use standard expressions + $targetField = $isModelAttribute + ? null + : $allFields?->firstWhere('code', $condition->field_code); $expression = $this->buildOperatorExpression( $condition->operator, @@ -173,7 +238,7 @@ private function buildOperatorExpression( mixed $value, ?CustomField $targetField ): ?string { - // Validate operator compatibility using core logic + // Validate operator compatibility using core logic (only for custom fields) if ( $targetField instanceof CustomField && ! $this->coreLogic->isOperatorCompatible($operator, $targetField) @@ -234,18 +299,16 @@ private function buildEqualsExpression( mixed $value, ?CustomField $targetField ): string { - return when( - $targetField->isChoiceField(), - fn (): string => $this->buildOptionExpression( - $fieldValue, - $value, - $targetField, - 'equals' - ), - fn (): string => $this->buildStandardEqualsExpression( - $fieldValue, - $value - ) + // For model attributes (null targetField) or non-choice fields, use standard expression + if (! $targetField instanceof CustomField || ! $targetField->isChoiceField()) { + return $this->buildStandardEqualsExpression($fieldValue, $value); + } + + return $this->buildOptionExpression( + $fieldValue, + $value, + $targetField, + 'equals' ); } @@ -257,18 +320,15 @@ private function buildNotEqualsExpression( mixed $value, ?CustomField $targetField ): string { - return when( - $targetField->isChoiceField(), - fn (): string => $this->buildOptionExpression( - $fieldValue, - $value, - $targetField, - 'not_equals' - ), - fn (): string => $this->buildStandardNotEqualsExpression( - $fieldValue, - $value - ) + if (! $targetField instanceof CustomField || ! $targetField->isChoiceField()) { + return $this->buildStandardNotEqualsExpression($fieldValue, $value); + } + + return $this->buildOptionExpression( + $fieldValue, + $value, + $targetField, + 'not_equals' ); } @@ -350,7 +410,7 @@ private function buildOptionExpression( ) : $this->buildSingleValueOptionCondition($fieldValue, $jsValue); - return Str::is('not_equals', $operator) + return $operator === 'not_equals' ? sprintf('!(%s)', $condition) : $condition; } @@ -483,7 +543,9 @@ private function buildContainsExpression( mixed $value, ?CustomField $targetField ): string { - $resolvedValue = $this->resolveOptionValue($value, $targetField); + $resolvedValue = $targetField instanceof CustomField + ? $this->resolveOptionValue($value, $targetField) + : $value; $jsValue = $this->formatJsValue($resolvedValue); return "(() => { diff --git a/tests/Feature/ModelAttributeConditionsTest.php b/tests/Feature/ModelAttributeConditionsTest.php new file mode 100644 index 00000000..43e3f891 --- /dev/null +++ b/tests/Feature/ModelAttributeConditionsTest.php @@ -0,0 +1,583 @@ +value)->toBe('custom_field') + ->and(ConditionSource::ModelAttribute->value)->toBe('model_attribute') + ->and(ConditionSource::CustomField->getLabel())->toBe('Custom Field') + ->and(ConditionSource::ModelAttribute->getLabel())->toBe('Model Attribute'); +}); + +// VisibilityConditionData backward compatibility + +test('VisibilityConditionData defaults source to CustomField when not provided', function (): void { + $condition = VisibilityConditionData::from([ + 'field_code' => 'status', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'active', + ]); + + expect($condition->source)->toBe(ConditionSource::CustomField) + ->and($condition->isCustomField())->toBeTrue() + ->and($condition->isModelAttribute())->toBeFalse(); +}); + +test('VisibilityConditionData correctly identifies model attribute source', function (): void { + $condition = VisibilityConditionData::from([ + 'field_code' => 'type', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'physical', + 'source' => ConditionSource::ModelAttribute, + ]); + + expect($condition->source)->toBe(ConditionSource::ModelAttribute) + ->and($condition->isModelAttribute())->toBeTrue() + ->and($condition->isCustomField())->toBeFalse(); +}); + +// ModelAttributeDiscoveryService tests + +test('ModelAttributeDiscoveryService discovers Post model attributes', function (): void { + $service = app(ModelAttributeDiscoveryService::class); + $attributes = $service->getAttributes(Post::class); + + expect($attributes)->not->toBeEmpty(); + + $names = $attributes->pluck('name')->toArray(); + + // Should include regular columns + expect($names)->toContain('title') + ->toContain('content') + ->toContain('is_published') + ->toContain('rating'); + + // Should exclude system columns + expect($names)->not->toContain('id') + ->not->toContain('created_at') + ->not->toContain('updated_at') + ->not->toContain('deleted_at'); +}); + +test('ModelAttributeDiscoveryService maps column types correctly', function (): void { + $service = app(ModelAttributeDiscoveryService::class); + $attributes = $service->getAttributes(Post::class); + + $byName = $attributes->keyBy('name'); + + // String column + expect($byName->get('title')['data_type'])->toBe(FieldDataType::STRING); + + // Boolean column + expect($byName->get('is_published')['data_type'])->toBe(FieldDataType::BOOLEAN); +}); + +test('ModelAttributeDiscoveryService excludes array/json cast columns', function (): void { + $service = app(ModelAttributeDiscoveryService::class); + $attributes = $service->getAttributes(Post::class); + $names = $attributes->pluck('name')->toArray(); + + // Columns with array/json casts should be excluded + expect($names)->not->toContain('tags') + ->not->toContain('json_array_of_objects'); +}); + +test('ModelAttributeDiscoveryService returns options format', function (): void { + $service = app(ModelAttributeDiscoveryService::class); + $options = $service->getAttributeOptions(Post::class); + + expect($options)->toBeArray() + ->toHaveKey('title') + ->and($options['title'])->toBe('Title'); +}); + +test('ModelAttributeDiscoveryService caches results', function (): void { + $service = app(ModelAttributeDiscoveryService::class); + + $first = $service->getAttributes(Post::class); + $second = $service->getAttributes(Post::class); + + expect($first)->toBe($second); + + ModelAttributeDiscoveryService::clearCache(); +}); + +test('ModelAttributeDiscoveryService returns empty for invalid entity type', function (): void { + $service = app(ModelAttributeDiscoveryService::class); + $attributes = $service->getAttributes('NonExistentModel'); + + expect($attributes)->toBeEmpty(); +}); + +test('ModelAttributeDiscoveryService gets specific attribute data type', function (): void { + $service = app(ModelAttributeDiscoveryService::class); + + expect($service->getAttributeDataType(Post::class, 'title'))->toBe(FieldDataType::STRING) + ->and($service->getAttributeDataType(Post::class, 'is_published'))->toBe(FieldDataType::BOOLEAN) + ->and($service->getAttributeDataType(Post::class, 'nonexistent'))->toBeNull(); +}); + +// VisibilityData evaluate with model record + +test('VisibilityData evaluates model attribute conditions using record', function (): void { + $visibility = VisibilityData::from([ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'Test Post', + 'source' => ConditionSource::ModelAttribute, + ], + ], + ]); + + $post = Post::factory()->create(['title' => 'Test Post']); + + // Should be visible because title matches + expect($visibility->evaluate([], $post))->toBeTrue(); + + $otherPost = Post::factory()->create(['title' => 'Other Post']); + + // Should be hidden because title doesn't match + expect($visibility->evaluate([], $otherPost))->toBeFalse(); +}); + +test('VisibilityData evaluates mixed conditions (custom field + model attribute)', function (): void { + $visibility = VisibilityData::from([ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'status', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'active', + 'source' => ConditionSource::CustomField, + ], + [ + 'field_code' => 'is_published', + 'operator' => VisibilityOperator::EQUALS, + 'value' => true, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ]); + + $publishedPost = Post::factory()->create(['is_published' => true]); + + // Both conditions met + expect($visibility->evaluate(['status' => 'active'], $publishedPost))->toBeTrue(); + + // Custom field condition not met + expect($visibility->evaluate(['status' => 'inactive'], $publishedPost))->toBeFalse(); + + // Model attribute condition not met + $unpublishedPost = Post::factory()->create(['is_published' => false]); + expect($visibility->evaluate(['status' => 'active'], $unpublishedPost))->toBeFalse(); +}); + +test('VisibilityData getDependentFields excludes model attribute conditions', function (): void { + $visibility = VisibilityData::from([ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'status', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'active', + 'source' => ConditionSource::CustomField, + ], + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::IS_NOT_EMPTY, + 'value' => null, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ]); + + $dependentFields = $visibility->getDependentFields(); + + // Only custom field dependencies should be returned + expect($dependentFields)->toBe(['status']) + ->not->toContain('title'); +}); + +test('VisibilityData hasModelAttributeConditions detects model attribute conditions', function (): void { + $withModelAttr = VisibilityData::from([ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'Test', + 'source' => ConditionSource::ModelAttribute, + ], + ], + ]); + + $withoutModelAttr = VisibilityData::from([ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'status', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'active', + 'source' => ConditionSource::CustomField, + ], + ], + ]); + + expect($withModelAttr->hasModelAttributeConditions())->toBeTrue() + ->and($withoutModelAttr->hasModelAttributeConditions())->toBeFalse(); +}); + +// CoreVisibilityLogicService with model attribute conditions + +test('CoreVisibilityLogicService evaluates visibility with model record', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Test Section', + 'entity_type' => Post::class, + 'active' => true, + ]); + + $field = CustomField::factory()->create([ + 'custom_field_section_id' => $section->id, + 'name' => 'Warranty Info', + 'code' => 'warranty_info', + 'type' => 'text', + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'is_published', + 'operator' => VisibilityOperator::EQUALS, + 'value' => true, + 'source' => ConditionSource::ModelAttribute, + ], + ], + 'always_save' => false, + ], + ], + ]); + + $coreLogic = app(CoreVisibilityLogicService::class); + $publishedPost = Post::factory()->create(['is_published' => true]); + $unpublishedPost = Post::factory()->create(['is_published' => false]); + + expect($coreLogic->evaluateVisibility($field, [], $publishedPost))->toBeTrue() + ->and($coreLogic->evaluateVisibility($field, [], $unpublishedPost))->toBeFalse(); +}); + +// Section visibility tests + +test('CoreVisibilityLogicService handles section visibility conditions', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Conditional Section', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'status', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'premium', + 'source' => ConditionSource::CustomField, + ], + ], + ], + ], + ]); + + $coreLogic = app(CoreVisibilityLogicService::class); + + expect($coreLogic->hasSectionVisibilityConditions($section))->toBeTrue() + ->and($coreLogic->evaluateSectionVisibility($section, ['status' => 'premium']))->toBeTrue() + ->and($coreLogic->evaluateSectionVisibility($section, ['status' => 'basic']))->toBeFalse(); +}); + +test('section without visibility conditions is always visible', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Always Visible Section', + 'entity_type' => Post::class, + 'active' => true, + ]); + + $coreLogic = app(CoreVisibilityLogicService::class); + + expect($coreLogic->hasSectionVisibilityConditions($section))->toBeFalse() + ->and($coreLogic->evaluateSectionVisibility($section, []))->toBeTrue(); +}); + +test('section visibility with model attribute conditions', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Published Only Section', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'is_published', + 'operator' => VisibilityOperator::EQUALS, + 'value' => true, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $coreLogic = app(CoreVisibilityLogicService::class); + $publishedPost = Post::factory()->create(['is_published' => true]); + $unpublishedPost = Post::factory()->create(['is_published' => false]); + + expect($coreLogic->evaluateSectionVisibility($section, [], $publishedPost))->toBeTrue() + ->and($coreLogic->evaluateSectionVisibility($section, [], $unpublishedPost))->toBeFalse(); +}); + +// Backend visibility service section tests + +test('BackendVisibilityService evaluates section visibility', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Conditional Section', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'is_published', + 'operator' => VisibilityOperator::EQUALS, + 'value' => true, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $field = CustomField::factory()->create([ + 'custom_field_section_id' => $section->id, + 'name' => 'Test Field', + 'code' => 'test_field', + 'type' => 'text', + ]); + + $backendService = app(BackendVisibilityService::class); + $publishedPost = Post::factory()->create(['is_published' => true]); + $unpublishedPost = Post::factory()->create(['is_published' => false]); + + expect($backendService->isSectionVisible($publishedPost, $section, collect([$field])))->toBeTrue() + ->and($backendService->isSectionVisible($unpublishedPost, $section, collect([$field])))->toBeFalse(); +}); + +// Frontend visibility service tests + +test('FrontendVisibilityService generates correct JS for model attribute conditions', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Test Section', + 'entity_type' => Post::class, + 'active' => true, + ]); + + $triggerField = CustomField::factory()->create([ + 'custom_field_section_id' => $section->id, + 'name' => 'Status', + 'code' => 'status', + 'type' => 'select', + ]); + + $conditionalField = CustomField::factory()->create([ + 'custom_field_section_id' => $section->id, + 'name' => 'Model Attr Field', + 'code' => 'model_attr_field', + 'type' => 'text', + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'Test', + 'source' => ConditionSource::ModelAttribute, + ], + ], + 'always_save' => false, + ], + ], + ]); + + $frontendService = app(FrontendVisibilityService::class); + $allFields = collect([$triggerField, $conditionalField]); + + $jsExpression = $frontendService->buildVisibilityExpression($conditionalField, $allFields); + + // Should use $get('title') for model attribute, not $get('custom_fields.title') + expect($jsExpression)->toBeString() + ->toContain("\$get('title')") + ->not->toContain('custom_fields.title'); +}); + +test('FrontendVisibilityService generates correct JS for custom field conditions', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Test Section', + 'entity_type' => Post::class, + 'active' => true, + ]); + + $triggerField = CustomField::factory()->create([ + 'custom_field_section_id' => $section->id, + 'name' => 'Status', + 'code' => 'status', + 'type' => 'select', + ]); + + $conditionalField = CustomField::factory()->create([ + 'custom_field_section_id' => $section->id, + 'name' => 'Custom Field Cond', + 'code' => 'custom_cond', + 'type' => 'text', + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'status', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'active', + 'source' => ConditionSource::CustomField, + ], + ], + 'always_save' => false, + ], + ], + ]); + + $frontendService = app(FrontendVisibilityService::class); + $allFields = collect([$triggerField, $conditionalField]); + + $jsExpression = $frontendService->buildVisibilityExpression($conditionalField, $allFields); + + // Should use $get('custom_fields.status') for custom field source + expect($jsExpression)->toBeString() + ->toContain("\$get('custom_fields.status')"); +}); + +test('FrontendVisibilityService builds section visibility expression', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Conditional Section', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'is_published', + 'operator' => VisibilityOperator::EQUALS, + 'value' => true, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $frontendService = app(FrontendVisibilityService::class); + $jsExpression = $frontendService->buildSectionVisibilityExpression($section, collect()); + + expect($jsExpression)->toBeString() + ->toContain("\$get('is_published')"); +}); + +test('FrontendVisibilityService returns null for section without visibility', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Always Visible', + 'entity_type' => Post::class, + 'active' => true, + ]); + + $frontendService = app(FrontendVisibilityService::class); + $jsExpression = $frontendService->buildSectionVisibilityExpression($section, collect()); + + expect($jsExpression)->toBeNull(); +}); + +// Fail-open behavior on create (no record) + +test('model attribute conditions fail open when no record provided', function (): void { + $visibility = VisibilityData::from([ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'Test', + 'source' => ConditionSource::ModelAttribute, + ], + ], + ]); + + // Without record, model attribute resolves to null + // EQUALS 'Test' with null field value = false + // This means the field would be hidden on create - the "fail-open" behavior + // is handled at the AbstractFormComponent level, not in VisibilityData + expect($visibility->evaluate([], null))->toBeFalse(); +}); + +// Existing tests still work (backward compatibility) + +test('existing custom field only conditions still work without source', function (): void { + $visibility = VisibilityData::from([ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'status', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'active', + // No source - should default to CustomField + ], + ], + ]); + + expect($visibility->evaluate(['status' => 'active']))->toBeTrue() + ->and($visibility->evaluate(['status' => 'inactive']))->toBeFalse() + ->and($visibility->getDependentFields())->toBe(['status']) + ->and($visibility->hasModelAttributeConditions())->toBeFalse(); +}); diff --git a/tests/Feature/SectionVisibilityIntegrationTest.php b/tests/Feature/SectionVisibilityIntegrationTest.php new file mode 100644 index 00000000..96370871 --- /dev/null +++ b/tests/Feature/SectionVisibilityIntegrationTest.php @@ -0,0 +1,973 @@ +user = User::factory()->create(); + $this->actingAs($this->user); + + $currentConfig = config('custom-fields.features'); + $newConfig = FeatureConfigurator::configure() + ->enable( + CustomFieldsFeature::FIELD_CONDITIONAL_VISIBILITY, + CustomFieldsFeature::UI_TABLE_COLUMNS, + CustomFieldsFeature::SYSTEM_MANAGEMENT_INTERFACE, + CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED, + CustomFieldsFeature::SECTION_CONDITIONAL_VISIBILITY, + CustomFieldsFeature::MODEL_ATTRIBUTE_CONDITIONS, + ); + config(['custom-fields.features' => $newConfig]); +}); + +describe('SectionComponentFactory visibility application', function (): void { + it('applies visibleJs without visible() for model attribute conditions', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Conditional Section', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::CONTAINS, + 'value' => 'Special', + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $post = Post::factory()->create(['title' => 'Normal Title']); + $factory = app(SectionComponentFactory::class); + $component = $factory->create($section, collect(), $post); + + // visibleJs should be set + expect($component->getVisibleJs())->not->toBeNull() + ->and($component->getVisibleJs())->toContain("\$get('title')"); + + // visible() should NOT have been overridden — component must be visible + // server-side so the JS can take effect + expect($component->isVisible())->toBeTrue(); + }); + + it('applies visibleJs without visible() for custom field conditions', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'CF Conditional Section', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'status_field', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'active', + 'source' => ConditionSource::CustomField, + ], + ], + ], + ], + ]); + + $triggerField = CustomField::factory()->create([ + 'custom_field_section_id' => $section->id, + 'name' => 'Status', + 'code' => 'status_field', + 'type' => 'select', + 'entity_type' => Post::class, + ]); + + $post = Post::factory()->create(); + $factory = app(SectionComponentFactory::class); + $component = $factory->create($section, collect([$triggerField]), $post); + + expect($component->getVisibleJs())->not->toBeNull() + ->and($component->getVisibleJs())->toContain("\$get('custom_fields.status_field')") + ->and($component->isVisible())->toBeTrue(); + }); + + it('applies visibleJs without visible() for mixed conditions', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Mixed Section', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::IS_NOT_EMPTY, + 'value' => null, + 'source' => ConditionSource::ModelAttribute, + ], + [ + 'field_code' => 'priority', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'high', + 'source' => ConditionSource::CustomField, + ], + ], + ], + ], + ]); + + $triggerField = CustomField::factory()->create([ + 'custom_field_section_id' => $section->id, + 'name' => 'Priority', + 'code' => 'priority', + 'type' => 'select', + 'entity_type' => Post::class, + ]); + + $post = Post::factory()->create(['title' => '']); + $factory = app(SectionComponentFactory::class); + $component = $factory->create($section, collect([$triggerField]), $post); + + // Even though the post title is empty (condition fails server-side), + // the component should still be visible server-side so visibleJs can work + expect($component->getVisibleJs())->not->toBeNull() + ->and($component->isVisible())->toBeTrue(); + }); + + it('does not apply visibility when feature is disabled', function (): void { + config(['custom-fields.features' => FeatureConfigurator::configure() + ->enable(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED) + ->disable(CustomFieldsFeature::SECTION_CONDITIONAL_VISIBILITY), + ]); + + $section = CustomFieldSection::factory()->create([ + 'name' => 'Section', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'Test', + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $factory = app(SectionComponentFactory::class); + $component = $factory->create($section, collect()); + + expect($component->getVisibleJs())->toBeNull() + ->and($component->isVisible())->toBeTrue(); + }); + + it('does not apply visibility to sections without conditions', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Always Visible', + 'entity_type' => Post::class, + 'active' => true, + ]); + + $factory = app(SectionComponentFactory::class); + $component = $factory->create($section); + + expect($component->getVisibleJs())->toBeNull() + ->and($component->isVisible())->toBeTrue(); + }); + + it('does not apply visibility to ALWAYS_VISIBLE sections', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Always Visible Section', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::ALWAYS_VISIBLE, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [], + ], + ], + ]); + + $factory = app(SectionComponentFactory::class); + $component = $factory->create($section); + + expect($component->getVisibleJs())->toBeNull() + ->and($component->isVisible())->toBeTrue(); + }); + + it('falls back to server-side visible() when JS expression is null', function (): void { + // Model attribute condition referencing a field not in allFields + // AND no custom fields exist — the JS expression might still generate + // since model attributes are always included in JS + $section = CustomFieldSection::factory()->create([ + 'name' => 'Fallback Section', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'Test', + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $factory = app(SectionComponentFactory::class); + // Model attribute conditions always generate JS, so visibleJs will be set + $component = $factory->create($section, collect(), Post::factory()->create(['title' => 'Wrong'])); + expect($component->getVisibleJs())->not->toBeNull(); + }); + + it('creates correct component types with visibility', function (CustomFieldSectionType $type, string $expectedClass): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Typed Section', + 'code' => 'typed_section', + 'entity_type' => Post::class, + 'active' => true, + 'type' => $type, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::IS_NOT_EMPTY, + 'value' => null, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $factory = app(SectionComponentFactory::class); + $component = $factory->create($section, collect(), Post::factory()->create()); + + expect($component)->toBeInstanceOf($expectedClass) + ->and($component->getVisibleJs())->not->toBeNull(); + })->with([ + 'section' => [CustomFieldSectionType::SECTION, Section::class], + 'fieldset' => [CustomFieldSectionType::FIELDSET, Fieldset::class], + 'headless' => [CustomFieldSectionType::HEADLESS, Grid::class], + ]); +}); + +describe('Section visibility JS expression generation', function (): void { + it('generates SHOW_WHEN expression for contains operator', function (): void { + $section = CustomFieldSection::factory()->create([ + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::CONTAINS, + 'value' => 'premium', + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $frontendService = app(FrontendVisibilityService::class); + $js = $frontendService->buildSectionVisibilityExpression($section, collect()); + + expect($js)->toContain("\$get('title')") + ->toContain("'premium'") + ->toContain('.includes('); + }); + + it('generates HIDE_WHEN expression with negation', function (): void { + $section = CustomFieldSection::factory()->create([ + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::HIDE_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'is_published', + 'operator' => VisibilityOperator::EQUALS, + 'value' => true, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $frontendService = app(FrontendVisibilityService::class); + $js = $frontendService->buildSectionVisibilityExpression($section, collect()); + + // HIDE_WHEN wraps the entire expression in !() + expect($js)->toStartWith('!(') + ->toContain("\$get('is_published')"); + }); + + it('generates ANY logic with OR operator', function (): void { + $section = CustomFieldSection::factory()->create([ + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ANY, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::CONTAINS, + 'value' => 'premium', + 'source' => ConditionSource::ModelAttribute, + ], + [ + 'field_code' => 'is_published', + 'operator' => VisibilityOperator::EQUALS, + 'value' => true, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $frontendService = app(FrontendVisibilityService::class); + $js = $frontendService->buildSectionVisibilityExpression($section, collect()); + + // ANY logic uses || operator + expect($js)->toContain(' || '); + }); + + it('generates ALL logic with AND operator', function (): void { + $section = CustomFieldSection::factory()->create([ + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::IS_NOT_EMPTY, + 'value' => null, + 'source' => ConditionSource::ModelAttribute, + ], + [ + 'field_code' => 'is_published', + 'operator' => VisibilityOperator::EQUALS, + 'value' => true, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $frontendService = app(FrontendVisibilityService::class); + $js = $frontendService->buildSectionVisibilityExpression($section, collect()); + + // ALL logic uses && operator + expect($js)->toContain(' && '); + }); + + it('generates IS_EMPTY expression for model attributes', function (): void { + $section = CustomFieldSection::factory()->create([ + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'content', + 'operator' => VisibilityOperator::IS_EMPTY, + 'value' => null, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $frontendService = app(FrontendVisibilityService::class); + $js = $frontendService->buildSectionVisibilityExpression($section, collect()); + + expect($js)->toContain("\$get('content')") + ->toContain('=== null') + ->toContain("=== ''"); + }); + + it('generates numeric comparison for GREATER_THAN', function (): void { + $section = CustomFieldSection::factory()->create([ + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'rating', + 'operator' => VisibilityOperator::GREATER_THAN, + 'value' => 5, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $frontendService = app(FrontendVisibilityService::class); + $js = $frontendService->buildSectionVisibilityExpression($section, collect()); + + expect($js)->toContain("\$get('rating')") + ->toContain('parseFloat') + ->toContain('>'); + }); + + it('filters out custom field conditions referencing non-existent fields', function (): void { + $section = CustomFieldSection::factory()->create([ + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'nonexistent_field', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'test', + 'source' => ConditionSource::CustomField, + ], + ], + ], + ], + ]); + + $frontendService = app(FrontendVisibilityService::class); + $js = $frontendService->buildSectionVisibilityExpression($section, collect()); + + // No valid conditions — should return null + expect($js)->toBeNull(); + }); + + it('includes model attribute conditions even without matching allFields', function (): void { + $section = CustomFieldSection::factory()->create([ + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'Test', + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $frontendService = app(FrontendVisibilityService::class); + // Empty allFields — model attribute conditions should still generate JS + $js = $frontendService->buildSectionVisibilityExpression($section, collect()); + + expect($js)->not->toBeNull() + ->toContain("\$get('title')"); + }); +}); + +describe('Section visibility in Livewire integration', function (): void { + it('renders edit page with section visibility conditions', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Premium Details', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::CONTAINS, + 'value' => 'Premium', + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $field = CustomField::factory()->create([ + 'custom_field_section_id' => $section->id, + 'name' => 'Premium Notes', + 'code' => 'premium_notes', + 'type' => 'text', + 'entity_type' => Post::class, + ]); + + // Post with matching title + $post = Post::factory()->create(['title' => 'Premium Package']); + + livewire(EditPost::class, ['record' => $post->getKey()]) + ->assertSuccessful(); + }); + + it('renders edit page when section condition does not match record', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Premium Details', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::CONTAINS, + 'value' => 'Premium', + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $field = CustomField::factory()->create([ + 'custom_field_section_id' => $section->id, + 'name' => 'Premium Notes', + 'code' => 'premium_notes', + 'type' => 'text', + 'entity_type' => Post::class, + ]); + + // Post WITHOUT matching title — section should still be rendered (for visibleJs) + $post = Post::factory()->create(['title' => 'Basic Package']); + + livewire(EditPost::class, ['record' => $post->getKey()]) + ->assertSuccessful() + ->assertFormFieldExists('custom_fields.premium_notes'); + }); + + it('renders section with custom field visibility conditions on edit', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Advanced Settings', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'priority', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'high', + 'source' => ConditionSource::CustomField, + ], + ], + ], + ], + ]); + + $triggerField = CustomField::factory()->create([ + 'custom_field_section_id' => $section->id, + 'name' => 'Priority', + 'code' => 'priority', + 'type' => 'select', + 'entity_type' => Post::class, + ]); + + $conditionalField = CustomField::factory()->create([ + 'custom_field_section_id' => $section->id, + 'name' => 'Advanced Note', + 'code' => 'advanced_note', + 'type' => 'text', + 'entity_type' => Post::class, + ]); + + $post = Post::factory()->create(); + + livewire(EditPost::class, ['record' => $post->getKey()]) + ->assertSuccessful() + ->assertFormFieldExists('custom_fields.priority') + ->assertFormFieldExists('custom_fields.advanced_note'); + }); + + it('renders section with HIDE_WHEN mode on edit', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Draft Section', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::HIDE_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'is_published', + 'operator' => VisibilityOperator::EQUALS, + 'value' => true, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $field = CustomField::factory()->create([ + 'custom_field_section_id' => $section->id, + 'name' => 'Draft Note', + 'code' => 'draft_note', + 'type' => 'text', + 'entity_type' => Post::class, + ]); + + $publishedPost = Post::factory()->create(['is_published' => true]); + + // Section should still be in the form (visibleJs handles hiding) + livewire(EditPost::class, ['record' => $publishedPost->getKey()]) + ->assertSuccessful() + ->assertFormFieldExists('custom_fields.draft_note'); + }); +}); + +describe('Backend section visibility evaluation', function (): void { + it('evaluates section visibility with ALL logic correctly', function (): void { + $visibility = VisibilityData::from([ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::IS_NOT_EMPTY, + 'value' => null, + 'source' => ConditionSource::ModelAttribute, + ], + [ + 'field_code' => 'is_published', + 'operator' => VisibilityOperator::EQUALS, + 'value' => true, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ]); + + $matchingPost = Post::factory()->create(['title' => 'Test', 'is_published' => true]); + $missingTitle = Post::factory()->create(['title' => '', 'is_published' => true]); + $notPublished = Post::factory()->create(['title' => 'Test', 'is_published' => false]); + + expect($visibility->evaluate([], $matchingPost))->toBeTrue() + ->and($visibility->evaluate([], $missingTitle))->toBeFalse() + ->and($visibility->evaluate([], $notPublished))->toBeFalse(); + }); + + it('evaluates section visibility with ANY logic correctly', function (): void { + $visibility = VisibilityData::from([ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ANY, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::CONTAINS, + 'value' => 'Premium', + 'source' => ConditionSource::ModelAttribute, + ], + [ + 'field_code' => 'is_published', + 'operator' => VisibilityOperator::EQUALS, + 'value' => true, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ]); + + $premiumTitle = Post::factory()->create(['title' => 'Premium Post', 'is_published' => false]); + $publishedOnly = Post::factory()->create(['title' => 'Basic Post', 'is_published' => true]); + $neitherMatch = Post::factory()->create(['title' => 'Basic Post', 'is_published' => false]); + + // ANY: at least one condition met + expect($visibility->evaluate([], $premiumTitle))->toBeTrue() + ->and($visibility->evaluate([], $publishedOnly))->toBeTrue() + ->and($visibility->evaluate([], $neitherMatch))->toBeFalse(); + }); + + it('evaluates HIDE_WHEN mode correctly', function (): void { + $visibility = VisibilityData::from([ + 'mode' => VisibilityMode::HIDE_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'is_published', + 'operator' => VisibilityOperator::EQUALS, + 'value' => true, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ]); + + $published = Post::factory()->create(['is_published' => true]); + $draft = Post::factory()->create(['is_published' => false]); + + // HIDE_WHEN: condition met = hidden, condition not met = visible + expect($visibility->evaluate([], $published))->toBeFalse() + ->and($visibility->evaluate([], $draft))->toBeTrue(); + }); + + it('evaluates mixed custom field and model attribute conditions', function (): void { + $visibility = VisibilityData::from([ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'priority', + 'operator' => VisibilityOperator::EQUALS, + 'value' => 'high', + 'source' => ConditionSource::CustomField, + ], + [ + 'field_code' => 'is_published', + 'operator' => VisibilityOperator::EQUALS, + 'value' => true, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ]); + + $publishedPost = Post::factory()->create(['is_published' => true]); + $draftPost = Post::factory()->create(['is_published' => false]); + + // Both conditions must be met + expect($visibility->evaluate(['priority' => 'high'], $publishedPost))->toBeTrue() + ->and($visibility->evaluate(['priority' => 'low'], $publishedPost))->toBeFalse() + ->and($visibility->evaluate(['priority' => 'high'], $draftPost))->toBeFalse() + ->and($visibility->evaluate(['priority' => 'low'], $draftPost))->toBeFalse(); + }); + + it('evaluates with various operators on model attributes', function ( + string $fieldCode, + VisibilityOperator $operator, + mixed $conditionValue, + array $postData, + bool $expectedVisible + ): void { + $visibility = VisibilityData::from([ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => $fieldCode, + 'operator' => $operator, + 'value' => $conditionValue, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ]); + + $post = Post::factory()->create($postData); + + expect($visibility->evaluate([], $post))->toBe($expectedVisible); + })->with([ + 'equals match' => ['title', VisibilityOperator::EQUALS, 'Test', ['title' => 'Test'], true], + 'equals no match' => ['title', VisibilityOperator::EQUALS, 'Test', ['title' => 'Other'], false], + 'not equals match' => ['title', VisibilityOperator::NOT_EQUALS, 'Test', ['title' => 'Other'], true], + 'not equals no match' => ['title', VisibilityOperator::NOT_EQUALS, 'Test', ['title' => 'Test'], false], + 'contains match' => ['title', VisibilityOperator::CONTAINS, 'rem', ['title' => 'Premium'], true], + 'contains no match' => ['title', VisibilityOperator::CONTAINS, 'xyz', ['title' => 'Premium'], false], + 'not contains match' => ['title', VisibilityOperator::NOT_CONTAINS, 'xyz', ['title' => 'Premium'], true], + 'not contains no match' => ['title', VisibilityOperator::NOT_CONTAINS, 'rem', ['title' => 'Premium'], false], + 'is empty match' => ['title', VisibilityOperator::IS_EMPTY, null, ['title' => ''], true], + 'is empty no match' => ['title', VisibilityOperator::IS_EMPTY, null, ['title' => 'Has content'], false], + 'is not empty match' => ['title', VisibilityOperator::IS_NOT_EMPTY, null, ['title' => 'Has content'], true], + 'is not empty no match' => ['title', VisibilityOperator::IS_NOT_EMPTY, null, ['title' => ''], false], + 'greater than match' => ['rating', VisibilityOperator::GREATER_THAN, 5, ['rating' => 8], true], + 'greater than no match' => ['rating', VisibilityOperator::GREATER_THAN, 5, ['rating' => 3], false], + 'less than match' => ['rating', VisibilityOperator::LESS_THAN, 5, ['rating' => 3], true], + 'less than no match' => ['rating', VisibilityOperator::LESS_THAN, 5, ['rating' => 8], false], + 'boolean equals true' => ['is_published', VisibilityOperator::EQUALS, true, ['is_published' => true], true], + 'boolean equals false' => ['is_published', VisibilityOperator::EQUALS, true, ['is_published' => false], false], + ]); +}); + +describe('Edge cases', function (): void { + it('handles section with empty conditions array', function (): void { + $section = CustomFieldSection::factory()->create([ + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [], + ], + ], + ]); + + $factory = app(SectionComponentFactory::class); + $component = $factory->create($section); + + // No conditions = no visibility expression applied + expect($component->getVisibleJs())->toBeNull() + ->and($component->isVisible())->toBeTrue(); + }); + + it('handles section with null record on create forms', function (): void { + $section = CustomFieldSection::factory()->create([ + 'name' => 'Conditional Section', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::CONTAINS, + 'value' => 'Premium', + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $factory = app(SectionComponentFactory::class); + // null record (create form) + $component = $factory->create($section, collect(), null); + + // Should still have visibleJs for client-side reactivity + expect($component->getVisibleJs())->not->toBeNull() + ->and($component->isVisible())->toBeTrue(); + }); + + it('handles multiple sections with different visibility', function (): void { + $alwaysVisible = CustomFieldSection::factory()->create([ + 'name' => 'Always Visible', + 'entity_type' => Post::class, + 'active' => true, + ]); + + $conditionalSection = CustomFieldSection::factory()->create([ + 'name' => 'Conditional', + 'entity_type' => Post::class, + 'active' => true, + 'settings' => [ + 'visibility' => [ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'is_published', + 'operator' => VisibilityOperator::EQUALS, + 'value' => true, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ], + ], + ]); + + $factory = app(SectionComponentFactory::class); + $post = Post::factory()->create(['is_published' => false]); + + $visible = $factory->create($alwaysVisible, collect(), $post); + $conditional = $factory->create($conditionalSection, collect(), $post); + + expect($visible->getVisibleJs())->toBeNull() + ->and($visible->isVisible())->toBeTrue() + ->and($conditional->getVisibleJs())->not->toBeNull() + ->and($conditional->isVisible())->toBeTrue(); + }); + + it('handles section visibility with NOT_CONTAINS operator', function (): void { + $visibility = VisibilityData::from([ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'title', + 'operator' => VisibilityOperator::NOT_CONTAINS, + 'value' => 'draft', + 'source' => ConditionSource::ModelAttribute, + ], + ], + ]); + + $publishedPost = Post::factory()->create(['title' => 'Published Article']); + $draftPost = Post::factory()->create(['title' => 'This is a draft post']); + + expect($visibility->evaluate([], $publishedPost))->toBeTrue() + ->and($visibility->evaluate([], $draftPost))->toBeFalse(); + }); + + it('handles section visibility with null model attribute value', function (): void { + $visibility = VisibilityData::from([ + 'mode' => VisibilityMode::SHOW_WHEN, + 'logic' => VisibilityLogic::ALL, + 'conditions' => [ + [ + 'field_code' => 'content', + 'operator' => VisibilityOperator::IS_NOT_EMPTY, + 'value' => null, + 'source' => ConditionSource::ModelAttribute, + ], + ], + ]); + + $withContent = Post::factory()->create(['content' => 'Some content']); + $nullContent = Post::factory()->create(['content' => null]); + + expect($visibility->evaluate([], $withContent))->toBeTrue() + ->and($visibility->evaluate([], $nullContent))->toBeFalse(); + }); +}); From 7c73ae3b9391213df8b8b3808269bf2bafc3a6f6 Mon Sep 17 00:00:00 2001 From: manukminasyan Date: Fri, 13 Feb 2026 21:09:19 +0400 Subject: [PATCH 02/11] refactor: rename SYSTEM_SECTIONS_ENABLED to SYSTEM_SECTIONS Remove redundant _ENABLED suffix for consistency with other SYSTEM_* feature names. --- CHANGELOG.md | 2 +- config/custom-fields.php | 2 +- database/migrations/create_custom_fields_table.php | 6 +++--- docs/content/2.essentials/1.configuration.md | 2 +- resources/boost/skills/custom-fields-development/SKILL.md | 4 ++-- src/Enums/CustomFieldsFeature.php | 2 +- src/Filament/Integration/Builders/BaseBuilder.php | 4 ++-- src/Filament/Integration/Builders/FormBuilder.php | 2 +- src/Filament/Integration/Builders/FormContainer.php | 2 +- src/Filament/Integration/Builders/InfolistBuilder.php | 2 +- src/Filament/Integration/Builders/InfolistContainer.php | 2 +- .../Integration/Migrations/CustomFieldsMigrator.php | 4 ++-- .../Management/Pages/CustomFieldsManagementPage.php | 2 +- src/Livewire/Concerns/CreatesCustomFields.php | 2 +- src/Livewire/ManageFieldsTable.php | 2 +- src/Models/CustomField.php | 2 +- src/Models/Scopes/CustomFieldsActivableScope.php | 2 +- .../Admin/Pages/CustomFieldsSectionManagementTest.php | 2 +- tests/Feature/SectionVisibilityIntegrationTest.php | 4 ++-- tests/TestCase.php | 2 +- 20 files changed, 26 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 370b7c01..b52670fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ All notable changes to `custom-fields` will be documented in this file. - **RichTextColumn**: Better rich editor display in tables - **Tags Filter**: Colored badges and filters for tags input - **Upgrade Command**: Automated migration tool (`vendor/bin/custom-fields-upgrade`) -- **Optional Sections**: `SYSTEM_SECTIONS_ENABLED` feature flag for sectionless mode +- **Optional Sections**: `SYSTEM_SECTIONS` feature flag for sectionless mode - **Field Search**: Search functionality in custom fields management page - **Field Deactivation**: Soft-disable non-system-defined fields and sections - **Avatar Configuration**: Display options for entity references diff --git a/config/custom-fields.php b/config/custom-fields.php index 5de8ba76..66966aee 100644 --- a/config/custom-fields.php +++ b/config/custom-fields.php @@ -56,7 +56,7 @@ CustomFieldsFeature::UI_TABLE_FILTERS, CustomFieldsFeature::UI_FIELD_WIDTH_CONTROL, CustomFieldsFeature::SYSTEM_MANAGEMENT_INTERFACE, - CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED, + CustomFieldsFeature::SYSTEM_SECTIONS, ) ->disable( CustomFieldsFeature::SYSTEM_MULTI_TENANCY, diff --git a/database/migrations/create_custom_fields_table.php b/database/migrations/create_custom_fields_table.php index 26554660..3fc96010 100644 --- a/database/migrations/create_custom_fields_table.php +++ b/database/migrations/create_custom_fields_table.php @@ -14,10 +14,10 @@ { public function up(): void { - $sectionsEnabled = FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED); + $sectionsEnabled = FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS); /** - * Custom Field Sections (only created when SYSTEM_SECTIONS_ENABLED) + * Custom Field Sections (only created when SYSTEM_SECTIONS) */ if ($sectionsEnabled) { Schema::create(config('custom-fields.database.table_names.custom_field_sections'), function (Blueprint $table): void { @@ -170,7 +170,7 @@ public function down(): void Schema::dropIfExists(config('custom-fields.database.table_names.custom_field_options')); Schema::dropIfExists(config('custom-fields.database.table_names.custom_fields')); - if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED)) { + if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS)) { Schema::dropIfExists(config('custom-fields.database.table_names.custom_field_sections')); } } diff --git a/docs/content/2.essentials/1.configuration.md b/docs/content/2.essentials/1.configuration.md index 00841292..465923c7 100644 --- a/docs/content/2.essentials/1.configuration.md +++ b/docs/content/2.essentials/1.configuration.md @@ -126,7 +126,7 @@ The package supports these features that can be enabled/disabled: | `UI_FIELD_WIDTH_CONTROL` | Custom field width per field | | `SYSTEM_MANAGEMENT_INTERFACE` | Enable the management interface | | `SYSTEM_MULTI_TENANCY` | Enable multi-tenant isolation | -| `SYSTEM_SECTIONS_ENABLED` | Enable field grouping in sections | +| `SYSTEM_SECTIONS` | Enable field grouping in sections | ::alert{type="info"} If your custom models include tenant-specific scoping logic, you'll need to register a [custom tenant resolver](#custom-tenant-resolution) to ensure validation works correctly. diff --git a/resources/boost/skills/custom-fields-development/SKILL.md b/resources/boost/skills/custom-fields-development/SKILL.md index 810fd09e..ed612a06 100644 --- a/resources/boost/skills/custom-fields-development/SKILL.md +++ b/resources/boost/skills/custom-fields-development/SKILL.md @@ -194,7 +194,7 @@ use Relaticle\CustomFields\FeatureSystem\FeatureConfigurator; CustomFieldsFeature::FIELD_OPTION_COLORS, CustomFieldsFeature::UI_TABLE_COLUMNS, CustomFieldsFeature::UI_TABLE_FILTERS, - CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED, + CustomFieldsFeature::SYSTEM_SECTIONS, ) ->disable( CustomFieldsFeature::SYSTEM_MULTI_TENANCY, @@ -214,7 +214,7 @@ use Relaticle\CustomFields\FeatureSystem\FeatureConfigurator; | `UI_TOGGLEABLE_COLUMNS` | Allow users to toggle column visibility | | `UI_FIELD_WIDTH_CONTROL` | Control field width in forms | | `SYSTEM_MANAGEMENT_INTERFACE` | Admin page for managing fields | -| `SYSTEM_SECTIONS_ENABLED` | Organize fields into sections | +| `SYSTEM_SECTIONS` | Organize fields into sections | | `SYSTEM_MULTI_TENANCY` | Tenant isolation for fields | ## Configuration diff --git a/src/Enums/CustomFieldsFeature.php b/src/Enums/CustomFieldsFeature.php index dc4548a3..4c376345 100644 --- a/src/Enums/CustomFieldsFeature.php +++ b/src/Enums/CustomFieldsFeature.php @@ -33,5 +33,5 @@ enum CustomFieldsFeature: string // System-level features case SYSTEM_MANAGEMENT_INTERFACE = 'system_management_interface'; case SYSTEM_MULTI_TENANCY = 'system_multi_tenancy'; - case SYSTEM_SECTIONS_ENABLED = 'system_sections_enabled'; + case SYSTEM_SECTIONS = 'system_sections'; } diff --git a/src/Filament/Integration/Builders/BaseBuilder.php b/src/Filament/Integration/Builders/BaseBuilder.php index a312fc0a..dfa9d26f 100644 --- a/src/Filament/Integration/Builders/BaseBuilder.php +++ b/src/Filament/Integration/Builders/BaseBuilder.php @@ -59,7 +59,7 @@ public function forModel(Model|string $model): static $this->explicitModel = $model; // Only initialize sections query when sections are enabled - if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED)) { + if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS)) { $this->sections = CustomFields::newSectionModel()->query() ->forEntityType($model::class) ->orderBy('sort_order'); @@ -141,7 +141,7 @@ protected function getFieldsDirectly(): Collection */ protected function getAllFields(): Collection { - if (! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED)) { + if (! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS)) { return $this->getFieldsDirectly(); } diff --git a/src/Filament/Integration/Builders/FormBuilder.php b/src/Filament/Integration/Builders/FormBuilder.php index 12b77e59..ca0ce562 100644 --- a/src/Filament/Integration/Builders/FormBuilder.php +++ b/src/Filament/Integration/Builders/FormBuilder.php @@ -74,7 +74,7 @@ public function values(): Collection ); // Return flat fields if sections are disabled or withoutSections is set - $sectionsDisabled = ! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED); + $sectionsDisabled = ! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS); if ($this->withoutSections || $sectionsDisabled) { return $allFields->map($createField); } diff --git a/src/Filament/Integration/Builders/FormContainer.php b/src/Filament/Integration/Builders/FormContainer.php index c88c125d..52ff93c7 100644 --- a/src/Filament/Integration/Builders/FormContainer.php +++ b/src/Filament/Integration/Builders/FormContainer.php @@ -66,7 +66,7 @@ private function generateSchema(): array // Use explicit setting if provided, otherwise check feature flag $withoutSections = $this->withoutSections - ?? ! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED); + ?? ! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS); $builder = app(FormBuilder::class); diff --git a/src/Filament/Integration/Builders/InfolistBuilder.php b/src/Filament/Integration/Builders/InfolistBuilder.php index cebe0bdc..4f8638de 100644 --- a/src/Filament/Integration/Builders/InfolistBuilder.php +++ b/src/Filament/Integration/Builders/InfolistBuilder.php @@ -59,7 +59,7 @@ public function values(null|(Model&HasCustomFields) $model = null): Collection ->when($this->visibleWhenFilled, fn (Entry $field): Entry => $field->visible(fn (mixed $state): bool => filled($state))); // Check if sections are disabled - $sectionsDisabled = ! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED); + $sectionsDisabled = ! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS); // When sections disabled, get fields directly without section context if ($this->withoutSections || $sectionsDisabled) { diff --git a/src/Filament/Integration/Builders/InfolistContainer.php b/src/Filament/Integration/Builders/InfolistContainer.php index ccbe5dac..3e460c52 100644 --- a/src/Filament/Integration/Builders/InfolistContainer.php +++ b/src/Filament/Integration/Builders/InfolistContainer.php @@ -88,7 +88,7 @@ private function generateSchema(): array // Use explicit setting if provided, otherwise check feature flag $withoutSections = $this->withoutSections - ?? ! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED); + ?? ! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS); $builder = app(InfolistBuilder::class) ->forModel($model) diff --git a/src/Filament/Integration/Migrations/CustomFieldsMigrator.php b/src/Filament/Integration/Migrations/CustomFieldsMigrator.php index a905c53e..07c7d265 100644 --- a/src/Filament/Integration/Migrations/CustomFieldsMigrator.php +++ b/src/Filament/Integration/Migrations/CustomFieldsMigrator.php @@ -57,7 +57,7 @@ public function new( $fieldData->entityType = $entityType; // Only set section entityType when sections are enabled - if ($fieldData->section instanceof CustomFieldSectionData && FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED)) { + if ($fieldData->section instanceof CustomFieldSectionData && FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS)) { $fieldData->section->entityType = $entityType; } @@ -125,7 +125,7 @@ public function create(): CustomField } // Only create/update section when sections are enabled - if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED) && $this->customFieldData->section instanceof CustomFieldSectionData) { + if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS) && $this->customFieldData->section instanceof CustomFieldSectionData) { $sectionData = $this->customFieldData->section->toArray(); $sectionAttributes = [ 'entity_type' => $this->customFieldData->entityType, diff --git a/src/Filament/Management/Pages/CustomFieldsManagementPage.php b/src/Filament/Management/Pages/CustomFieldsManagementPage.php index eecaa53d..45df54ec 100644 --- a/src/Filament/Management/Pages/CustomFieldsManagementPage.php +++ b/src/Filament/Management/Pages/CustomFieldsManagementPage.php @@ -57,7 +57,7 @@ public function mount(): void #[Computed] public function isSectionsDisabled(): bool { - return ! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED); + return ! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS); } #[Computed] diff --git a/src/Livewire/Concerns/CreatesCustomFields.php b/src/Livewire/Concerns/CreatesCustomFields.php index c3336a1b..5dc50e96 100644 --- a/src/Livewire/Concerns/CreatesCustomFields.php +++ b/src/Livewire/Concerns/CreatesCustomFields.php @@ -31,7 +31,7 @@ protected function mutateFieldData(array $data, string $entityType, int|string|n ]; // Only include section_id when sections are enabled - if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED) && $sectionId !== null) { + if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS) && $sectionId !== null) { $result['custom_field_section_id'] = $sectionId; } diff --git a/src/Livewire/ManageFieldsTable.php b/src/Livewire/ManageFieldsTable.php index 6aba94c1..686d0d35 100644 --- a/src/Livewire/ManageFieldsTable.php +++ b/src/Livewire/ManageFieldsTable.php @@ -26,7 +26,7 @@ * Livewire component for managing custom fields in a flat table layout. * * Shows ALL fields for the entity type with search, reordering, and inline editing. - * Used when sections are disabled (SYSTEM_SECTIONS_ENABLED = false). + * Used when sections are disabled (SYSTEM_SECTIONS = false). */ final class ManageFieldsTable extends Component implements HasActions, HasForms { diff --git a/src/Models/CustomField.php b/src/Models/CustomField.php index 4040bdb7..13e7d89c 100644 --- a/src/Models/CustomField.php +++ b/src/Models/CustomField.php @@ -126,7 +126,7 @@ protected function casts(): array */ public function section(): ?BelongsTo { - if (! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED)) { + if (! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS)) { return null; } diff --git a/src/Models/Scopes/CustomFieldsActivableScope.php b/src/Models/Scopes/CustomFieldsActivableScope.php index df033967..e6025a65 100644 --- a/src/Models/Scopes/CustomFieldsActivableScope.php +++ b/src/Models/Scopes/CustomFieldsActivableScope.php @@ -28,7 +28,7 @@ public function apply(Builder $builder, Model $model): void $builder->where($model->getQualifiedActiveColumn(), true); // Only check section activation when sections are enabled - if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED)) { + if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS)) { $builder->whereHas('section', function (Builder $query): void { /** @phpstan-ignore-next-line */ $query->active(); diff --git a/tests/Feature/Admin/Pages/CustomFieldsSectionManagementTest.php b/tests/Feature/Admin/Pages/CustomFieldsSectionManagementTest.php index 3e2fe7f7..3a04552f 100644 --- a/tests/Feature/Admin/Pages/CustomFieldsSectionManagementTest.php +++ b/tests/Feature/Admin/Pages/CustomFieldsSectionManagementTest.php @@ -13,7 +13,7 @@ beforeEach(function (): void { // Skip if sections are disabled (these tests require sections) - if (! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED)) { + if (! FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_SECTIONS)) { $this->markTestSkipped('Sections feature must be enabled for these tests.'); } diff --git a/tests/Feature/SectionVisibilityIntegrationTest.php b/tests/Feature/SectionVisibilityIntegrationTest.php index 96370871..efbfa225 100644 --- a/tests/Feature/SectionVisibilityIntegrationTest.php +++ b/tests/Feature/SectionVisibilityIntegrationTest.php @@ -31,7 +31,7 @@ CustomFieldsFeature::FIELD_CONDITIONAL_VISIBILITY, CustomFieldsFeature::UI_TABLE_COLUMNS, CustomFieldsFeature::SYSTEM_MANAGEMENT_INTERFACE, - CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED, + CustomFieldsFeature::SYSTEM_SECTIONS, CustomFieldsFeature::SECTION_CONDITIONAL_VISIBILITY, CustomFieldsFeature::MODEL_ATTRIBUTE_CONDITIONS, ); @@ -158,7 +158,7 @@ it('does not apply visibility when feature is disabled', function (): void { config(['custom-fields.features' => FeatureConfigurator::configure() - ->enable(CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED) + ->enable(CustomFieldsFeature::SYSTEM_SECTIONS) ->disable(CustomFieldsFeature::SECTION_CONDITIONAL_VISIBILITY), ]); diff --git a/tests/TestCase.php b/tests/TestCase.php index c78b823a..455debac 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -129,7 +129,7 @@ protected function defineEnvironment($app): void CustomFieldsFeature::UI_TOGGLEABLE_COLUMNS, CustomFieldsFeature::UI_TABLE_FILTERS, CustomFieldsFeature::SYSTEM_MANAGEMENT_INTERFACE, - CustomFieldsFeature::SYSTEM_SECTIONS_ENABLED, + CustomFieldsFeature::SYSTEM_SECTIONS, ) ); From f6da977b04ce4984f5c3e8baa0744ec73b0461b8 Mon Sep 17 00:00:00 2001 From: manukminasyan Date: Sat, 14 Feb 2026 01:09:18 +0400 Subject: [PATCH 03/11] refactor: enhance column customization and value handling logic - Unified badge styling for MultiChoiceColumn and SingleChoiceColumn with gray fallback for non-badge types. - Improved date and time formatting in DateTimeColumn to be more human-readable. - Simplified column attributes for Link, Email, and Phone columns, removing hardcoded widths and ensuring disabled clicks. - Revamped SafeValueConverter logic for converting field values based on FieldDataType, enabling better extensibility and clarity. - Updated blade views for accessibility and optimized layout styles, including improved handling of truncated entries and expanded popovers. --- .../tables/columns/email-column.blade.php | 29 +++--- .../tables/columns/link-column.blade.php | 92 +++++++++---------- .../tables/columns/phone-column.blade.php | 8 +- .../Tables/Columns/DateTimeColumn.php | 4 +- .../Components/Tables/Columns/EmailColumn.php | 4 +- .../Components/Tables/Columns/LinkColumn.php | 5 +- .../Tables/Columns/MultiChoiceColumn.php | 8 +- .../Components/Tables/Columns/PhoneColumn.php | 6 +- .../Tables/Columns/SingleChoiceColumn.php | 8 +- .../Factories/FieldColumnFactory.php | 3 +- src/Support/SafeValueConverter.php | 30 ++++-- 11 files changed, 105 insertions(+), 92 deletions(-) diff --git a/resources/views/tables/columns/email-column.blade.php b/resources/views/tables/columns/email-column.blade.php index fdf8ec04..ec34ad2e 100644 --- a/resources/views/tables/columns/email-column.blade.php +++ b/resources/views/tables/columns/email-column.blade.php @@ -41,15 +41,13 @@ }" x-on:keydown.esc="isOpen() && (closePanel(), $event.stopPropagation())" x-on:click.outside="closePanel()" - class="relative" + class="relative px-3 py-4" > - {{-- Screen reader live region --}}
@if (empty($entries)) - + {{-- Empty: no value --}} @else - {{-- Collapsed View - Single line with truncated values --}}
0) x-ref="trigger" @@ -62,20 +60,20 @@ class="relative" x-on:keydown.enter.prevent="togglePanel()" x-on:keydown.space.prevent="togglePanel()" @endif - class="flex items-center gap-x-3 text-left overflow-hidden {{ $hiddenCount > 0 ? 'cursor-pointer' : '' }}" + class="flex items-center gap-x-2 {{ $hiddenCount > 0 ? 'cursor-pointer' : '' }}" > @foreach ($visibleEntries as $index => $entry) -
+
{{ $entry }}
- {{-- Expanded Popover - All values --}} @if ($hiddenCount > 0)