Skip to content
6 changes: 5 additions & 1 deletion src/FieldTypeSystem/FieldManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,12 @@ public function getFieldTypes(): array
return $this->cachedFieldTypes;
}

public function getFieldType(string $fieldType): ?FieldTypeData
public function getFieldType(?string $fieldType): ?FieldTypeData
{
if ($fieldType === null) {
return null;
}

return $this->toCollection()->firstWhere('key', $fieldType);
}

Expand Down
5 changes: 3 additions & 2 deletions src/Filament/Integration/Base/AbstractFormComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ protected function configure(
)
)
->dehydrated(
fn (mixed $state): bool => FeatureManager::isEnabled(CustomFieldsFeature::FIELD_CONDITIONAL_VISIBILITY) &&
($this->coreVisibilityLogic->shouldAlwaysSave($customField) || filled($state))
fn (mixed $state): bool => ! FeatureManager::isEnabled(CustomFieldsFeature::FIELD_CONDITIONAL_VISIBILITY) ||
$this->coreVisibilityLogic->shouldAlwaysSave($customField) ||
filled($state)
)
->required($this->validationService->isRequired($customField))
->rules($this->validationService->getValidationRules($customField))
Expand Down
16 changes: 1 addition & 15 deletions src/Filament/Integration/Builders/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,6 @@ public function only(array $fieldCodes): static
*/
protected function getFilteredSections(): Collection
{
// Use a static cache within the request to prevent duplicate queries
static $sectionsCache = [];

$cacheKey = get_class($this).':'.$this->model::class.':'.
hash('xxh128', serialize($this->only).serialize($this->except));

if (isset($sectionsCache[$cacheKey])) {
return $sectionsCache[$cacheKey];
}

/** @var Collection<int, CustomFieldSection> $sections */
$sections = $this->sections
->with(['fields' => function (mixed $query): mixed {
Expand All @@ -105,16 +95,12 @@ protected function getFilteredSections(): Collection
}])
->get();

$filteredSections = $sections
return $sections
->map(function (CustomFieldSection $section): CustomFieldSection {
$section->setRelation('fields', $section->fields->filter(fn (CustomField $field): bool => $field->typeData !== null));

return $section;
})
->filter(fn (CustomFieldSection $section) => $section->fields->isNotEmpty());

$sectionsCache[$cacheKey] = $filteredSections;

return $filteredSections;
}
}
19 changes: 12 additions & 7 deletions src/Filament/Integration/Builders/FormBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@
use Filament\Schemas\Components\Grid;
use Illuminate\Support\Collection;
use Relaticle\CustomFields\Filament\Integration\Factories\FieldComponentFactory;
use Relaticle\CustomFields\Filament\Integration\Factories\SectionComponentFactory;
use Relaticle\CustomFields\Models\CustomField;
use Relaticle\CustomFields\Models\CustomFieldSection;

class FormBuilder extends BaseBuilder
{
Expand Down Expand Up @@ -42,14 +40,21 @@ private function getDependentFieldCodes(Collection $fields): array
public function values(): Collection
{
$fieldComponentFactory = app(FieldComponentFactory::class);
$sectionComponentFactory = app(SectionComponentFactory::class);

$allFields = $this->getFilteredSections()->flatMap(fn (mixed $section) => $section->fields);
$dependentFieldCodes = $this->getDependentFieldCodes($allFields);

return $this->getFilteredSections()
->map(fn (CustomFieldSection $section) => $sectionComponentFactory->create($section)->schema(
fn () => $section->fields->map(fn (CustomField $customField) => $fieldComponentFactory->create($customField, $dependentFieldCodes, $allFields))->toArray()
));
// Return fields directly without Section/Fieldset wrappers
// This ensures the flat structure: custom_fields.{field_code}
// Note: We skip section grouping to avoid nested paths like custom_fields.{section_code}.{field_code}
// which causes issues with Filament v4's child schema nesting behavior.
// Visual grouping can be added later using alternative methods if needed.
return $allFields->map(
fn (CustomField $customField) => $fieldComponentFactory->create(
$customField,
$dependentFieldCodes,
$allFields
)
);
}
}
18 changes: 17 additions & 1 deletion src/Filament/Integration/Builders/FormContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Filament\Schemas\Components\Grid;
use Illuminate\Database\Eloquent\Model;
use Throwable;

final class FormContainer extends Grid
{
Expand Down Expand Up @@ -47,7 +48,22 @@ public function only(array $fieldCodes): static
private function generateSchema(): array
{
// Inline priority: explicit ?? record ?? model class
$model = $this->explicitModel ?? $this->getRecord() ?? $this->getModel();
$record = null;
$modelClass = null;

try {
$record = $this->getRecord();
} catch (Throwable $throwable) {
// Record not available yet
}

try {
$modelClass = $this->getModel();
} catch (Throwable $throwable) {
// Model class not available yet
}

$model = $this->explicitModel ?? $record ?? $modelClass;

if ($model === null) {
return []; // Graceful fallback
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ protected function hasColorOptionsEnabled(CustomField $customField): bool
protected function getColoredOptions(CustomField $customField): array
{
return $customField->options
->filter(fn (mixed $option): bool => $option->settings->color ?? false)
->filter(fn (mixed $option): bool => filled($option->settings->color ?? null))
->mapWithKeys(fn (mixed $option): array => [$option->id => $option->name])
->all();
}
Expand All @@ -51,7 +51,7 @@ protected function getColoredOptions(CustomField $customField): array
protected function getColorMapping(CustomField $customField): array
{
return $customField->options
->filter(fn (mixed $option): bool => $option->settings->color ?? false)
->filter(fn (mixed $option): bool => filled($option->settings->color ?? null))
->mapWithKeys(fn (mixed $option): array => [$option->id => $option->settings->color])
->all();
}
Expand Down
3 changes: 1 addition & 2 deletions src/Filament/Management/Schemas/FieldForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
namespace Relaticle\CustomFields\Filament\Management\Schemas;

use Exception;
use Filament\Facades\Filament;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Repeater;
Expand Down Expand Up @@ -323,7 +322,7 @@ public static function schema(bool $withOptionsRelationship = true): array
->visible(
fn (
Get $get
): bool => CustomFieldsType::getFieldType($get('type'))->searchable
): bool => CustomFieldsType::getFieldType($get('type'))->searchable ?? false
)
->disabled(
fn (Get $get): bool => $get(
Expand Down
69 changes: 58 additions & 11 deletions src/Models/Concerns/UsesCustomFields.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,29 +36,76 @@ public function __construct($attributes = [])
}

/**
* @var array<int, array<string, mixed>>
* @var array<string, array<string, mixed>>
*/
protected static array $tempCustomFields = [];

/**
* Generate a unique key for storing temporary custom fields data.
*/
protected function getTempCustomFieldsKey(): string
{
// Use class name + key for existing models, or object ID for new models
if ($this->exists) {
return static::class.':'.$this->getKey();
}

return static::class.':new:'.spl_object_id($this);
}

protected static function bootUsesCustomFields(): void
{
static::saving(function (Model $model): void {
$model->handleCustomFields();
});
}

static::saved(function (Model $model): void {
$model->saveCustomFieldsFromTemp();
});
/**
* Override save to handle custom fields after saving.
*/
public function save(array $options = []): bool
{
$result = parent::save($options);

if ($result) {
$this->saveCustomFieldsFromTemp();
}

return $result;
}

/**
* Mutator to intercept custom_fields attribute and store it temporarily.
*
* @param array<string, mixed>|null $value
*/
public function setCustomFieldsAttribute(?array $value): void
{
// Handle null value (when custom_fields is not provided)
if ($value === null) {
return;
}

// Store in temporary storage instead of attributes
$key = $this->getTempCustomFieldsKey();
self::$tempCustomFields[$key] = $value;

// Mark the model as dirty by updating the updated_at timestamp
// This ensures the model will be saved even if no other attributes changed
if ($this->usesTimestamps() && ! $this->isDirty('updated_at')) {
$this->updated_at = $this->freshTimestamp();
}
}

/**
* Handle the custom fields before saving the model.
*/
protected function handleCustomFields(): void
{
if (isset($this->custom_fields) && is_array($this->custom_fields)) {
self::$tempCustomFields[spl_object_id($this)] = $this->custom_fields;
unset($this->custom_fields);
if (isset($this->attributes['custom_fields']) && is_array($this->attributes['custom_fields'])) {
$key = $this->getTempCustomFieldsKey();
self::$tempCustomFields[$key] = $this->attributes['custom_fields'];
unset($this->attributes['custom_fields']);
}
}

Expand All @@ -67,11 +114,11 @@ protected function handleCustomFields(): void
*/
protected function saveCustomFieldsFromTemp(): void
{
$objectId = spl_object_id($this);
$key = $this->getTempCustomFieldsKey();

if (isset(self::$tempCustomFields[$objectId]) && method_exists($this, 'saveCustomFields')) {
$this->saveCustomFields(self::$tempCustomFields[$objectId]);
unset(self::$tempCustomFields[$objectId]);
if (isset(self::$tempCustomFields[$key]) && method_exists($this, 'saveCustomFields')) {
$this->saveCustomFields(self::$tempCustomFields[$key]);
unset(self::$tempCustomFields[$key]);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Models/CustomFieldOption.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ protected static function boot(): void
}

// Check if encryption is enabled
if ($option->customField->settings->encrypted) {
if ($option->customField && $option->customField->settings->encrypted) {
$option->attributes['name'] = Crypt::encryptString($rawName);
}
});
Expand Down
48 changes: 41 additions & 7 deletions src/Services/TenantContextService.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,21 @@

namespace Relaticle\CustomFields\Services;

use Closure;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Context;

final class TenantContextService
{
private const string TENANT_ID_KEY = 'custom_fields_tenant_id';

/**
* Custom tenant resolver callback.
*
* @var Closure(): (int|string|null)|null
*/
private static ?Closure $tenantResolver = null;

/**
* Set the tenant ID in the context.
* This will persist across queue jobs and other async operations.
Expand All @@ -25,24 +33,50 @@ public static function setTenantId(null|int|string $tenantId): void
}

/**
* Get the current tenant ID from context or Filament.
* Register a custom tenant resolver.
*
* @param Closure(): (int|string|null) $callback
*/
public static function setTenantResolver(Closure $callback): void
{
self::$tenantResolver = $callback;
}

/**
* Clear the custom tenant resolver.
*/
public static function clearTenantResolver(): void
{
self::$tenantResolver = null;
}

/**
* Get the current tenant ID from custom resolver, context, or Filament.
* This works in both web requests and queue jobs.
*
* Resolution order:
* 1. Custom resolver (if registered)
* 2. Laravel Context (works in queues)
* 3. Filament tenant (works in web requests)
* 4. null (no tenant)
*/
public static function getCurrentTenantId(): null|int|string
{
// First try to get tenant from Laravel Context (works in queues)
// First priority: custom resolver
if (self::$tenantResolver instanceof Closure) {
return (self::$tenantResolver)();
}

// Second priority: Laravel Context (works in queues)
$contextTenantId = Context::getHidden(self::TENANT_ID_KEY);
if ($contextTenantId !== null) {
return $contextTenantId;
}

// Fallback to Filament tenant (works in web requests)
// Third priority: Filament tenant (works in web requests)
$filamentTenant = Filament::getTenant();
if ($filamentTenant !== null) {
return $filamentTenant->getKey();
}

return null;
return $filamentTenant?->getKey();
}

/**
Expand Down
3 changes: 1 addition & 2 deletions src/Services/Visibility/FrontendVisibilityService.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
use Relaticle\CustomFields\Enums\VisibilityMode;
use Relaticle\CustomFields\Enums\VisibilityOperator;
use Relaticle\CustomFields\Models\CustomField;
use Relaticle\CustomFields\Models\CustomFieldOption;

/**
* Frontend Visibility Service
Expand Down Expand Up @@ -467,7 +466,7 @@ private function convertOptionValue(
return rescue(function () use ($value, $targetField) {
if (is_string($value) && $targetField->options->isNotEmpty()) {
return $targetField->options->first(
fn (CustomFieldOption $opt): bool => Str::lower(trim((string) $opt->name)) ===
fn (mixed $opt): bool => Str::lower(trim((string) $opt->name)) ===
Str::lower(trim($value))
)->id ?? $value;
}
Expand Down
Loading