diff --git a/resources/lang/en/custom-fields.php b/resources/lang/en/custom-fields.php index 9bd318cb..43ce13bd 100644 --- a/resources/lang/en/custom-fields.php +++ b/resources/lang/en/custom-fields.php @@ -24,6 +24,7 @@ 'default' => [ 'new_section' => 'New Section', ], + 'default_section_name' => 'Default', ], 'field' => [ @@ -65,6 +66,12 @@ ], 'add_field' => 'Add Field', 'system_defined_cannot_delete' => 'System-defined fields cannot be deleted.', + 'allow_multiple' => 'Allow Multiple Values', + 'allow_multiple_help' => 'When enabled, users can enter multiple values for this field.', + 'max_values' => 'Maximum Values', + 'max_values_help' => 'The maximum number of values that can be entered.', + 'unique_per_entity_type' => 'Unique Per Entity Type', + 'unique_per_entity_type_help' => 'Each value can only be assigned to one record of this entity type.', 'validation' => [ 'label' => 'Validation', 'rules' => 'Validation Rules', @@ -315,6 +322,7 @@ 'multi_parameter_missing' => 'This validation rule requires multiple parameters. Please add all required parameters.', 'parameter_missing' => 'This validation rule requires exactly :count parameter(s). Please add all required parameters.', 'invalid_rule_for_field_type' => 'The selected rule is not valid for this field type.', + 'unique_value' => 'The value ":value" is already assigned to another record.', ], 'empty_states' => [ @@ -328,9 +336,19 @@ 'description' => 'Drag and drop fields here or click the button below to add your first field.', 'icon' => 'heroicon-o-squares-plus', ], + 'fields_no_sections' => [ + 'heading' => 'No custom fields yet', + 'description' => 'Click the button below to add your first custom field.', + 'icon' => 'heroicon-o-squares-plus', + ], ], 'common' => [ 'inactive' => 'Inactive', ], + + 'email' => [ + 'add_email_placeholder' => 'Add email address...', + ], + ]; diff --git a/resources/views/filament/pages/custom-fields-management.blade.php b/resources/views/filament/pages/custom-fields-management.blade.php index 45d14a57..5ea46836 100644 --- a/resources/views/filament/pages/custom-fields-management.blade.php +++ b/resources/views/filament/pages/custom-fields-management.blade.php @@ -9,43 +9,57 @@
-
- @foreach ($this->sections as $section) - @livewire('manage-custom-field-section', ['entityType' => $this->currentEntityType, 'section' => $section], key($section->id . str()->random(16))) - @endforeach + @if($this->isSectionsDisabled) + {{-- Sections disabled mode: show flat field list with new component --}} + @if($this->sections->first()) + @livewire('manage-fields-without-sections', [ + 'entityType' => $this->currentEntityType, + 'section' => $this->sections->first(), + ], key('fields-without-sections-' . $this->currentEntityType)) + @endif + @else + {{-- Normal sections mode --}} +
+ @foreach ($this->sections as $section) + @livewire('manage-custom-field-section', [ + 'entityType' => $this->currentEntityType, + 'section' => $section, + ], key($section->id . str()->random(16))) + @endforeach - @if(!count($this->sections)) -
-
-
- -
+ @if(!count($this->sections)) +
+
+
+ +
-

- {{ __('custom-fields::custom-fields.empty_states.sections.heading') }} -

+

+ {{ __('custom-fields::custom-fields.empty_states.sections.heading') }} +

-

- {{ __('custom-fields::custom-fields.empty_states.sections.description') }} -

+

+ {{ __('custom-fields::custom-fields.empty_states.sections.description') }} +

-
- {{ $this->createSectionAction }} +
+ {{ $this->createSectionAction }} +
-
- @else -
- {{ $this->createSectionAction }} -
- @endif -
+ @else +
+ {{ $this->createSectionAction }} +
+ @endif +
+ @endif
diff --git a/resources/views/livewire/manage-custom-field-section.blade.php b/resources/views/livewire/manage-custom-field-section.blade.php index 7715fe0d..b350f1a3 100644 --- a/resources/views/livewire/manage-custom-field-section.blade.php +++ b/resources/views/livewire/manage-custom-field-section.blade.php @@ -10,6 +10,8 @@ @@ -30,7 +32,7 @@ x-sortable-group="fields" data-section-id="{{ $section->id }}" default="12" - class="fi-sc fi-sc-has-gap fi-grid lg:fi-grid-cols" + class="fi-sc fi-sc-has-gap fi-sc-dense fi-grid lg:fi-grid-cols" style="--cols-lg: repeat(12, minmax(0, 12fr)); --cols-default: repeat(2, minmax(0, 1fr));" @end.stop="$wire.updateFieldsOrder($event.to.getAttribute('data-section-id'), $event.to.sortable.toArray())" > @@ -40,8 +42,8 @@ class="fi-sc fi-sc-has-gap fi-grid lg:fi-grid-cols" @if(!count($this->fields))
-
-
+
+
-
{{ $selectedWidth }}%
+
{{ $selectedWidth }}%
diff --git a/resources/views/livewire/manage-custom-field.blade.php b/resources/views/livewire/manage-custom-field.blade.php index 05df5828..5d1bf0f8 100644 --- a/resources/views/livewire/manage-custom-field.blade.php +++ b/resources/views/livewire/manage-custom-field.blade.php @@ -10,11 +10,13 @@ class="fi-section !px-2 fi-compact !py-2 shadow-none fi-grid-col flex justify-be diff --git a/resources/views/livewire/manage-fields-without-sections.blade.php b/resources/views/livewire/manage-fields-without-sections.blade.php new file mode 100644 index 00000000..de245654 --- /dev/null +++ b/resources/views/livewire/manage-fields-without-sections.blade.php @@ -0,0 +1,44 @@ +
+ @if(count($this->fields)) +
+ @foreach ($this->fields as $field) + @livewire('manage-custom-field', ['field' => $field], key($field->id . $field->width->value . str()->random(16))) + @endforeach +
+ +
+ {{ $this->createFieldAction() }} +
+ @else +
+
+
+ +
+ +

+ {{ __('custom-fields::custom-fields.empty_states.fields_no_sections.heading') }} +

+ +

+ {{ __('custom-fields::custom-fields.empty_states.fields_no_sections.description') }} +

+ +
+ {{ $this->createFieldAction() }} +
+
+
+ @endif + + +
diff --git a/src/Console/Commands/MigrateEmailFieldValuesCommand.php b/src/Console/Commands/MigrateEmailFieldValuesCommand.php new file mode 100644 index 00000000..dc8427d1 --- /dev/null +++ b/src/Console/Commands/MigrateEmailFieldValuesCommand.php @@ -0,0 +1,121 @@ +option('dry-run'); + + if (app()->isProduction() && ! $isDryRun && ! $this->option('force') && ! $this->confirm('You are running in production. Are you sure you want to continue?')) { + $this->info('Migration cancelled.'); + + return self::SUCCESS; + } + + $fieldTable = config('custom-fields.database.table_names.custom_fields'); + $valueTable = config('custom-fields.database.table_names.custom_field_values'); + + // Find all email type custom fields + $emailFields = DB::table($fieldTable) + ->where('type', 'email') + ->pluck('id'); + + if ($emailFields->isEmpty()) { + $this->info('No email fields found. Nothing to migrate.'); + + return self::SUCCESS; + } + + $this->info(sprintf('Found %d email field(s).', $emailFields->count())); + + // Find values that need migration (have string_value but no json_value) + $valuesToMigrate = DB::table($valueTable) + ->whereIn('custom_field_id', $emailFields) + ->whereNotNull('string_value') + ->where('string_value', '!=', '') + ->where(function ($query): void { + $query->whereNull('json_value') + ->orWhere('json_value', '=', '[]') + ->orWhere('json_value', '=', 'null'); + }) + ->get(); + + if ($valuesToMigrate->isEmpty()) { + $this->info('No email values need migration. All values are already in the correct format.'); + + return self::SUCCESS; + } + + $this->info(sprintf('Found %d email value(s) to migrate.', $valuesToMigrate->count())); + + if ($isDryRun) { + $this->warn('Dry run mode - no changes will be made.'); + $this->newLine(); + + $this->table( + ['ID', 'Entity Type', 'Entity ID', 'Current Value', 'New Format'], + $valuesToMigrate->map(fn ($value): array => [ + $value->id, + $value->entity_type, + $value->entity_id, + $value->string_value, + json_encode([$value->string_value]), + ])->toArray() + ); + + return self::SUCCESS; + } + + $bar = $this->output->createProgressBar($valuesToMigrate->count()); + $bar->start(); + + $migrated = 0; + $errors = 0; + + foreach ($valuesToMigrate as $value) { + try { + DB::table($valueTable) + ->where('id', $value->id) + ->update([ + 'json_value' => json_encode([$value->string_value]), + 'string_value' => null, + ]); + + $migrated++; + } catch (Throwable $e) { + $this->newLine(); + $this->error(sprintf('Failed to migrate value ID %d: %s', $value->id, $e->getMessage())); + $errors++; + } + + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + + $this->info(sprintf('Migration complete. Migrated: %d, Errors: %d', $migrated, $errors)); + + return $errors > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/src/CustomFieldsServiceProvider.php b/src/CustomFieldsServiceProvider.php index c9020212..02d4b48e 100644 --- a/src/CustomFieldsServiceProvider.php +++ b/src/CustomFieldsServiceProvider.php @@ -15,6 +15,7 @@ use Livewire\Livewire; use Relaticle\CustomFields\Console\Commands\MakeCustomFieldsMigrationCommand; use Relaticle\CustomFields\Console\Commands\MakeFieldTypeCommand; +use Relaticle\CustomFields\Console\Commands\MigrateEmailFieldValuesCommand; use Relaticle\CustomFields\Contracts\CustomsFieldsMigrators; use Relaticle\CustomFields\Contracts\ValueResolvers; use Relaticle\CustomFields\Enums\CustomFieldsFeature; @@ -23,6 +24,7 @@ use Relaticle\CustomFields\Livewire\ManageCustomField; use Relaticle\CustomFields\Livewire\ManageCustomFieldSection; use Relaticle\CustomFields\Livewire\ManageCustomFieldWidth; +use Relaticle\CustomFields\Livewire\ManageFieldsWithoutSections; use Relaticle\CustomFields\Models\CustomField; use Relaticle\CustomFields\Models\CustomFieldSection; use Relaticle\CustomFields\Providers\EntityServiceProvider; @@ -73,6 +75,7 @@ public function bootingPackage(): void Livewire::component('manage-custom-field-section', ManageCustomFieldSection::class); Livewire::component('manage-custom-field', ManageCustomField::class); Livewire::component('manage-custom-field-width', ManageCustomFieldWidth::class); + Livewire::component('manage-fields-without-sections', ManageFieldsWithoutSections::class); } public function configurePackage(Package $package): void @@ -166,6 +169,7 @@ private function getCommands(): array return [ MakeCustomFieldsMigrationCommand::class, MakeFieldTypeCommand::class, + MigrateEmailFieldValuesCommand::class, ]; } diff --git a/src/Data/CustomFieldSettingsData.php b/src/Data/CustomFieldSettingsData.php index 2ef47d63..993432c0 100644 --- a/src/Data/CustomFieldSettingsData.php +++ b/src/Data/CustomFieldSettingsData.php @@ -20,6 +20,9 @@ public function __construct( public bool $searchable = false, public bool $encrypted = false, public bool $enable_option_colors = false, + public bool $allow_multiple = false, + public int $max_values = 1, + public bool $unique_per_entity_type = false, public VisibilityData $visibility = new VisibilityData, public array $additional = [], ) { diff --git a/src/Data/FieldTypeData.php b/src/Data/FieldTypeData.php index 2ac897b6..1d9777ce 100644 --- a/src/Data/FieldTypeData.php +++ b/src/Data/FieldTypeData.php @@ -27,6 +27,8 @@ public function __construct( public bool $encryptable = false, public bool $withoutUserOptions = false, public bool $acceptsArbitraryValues = false, + public bool $supportsMultiValue = false, + public bool $supportsUniqueConstraint = false, public array $validationRules = [], public ?string $settingsDataClass = null, public string|Closure|null $settingsSchema = null, diff --git a/src/Enums/CustomFieldsFeature.php b/src/Enums/CustomFieldsFeature.php index b7a5c234..30101c6e 100644 --- a/src/Enums/CustomFieldsFeature.php +++ b/src/Enums/CustomFieldsFeature.php @@ -14,12 +14,16 @@ enum CustomFieldsFeature: string case FIELD_CONDITIONAL_VISIBILITY = 'field_conditional_visibility'; case FIELD_ENCRYPTION = 'field_encryption'; case FIELD_OPTION_COLORS = 'field_option_colors'; + case FIELD_CODE_AUTO_GENERATE = 'field_code_auto_generate'; + case FIELD_MULTI_VALUE = 'field_multi_value'; + case FIELD_UNIQUE_VALUE = 'field_unique_value'; // Table/UI integration features case UI_TABLE_COLUMNS = 'ui_table_columns'; case UI_TABLE_FILTERS = 'ui_table_filters'; case UI_TOGGLEABLE_COLUMNS = 'ui_toggleable_columns'; case UI_TOGGLEABLE_COLUMNS_HIDDEN_DEFAULT = 'ui_toggleable_columns_hidden_default'; + case UI_FLAT_FIELD_LAYOUT = 'ui_flat_field_layout'; // System-level features case SYSTEM_MANAGEMENT_INTERFACE = 'system_management_interface'; diff --git a/src/FieldTypeSystem/Definitions/EmailFieldType.php b/src/FieldTypeSystem/Definitions/EmailFieldType.php index 0c3f4be2..fcf72b78 100644 --- a/src/FieldTypeSystem/Definitions/EmailFieldType.php +++ b/src/FieldTypeSystem/Definitions/EmailFieldType.php @@ -19,7 +19,7 @@ class EmailFieldType extends BaseFieldType { public function configure(): FieldSchema { - return FieldSchema::string() + return FieldSchema::multiChoice() ->key('email') ->label('Email') ->icon('heroicon-o-envelope') @@ -27,18 +27,17 @@ public function configure(): FieldSchema ->tableColumn(TextColumn::class) ->infolistEntry(TextEntry::class) ->priority(15) - ->encryptable() ->searchable() ->sortable() - ->defaultValidationRules([ValidationRule::EMAIL]) + ->supportsMultiValue() + ->supportsUniqueConstraint() + ->withArbitraryValues() + ->withoutUserOptions() ->availableValidationRules([ ValidationRule::REQUIRED, - ValidationRule::EMAIL, ValidationRule::MIN, ValidationRule::MAX, - ValidationRule::REGEX, ValidationRule::UNIQUE, - ValidationRule::EXISTS, ]); } } diff --git a/src/FieldTypeSystem/Definitions/LinkFieldType.php b/src/FieldTypeSystem/Definitions/LinkFieldType.php index 1f62e496..df4b1979 100644 --- a/src/FieldTypeSystem/Definitions/LinkFieldType.php +++ b/src/FieldTypeSystem/Definitions/LinkFieldType.php @@ -26,6 +26,7 @@ public function configure(): FieldSchema ->formComponent(LinkComponent::class) ->tableColumn(TextColumn::class) ->infolistEntry(TextEntry::class) + ->supportsUniqueConstraint() ->priority(60) ->availableValidationRules([ ValidationRule::REQUIRED, diff --git a/src/FieldTypeSystem/Definitions/NumberFieldType.php b/src/FieldTypeSystem/Definitions/NumberFieldType.php index 61ded7b2..f18d228c 100644 --- a/src/FieldTypeSystem/Definitions/NumberFieldType.php +++ b/src/FieldTypeSystem/Definitions/NumberFieldType.php @@ -26,6 +26,7 @@ public function configure(): FieldSchema ->formComponent(NumberComponent::class) ->tableColumn(TextColumn::class) ->infolistEntry(TextEntry::class) + ->supportsUniqueConstraint() ->priority(20) ->availableValidationRules([ ValidationRule::REQUIRED, diff --git a/src/FieldTypeSystem/Definitions/TextFieldType.php b/src/FieldTypeSystem/Definitions/TextFieldType.php index 5e3b7ced..9be16b1f 100644 --- a/src/FieldTypeSystem/Definitions/TextFieldType.php +++ b/src/FieldTypeSystem/Definitions/TextFieldType.php @@ -27,6 +27,7 @@ public function configure(): FieldSchema ->tableColumn(TextColumn::class) ->infolistEntry(TextEntry::class) ->encryptable() + ->supportsUniqueConstraint() ->priority(10) ->availableValidationRules([ ValidationRule::REQUIRED, diff --git a/src/FieldTypeSystem/Definitions/TextareaFieldType.php b/src/FieldTypeSystem/Definitions/TextareaFieldType.php index 886c8e1b..4feea8f1 100644 --- a/src/FieldTypeSystem/Definitions/TextareaFieldType.php +++ b/src/FieldTypeSystem/Definitions/TextareaFieldType.php @@ -26,6 +26,7 @@ public function configure(): FieldSchema ->formComponent(TextareaFormComponent::class) ->tableColumn(TextColumn::class) ->infolistEntry(TextEntry::class) + ->supportsUniqueConstraint() ->priority(15) ->availableValidationRules([ ValidationRule::REQUIRED, diff --git a/src/FieldTypeSystem/FieldSchema.php b/src/FieldTypeSystem/FieldSchema.php index e58502be..e68ebcbf 100644 --- a/src/FieldTypeSystem/FieldSchema.php +++ b/src/FieldTypeSystem/FieldSchema.php @@ -53,6 +53,10 @@ class FieldSchema private bool $acceptsArbitraryValues = false; + private bool $supportsMultiValue = false; + + private bool $supportsUniqueConstraint = false; + protected bool $withoutUserOptions = false; private ?string $settingsDataClass = null; @@ -311,6 +315,26 @@ public function withArbitraryValues(bool $accepts = true): self return $this; } + /** + * Configure whether field supports multiple values (e.g., multiple emails, phones) + */ + public function supportsMultiValue(bool $supports = true): self + { + $this->supportsMultiValue = $supports; + + return $this; + } + + /** + * Configure whether field supports unique value constraint per entity type + */ + public function supportsUniqueConstraint(bool $supports = true): self + { + $this->supportsUniqueConstraint = $supports; + + return $this; + } + // ========== Data Type Specific Methods (from DataTypeConfigurators) ========== /** @@ -542,6 +566,8 @@ public function data(): FieldTypeData encryptable: $this->encryptable, withoutUserOptions: $this->withoutUserOptions, acceptsArbitraryValues: $this->acceptsArbitraryValues, + supportsMultiValue: $this->supportsMultiValue, + supportsUniqueConstraint: $this->supportsUniqueConstraint, validationRules: $this->availableValidationRules, settingsDataClass: $this->settingsDataClass, settingsSchema: $this->settingsSchema diff --git a/src/Filament/Integration/Base/AbstractFormComponent.php b/src/Filament/Integration/Base/AbstractFormComponent.php index 7dbbed71..aef92769 100644 --- a/src/Filament/Integration/Base/AbstractFormComponent.php +++ b/src/Filament/Integration/Base/AbstractFormComponent.php @@ -66,7 +66,12 @@ protected function configure( filled($state) ) ->required($this->validationService->isRequired($customField)) - ->rules($this->validationService->getValidationRules($customField)) + ->rules( + fn (Field $component): array => $this->validationService->getValidationRules( + $customField, + $component->getRecord()?->getKey() + ) + ) ->columnSpan($customField->width->getSpanValue()) ->when( FeatureManager::isEnabled(CustomFieldsFeature::FIELD_CONDITIONAL_VISIBILITY) && diff --git a/src/Filament/Integration/Builders/FormBuilder.php b/src/Filament/Integration/Builders/FormBuilder.php index c88381cf..2dc6ff44 100644 --- a/src/Filament/Integration/Builders/FormBuilder.php +++ b/src/Filament/Integration/Builders/FormBuilder.php @@ -14,15 +14,21 @@ class FormBuilder extends BaseBuilder { - private bool $withoutSections = false; + private ?bool $withoutSections = null; public function build(): Grid { - return FormContainer::make() + $container = FormContainer::make() ->forModel($this->explicitModel ?? null) - ->withoutSections($this->withoutSections) ->only($this->only) ->except($this->except); + + // Only set withoutSections if explicitly configured + if ($this->withoutSections !== null) { + $container->withoutSections($this->withoutSections); + } + + return $container; } public function withoutSections(bool $withoutSections = true): static diff --git a/src/Filament/Integration/Builders/FormContainer.php b/src/Filament/Integration/Builders/FormContainer.php index 4e9f310a..687dc74a 100644 --- a/src/Filament/Integration/Builders/FormContainer.php +++ b/src/Filament/Integration/Builders/FormContainer.php @@ -4,6 +4,8 @@ use Filament\Schemas\Components\Grid; use Illuminate\Database\Eloquent\Model; +use Relaticle\CustomFields\Enums\CustomFieldsFeature; +use Relaticle\CustomFields\FeatureSystem\FeatureManager; final class FormContainer extends Grid { @@ -13,7 +15,7 @@ final class FormContainer extends Grid private array $only = []; - private bool $withoutSections = false; + private ?bool $withoutSections = null; public static function make(array|int|null $columns = 12): static { @@ -62,11 +64,15 @@ private function generateSchema(): array return []; // Graceful fallback } + // Use explicit setting if provided, otherwise check feature flag + $withoutSections = $this->withoutSections + ?? FeatureManager::isEnabled(CustomFieldsFeature::UI_FLAT_FIELD_LAYOUT); + $builder = app(FormBuilder::class); return $builder ->forModel($model) - ->withoutSections($this->withoutSections) + ->withoutSections($withoutSections) ->only($this->only) ->except($this->except) ->values() diff --git a/src/Filament/Integration/Builders/InfolistBuilder.php b/src/Filament/Integration/Builders/InfolistBuilder.php index d090a882..29ceea0e 100644 --- a/src/Filament/Integration/Builders/InfolistBuilder.php +++ b/src/Filament/Integration/Builders/InfolistBuilder.php @@ -21,17 +21,23 @@ final class InfolistBuilder extends BaseBuilder private bool $visibleWhenFilled = false; - private bool $withoutSections = false; + private ?bool $withoutSections = null; public function build(): Component { - return InfolistContainer::make() + $container = InfolistContainer::make() ->forModel($this->explicitModel ?? null) ->hiddenLabels($this->hiddenLabels) ->visibleWhenFilled($this->visibleWhenFilled) - ->withoutSections($this->withoutSections) ->only($this->only) ->except($this->except); + + // Only set withoutSections if explicitly configured + if ($this->withoutSections !== null) { + $container->withoutSections($this->withoutSections); + } + + return $container; } /** diff --git a/src/Filament/Integration/Builders/InfolistContainer.php b/src/Filament/Integration/Builders/InfolistContainer.php index 2e315525..d8d54c26 100644 --- a/src/Filament/Integration/Builders/InfolistContainer.php +++ b/src/Filament/Integration/Builders/InfolistContainer.php @@ -5,6 +5,8 @@ use Filament\Forms\Components\Field; use Filament\Schemas\Components\Grid; use Illuminate\Database\Eloquent\Model; +use Relaticle\CustomFields\Enums\CustomFieldsFeature; +use Relaticle\CustomFields\FeatureSystem\FeatureManager; final class InfolistContainer extends Grid { @@ -18,7 +20,7 @@ final class InfolistContainer extends Grid private bool $visibleWhenFilled = false; - private bool $withoutSections = false; + private ?bool $withoutSections = null; public static function make(array|int|null $columns = 12): static { @@ -84,13 +86,17 @@ private function generateSchema(): array return []; // Graceful fallback } + // Use explicit setting if provided, otherwise check feature flag + $withoutSections = $this->withoutSections + ?? FeatureManager::isEnabled(CustomFieldsFeature::UI_FLAT_FIELD_LAYOUT); + $builder = app(InfolistBuilder::class) ->forModel($model) ->only($this->only) ->except($this->except) ->hiddenLabels($this->hiddenLabels) ->visibleWhenFilled($this->visibleWhenFilled) - ->withoutSections($this->withoutSections); + ->withoutSections($withoutSections); return $builder->values()->toArray(); } diff --git a/src/Filament/Integration/Components/Forms/EmailComponent.php b/src/Filament/Integration/Components/Forms/EmailComponent.php index 6cbfc1f9..18dccb55 100644 --- a/src/Filament/Integration/Components/Forms/EmailComponent.php +++ b/src/Filament/Integration/Components/Forms/EmailComponent.php @@ -5,7 +5,7 @@ namespace Relaticle\CustomFields\Filament\Integration\Components\Forms; use Filament\Forms\Components\Field; -use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\TagsInput; use Relaticle\CustomFields\Filament\Integration\Base\AbstractFormComponent; use Relaticle\CustomFields\Models\CustomField; @@ -13,16 +13,15 @@ { public function create(CustomField $customField): Field { - $defaults = [ - 'email' => true, // Client-side validation for UX - 'maxLength' => 255, - 'suffixIcon' => 'heroicon-m-envelope', - 'autocomplete' => 'email', - 'type' => 'email', - ]; + $maxValues = $customField->settings->allow_multiple + ? $customField->settings->max_values + : 1; - $component = TextInput::make($customField->getFieldName()); - - return $this->applySettingsToComponent($component, $defaults); + return TagsInput::make($customField->getFieldName()) + ->placeholder(__('custom-fields::custom-fields.email.add_email_placeholder')) + ->splitKeys(['Tab', ',', 'Enter']) + ->nestedRecursiveRules(['email']) + ->rules(['array', 'max:'.$maxValues]) + ->reorderable(); } } diff --git a/src/Filament/Management/Pages/CustomFieldsManagementPage.php b/src/Filament/Management/Pages/CustomFieldsManagementPage.php index 50da4299..50459803 100644 --- a/src/Filament/Management/Pages/CustomFieldsManagementPage.php +++ b/src/Filament/Management/Pages/CustomFieldsManagementPage.php @@ -24,7 +24,9 @@ use Relaticle\CustomFields\FeatureSystem\FeatureManager; use Relaticle\CustomFields\Filament\Management\Schemas\SectionForm; use Relaticle\CustomFields\Models\CustomFieldSection; +use Relaticle\CustomFields\Services\DefaultSectionService; use Relaticle\CustomFields\Services\TenantContextService; +use Relaticle\CustomFields\Support\CodeGenerator; use Relaticle\CustomFields\Support\Utils; class CustomFieldsManagementPage extends Page @@ -46,6 +48,20 @@ public function mount(): void $firstEntity = Entities::withCustomFields()->first(); $this->setCurrentEntityType($firstEntity?->getAlias() ?? ''); } + + // When sections are disabled, ensure a default hidden section exists + if ($this->isSectionsDisabled() && filled($this->currentEntityType)) { + app(DefaultSectionService::class)->getOrCreateDefaultSection($this->currentEntityType); + } + } + + /** + * Check if sections are disabled (single hidden section mode). + */ + #[Computed] + public function isSectionsDisabled(): bool + { + return FeatureManager::isEnabled(CustomFieldsFeature::UI_FLAT_FIELD_LAYOUT); } #[Computed] @@ -97,6 +113,11 @@ public function entityTypes(): Collection public function setCurrentEntityType(?string $entityType): void { $this->currentEntityType = $entityType; + + // When sections are disabled, ensure a default hidden section exists for the new entity type + if ($this->isSectionsDisabled() && filled($entityType)) { + app(DefaultSectionService::class)->getOrCreateDefaultSection($entityType); + } } public function createSectionAction(): Action @@ -108,6 +129,7 @@ public function createSectionAction(): Action ->color('gray') ->button() ->outlined() + ->visible(fn (): bool => ! $this->isSectionsDisabled()) ->extraAttributes([ 'class' => 'flex justify-center items-center rounded-lg border-gray-300 hover:border-gray-400 border-dashed', ]) @@ -142,6 +164,14 @@ private function storeSection(array $data): CustomFieldSection $data['type'] ??= CustomFieldSectionType::SECTION->value; $data['entity_type'] = $this->currentEntityType; + // Auto-generate code if feature is enabled and code is empty + if (FeatureManager::isEnabled(CustomFieldsFeature::FIELD_CODE_AUTO_GENERATE) && blank($data['code'] ?? null)) { + $data['code'] = CodeGenerator::generateUniqueSectionCode( + $data['name'], + $this->currentEntityType + ); + } + return CustomFieldsModel::newSectionModel()->create($data); } diff --git a/src/Filament/Management/Schemas/FieldForm.php b/src/Filament/Management/Schemas/FieldForm.php index 89cc06eb..f5250073 100644 --- a/src/Filament/Management/Schemas/FieldForm.php +++ b/src/Filament/Management/Schemas/FieldForm.php @@ -14,6 +14,7 @@ use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Component; use Filament\Schemas\Components\Fieldset; +use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; @@ -34,6 +35,34 @@ class FieldForm implements FormInterface { + /** + * Get type-specific settings schema components. + * + * @return array + */ + private static function getTypeSettingsSchema(): array + { + $components = []; + + foreach (CustomFieldsType::toCollection() as $fieldTypeData) { + if ($fieldTypeData->settingsSchema === null) { + continue; + } + + $schema = is_callable($fieldTypeData->settingsSchema) + ? ($fieldTypeData->settingsSchema)() + : $fieldTypeData->settingsSchema; + + foreach ($schema as $component) { + $components[] = $component->visible( + fn (Get $get): bool => $get('type') === $fieldTypeData->key + ); + } + } + + return $components; + } + /** * @return array * @@ -103,152 +132,89 @@ public static function schema(bool $withOptionsRelationship = true): array Tab::make( __('custom-fields::custom-fields.field.form.general') )->schema([ - Select::make('entity_type') - ->label( - __( - 'custom-fields::custom-fields.field.form.entity_type' - ) - ) - ->options(Entities::getOptions(onlyCustomFields: true)) - ->disabled() + Hidden::make('entity_type') ->default( fn () => request( 'entityType', (Entities::withCustomFields()->first()?->getAlias()) ?? '' ) - ) - ->required(), - TypeField::make('type') - ->label( - __( - 'custom-fields::custom-fields.field.form.type' - ) - ) - ->disabled( - fn ( - ?CustomField $record - ): bool => (bool) $record?->exists - ) - ->live() - ->afterStateHydrated(function ( - Select $component, - mixed $state, - ?CustomField $record - ): void { - if (blank($state)) { - $component->state( - $record->type ?? CustomFieldsType::toCollection()->first()->key - ); - } - }) - ->required(), - TextInput::make('name') - ->label( - __( - 'custom-fields::custom-fields.field.form.name' - ) - ) - ->helperText( - __( - 'custom-fields::custom-fields.field.form.name_helper_text' - ) - ) - ->live(onBlur: true) - ->required() - ->maxLength(50) - ->disabled( - fn ( - ?CustomField $record - ): bool => (bool) $record?->system_defined - ) - ->unique( - table: CustomFields::customFieldModel(), - column: 'name', - ignoreRecord: true, - modifyRuleUsing: fn ( - Unique $rule, - Get $get - ) => $rule - ->where('entity_type', $get('entity_type')) - ->when( - FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY), - fn (Unique $rule) => $rule->where( - config( - 'custom-fields.database.column_names.tenant_foreign_key' - ), - TenantContextService::getCurrentTenantId() - ) + ), + Grid::make() + ->columns(fn (): int => FeatureManager::isEnabled(CustomFieldsFeature::FIELD_CODE_AUTO_GENERATE) ? 2 : 3) + ->columnSpanFull() + ->schema([ + TypeField::make('type') + ->label(__('custom-fields::custom-fields.field.form.type')) + ->disabled(fn (?CustomField $record): bool => (bool) $record?->exists) + ->live() + ->afterStateHydrated(function ( + Select $component, + mixed $state, + ?CustomField $record + ): void { + if (blank($state)) { + $component->state( + $record->type ?? CustomFieldsType::toCollection()->first()->key + ); + } + }) + ->required(), + TextInput::make('name') + ->label(__('custom-fields::custom-fields.field.form.name')) + ->live(onBlur: true) + ->required() + ->maxLength(50) + ->disabled(fn (?CustomField $record): bool => (bool) $record?->system_defined) + ->unique( + table: CustomFields::customFieldModel(), + column: 'name', + ignoreRecord: true, + modifyRuleUsing: fn (Unique $rule, Get $get) => $rule + ->where('entity_type', $get('entity_type')) + ->when( + FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY), + fn (Unique $rule) => $rule->where( + config('custom-fields.database.column_names.tenant_foreign_key'), + TenantContextService::getCurrentTenantId() + ) + ) ) - ) - ->afterStateUpdated(function ( - Get $get, - Set $set, - ?string $old, - ?string $state - ): void { - $old ??= ''; - $state ??= ''; + ->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state): void { + $old ??= ''; + $state ??= ''; - if ( - ($get('code') ?? '') !== - Str::of($old)->slug('_')->toString() - ) { - return; - } + if (($get('code') ?? '') !== Str::of($old)->slug('_')->toString()) { + return; + } - $set( - 'code', - Str::of($state)->slug('_')->toString() - ); - }), - TextInput::make('code') - ->label( - __( - 'custom-fields::custom-fields.field.form.code' - ) - ) - ->helperText( - __( - 'custom-fields::custom-fields.field.form.code_helper_text' - ) - ) - ->live(onBlur: true) - ->required() - ->alphaDash() - ->maxLength(50) - ->disabled( - fn ( - ?CustomField $record - ): bool => (bool) $record?->system_defined - ) - ->unique( - table: CustomFields::customFieldModel(), - column: 'code', - ignoreRecord: true, - modifyRuleUsing: fn ( - Unique $rule, - Get $get - ) => $rule - ->where('entity_type', $get('entity_type')) - ->when( - FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY), - fn (Unique $rule) => $rule->where( - config( - 'custom-fields.database.column_names.tenant_foreign_key' - ), - TenantContextService::getCurrentTenantId() - ) + $set('code', Str::of($state)->slug('_')->toString()); + }), + TextInput::make('code') + ->label(__('custom-fields::custom-fields.field.form.code')) + ->live(onBlur: true) + ->required(fn (): bool => ! FeatureManager::isEnabled(CustomFieldsFeature::FIELD_CODE_AUTO_GENERATE)) + ->alphaDash() + ->maxLength(50) + ->disabled(fn (?CustomField $record): bool => (bool) $record?->system_defined) + ->visible(fn (): bool => ! FeatureManager::isEnabled(CustomFieldsFeature::FIELD_CODE_AUTO_GENERATE)) + ->unique( + table: CustomFields::customFieldModel(), + column: 'code', + ignoreRecord: true, + modifyRuleUsing: fn (Unique $rule, Get $get) => $rule + ->where('entity_type', $get('entity_type')) + ->when( + FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY), + fn (Unique $rule) => $rule->where( + config('custom-fields.database.column_names.tenant_foreign_key'), + TenantContextService::getCurrentTenantId() + ) + ) ) - ) - ->afterStateUpdated(function ( - Set $set, - ?string $state - ): void { - $set( - 'code', - Str::of($state)->slug('_')->toString() - ); - }), + ->afterStateUpdated(function (Set $set, ?string $state): void { + $set('code', Str::of($state)->slug('_')->toString()); + }), + ]), Fieldset::make( __( 'custom-fields::custom-fields.field.form.settings' @@ -387,8 +353,67 @@ public static function schema(bool $withOptionsRelationship = true): array 'multi_select', ]) ), + // Multi-value settings + Toggle::make('settings.allow_multiple') + ->inline(false) + ->live() + ->label(__('custom-fields::custom-fields.field.form.allow_multiple')) + ->helperText(__('custom-fields::custom-fields.field.form.allow_multiple_help')) + ->visible( + fn (Get $get): bool => FeatureManager::isEnabled(CustomFieldsFeature::FIELD_MULTI_VALUE) && + CustomFieldsType::getFieldType($get('type'))?->supportsMultiValue === true + ) + ->afterStateUpdated(function (Set $set, bool $state): void { + if ($state) { + $set('settings.max_values', 2); + } + }) + ->default(false), + TextInput::make('settings.max_values') + ->label( + __( + 'custom-fields::custom-fields.field.form.max_values' + ) + ) + ->helperText( + __( + 'custom-fields::custom-fields.field.form.max_values_help' + ) + ) + ->numeric() + ->minValue(1) + ->maxValue(20) + ->default(5) + ->visible( + fn ( + Get $get + ): bool => FeatureManager::isEnabled(CustomFieldsFeature::FIELD_MULTI_VALUE) && + CustomFieldsType::getFieldType($get('type'))?->supportsMultiValue === true && + $get('settings.allow_multiple') === true + ), + // Uniqueness constraint + Toggle::make('settings.unique_per_entity_type') + ->inline(false) + ->label( + __( + 'custom-fields::custom-fields.field.form.unique_per_entity_type' + ) + ) + ->helperText( + __( + 'custom-fields::custom-fields.field.form.unique_per_entity_type_help' + ) + ) + ->visible( + fn (Get $get): bool => FeatureManager::isEnabled(CustomFieldsFeature::FIELD_UNIQUE_VALUE) && + CustomFieldsType::getFieldType($get('type'))?->supportsUniqueConstraint === true + ) + ->default(false), ]), + // Dynamic type-specific settings from field type definition + ...self::getTypeSettingsSchema(), + Select::make('options_lookup_type') ->label( __( diff --git a/src/Filament/Management/Schemas/SectionForm.php b/src/Filament/Management/Schemas/SectionForm.php index 39e1668c..b40036f2 100644 --- a/src/Filament/Management/Schemas/SectionForm.php +++ b/src/Filament/Management/Schemas/SectionForm.php @@ -78,14 +78,21 @@ public static function schema(): array $set('code', Str::of($state)->slug('_')->toString()); }) - ->columnSpan(6), + ->columnSpan( + fn (): int => FeatureManager::isEnabled(CustomFieldsFeature::FIELD_CODE_AUTO_GENERATE) ? 12 : 6 + ), TextInput::make('code') ->label( __('custom-fields::custom-fields.section.form.code') ) - ->required() + ->required( + fn (): bool => ! FeatureManager::isEnabled(CustomFieldsFeature::FIELD_CODE_AUTO_GENERATE) + ) ->alphaDash() ->maxLength(50) + ->visible( + fn (): bool => ! FeatureManager::isEnabled(CustomFieldsFeature::FIELD_CODE_AUTO_GENERATE) + ) ->unique( table: CustomFields::sectionModel(), column: 'code', diff --git a/src/Livewire/Concerns/CreatesCustomFields.php b/src/Livewire/Concerns/CreatesCustomFields.php new file mode 100644 index 00000000..86119bec --- /dev/null +++ b/src/Livewire/Concerns/CreatesCustomFields.php @@ -0,0 +1,54 @@ + $entityType, + 'custom_field_section_id' => $sectionId, + ]; + } + + protected function storeField(array $data): void + { + $options = collect($data['options'] ?? []) + ->filter() + ->map(function (array $option): array { + if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) { + $option[config('custom-fields.database.column_names.tenant_foreign_key')] = TenantContextService::getCurrentTenantId(); + } + + return $option; + }) + ->values(); + + unset($data['options']); + + $customField = CustomFields::newCustomFieldModel()->create($data); + + $customField->options()->createMany($options); + } +} diff --git a/src/Livewire/Concerns/ManagesFields.php b/src/Livewire/Concerns/ManagesFields.php new file mode 100644 index 00000000..bd45f683 --- /dev/null +++ b/src/Livewire/Concerns/ManagesFields.php @@ -0,0 +1,43 @@ +section->fields()->withDeactivated()->orderBy('sort_order')->get(); + } + + #[On('field-width-updated')] + public function fieldWidthUpdated(int|string $fieldId, int $width): void + { + $model = CustomFields::newCustomFieldModel(); + $model->where($model->getKeyName(), $fieldId)->update(['width' => $width]); + + $this->section->refresh(); + } + + #[On('field-deleted')] + public function fieldDeleted(): void + { + $this->section->refresh(); + } + + #[On('fields-reordered')] + public function fieldsReordered(): void + { + unset($this->fields); + } +} diff --git a/src/Livewire/ManageCustomFieldSection.php b/src/Livewire/ManageCustomFieldSection.php index acde13d8..8690b1b2 100644 --- a/src/Livewire/ManageCustomFieldSection.php +++ b/src/Livewire/ManageCustomFieldSection.php @@ -13,56 +13,25 @@ use Filament\Support\Enums\Size; use Filament\Support\Enums\Width; use Illuminate\Contracts\View\View; -use Illuminate\Database\Eloquent\Collection; -use Livewire\Attributes\Computed; -use Livewire\Attributes\On; 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; +use Relaticle\CustomFields\Livewire\Concerns\ManagesFields; use Relaticle\CustomFields\Models\CustomFieldSection; -use Relaticle\CustomFields\Services\TenantContextService; final class ManageCustomFieldSection extends Component implements HasActions, HasForms { + use CreatesCustomFields; use InteractsWithActions; use InteractsWithForms; + use ManagesFields; public string $entityType; public CustomFieldSection $section; - #[Computed] - public function fields(): Collection - { - return $this->section->fields()->withDeactivated()->orderBy('sort_order')->get(); - } - - #[On('field-width-updated')] - public function fieldWidthUpdated(int|string $fieldId, int $width): void - { - // Update the width - $model = CustomFields::newCustomFieldModel(); - $model->where($model->getKeyName(), $fieldId)->update(['width' => $width]); - - // Re-fetch the fields - $this->section->refresh(); - } - - #[On('field-deleted')] - public function fieldDeleted(): void - { - $this->section->refresh(); - } - - #[On('fields-reordered')] - public function fieldsReordered(): void - { - unset($this->fields); - } - public function updateFieldsOrder(int|string $sectionId, array $fields): void { $model = CustomFields::newCustomFieldModel(); @@ -162,38 +131,9 @@ public function createFieldAction(): Action ->label(__('custom-fields::custom-fields.field.form.add_field')) ->model(CustomFields::customFieldModel()) ->schema(FieldForm::schema(withOptionsRelationship: false)) - ->fillForm([ - 'entity_type' => $this->entityType, - ]) - ->mutateDataUsing(function (array $data): array { - if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) { - $data[config('custom-fields.database.column_names.tenant_foreign_key')] = TenantContextService::getCurrentTenantId(); - } - - return [ - ...$data, - 'entity_type' => $this->entityType, - 'custom_field_section_id' => $this->section->getKey(), - ]; - }) - ->action(function (array $data): void { - $options = collect($data['options'] ?? []) - ->filter() - ->map(function (array $option): array { - if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) { - $option[config('custom-fields.database.column_names.tenant_foreign_key')] = TenantContextService::getCurrentTenantId(); - } - - return $option; - }) - ->values(); - - unset($data['options']); - - $customField = CustomFields::newCustomFieldModel()->create($data); - - $customField->options()->createMany($options); - }) + ->fillForm(['entity_type' => $this->entityType]) + ->mutateDataUsing(fn (array $data): array => $this->mutateFieldData($data, $this->entityType, $this->section->getKey())) + ->action(fn (array $data) => $this->storeField($data)) ->modalWidth(Width::ScreenLarge) ->slideOver(); } diff --git a/src/Livewire/ManageFieldsWithoutSections.php b/src/Livewire/ManageFieldsWithoutSections.php new file mode 100644 index 00000000..95b8fc63 --- /dev/null +++ b/src/Livewire/ManageFieldsWithoutSections.php @@ -0,0 +1,116 @@ +newQuery() + ->withDeactivated() + ->where('entity_type', $this->entityType) + ->orderBy('sort_order') + ->get(); + } + + #[On('field-width-updated')] + public function fieldWidthUpdated(int|string $fieldId, int $width): void + { + $model = CustomFields::newCustomFieldModel(); + $model->where($model->getKeyName(), $fieldId)->update(['width' => $width]); + unset($this->fields); + } + + #[On('field-deleted')] + public function fieldDeleted(): void + { + unset($this->fields); + } + + #[On('fields-reordered')] + public function fieldsReordered(): void + { + unset($this->fields); + } + + public function updateFieldsOrder(array $fields): void + { + $model = CustomFields::newCustomFieldModel(); + foreach ($fields as $index => $field) { + $model->query() + ->withDeactivated() + ->where($model->getKeyName(), $field) + ->update([ + 'custom_field_section_id' => $this->section->getKey(), + 'sort_order' => $index, + ]); + } + + $this->dispatch('fields-reordered')->self(); + } + + public function createFieldAction(): Action + { + return Action::make('createField') + ->size(Size::Small) + ->label(__('custom-fields::custom-fields.field.form.add_field')) + ->icon('heroicon-s-plus') + ->color('gray') + ->button() + ->outlined() + ->extraAttributes([ + 'class' => 'flex justify-center items-center rounded-lg border-gray-300 hover:border-gray-400 border-dashed', + ]) + ->model(CustomFields::customFieldModel()) + ->schema(FieldForm::schema(withOptionsRelationship: false)) + ->fillForm(['entity_type' => $this->entityType]) + ->mutateDataUsing(fn (array $data): array => $this->mutateFieldData($data, $this->entityType, $this->section->getKey())) + ->action(fn (array $data) => $this->storeField($data)) + ->modalWidth(Width::ScreenLarge) + ->slideOver(); + } + + public function render(): View + { + return view('custom-fields::livewire.manage-fields-without-sections'); + } +} diff --git a/src/Rules/UniqueCustomFieldValue.php b/src/Rules/UniqueCustomFieldValue.php new file mode 100644 index 00000000..053a2678 --- /dev/null +++ b/src/Rules/UniqueCustomFieldValue.php @@ -0,0 +1,79 @@ +customField->getValueColumn(); + + foreach ($values as $singleValue) { + if (blank($singleValue)) { + continue; + } + + $query = $valueModel->newQuery() + ->where('custom_field_id', $this->customField->getKey()) + ->where('entity_type', $this->customField->entity_type); + + // Check the appropriate value column based on field type's storage column + if ($valueColumn === 'json_value') { + // For JSON columns, search within the array + $query->whereJsonContains('json_value', $singleValue); + } else { + // For scalar columns (string_value, integer_value, etc.) + $query->where($valueColumn, $singleValue); + } + + // Apply tenant scope if multi-tenancy is enabled + if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) { + $tenantFk = config('custom-fields.database.column_names.tenant_foreign_key'); + $query->where($tenantFk, TenantContextService::getCurrentTenantId()); + } + + // Exclude the current entity if updating + if ($this->ignoreEntityId !== null) { + $query->where('entity_id', '!=', $this->ignoreEntityId); + } + + if ($query->exists()) { + $fail(__('custom-fields::custom-fields.validation.unique_value', [ + 'value' => $singleValue, + ])); + + return; + } + } + } +} diff --git a/src/Services/DefaultSectionService.php b/src/Services/DefaultSectionService.php new file mode 100644 index 00000000..67fdcfb2 --- /dev/null +++ b/src/Services/DefaultSectionService.php @@ -0,0 +1,79 @@ +newQuery() + ->withDeactivated() + ->where('code', self::DEFAULT_SECTION_CODE) + ->where('entity_type', $entityType); + + if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) { + $query->where( + config('custom-fields.database.column_names.tenant_foreign_key'), + TenantContextService::getCurrentTenantId() + ); + } + + $section = $query->first(); + + if ($section instanceof CustomFieldSection) { + return $section; + } + + return $this->createDefaultSection($entityType); + } + + /** + * Check if a section is the default hidden section. + */ + public function isDefaultSection(CustomFieldSection $section): bool + { + return $section->code === self::DEFAULT_SECTION_CODE; + } + + /** + * Create the default hidden section for an entity type. + */ + private function createDefaultSection(string $entityType): CustomFieldSection + { + $data = [ + 'name' => __('custom-fields::custom-fields.section.default_section_name'), + 'code' => self::DEFAULT_SECTION_CODE, + 'type' => CustomFieldSectionType::HEADLESS->value, + 'entity_type' => $entityType, + 'sort_order' => 0, + 'active' => true, + ]; + + if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) { + $data[config('custom-fields.database.column_names.tenant_foreign_key')] = TenantContextService::getCurrentTenantId(); + } + + return CustomFields::newSectionModel()->create($data); + } +} diff --git a/src/Services/ValidationService.php b/src/Services/ValidationService.php index e4d30b34..92840390 100644 --- a/src/Services/ValidationService.php +++ b/src/Services/ValidationService.php @@ -9,6 +9,7 @@ use Relaticle\CustomFields\FieldTypeSystem\FieldManager; use Relaticle\CustomFields\Models\CustomField; use Relaticle\CustomFields\Models\CustomFieldValue; +use Relaticle\CustomFields\Rules\UniqueCustomFieldValue; use Relaticle\CustomFields\Support\DatabaseFieldConstraints; use Spatie\LaravelData\DataCollection; @@ -21,14 +22,16 @@ final class ValidationService * Get all validation rules for a custom field, applying both: * - User-defined validation rules from the field configuration * - Database field constraints based on field type + * - Type-specific settings (e.g., unique per entity type for email fields) * - Special handling for numeric values to prevent database errors * * Returns a combined array of validation rules in Laravel validator format. * * @param CustomField $customField The custom field to get validation rules for - * @return array Combined array of validation rules + * @param int|null $ignoreEntityId Entity ID to ignore for unique checks (when updating) + * @return array Combined array of validation rules */ - public function getValidationRules(CustomField $customField): array + public function getValidationRules(CustomField $customField, ?int $ignoreEntityId = null): array { // Convert user rules to Laravel validator format $userRules = $this->convertUserRulesToValidatorFormat($customField->validation_rules, $customField); @@ -41,7 +44,12 @@ public function getValidationRules(CustomField $customField): array $databaseRules = $this->getDatabaseValidationRules($customField->type, $isEncrypted); // Merge all rule types: field defaults + user rules + database constraints - return $this->mergeAllValidationRules($fieldTypeDefaultRules, $userRules, $databaseRules, $customField->type); + $rules = $this->mergeAllValidationRules($fieldTypeDefaultRules, $userRules, $databaseRules, $customField->type); + + // Add type-specific rules based on settings + $typeSpecificRules = $this->getTypeSpecificRules($customField, $ignoreEntityId); + + return array_merge($rules, $typeSpecificRules); } /** @@ -183,6 +191,25 @@ private function getFieldTypeDefaultRules(string $fieldType): array return []; } + /** + * Get type-specific validation rules based on field settings. + * + * @param CustomField $customField The custom field + * @param int|null $ignoreEntityId Entity ID to ignore for unique checks + * @return array Type-specific validation rules + */ + private function getTypeSpecificRules(CustomField $customField, ?int $ignoreEntityId = null): array + { + $rules = []; + + // Handle unique per entity type setting (available for any field type) + if ($customField->settings->unique_per_entity_type) { + $rules[] = new UniqueCustomFieldValue($customField, $ignoreEntityId); + } + + return $rules; + } + /** * Merge field type defaults, user rules, and database constraints with proper precedence. * Field type defaults are always applied, user rules can add additional constraints, diff --git a/src/Support/CodeGenerator.php b/src/Support/CodeGenerator.php new file mode 100644 index 00000000..16923099 --- /dev/null +++ b/src/Support/CodeGenerator.php @@ -0,0 +1,107 @@ +slug('_')->toString(); + } + + /** + * Generate a unique code for a custom field within an entity type. + */ + public static function generateUniqueFieldCode(string $name, string $entityType, ?int $ignoreId = null): string + { + $baseCode = self::generateFromName($name); + + return self::ensureUniqueCode( + $baseCode, + $entityType, + 'field', + $ignoreId + ); + } + + /** + * Generate a unique code for a section within an entity type. + */ + public static function generateUniqueSectionCode(string $name, string $entityType, ?int $ignoreId = null): string + { + $baseCode = self::generateFromName($name); + + return self::ensureUniqueCode( + $baseCode, + $entityType, + 'section', + $ignoreId + ); + } + + /** + * Check if a code already exists and append a counter if needed. + */ + private static function ensureUniqueCode( + string $baseCode, + string $entityType, + string $type, + ?int $ignoreId = null + ): string { + $code = $baseCode; + $counter = 1; + + while (self::codeExists($code, $entityType, $type, $ignoreId)) { + $code = "{$baseCode}_{$counter}"; + $counter++; + } + + return $code; + } + + /** + * Check if a code exists in the database. + */ + private static function codeExists( + string $code, + string $entityType, + string $type, + ?int $ignoreId = null + ): bool { + $model = $type === 'field' + ? CustomFields::newCustomFieldModel() + : CustomFields::newSectionModel(); + + $query = $model->newQuery() + ->withDeactivated() + ->where('code', $code) + ->where('entity_type', $entityType); + + if ($ignoreId !== null) { + $query->where($model->getKeyName(), '!=', $ignoreId); + } + + if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) { + $query->where( + config('custom-fields.database.column_names.tenant_foreign_key'), + TenantContextService::getCurrentTenantId() + ); + } + + return $query->exists(); + } +}