diff --git a/database/migrations/add_entity_column_support.php b/database/migrations/add_entity_column_support.php new file mode 100644 index 00000000..0319a94d --- /dev/null +++ b/database/migrations/add_entity_column_support.php @@ -0,0 +1,34 @@ +boolean('uses_entity_column')->default(false)->after('system_defined'); + }); + + // Add value column to custom_field_options table + Schema::table(config('custom-fields.database.table_names.custom_field_options'), function (Blueprint $table): void { + $table->string('value')->nullable()->after('name'); + }); + } + + public function down(): void + { + Schema::table(config('custom-fields.database.table_names.custom_fields'), function (Blueprint $table): void { + $table->dropColumn('uses_entity_column'); + }); + + Schema::table(config('custom-fields.database.table_names.custom_field_options'), function (Blueprint $table): void { + $table->dropColumn('value'); + }); + } +}; diff --git a/src/Data/CustomFieldData.php b/src/Data/CustomFieldData.php index 7df04e4b..de19b2a5 100644 --- a/src/Data/CustomFieldData.php +++ b/src/Data/CustomFieldData.php @@ -27,6 +27,7 @@ public function __construct( public CustomFieldSectionData $section, public bool $active = true, public bool $systemDefined = false, + public bool $usesEntityColumn = false, public CustomFieldWidth $width = CustomFieldWidth::_100, public ?string $entityType = null, public ?array $options = null, diff --git a/src/Filament/Integration/Migrations/CustomFieldsMigrator.php b/src/Filament/Integration/Migrations/CustomFieldsMigrator.php index 14f7a5de..a4184b20 100644 --- a/src/Filament/Integration/Migrations/CustomFieldsMigrator.php +++ b/src/Filament/Integration/Migrations/CustomFieldsMigrator.php @@ -264,10 +264,24 @@ protected function createOptions( $customField->options()->createMany( collect($options) ->map(function (mixed $value, mixed $key): array { - $data = [ - 'name' => $value, - 'sort_order' => $key, - ]; + // Handle both formats: + // 1. Simple: ['M' => 'Male', 'F' => 'Female'] + // 2. Detailed: [['value' => 'M', 'label' => 'Male'], ...] + if (is_array($value) && isset($value['value'], $value['label'])) { + // Detailed format + $data = [ + 'name' => $value['label'], + 'value' => $value['value'], + 'sort_order' => is_int($key) ? $key : 0, + ]; + } else { + // Simple format: key => value + $data = [ + 'name' => $value, + 'value' => is_string($key) ? $key : null, + 'sort_order' => is_int($key) ? $key : 0, + ]; + } if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) { $data[config( diff --git a/src/Filament/Management/Schemas/FieldForm.php b/src/Filament/Management/Schemas/FieldForm.php index 89cc06eb..4e7ae177 100644 --- a/src/Filament/Management/Schemas/FieldForm.php +++ b/src/Filament/Management/Schemas/FieldForm.php @@ -42,22 +42,57 @@ class FieldForm implements FormInterface public static function schema(bool $withOptionsRelationship = true): array { $optionsRepeater = Repeater::make('options') - ->table([ - TableColumn::make('Color')->width('150px')->hiddenHeaderLabel(), - TableColumn::make('Name')->hiddenHeaderLabel(), - ]) - ->schema([ - ColorPicker::make('settings.color') - ->columnSpan(3) - ->hexColor() - ->visible( - fn ( - Get $get - ): bool => FeatureManager::isEnabled(CustomFieldsFeature::FIELD_OPTION_COLORS) && - $get('../../settings.enable_option_colors') - ), - TextInput::make('name')->required()->columnSpan(9)->distinct(), - ]) + ->table(function (Get $get): array { + $hasColors = FeatureManager::isEnabled(CustomFieldsFeature::FIELD_OPTION_COLORS) && + $get('settings.enable_option_colors'); + + $columns = []; + + if ($hasColors) { + $columns[] = TableColumn::make('Color')->width('100px'); + } + + if (! $hasColors) { + $columns[] = TableColumn::make('Value')->width('150px'); + } + + $columns[] = TableColumn::make('Name'); + + return $columns; + }) + ->schema(function (Get $get): array { + $hasColors = FeatureManager::isEnabled(CustomFieldsFeature::FIELD_OPTION_COLORS) && + $get('settings.enable_option_colors'); + + $fields = []; + + if ($hasColors) { + $fields[] = ColorPicker::make('settings.color') + ->hexColor() + ->label('Color') + ->columnSpan(2) + ; + } else { + $fields[] = TextInput::make('value') + ->label('Value') + ->placeholder('M') + ->distinct() + ->disabled(fn (Get $get): bool => (bool) $get('../../uses_entity_column')) + ->columnSpan(3) + ; + } + + $fields[] = TextInput::make('name') + ->required() + ->label('Name') + ->placeholder('Male') + ->distinct() + ->disabled(fn (Get $get): bool => (bool) $get('../../uses_entity_column')) + ->columnSpan($hasColors ? 10 : 9) + ; + + return $fields; + }) ->columns(12) ->columnSpanFull() ->requiredUnless('type', function (callable $get) { @@ -81,6 +116,7 @@ public static function schema(bool $withOptionsRelationship = true): array && CustomFieldsType::getFieldType($get('type'))->dataType->isChoiceField() && ! CustomFieldsType::getFieldType($get('type'))->withoutUserOptions ) + ->disabled(fn (Get $get): bool => (bool) $get('uses_entity_column')) ->mutateRelationshipDataBeforeCreateUsing(function ( array $data ): array { @@ -89,7 +125,8 @@ public static function schema(bool $withOptionsRelationship = true): array } return $data; - }); + }) + ; if ($withOptionsRelationship) { $optionsRepeater = $optionsRepeater->relationship(); @@ -156,11 +193,6 @@ public static function schema(bool $withOptionsRelationship = true): array ->live(onBlur: true) ->required() ->maxLength(50) - ->disabled( - fn ( - ?CustomField $record - ): bool => (bool) $record?->system_defined - ) ->unique( table: CustomFields::customFieldModel(), column: 'name', @@ -257,64 +289,79 @@ public static function schema(bool $withOptionsRelationship = true): array ->columnSpanFull() ->columns(2) ->schema([ - // Visibility settings - Toggle::make('settings.visible_in_list') + // Storage settings + Toggle::make('uses_entity_column') ->inline(false) ->live() + ->label('Store in Entity Column') + ->helperText('When enabled, this field will store its value directly in a column on the entity model instead of the custom_field_values table. The column name must match the field code.') + ->visible( + fn (Get $get): bool => $get('type') !== null + ) + ->disabled( + fn ( + ?CustomField $record + ): bool => (bool) $record?->exists + ) + ->default(false), + Toggle::make('settings.list_toggleable_hidden') + ->inline(false) ->label( __( - 'custom-fields::custom-fields.field.form.visible_in_list' + 'custom-fields::custom-fields.field.form.list_toggleable_hidden' + ) + ) + ->helperText( + __( + 'custom-fields::custom-fields.field.form.list_toggleable_hidden_hint' ) ) + ->visible( + fn (Get $get): bool => $get( + 'settings.visible_in_list' + ) && + FeatureManager::isEnabled(CustomFieldsFeature::UI_TOGGLEABLE_COLUMNS) + ) ->afterStateHydrated(function ( Toggle $component, ?Model $record ): void { - if (is_null($record)) { - $component->state(true); + if ($record === null) { + $component->state( + FeatureManager::isEnabled(CustomFieldsFeature::UI_TOGGLEABLE_COLUMNS_HIDDEN_DEFAULT) + ); } }), - Toggle::make('settings.visible_in_view') + // Visibility settings + Toggle::make('settings.visible_in_list') ->inline(false) + ->live() ->label( __( - 'custom-fields::custom-fields.field.form.visible_in_view' + 'custom-fields::custom-fields.field.form.visible_in_list' ) ) ->afterStateHydrated(function ( Toggle $component, ?Model $record ): void { - if (is_null($record)) { + if ($record === null) { $component->state(true); } }), - Toggle::make('settings.list_toggleable_hidden') + Toggle::make('settings.visible_in_view') ->inline(false) ->label( __( - 'custom-fields::custom-fields.field.form.list_toggleable_hidden' - ) - ) - ->helperText( - __( - 'custom-fields::custom-fields.field.form.list_toggleable_hidden_hint' + 'custom-fields::custom-fields.field.form.visible_in_view' ) ) - ->visible( - fn (Get $get): bool => $get( - 'settings.visible_in_list' - ) && - FeatureManager::isEnabled(CustomFieldsFeature::UI_TOGGLEABLE_COLUMNS) - ) ->afterStateHydrated(function ( Toggle $component, ?Model $record ): void { - if (is_null($record)) { - $component->state( - FeatureManager::isEnabled(CustomFieldsFeature::UI_TOGGLEABLE_COLUMNS_HIDDEN_DEFAULT) - ); + if ($record === null) { + $component->state(true); } }), // Data settings @@ -339,7 +386,7 @@ public static function schema(bool $withOptionsRelationship = true): array Toggle $component, mixed $state ): void { - if (is_null($state)) { + if ($state === null) { $component->state(false); } }), @@ -381,7 +428,8 @@ public static function schema(bool $withOptionsRelationship = true): array ->visible( fn ( Get $get - ): bool => FeatureManager::isEnabled(CustomFieldsFeature::FIELD_OPTION_COLORS) && + ): bool => ! $get('uses_entity_column') && + FeatureManager::isEnabled(CustomFieldsFeature::FIELD_OPTION_COLORS) && in_array((string) $get('type'), [ 'select', 'multi_select', @@ -410,7 +458,7 @@ public static function schema(bool $withOptionsRelationship = true): array 'options' => __( 'custom-fields::custom-fields.field.form.options_lookup_type.options' ), - 'lookup' => __( + 'lookup' => __( 'custom-fields::custom-fields.field.form.options_lookup_type.lookup' ), ]) diff --git a/src/Models/Concerns/UsesCustomFields.php b/src/Models/Concerns/UsesCustomFields.php index e0aadad9..c21f3e03 100644 --- a/src/Models/Concerns/UsesCustomFields.php +++ b/src/Models/Concerns/UsesCustomFields.php @@ -98,9 +98,20 @@ public function scopeWithCustomFieldValues(Builder $query): Builder public function getCustomFieldValue(CustomField $customField): mixed { + // If field uses entity column, read directly from model + if ($customField->usesEntityColumn()) { + $value = $this->getAttribute($customField->code); + + // For choice fields with options, we need to return the value as-is + // since the form components will use it with pluck('name', 'id') + // where 'id' is actually the 'value' thanks to our accessor + return $value; + } + $fieldValue = $this->customFieldValues ->firstWhere('custom_field_id', $customField->getKey()) - ?->getValue(); + ?->getValue() + ; if (empty($fieldValue)) { return $fieldValue; @@ -117,6 +128,11 @@ public function getCustomFieldValue(CustomField $customField): mixed public function saveCustomFieldValue(CustomField $customField, mixed $value, ?Model $tenant = null): void { + // Entity column fields should not use this method + if ($customField->usesEntityColumn()) { + return; + } + $data = ['custom_field_id' => $customField->getKey()]; if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) { @@ -163,7 +179,11 @@ public function saveCustomFields(array $customFields, ?Model $tenant = null): vo { $this->customFields()->each(function (CustomField $customField) use ($customFields, $tenant): void { $value = $customFields[$customField->code] ?? null; - $this->saveCustomFieldValue($customField, $value, $tenant); + + // Skip entity column fields - they're already saved as regular model attributes + if (! $customField->usesEntityColumn()) { + $this->saveCustomFieldValue($customField, $value, $tenant); + } }); } } diff --git a/src/Models/CustomField.php b/src/Models/CustomField.php index 0cfb734b..11c1078c 100644 --- a/src/Models/CustomField.php +++ b/src/Models/CustomField.php @@ -39,6 +39,7 @@ * @property int $sort_order * @property bool $active * @property bool $system_defined + * @property bool $uses_entity_column * @property FieldTypeData $typeData * @property CustomFieldWidth $width * @@ -115,6 +116,7 @@ protected function casts(): array 'validation_rules' => DataCollection::class.':'.ValidationRuleData::class.',default', 'active' => 'boolean', 'system_defined' => 'boolean', + 'uses_entity_column' => 'boolean', 'settings' => CustomFieldSettingsData::class.':default', ]; } @@ -163,6 +165,14 @@ public function isSystemDefined(): bool return $this->system_defined === true; } + /** + * Determine if the field saves to entity column instead of custom_field_values. + */ + public function usesEntityColumn(): bool + { + return $this->uses_entity_column === true; + } + public function getValueColumn(): string { return CustomFields::newValueModel()::getValueColumn($this->type); @@ -170,6 +180,11 @@ public function getValueColumn(): string public function getFieldName(): string { + // Entity column fields are saved directly to model columns, not under custom_fields + if ($this->usesEntityColumn()) { + return $this->code; + } + return 'custom_fields.'.$this->code; } } diff --git a/src/Models/CustomFieldOption.php b/src/Models/CustomFieldOption.php index 4455cf76..d91a00eb 100644 --- a/src/Models/CustomFieldOption.php +++ b/src/Models/CustomFieldOption.php @@ -20,6 +20,7 @@ /** * @property int $id * @property ?string $name + * @property ?string $value * @property ?int $sort_order * @property CustomFieldOptionSettingsData $settings * @property int $custom_field_id @@ -72,6 +73,7 @@ protected static function boot(): void protected $visible = [ 'id', 'name', + 'value', 'settings', 'sort_order', 'custom_field_id', @@ -104,6 +106,17 @@ protected function name(): Attribute ); } + /** + * Return value if it exists, otherwise fallback to id for backward compatibility. + * This allows pluck('name', 'id') to work with value-based options. + */ + protected function id(): Attribute + { + return Attribute::make( + get: fn (int $value): int|string => $this->attributes['value'] ?? $value + ); + } + /** * @param array $attributes */ diff --git a/src/Services/ValidationService.php b/src/Services/ValidationService.php index e4d30b34..31c9a674 100644 --- a/src/Services/ValidationService.php +++ b/src/Services/ValidationService.php @@ -34,10 +34,16 @@ public function getValidationRules(CustomField $customField): array $userRules = $this->convertUserRulesToValidatorFormat($customField->validation_rules, $customField); // Get field type default rules (always applied for data integrity) - $fieldTypeDefaultRules = $this->getFieldTypeDefaultRules($customField->type); + $fieldTypeDefaultRules = $this->getFieldTypeDefaultRules($customField->type, $customField); + + // For fields that use entity column, skip database constraint rules + // since they save to model columns with their own types + if ($customField->usesEntityColumn()) { + return $this->combineRules($fieldTypeDefaultRules, $userRules); + } // Get database constraint rules based on storage column - $isEncrypted = $customField->settings->encrypted ?? false; + $isEncrypted = $customField->settings->encrypted ?? false; $databaseRules = $this->getDatabaseValidationRules($customField->type, $isEncrypted); // Merge all rule types: field defaults + user rules + database constraints @@ -53,7 +59,8 @@ public function getValidationRules(CustomField $customField): array public function isRequired(CustomField $customField): bool { return $customField->validation_rules->toCollection() - ->contains('name', ValidationRule::REQUIRED->value); + ->contains('name', ValidationRule::REQUIRED->value) + ; } /** @@ -70,21 +77,31 @@ private function convertUserRulesToValidatorFormat(?DataCollection $rules, Custo } return $rules->toCollection() + ->filter(function (ValidationRuleData $ruleData) use ($customField): bool { + // For choice fields using entity columns, filter out numeric/integer rules + // since these fields store enum values (strings) not integer IDs + if ($customField->usesEntityColumn() && $customField->isChoiceField()) { + return ! in_array($ruleData->name, ['numeric', 'integer']); + } + + return true; + }) ->map(function (ValidationRuleData $ruleData) use ($customField): string { if ($ruleData->parameters === []) { return $ruleData->name; } - // For choice fields with IN or NOT_IN rules, convert option names to IDs + // For choice fields with IN or NOT_IN rules, convert option names to IDs/values if ($customField->isChoiceField() && in_array($ruleData->name, ['in', 'not_in'])) { $parameters = $this->convertOptionNamesToIds($ruleData->parameters, $customField); - return $ruleData->name.':'.implode(',', $parameters); + return $ruleData->name . ':' . implode(',', $parameters); } - return $ruleData->name.':'.implode(',', $ruleData->parameters); + return $ruleData->name . ':' . implode(',', $ruleData->parameters); }) - ->toArray(); + ->toArray() + ; } /** @@ -120,7 +137,7 @@ public function getDatabaseValidationRules(string $fieldType, bool $isEncrypted * @param array $secondaryRules Rules that are overridden by primary rules * @return array Combined rules */ - private function combineRules(array $primaryRules, array $secondaryRules): array + protected function combineRules(array $primaryRules, array $secondaryRules): array { // Extract rule names (without parameters) from primary rules $primaryRuleNames = array_map(fn (string $rule): string => explode(':', $rule, 2)[0], $primaryRules); @@ -166,18 +183,25 @@ private function convertOptionNamesToIds(array $optionNames, CustomField $custom * 2. Field type definition's defaultValidationRules * * @param string $fieldType The field type + * @param CustomField $customField The custom field for context * @return array Array of default validation rules */ - private function getFieldTypeDefaultRules(string $fieldType): array + private function getFieldTypeDefaultRules(string $fieldType, CustomField $customField): array { // Get from field type definition's defaultValidationRules - $fieldTypeManager = app(FieldManager::class); + $fieldTypeManager = app(FieldManager::class); $fieldTypeInstance = $fieldTypeManager->getFieldTypeInstance($fieldType); if ($fieldTypeInstance) { $configurator = $fieldTypeInstance->configure(); + $rules = $configurator->getDefaultValidationRules(); - return $configurator->getDefaultValidationRules(); + // For choice fields using entity columns, filter out numeric/integer rules + if ($customField->usesEntityColumn() && $customField->isChoiceField()) { + $rules = array_filter($rules, fn (string $rule): bool => ! in_array(explode(':', $rule, 2)[0], ['numeric', 'integer'])); + } + + return $rules; } return []; @@ -203,7 +227,7 @@ private function mergeAllValidationRules(array $fieldTypeDefaults, array $userRu $mergedRules = $this->combineRules($mergedRules, $userRules); // Apply database constraint rules using existing logic - $columnName = CustomFieldValue::getValueColumn($fieldType); + $columnName = CustomFieldValue::getValueColumn($fieldType); $dbConstraints = DatabaseFieldConstraints::getConstraintsForColumn($columnName); if ($dbConstraints !== null && $dbConstraints !== []) {