Skip to content
18 changes: 14 additions & 4 deletions src/FieldTypeSystem/BaseFieldType.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@
use Relaticle\CustomFields\Data\FieldTypeData;

/**
* Abstract base class for Custom Fields field types
* Provides sensible defaults and supports both class-based and inline component definitions
*
* @property-read FieldTypeData $data Field type configuration data with full type hints
*/
abstract class BaseFieldType implements FieldTypeDefinitionInterface
Expand All @@ -21,8 +18,21 @@ abstract class BaseFieldType implements FieldTypeDefinitionInterface
abstract public function configure(): FieldSchema;

/**
* Get field type data with proper type hints and caching
* Normalize a value before storage and comparison.
*/
public function setValue(string $value): string
{
return $value;
}

/**
* Transform a stored value for display.
*/
public function getValue(string $value): string
{
return $value;
}

public function getData(): FieldTypeData
{
if (! $this->_data instanceof FieldTypeData) {
Expand Down
9 changes: 5 additions & 4 deletions src/FieldTypeSystem/Definitions/LinkFieldType.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@
use Relaticle\CustomFields\Filament\Integration\Components\Infolists\LinkEntry;
use Relaticle\CustomFields\Filament\Integration\Components\Tables\Columns\LinkColumn;

/**
* ABOUTME: Field type definition for Link fields
* ABOUTME: Provides Link functionality with URL validation and multi-value support
*/
class LinkFieldType extends BaseFieldType
{
public function setValue(string $value): string
{
return preg_replace('#^https?://#i', '', trim($value));
}

public function configure(): FieldSchema
{
return FieldSchema::multiChoice()
Expand Down
38 changes: 20 additions & 18 deletions src/Filament/Integration/Base/AbstractFormComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ protected function configure(
filled($state)
)
->required($this->validationService->isRequired($customField))
->rules($this->getFieldValidationRules($customField))
->rules(fn (Field $component): array => $this->getFieldValidationRules(
$customField,
$component->getRecord()?->getKey()
))
->columnSpan(
FeatureManager::isEnabled(CustomFieldsFeature::UI_FIELD_WIDTH_CONTROL)
? $customField->width->getSpanValue()
Expand All @@ -96,18 +99,15 @@ private function getFieldValue(
mixed $state,
mixed $record
): mixed {
return value(function () use ($customField, $state, $record) {
$value = $record?->getCustomFieldValue($customField) ??
($state ?? ($customField->isMultiChoiceField() ? [] : null));

return $value instanceof Carbon
? $value->format(
$customField->isDateField()
? 'Y-m-d'
: 'Y-m-d H:i:s'
)
: $value;
});
$value = $record?->getCustomFieldValue($customField)
?? $state
?? ($customField->isMultiChoiceField() ? [] : null);

if ($value instanceof Carbon) {
return $value->format($customField->isDateField() ? 'Y-m-d' : 'Y-m-d H:i:s');
}

return $value;
}

/**
Expand Down Expand Up @@ -136,15 +136,17 @@ private function applyVisibility(
$allFields
);

return in_array($jsExpression, [null, '', '0'], true)
? $field
: $field->live()->visibleJs($jsExpression);
if (blank($jsExpression)) {
return $field;
}

return $field->live()->visibleJs($jsExpression);
}

/** @return array<int, mixed> */
protected function getFieldValidationRules(CustomField $customField): array
protected function getFieldValidationRules(CustomField $customField, string|int|null $ignoreEntityId = null): array
{
return $this->validationService->getValidationRules($customField);
return $this->validationService->getValidationRules($customField, $ignoreEntityId);
}

/**
Expand Down
20 changes: 14 additions & 6 deletions src/Filament/Integration/Components/Forms/LinkComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Relaticle\CustomFields\Filament\Integration\Components\Forms;

use Relaticle\CustomFields\FieldTypeSystem\FieldManager;
use Relaticle\CustomFields\Filament\Integration\Base\AbstractFormComponent;
use Relaticle\CustomFields\Filament\Integration\Components\Forms\MultiValueInput\MultiValueInputComponent;
use Relaticle\CustomFields\Models\CustomField;
Expand All @@ -17,6 +18,8 @@ public function create(CustomField $customField): MultiValueInputComponent
? ($customField->settings->max_values ?? 10)
: 1;

$fieldType = app(FieldManager::class)->getFieldTypeInstance($customField->type);

return MultiValueInputComponent::make($customField->getFieldName())
->url()
->allowMultiple($allowMultiple)
Expand All @@ -25,11 +28,16 @@ public function create(CustomField $customField): MultiValueInputComponent
->placeholder(__('custom-fields::custom-fields.link.add_link_placeholder'))
->nestedRecursiveRules(['max:2048', 'regex:/^(https?:\/\/)?([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(\/.*)?$/'])
->rules(['array', 'max:'.$maxValues])
->dehydrateStateUsing(static fn (mixed $state): array => collect($state)
->map(fn (mixed $v): ?string => preg_replace('#^https?://#i', '', trim((string) $v)))
->filter(fn (mixed $v): bool => filled($v))
->values()
->all()
);
->dehydrateStateUsing(function (mixed $state) use ($fieldType): array {
return collect($state)
->map(function (mixed $v) use ($fieldType): string {
$trimmed = trim((string) $v);

return $fieldType ? $fieldType->setValue($trimmed) : $trimmed;
})
->filter(fn (mixed $v): bool => filled($v))
->values()
->all();
});
}
}
75 changes: 36 additions & 39 deletions src/Rules/UniqueCustomFieldValue.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,10 @@
use Relaticle\CustomFields\CustomFields;
use Relaticle\CustomFields\Enums\CustomFieldsFeature;
use Relaticle\CustomFields\FeatureSystem\FeatureManager;
use Relaticle\CustomFields\FieldTypeSystem\FieldManager;
use Relaticle\CustomFields\Models\CustomField;
use Relaticle\CustomFields\Services\TenantContextService;

/**
* Validation rule that ensures a custom field value is unique per entity type.
*
* This rule checks if any other entity of the same type already has the given value
* for this custom field. Works with any field type that has uniqueness enabled.
* For multi-value fields (arrays stored in json_value), each value is checked.
*/
final class UniqueCustomFieldValue implements ValidationRule
{
public function __construct(
Expand All @@ -33,46 +27,19 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
return;
}

// Handle both single values and arrays (for multi-value fields)
$values = is_array($value) ? $value : [$value];

$valueModel = CustomFields::newValueModel();
$valueColumn = $this->customField->getValueColumn();
$fieldType = app(FieldManager::class)->getFieldTypeInstance($this->customField->type);

foreach ($values as $singleValue) {
if (blank($singleValue)) {
continue;
}

// Resolve the morph alias for the entity type (e.g., 'App\Models\People' -> 'people')
$entityType = $this->customField->entity_type;
$morphAlias = Relation::getMorphAlias($entityType) ?? (new $entityType)->getMorphClass();

$query = $valueModel->newQuery()
->where('custom_field_id', $this->customField->getKey())
->where('entity_type', $morphAlias);

// 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());
}
$normalizedValue = $fieldType
? $fieldType->setValue((string) $singleValue)
: (string) $singleValue;

// Exclude the current entity if updating
if ($this->ignoreEntityId !== null) {
$query->where('entity_id', '!=', $this->ignoreEntityId);
}

if ($query->exists()) {
if ($this->existsOnAnotherEntity($normalizedValue)) {
$fail(__('custom-fields::custom-fields.validation.unique_value', [
'value' => $singleValue,
]));
Expand All @@ -81,4 +48,34 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
}
}
}

private function existsOnAnotherEntity(string $normalizedValue): bool
{
$valueModel = CustomFields::newValueModel();
$valueColumn = $this->customField->getValueColumn();

$entityType = $this->customField->entity_type;
$morphAlias = Relation::getMorphAlias($entityType) ?? (new $entityType)->getMorphClass();

$query = $valueModel->newQuery()
->where('custom_field_id', $this->customField->getKey())
->where('entity_type', $morphAlias);

if ($valueColumn === 'json_value') {
$query->whereJsonContains('json_value', $normalizedValue);
} else {
$query->where($valueColumn, $normalizedValue);
}

if (FeatureManager::isEnabled(CustomFieldsFeature::SYSTEM_MULTI_TENANCY)) {
$tenantFk = config('custom-fields.database.column_names.tenant_foreign_key');
$query->where($tenantFk, TenantContextService::getCurrentTenantId());
}

if ($this->ignoreEntityId !== null) {
$query->where('entity_id', '!=', $this->ignoreEntityId);
}

return $query->exists();
}
}
Loading