-
+
+
-
{{ $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();
+ }
+}