diff --git a/database/migrations/add_schema_id_to_fields_table.php.stub b/database/migrations/add_schema_id_to_fields_table.php.stub new file mode 100644 index 0000000..9876761 --- /dev/null +++ b/database/migrations/add_schema_id_to_fields_table.php.stub @@ -0,0 +1,24 @@ +ulid('schema_id')->nullable()->after('group'); + $table->foreign('schema_id')->references('ulid')->on('schemas')->onDelete('set null'); + }); + } + + public function down() + { + Schema::table('fields', function (Blueprint $table) { + $table->dropForeign(['schema_id']); + $table->dropColumn('schema_id'); + }); + } +}; diff --git a/database/migrations/create_schemas_table.php.stub b/database/migrations/create_schemas_table.php.stub new file mode 100644 index 0000000..fc763e2 --- /dev/null +++ b/database/migrations/create_schemas_table.php.stub @@ -0,0 +1,35 @@ +ulid('ulid')->primary(); + $table->string('name'); + $table->string('slug'); + $table->string('field_type'); + $table->json('config')->nullable(); + $table->integer('position')->default(0); + $table->string('model_type'); + $table->string('model_key'); + $table->ulid('parent_ulid')->nullable(); + $table->timestamps(); + + $table->index(['model_type', 'model_key']); + $table->index(['model_type', 'model_key', 'position']); + $table->index(['parent_ulid']); + + $table->unique(['model_type', 'model_key', 'slug']); + }); + } + + public function down(): void + { + Schema::dropIfExists('schemas'); + } +}; diff --git a/src/Concerns/CanMapDynamicFields.php b/src/Concerns/CanMapDynamicFields.php index 62520b6..84d12da 100644 --- a/src/Concerns/CanMapDynamicFields.php +++ b/src/Concerns/CanMapDynamicFields.php @@ -56,15 +56,11 @@ trait CanMapDynamicFields 'tags' => Tags::class, ]; - public function boot(): void - { - $this->fieldInspector = app(FieldInspector::class); - } - #[On('refreshFields')] - public function refresh(): void + #[On('refreshSchemas')] + public function refreshFields(): void { - // + // Custom refresh logic for fields } /** @@ -123,17 +119,21 @@ protected function mutateBeforeSave(array $data): array private function hasValidRecordWithFields(): bool { - return isset($this->record) && ! $this->record->fields->isEmpty(); + return property_exists($this, 'record') && isset($this->record) && ! $this->record->fields->isEmpty(); } private function hasValidRecord(): bool { - return isset($this->record); + return property_exists($this, 'record') && isset($this->record); } private function extractFormValues(array $data): array { - return isset($data[$this->record?->valueColumn]) ? $data[$this->record?->valueColumn] : []; + if (! property_exists($this, 'record') || ! $this->record) { + return []; + } + + return isset($data[$this->record->valueColumn]) ? $data[$this->record->valueColumn] : []; } /** @@ -165,6 +165,10 @@ private function extractBuilderBlocks(array $values): array */ private function getAllFieldsIncludingBuilderFields(array $builderBlocks): Collection { + if (! property_exists($this, 'record') || ! $this->record) { + return collect(); + } + return $this->record->fields->merge( $this->getFieldsFromBlocks($builderBlocks) ); @@ -189,11 +193,17 @@ private function applyFieldFillMutation(Model $field, array $fieldConfig, object return $this->processBuilderFieldFillMutation($field, $fieldInstance, $data, $fieldLocation['builderData'], $builderBlocks); } - return $fieldInstance->mutateFormDataCallback($this->record, $field, $data); + if (property_exists($this, 'record') && $this->record) { + return $fieldInstance->mutateFormDataCallback($this->record, $field, $data); + } + + return $data; } // Default behavior: copy value from record to form data - $data[$this->record->valueColumn][$field->ulid] = $this->record->values[$field->ulid] ?? null; + if (property_exists($this, 'record') && $this->record) { + $data[$this->record->valueColumn][$field->ulid] = $this->record->values[$field->ulid] ?? null; + } return $data; } @@ -205,7 +215,7 @@ private function applyFieldFillMutation(Model $field, array $fieldConfig, object */ private function extractBuilderBlocksFromRecord(): array { - if (! isset($this->record->values) || ! is_array($this->record->values)) { + if (! property_exists($this, 'record') || ! $this->record || ! isset($this->record->values) || ! is_array($this->record->values)) { return []; } @@ -235,14 +245,20 @@ private function processBuilderFieldFillMutation(Model $field, object $fieldInst $mockRecord = $this->createMockRecordForBuilder($builderData); // Create a temporary data structure for the callback - $tempData = [$this->record->valueColumn => $builderData]; - $tempData = $fieldInstance->mutateFormDataCallback($mockRecord, $field, $tempData); + if (property_exists($this, 'record') && $this->record) { + $tempData = [$this->record->valueColumn => $builderData]; + $tempData = $fieldInstance->mutateFormDataCallback($mockRecord, $field, $tempData); + } else { + $tempData = []; + } // Update the original data structure with the mutated values $this->updateBuilderBlocksWithMutatedData($builderBlocks, $field, $tempData); // Update the main data structure - $data[$this->record->valueColumn] = array_merge($data[$this->record->valueColumn], $builderBlocks); + if (property_exists($this, 'record') && $this->record) { + $data[$this->record->valueColumn] = array_merge($data[$this->record->valueColumn], $builderBlocks); + } return $data; } @@ -255,6 +271,9 @@ private function processBuilderFieldFillMutation(Model $field, object $fieldInst */ private function createMockRecordForBuilder(array $builderData): object { + if (! property_exists($this, 'record') || ! $this->record) { + throw new \RuntimeException('Record property is not available'); + } $mockRecord = clone $this->record; $mockRecord->values = $builderData; @@ -274,7 +293,9 @@ private function updateBuilderBlocksWithMutatedData(array &$builderBlocks, Model if (is_array($builderBlocks)) { foreach ($builderBlocks as &$block) { if (isset($block['data']) && is_array($block['data']) && isset($block['data'][$field->ulid])) { - $block['data'][$field->ulid] = $tempData[$this->record->valueColumn][$field->ulid] ?? $block['data'][$field->ulid]; + if (property_exists($this, 'record') && $this->record) { + $block['data'][$field->ulid] = $tempData[$this->record->valueColumn][$field->ulid] ?? $block['data'][$field->ulid]; + } } } } @@ -292,6 +313,11 @@ private function updateBuilderBlocksWithMutatedData(array &$builderBlocks, Model */ private function resolveFieldConfigAndInstance(Model $field): array { + // Initialize field inspector if not already done + if (! isset($this->fieldInspector)) { + $this->fieldInspector = app(FieldInspector::class); + } + // Try to resolve from custom fields first $fieldConfig = Fields::resolveField($field->field_type) ? $this->fieldInspector->initializeCustomField($field->field_type) : @@ -391,9 +417,9 @@ private function processNestedFields(Model $field, array $data, callable $mutati */ private function resolveFormFields(mixed $record = null, bool $isNested = false): array { - $record = $record ?? $this->record; + $record = $record ?? (property_exists($this, 'record') ? $this->record : null); - if (! isset($record->fields) || $record->fields->isEmpty()) { + if (! $record || ! isset($record->fields) || $record->fields->isEmpty()) { return []; } @@ -409,7 +435,7 @@ private function resolveFormFields(mixed $record = null, bool $isNested = false) private function resolveCustomFields(): Collection { return collect(Fields::getFields()) - ->map(fn ($fieldClass) => new $fieldClass); + ->mapWithKeys(fn ($fieldClass, $key) => [$key => $fieldClass]); } /** @@ -426,18 +452,26 @@ private function resolveCustomFields(): Collection */ private function resolveFieldInput(Model $field, Collection $customFields, mixed $record = null, bool $isNested = false): ?object { - $record = $record ?? $this->record; + $record = $record ?? (property_exists($this, 'record') ? $this->record : null); + + if (! $record) { + return null; + } $inputName = $this->generateInputName($field, $record, $isNested); // Try to resolve from custom fields first (giving them priority) - if ($customField = $customFields->get($field->field_type)) { - return $customField::make($inputName, $field); + if ($customFieldClass = $customFields->get($field->field_type)) { + $input = $customFieldClass::make($inputName, $field); + + return $input; } // Fall back to standard field type map if no custom field found if ($fieldClass = self::FIELD_TYPE_MAP[$field->field_type] ?? null) { - return $fieldClass::make(name: $inputName, field: $field); + $input = $fieldClass::make(name: $inputName, field: $field); + + return $input; } return null; @@ -445,7 +479,9 @@ private function resolveFieldInput(Model $field, Collection $customFields, mixed private function generateInputName(Model $field, mixed $record, bool $isNested): string { - return $isNested ? "{$field->ulid}" : "{$record->valueColumn}.{$field->ulid}"; + $name = $isNested ? "{$field->ulid}" : "{$record->valueColumn}.{$field->ulid}"; + + return $name; } /** @@ -474,7 +510,11 @@ private function applyFieldSaveMutation(Model $field, array $fieldConfig, object } // Regular field processing - return $fieldInstance->mutateBeforeSaveCallback($this->record, $field, $data); + if (property_exists($this, 'record') && $this->record) { + return $fieldInstance->mutateBeforeSaveCallback($this->record, $field, $data); + } + + return $data; } /** @@ -532,18 +572,22 @@ private function processBuilderFieldMutation(Model $field, object $fieldInstance $mockRecord = $this->createMockRecordForBuilder($block['data']); // Create a temporary data structure for the callback - $tempData = [$this->record->valueColumn => $block['data']]; - $tempData = $fieldInstance->mutateBeforeSaveCallback($mockRecord, $field, $tempData); + if (property_exists($this, 'record') && $this->record) { + $tempData = [$this->record->valueColumn => $block['data']]; + $tempData = $fieldInstance->mutateBeforeSaveCallback($mockRecord, $field, $tempData); - if (isset($tempData[$this->record->valueColumn][$field->ulid])) { - $block['data'][$field->ulid] = $tempData[$this->record->valueColumn][$field->ulid]; + if (isset($tempData[$this->record->valueColumn][$field->ulid])) { + $block['data'][$field->ulid] = $tempData[$this->record->valueColumn][$field->ulid]; + } } } } } } - $data[$this->record->valueColumn] = array_merge($data[$this->record->valueColumn], $builderBlocks); + if (property_exists($this, 'record') && $this->record) { + $data[$this->record->valueColumn] = array_merge($data[$this->record->valueColumn], $builderBlocks); + } return $data; } diff --git a/src/Concerns/CanMapSchemasWithFields.php b/src/Concerns/CanMapSchemasWithFields.php new file mode 100644 index 0000000..1b65752 --- /dev/null +++ b/src/Concerns/CanMapSchemasWithFields.php @@ -0,0 +1,103 @@ +record->fields; + + foreach ($this->record->schemas as $schema) { + $schemaFields = Field::where('schema_id', $schema->ulid)->get(); + $allFields = $allFields->merge($schemaFields); + } + + $this->record->setRelation('fields', $allFields); + } + + protected function loadDefaultValuesIntoRecord(): void + { + $defaultValues = []; + $allFields = $this->record->fields; + + foreach ($allFields as $field) { + $defaultValue = $field->config['defaultValue'] ?? null; + + if ($field->field_type === 'select' && $defaultValue === null) { + continue; + } + + $defaultValues[$field->ulid] = $defaultValue; + } + + $this->record->setAttribute('values', $defaultValues); + } + + protected function getFieldsFromSchema(Schema $schema): \Illuminate\Support\Collection + { + $fields = collect(); + $schemaFields = Field::where('schema_id', $schema->ulid)->get(); + $fields = $fields->merge($schemaFields); + + $childSchemas = $schema->children()->get(); + foreach ($childSchemas as $childSchema) { + $fields = $fields->merge($this->getFieldsFromSchema($childSchema)); + } + + return $fields; + } + + protected function getAllSchemaFields(): \Illuminate\Support\Collection + { + $allFields = collect(); + $rootSchemas = $this->record->schemas() + ->whereNull('parent_ulid') + ->orderBy('position') + ->get(); + + foreach ($rootSchemas as $schema) { + $allFields = $allFields->merge($this->getFieldsFromSchema($schema)); + } + + return $allFields; + } + + protected function initializeFormData(): void + { + $this->loadAllFieldsIntoRecord(); + $this->loadDefaultValuesIntoRecord(); + $this->data = $this->mutateBeforeFill($this->data); + } + + protected function mutateBeforeFill(array $data): array + { + if (! $this->hasValidRecordWithFields()) { + return $data; + } + + $builderBlocks = $this->extractBuilderBlocksFromRecord(); + $allFields = $this->getAllFieldsIncludingBuilderFields($builderBlocks); + + if (! isset($data[$this->record->valueColumn])) { + $data[$this->record->valueColumn] = []; + } + + return $this->mutateFormData($data, $allFields, function ($field, $fieldConfig, $fieldInstance, $data) use ($builderBlocks) { + if ($field->field_type === 'select') { + if (isset($this->record->values[$field->ulid])) { + $data[$this->record->valueColumn][$field->ulid] = $this->record->values[$field->ulid]; + } + + return $data; + } + + return $this->applyFieldFillMutation($field, $fieldConfig, $fieldInstance, $data, $builderBlocks); + }); + } +} diff --git a/src/Concerns/HasFieldTypeResolver.php b/src/Concerns/HasFieldTypeResolver.php index 12edaa1..de694b6 100644 --- a/src/Concerns/HasFieldTypeResolver.php +++ b/src/Concerns/HasFieldTypeResolver.php @@ -3,6 +3,7 @@ namespace Backstage\Fields\Concerns; use Backstage\Fields\Enums\Field; +use Backstage\Fields\Enums\Schema as SchemaEnum; use Backstage\Fields\Facades\Fields; use Exception; use Illuminate\Support\Str; @@ -35,10 +36,16 @@ protected static function resolveFieldTypeClassName(string $fieldType): ?string return Fields::getFields()[$fieldType]; } + // Check if it's a field type if (Field::tryFrom($fieldType)) { return sprintf('Backstage\\Fields\\Fields\\%s', Str::studly($fieldType)); } + // Check if it's a schema type + if (SchemaEnum::tryFrom($fieldType)) { + return sprintf('Backstage\\Fields\\Schemas\\%s', Str::studly($fieldType)); + } + return null; } diff --git a/src/Concerns/HasSelectableValues.php b/src/Concerns/HasSelectableValues.php index 569256d..97e7d86 100644 --- a/src/Concerns/HasSelectableValues.php +++ b/src/Concerns/HasSelectableValues.php @@ -18,6 +18,7 @@ trait HasSelectableValues protected static function resolveResourceModel(string $tableName): ?object { $resources = config('backstage.fields.selectable_resources'); + $resourceClass = collect($resources)->first(function ($resource) use ($tableName) { $res = new $resource; $model = $res->getModel(); @@ -115,7 +116,8 @@ protected static function buildRelationshipOptions(mixed $field): array // Apply filters if they exist if (isset($relation['relationValue_filters'])) { foreach ($relation['relationValue_filters'] as $filter) { - if (isset($filter['column'], $filter['operator'], $filter['value'])) { + if (isset($filter['column'], $filter['operator'], $filter['value']) && + ! empty($filter['column']) && ! empty($filter['operator']) && $filter['value'] !== '') { if (preg_match('/{session\.([^\}]+)}/', $filter['value'], $matches)) { $sessionValue = session($matches[1]); $filter['value'] = str_replace($matches[0], $sessionValue, $filter['value']); diff --git a/src/Contracts/SchemaContract.php b/src/Contracts/SchemaContract.php new file mode 100644 index 0000000..e146158 --- /dev/null +++ b/src/Contracts/SchemaContract.php @@ -0,0 +1,14 @@ + false, 'columns' => 1, 'form' => [], - 'table' => false, + 'tableMode' => false, + 'tableColumns' => [], 'compact' => false, ]; } @@ -108,14 +110,14 @@ public static function make(string $name, ?Field $field = null): Input } if ($field && $field->children->count() > 0) { - $isTableMode = $field->config['table'] ?? self::getDefaultConfig()['table']; - - if ($isTableMode) { - $input = $input - ->table(self::generateTableColumns($field->children)) - ->schema(self::generateSchemaFromChildren($field->children, false)); - } else { - $input = $input->schema(self::generateSchemaFromChildren($field->children, false)); + $input = $input->schema(self::generateSchemaFromChildren($field->children)); + + // Apply table mode if enabled + if ($field->config['tableMode'] ?? self::getDefaultConfig()['tableMode']) { + $tableColumns = self::generateTableColumnsFromChildren($field->children, $field->config['tableColumns'] ?? []); + if (! empty($tableColumns)) { + $input = $input->table($tableColumns); + } } } @@ -134,39 +136,42 @@ public function getForm(): array ->label(__('Field specific')) ->schema([ Grid::make(3)->schema([ - Toggle::make('config.addable') + Forms\Components\Toggle::make('config.addable') ->label(__('Addable')), - Toggle::make('config.deletable') + Forms\Components\Toggle::make('config.deletable') ->label(__('Deletable')), - Toggle::make('config.reorderable') + Forms\Components\Toggle::make('config.reorderable') ->label(__('Reorderable')) ->live(), - Toggle::make('config.reorderableWithButtons') + Forms\Components\Toggle::make('config.reorderableWithButtons') ->label(__('Reorderable with buttons')) ->dehydrated() ->disabled(fn (Get $get): bool => $get('config.reorderable') === false), - Toggle::make('config.collapsible') + Forms\Components\Toggle::make('config.collapsible') ->label(__('Collapsible')), - Toggle::make('config.collapsed') + Forms\Components\Toggle::make('config.collapsed') ->label(__('Collapsed')) ->visible(fn (Get $get): bool => $get('config.collapsible') === true), - Toggle::make('config.cloneable') + Forms\Components\Toggle::make('config.cloneable') ->label(__('Cloneable')), - ]), + ])->columnSpanFull(), Grid::make(2)->schema([ TextInput::make('config.addActionLabel') - ->label(__('Add action label')), + ->label(__('Add action label')) + ->columnSpan(fn (Get $get) => ($get('config.tableMode') ?? false) ? 'full' : 1), TextInput::make('config.columns') ->label(__('Columns')) ->default(1) - ->numeric(), - Toggle::make('config.table') - ->label(__('Table repeater')), - Toggle::make('config.compact') + ->numeric() + ->visible(fn (Get $get): bool => ! ($get('config.tableMode') ?? false)), + Forms\Components\Toggle::make('config.tableMode') + ->label(__('Table Mode')) + ->live(), + Forms\Components\Toggle::make('config.compact') ->label(__('Compact table')) ->live() - ->visible(fn (Get $get): bool => $get('config.table') === true), - ]), + ->visible(fn (Get $get): bool => ($get('config.tableMode') ?? false)), + ])->columnSpanFull(), AdjacencyList::make('config.form') ->columnSpanFull() ->label(__('Fields')) @@ -240,7 +245,7 @@ function () { )) ->visible(fn (Get $get) => filled($get('field_type'))), ]), - ]), + ])->columns(2), ])->columnSpanFull(), ]; } @@ -250,37 +255,71 @@ protected function excludeFromBaseSchema(): array return ['defaultValue']; } - private static function generateTableColumns(Collection $children): array + private static function generateSchemaFromChildren(Collection $children, bool $isTableMode = false): array { - $columns = []; + $schema = []; $children = $children->sortBy('position'); foreach ($children as $child) { - $columns[] = TableColumn::make($child['slug']); + $fieldType = $child['field_type']; + + $field = self::resolveFieldTypeClassName($fieldType); + + if ($field === null) { + continue; + } + + $schema[] = $field::make($child['slug'], $child); } - return $columns; + return $schema; } - private static function generateSchemaFromChildren(Collection $children, bool $isTableMode = false): array + private static function generateTableColumnsFromChildren(Collection $children, array $tableColumnsConfig = []): array { - $schema = []; + $tableColumns = []; $children = $children->sortBy('position'); foreach ($children as $child) { - $fieldType = $child['field_type']; + $slug = $child['slug']; + $name = $child['name']; - $field = self::resolveFieldTypeClassName($fieldType); + $columnConfig = $tableColumnsConfig[$slug] ?? []; - if ($field === null) { - continue; + $tableColumn = TableColumn::make($name); + + // Apply custom configuration if provided + if (isset($columnConfig['hiddenHeaderLabel']) && $columnConfig['hiddenHeaderLabel']) { + $tableColumn = $tableColumn->hiddenHeaderLabel(); } - $schema[] = $field::make($child['slug'], $child); + if (isset($columnConfig['markAsRequired']) && $columnConfig['markAsRequired']) { + $tableColumn = $tableColumn->markAsRequired(); + } + + if (isset($columnConfig['wrapHeader']) && $columnConfig['wrapHeader']) { + $tableColumn = $tableColumn->wrapHeader(); + } + + if (isset($columnConfig['alignment'])) { + $alignment = match ($columnConfig['alignment']) { + 'start' => Alignment::Start, + 'center' => Alignment::Center, + 'end' => Alignment::End, + default => Alignment::Start, + }; + $tableColumn = $tableColumn->alignment($alignment); + } + + if (isset($columnConfig['width'])) { + $tableColumn = $tableColumn->width($columnConfig['width']); + } + + $tableColumns[] = $tableColumn; } - return $schema; + return $tableColumns; } } diff --git a/src/Fields/Select.php b/src/Fields/Select.php index dcbff15..89600c7 100644 --- a/src/Fields/Select.php +++ b/src/Fields/Select.php @@ -44,6 +44,7 @@ public static function getDefaultConfig(): array 'optionsLimit' => null, 'minItemsForSearch' => null, 'maxItemsForSearch' => null, + 'dependsOnField' => null, // Simple field dependency ]; } @@ -52,7 +53,6 @@ public static function make(string $name, ?Field $field = null): Input $input = self::applyDefaultSettings(Input::make($name), $field); $input = $input->label($field->name ?? null) - ->options($field->config['options'] ?? self::getDefaultConfig()['options']) ->searchable($field->config['searchable'] ?? self::getDefaultConfig()['searchable']) ->multiple($field->config['multiple'] ?? self::getDefaultConfig()['multiple']) ->preload($field->config['preload'] ?? self::getDefaultConfig()['preload']) @@ -61,11 +61,26 @@ public static function make(string $name, ?Field $field = null): Input ->loadingMessage($field->config['loadingMessage'] ?? self::getDefaultConfig()['loadingMessage']) ->noSearchResultsMessage($field->config['noSearchResultsMessage'] ?? self::getDefaultConfig()['noSearchResultsMessage']) ->searchPrompt($field->config['searchPrompt'] ?? self::getDefaultConfig()['searchPrompt']) - ->searchingMessage($field->config['searchingMessage'] ?? self::getDefaultConfig()['searchingMessage']); + ->searchingMessage($field->config['searchingMessage'] ?? self::getDefaultConfig()['searchingMessage']) + ->live() // Add live binding for real-time updates + ->dehydrated() // Ensure the field is included in form submission + ->reactive(); // Ensure the field reacts to state changes - $input = self::addAffixesToInput($input, $field); + // Handle field dependencies + if (isset($field->config['dependsOnField']) && $field->config['dependsOnField']) { + $input = self::addFieldDependency($input, $field); + } + + // Add dynamic options first (from relationships, etc.) $input = self::addOptionsToInput($input, $field); + // Set static options as fallback if no dynamic options were added + if (empty($field->config['optionType']) || ! is_array($field->config['optionType']) || ! in_array('relationship', $field->config['optionType'])) { + $input = $input->options($field->config['options'] ?? self::getDefaultConfig()['options']); + } + + $input = self::addAffixesToInput($input, $field); + if (isset($field->config['searchDebounce'])) { $input->searchDebounce($field->config['searchDebounce']); } @@ -85,13 +100,29 @@ public static function make(string $name, ?Field $field = null): Input return $input; } + protected static function addFieldDependency(Input $input, Field $field): Input + { + $dependsOnField = $field->config['dependsOnField']; + + return $input + ->live() + ->visible(function (Get $get) use ($dependsOnField) { + // The field name in the form is {valueColumn}.{field_ulid} + $dependentFieldName = "values.{$dependsOnField}"; + $dependentValue = $get($dependentFieldName); + + // Show this field only when the dependent field has a value + return ! empty($dependentValue); + }); + } + public static function mutateFormDataCallback(Model $record, Field $field, array $data): array { - if (! property_exists($record, 'valueColumn') || ! isset($record->values[$field->ulid])) { + if (! property_exists($record, 'valueColumn')) { return $data; } - $value = $record->values[$field->ulid]; + $value = $record->values[$field->ulid] ?? null; $data[$record->valueColumn][$field->ulid] = self::normalizeSelectValue($value, $field); return $data; @@ -99,12 +130,12 @@ public static function mutateFormDataCallback(Model $record, Field $field, array public static function mutateBeforeSaveCallback(Model $record, Field $field, array $data): array { - if (! property_exists($record, 'valueColumn') || ! isset($data[$record->valueColumn][$field->ulid])) { + if (! property_exists($record, 'valueColumn') || ! isset($data[$record->valueColumn][(string) $field->ulid])) { return $data; } - $value = $data[$record->valueColumn][$field->ulid]; - $data[$record->valueColumn][$field->ulid] = self::normalizeSelectValue($value, $field); + $value = $data[$record->valueColumn][(string) $field->ulid]; + $data[$record->valueColumn][(string) $field->ulid] = self::normalizeSelectValue($value, $field); return $data; } @@ -210,6 +241,62 @@ public function getForm(): array ->visible(fn (Get $get): bool => $get('config.searchable')), ]), ]), + Tab::make('Field Dependencies') + ->label(__('Field Dependencies')) + ->schema([ + Grid::make(1) + ->schema([ + \Filament\Forms\Components\Select::make('config.dependsOnField') + ->label(__('Depends on Field')) + ->helperText(__('Select another field in this form that this select should depend on. When the dependent field changes, this field will show its options.')) + ->options(function ($record, $component) { + // Try to get the form slug from various sources + $formSlug = null; + + // Method 1: From the record's model_key (most reliable) + if ($record && isset($record->model_key)) { + $formSlug = $record->model_key; + } + + // Method 2: From route parameters as fallback + if (! $formSlug) { + $routeParams = request()->route()?->parameters() ?? []; + $formSlug = $routeParams['record'] ?? $routeParams['form'] ?? $routeParams['id'] ?? null; + } + + // Method 3: Try to get from the component's owner record if available + if (! $formSlug && method_exists($component, 'getOwnerRecord')) { + $ownerRecord = $component->getOwnerRecord(); + if ($ownerRecord) { + $formSlug = $ownerRecord->getKey(); + } + } + + if (! $formSlug) { + return ['debug' => 'No form slug found. Record: ' . ($record ? json_encode($record->toArray()) : 'null')]; + } + + // Get all select fields in the same form + $fields = \Backstage\Fields\Models\Field::where('model_type', 'App\Models\Form') + ->where('model_key', $formSlug) + ->where('field_type', 'select') + ->when($record && isset($record->ulid), function ($query) use ($record) { + return $query->where('ulid', '!=', $record->ulid); + }) + ->orderBy('name') + ->pluck('name', 'ulid') + ->toArray(); + + if (empty($fields)) { + return ['debug' => 'No select fields found for form: ' . $formSlug . '. Total fields: ' . \Backstage\Fields\Models\Field::where('model_type', 'App\Models\Form')->where('model_key', $formSlug)->count()]; + } + + return $fields; + }) + ->searchable() + ->live(), + ]), + ]), Tab::make('Rules') ->label(__('Rules')) ->schema([ diff --git a/src/FieldsServiceProvider.php b/src/FieldsServiceProvider.php index 8773fa1..8f3e3c2 100644 --- a/src/FieldsServiceProvider.php +++ b/src/FieldsServiceProvider.php @@ -169,6 +169,8 @@ protected function getMigrations(): array 'create_fields_table', 'change_unique_column_in_fields', 'add_group_column_to_fields_table', + 'create_schemas_table', + 'add_schema_id_to_fields_table', 'fix_option_type_string_values_in_fields_table', ]; } diff --git a/src/Filament/RelationManagers/FieldsRelationManager.php b/src/Filament/RelationManagers/FieldsRelationManager.php index 4d186a5..de043dc 100644 --- a/src/Filament/RelationManagers/FieldsRelationManager.php +++ b/src/Filament/RelationManagers/FieldsRelationManager.php @@ -48,6 +48,7 @@ public function form(Schema $schema): Schema TextInput::make('name') ->label(__('Name')) ->required() + ->autocomplete(false) ->placeholder(__('Name')) ->live(onBlur: true) ->afterStateUpdated(function (Set $set, Get $get, ?string $state, ?string $old, ?Field $record) { @@ -120,6 +121,15 @@ public function form(Schema $schema): Schema return $existingGroups; }), + Select::make('schema_id') + ->label(__('Attach to Schema')) + ->placeholder(__('Select a schema (optional)')) + ->options($this->getSchemaOptions()) + ->searchable() + ->live() + ->reactive() + ->helperText(__('Attach this field to a specific schema for better organization')), + ]), Section::make('Configuration') ->columnSpanFull() @@ -139,28 +149,60 @@ public function table(Table $table): Table ->recordTitleAttribute('name') ->reorderable('position') ->defaultSort('position', 'asc') + ->modifyQueryUsing(fn ($query) => $query->with(['schema'])) ->columns([ TextColumn::make('name') ->label(__('Name')) ->searchable() ->limit(), + TextColumn::make('group') + ->label(__('Group')) + ->placeholder(__('No Group')) + ->searchable() + ->sortable() + ->getStateUsing(fn (Field $record): string => $record->group ?? __('No Group')), + TextColumn::make('field_type') ->label(__('Type')) ->searchable(), + + TextColumn::make('schema.name') + ->label(__('Schema')) + ->placeholder(__('No schema')) + ->searchable() + ->getStateUsing(fn (Field $record): string => $record->schema->name ?? __('No Schema')), + ]) + ->filters([ + \Filament\Tables\Filters\SelectFilter::make('group') + ->label(__('Group')) + ->options(function () { + return Field::where('model_type', get_class($this->ownerRecord)) + ->where('model_key', $this->ownerRecord->getKey()) + ->pluck('group') + ->filter() + ->unique() + ->mapWithKeys(fn ($group) => [$group => $group]) + ->prepend(__('No Group'), '') + ->toArray(); + }), + \Filament\Tables\Filters\SelectFilter::make('schema_id') + ->label(__('Schema')) + ->relationship('schema', 'name') + ->placeholder(__('All Schemas')), ]) - ->filters([]) ->headerActions([ CreateAction::make() ->slideOver() ->mutateDataUsing(function (array $data) { - $key = $this->ownerRecord->getKeyName(); - return [ ...$data, - 'position' => Field::where('model_key', $key)->get()->max('position') + 1, - 'model_type' => 'setting', + 'position' => Field::where('model_key', $this->ownerRecord->getKey()) + ->where('model_type', get_class($this->ownerRecord)) + ->get() + ->max('position') + 1, + 'model_type' => get_class($this->ownerRecord), 'model_key' => $this->ownerRecord->getKey(), ]; }) @@ -173,12 +215,10 @@ public function table(Table $table): Table ->slideOver() ->mutateRecordDataUsing(function (array $data) { - $key = $this->ownerRecord->getKeyName(); - return [ ...$data, - 'model_type' => 'setting', - 'model_key' => $this->ownerRecord->{$key}, + 'model_type' => get_class($this->ownerRecord), + 'model_key' => $this->ownerRecord->getKey(), ]; }) ->after(function (Component $livewire) { @@ -227,4 +267,15 @@ public static function getPluralModelLabel(): string { return __('Fields'); } + + protected function getSchemaOptions(): array + { + $options = \Backstage\Fields\Models\Schema::where('model_key', $this->ownerRecord->getKey()) + ->where('model_type', get_class($this->ownerRecord)) + ->orderBy('position') + ->pluck('name', 'ulid') + ->toArray(); + + return $options; + } } diff --git a/src/Filament/RelationManagers/SchemaRelationManager.php b/src/Filament/RelationManagers/SchemaRelationManager.php new file mode 100644 index 0000000..85182c8 --- /dev/null +++ b/src/Filament/RelationManagers/SchemaRelationManager.php @@ -0,0 +1,228 @@ +schema([ + Section::make('Schema') + ->columnSpanFull() + ->columns(2) + ->schema([ + TextInput::make('name') + ->label(__('Name')) + ->autocomplete(false) + ->required() + ->live(onBlur: true) + ->afterStateUpdated(function (Set $set, Get $get, ?string $state, ?string $old, ?Field $record) { + if (! $record || blank($get('slug'))) { + $set('slug', Str::slug($state)); + } + + $currentSlug = $get('slug'); + + if (! $record?->slug && (! $currentSlug || $currentSlug === Str::slug($old))) { + $set('slug', Str::slug($state)); + } + }), + + TextInput::make('slug'), + + Select::make('parent_ulid') + ->label(__('Parent Schema')) + ->placeholder(__('Select a parent schema (optional)')) + ->relationship( + name: 'parent', + titleAttribute: 'name', + modifyQueryUsing: function ($query) { + $key = $this->ownerRecord->getKeyName(); + + return $query->where('model_key', $this->ownerRecord->{$key}) + ->where('model_type', get_class($this->ownerRecord)) + ->orderBy('position'); + } + ) + ->searchable() + ->helperText(__('Attach this schema to a parent schema for nested layouts')), + + Select::make('field_type') + ->searchable() + ->preload() + ->label(__('Schema Type')) + ->live(debounce: 250) + ->reactive() + ->default(SchemaEnum::Section->value) + ->options(function () { + return collect(SchemaEnum::array()) + ->sortBy(fn ($value) => $value) + ->mapWithKeys(fn ($value, $key) => [ + $key => Str::headline($value), + ]) + ->toArray(); + }) + ->required() + ->afterStateUpdated(function ($state, Set $set) { + $set('config', []); + + if (blank($state)) { + return; + } + + $set('config', $this->initializeConfig($state)); + }), + ]), + Section::make('Configuration') + ->columnSpanFull() + ->schema(fn (Get $get) => $this->getFieldTypeFormSchema( + $get('field_type') + )) + ->visible(fn (Get $get) => filled($get('field_type'))), + ]); + } + + public function table(Table $table): Table + { + return $table + ->defaultPaginationPageOption(25) + ->paginationPageOptions([25, 50, 100]) + ->recordTitleAttribute('name') + ->reorderable('position') + ->defaultSort('position', 'asc') + ->modifyQueryUsing(fn ($query) => $query->with(['parent'])) + ->columns([ + TextColumn::make('name') + ->label(__('Name')) + ->searchable() + ->limit(), + + TextColumn::make('field_type') + ->label(__('Type')) + ->searchable(), + + TextColumn::make('parent.name') + ->label(__('Parent Schema')) + ->placeholder(__('Root level')) + ->searchable(), + ]) + ->filters([]) + ->headerActions([ + CreateAction::make() + ->slideOver() + ->mutateDataUsing(function (array $data) { + + $key = $this->ownerRecord->getKeyName(); + $parentUlid = $data['parent_ulid'] ?? null; + + // Calculate position based on parent + $positionQuery = SchemaModel::where('model_key', $this->ownerRecord->{$key}) + ->where('model_type', get_class($this->ownerRecord)); + + if ($parentUlid) { + $positionQuery->where('parent_ulid', $parentUlid); + } else { + $positionQuery->whereNull('parent_ulid'); + } + + return [ + ...$data, + 'position' => $positionQuery->get()->max('position') + 1, + 'model_type' => get_class($this->ownerRecord), + 'model_key' => $this->ownerRecord->getKey(), + ]; + }) + ->after(function (Component $livewire) { + $livewire->dispatch('$refresh'); + }), + ]) + ->recordActions([ + EditAction::make() + ->slideOver() + ->mutateRecordDataUsing(function (array $data) { + + return [ + ...$data, + 'model_type' => get_class($this->ownerRecord), + 'model_key' => $this->ownerRecord->getKey(), + ]; + }) + ->after(function (Component $livewire) { + $livewire->dispatch('refreshSchemas'); + }), + DeleteAction::make() + ->after(function (Component $livewire, array $data, Model $record, array $arguments) { + if ( + isset($record->valueColumn) && $this->ownerRecord->getConnection() + ->getSchemaBuilder() + ->hasColumn($this->ownerRecord->getTable(), $record->valueColumn) + ) { + + $key = $this->ownerRecord->getKeyName(); + + $this->ownerRecord->update([ + $record->valueColumn => collect($this->ownerRecord->{$record->valueColumn})->forget($record->{$key})->toArray(), + ]); + } + + $livewire->dispatch('refreshSchemas'); + }), + + ]) + ->toolbarActions([ + BulkActionGroup::make([ + BulkAction::make('delete') + ->requiresConfirmation() + ->after(function (Component $livewire) { + $livewire->dispatch('refreshSchemas'); + }), + ])->label('Actions'), + ]); + } + + public static function getTitle(Model $ownerRecord, string $pageClass): string + { + return __('Schemas'); + } + + public static function getModelLabel(): string + { + return __('Schema'); + } + + public static function getPluralModelLabel(): string + { + return __('Schemas'); + } +} diff --git a/src/Models/Field.php b/src/Models/Field.php index 3b5cac1..5d9274d 100644 --- a/src/Models/Field.php +++ b/src/Models/Field.php @@ -3,8 +3,6 @@ namespace Backstage\Fields\Models; use Backstage\Fields\Shared\HasPackageFactory; -use Carbon\Carbon; -use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -24,11 +22,13 @@ * @property array|null $config * @property int $position * @property string|null $group - * @property Carbon $created_at - * @property Carbon $updated_at - * @property-read Model|null $model - * @property-read Collection $children - * @property-read Model|null $tenant + * @property string|null $schema_id + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * @property-read \Illuminate\Database\Eloquent\Model|null $model + * @property-read \Illuminate\Database\Eloquent\Collection $children + * @property-read \Backstage\Fields\Models\Schema|null $schema + * @property-read \Illuminate\Database\Eloquent\Model|null $tenant */ class Field extends Model { @@ -57,6 +57,11 @@ public function children(): HasMany return $this->hasMany(Field::class, 'parent_ulid')->with('children')->orderBy('position'); } + public function schema(): BelongsTo + { + return $this->belongsTo(Schema::class, 'schema_id', 'ulid'); + } + public function tenant(): ?BelongsTo { $tenantRelationship = Config::get('fields.tenancy.relationship'); diff --git a/src/Models/Schema.php b/src/Models/Schema.php new file mode 100644 index 0000000..6045fa1 --- /dev/null +++ b/src/Models/Schema.php @@ -0,0 +1,78 @@ +|null $config + * @property int $position + * @property string $model_type + * @property string $model_key + * @property string|null $parent_ulid + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at + * @property-read \Illuminate\Database\Eloquent\Model|null $model + * @property-read \Illuminate\Database\Eloquent\Collection $fields + * @property-read Schema|null $parent + * @property-read \Illuminate\Database\Eloquent\Collection $children + */ +class Schema extends Model +{ + use CanMapDynamicFields; + use HasConfigurableFields; + use HasFieldTypeResolver; + use HasPackageFactory; + use HasRecursiveRelationships; + use HasUlids; + + protected $primaryKey = 'ulid'; + + protected $guarded = []; + + protected function casts(): array + { + return [ + 'config' => 'array', + ]; + } + + public function model(): MorphTo + { + return $this->morphTo('model'); + } + + public function fields(): HasMany + { + return $this->hasMany(Field::class, 'schema_id'); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(Schema::class, 'parent_ulid'); + } + + public function children(): HasMany + { + return $this->hasMany(Schema::class, 'parent_ulid'); + } + + public function getParentNameAttribute(): ?string + { + return $this->parent?->name; + } +} diff --git a/src/Schemas/Base.php b/src/Schemas/Base.php new file mode 100644 index 0000000..120f325 --- /dev/null +++ b/src/Schemas/Base.php @@ -0,0 +1,93 @@ +getBaseFormSchema(); + } + + protected function getBaseFormSchema(): array + { + $schema = [ + Grid::make(3) + ->schema([ + // + ]), + ]; + + return $this->filterExcludedFields($schema); + } + + protected function excludeFromBaseSchema(): array + { + return []; + } + + private function filterExcludedFields(array $schema): array + { + $excluded = $this->excludeFromBaseSchema(); + + if (empty($excluded)) { + return $schema; + } + + return array_filter($schema, function ($field) use ($excluded) { + foreach ($excluded as $excludedField) { + if ($this->fieldContainsConfigKey($field, $excludedField)) { + return false; + } + } + + return true; + }); + } + + private function fieldContainsConfigKey($field, string $configKey): bool + { + $reflection = new \ReflectionObject($field); + $propertiesToCheck = ['name', 'statePath']; + + foreach ($propertiesToCheck as $propertyName) { + if ($reflection->hasProperty($propertyName)) { + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + $value = $property->getValue($field); + + if (str_contains($value, "config.{$configKey}")) { + return true; + } + } + } + + return false; + } + + public static function getDefaultConfig(): array + { + return [ + // + ]; + } + + public static function make(string $name, Schema $schema) + { + // Base implementation - should be overridden by child classes + return null; + } + + protected static function ensureArray($value, string $delimiter = ','): array + { + if (is_array($value)) { + return $value; + } + + return ! empty($value) ? explode($delimiter, $value) : []; + } +} diff --git a/src/Schemas/Fieldset.php b/src/Schemas/Fieldset.php new file mode 100644 index 0000000..af0e721 --- /dev/null +++ b/src/Schemas/Fieldset.php @@ -0,0 +1,65 @@ + null, + 'columns' => 1, + 'collapsible' => false, + 'collapsed' => false, + ]; + } + + public static function make(string $name, Schema $schema): FilamentFieldset + { + $fieldset = FilamentFieldset::make($schema->config['label'] ?? self::getDefaultConfig()['label']) + ->columns($schema->config['columns'] ?? self::getDefaultConfig()['columns']); + + // Note: collapsible and collapsed methods may not be available on Fieldset in Filament v4 + + return $fieldset; + } + + public function getForm(): array + { + return [ + Grid::make(2) + ->schema([ + TextInput::make('config.label') + ->label(__('Label')) + ->live(onBlur: true), + TextInput::make('config.columns') + ->label(__('Columns')) + ->numeric() + ->minValue(1) + ->maxValue(12) + ->default(1) + ->live(onBlur: true), + ]), + Grid::make(2) + ->schema([ + Toggle::make('config.collapsible') + ->label(__('Collapsible')) + ->inline(false) + ->live(), + Toggle::make('config.collapsed') + ->label(__('Collapsed')) + ->inline(false) + ->visible(fn (Get $get): bool => $get('config.collapsible')), + ]), + ]; + } +} diff --git a/src/Schemas/Grid.php b/src/Schemas/Grid.php new file mode 100644 index 0000000..c1fb6c8 --- /dev/null +++ b/src/Schemas/Grid.php @@ -0,0 +1,123 @@ + 1, + 'responsive' => false, + 'columnsSm' => null, + 'columnsMd' => null, + 'columnsLg' => null, + 'columnsXl' => null, + 'columns2xl' => null, + 'gap' => null, + ]; + } + + public static function make(string $name, Schema $schema): FilamentGrid + { + $columns = $schema->config['columns'] ?? self::getDefaultConfig()['columns']; + + if ($schema->config['responsive'] ?? self::getDefaultConfig()['responsive']) { + $responsiveColumns = []; + + if (isset($schema->config['columnsSm'])) { + $responsiveColumns['sm'] = $schema->config['columnsSm']; + } + if (isset($schema->config['columnsMd'])) { + $responsiveColumns['md'] = $schema->config['columnsMd']; + } + if (isset($schema->config['columnsLg'])) { + $responsiveColumns['lg'] = $schema->config['columnsLg']; + } + if (isset($schema->config['columnsXl'])) { + $responsiveColumns['xl'] = $schema->config['columnsXl']; + } + if (isset($schema->config['columns2xl'])) { + $responsiveColumns['2xl'] = $schema->config['columns2xl']; + } + + if (! empty($responsiveColumns)) { + $responsiveColumns['default'] = $columns; + $columns = $responsiveColumns; + } + } + + $grid = FilamentGrid::make($columns); + + if (isset($schema->config['gap'])) { + $grid->gap($schema->config['gap']); + } + + return $grid; + } + + public function getForm(): array + { + return [ + FilamentGrid::make(2) + ->schema([ + TextInput::make('config.columns') + ->label(__('Columns')) + ->numeric() + ->minValue(1) + ->maxValue(12) + ->default(1) + ->live(onBlur: true), + Toggle::make('config.responsive') + ->label(__('Responsive')) + ->inline(false) + ->live(), + ]), + FilamentGrid::make(2) + ->schema([ + TextInput::make('config.columnsSm') + ->label(__('Columns (SM)')) + ->numeric() + ->minValue(1) + ->maxValue(12) + ->visible(fn (Get $get): bool => $get('config.responsive')), + TextInput::make('config.columnsMd') + ->label(__('Columns (MD)')) + ->numeric() + ->minValue(1) + ->maxValue(12) + ->visible(fn (Get $get): bool => $get('config.responsive')), + TextInput::make('config.columnsLg') + ->label(__('Columns (LG)')) + ->numeric() + ->minValue(1) + ->maxValue(12) + ->visible(fn (Get $get): bool => $get('config.responsive')), + TextInput::make('config.columnsXl') + ->label(__('Columns (XL)')) + ->numeric() + ->minValue(1) + ->maxValue(12) + ->visible(fn (Get $get): bool => $get('config.responsive')), + TextInput::make('config.columns2xl') + ->label(__('Columns (2XL)')) + ->numeric() + ->minValue(1) + ->maxValue(12) + ->visible(fn (Get $get): bool => $get('config.responsive')), + ]), + TextInput::make('config.gap') + ->label(__('Gap')) + ->placeholder('4') + ->helperText(__('Spacing between grid items (e.g., 4, 6, 8)')), + ]; + } +} diff --git a/src/Schemas/Section.php b/src/Schemas/Section.php new file mode 100644 index 0000000..858cf27 --- /dev/null +++ b/src/Schemas/Section.php @@ -0,0 +1,76 @@ + null, + 'description' => null, + 'icon' => null, + 'collapsible' => false, + 'collapsed' => false, + 'compact' => false, + 'aside' => false, + ]; + } + + public static function make(string $name, Schema $schema): FilamentSection + { + $section = FilamentSection::make($schema->name ?? self::getDefaultConfig()['heading']) + ->description($schema->config['description'] ?? self::getDefaultConfig()['description']) + ->icon($schema->config['icon'] ?? self::getDefaultConfig()['icon']) + ->collapsible($schema->config['collapsible'] ?? self::getDefaultConfig()['collapsible']) + ->collapsed($schema->config['collapsed'] ?? self::getDefaultConfig()['collapsed']) + ->compact($schema->config['compact'] ?? self::getDefaultConfig()['compact']) + ->aside($schema->config['aside'] ?? self::getDefaultConfig()['aside']); + + return $section; + } + + public function getForm(): array + { + return [ + Grid::make(2) + ->schema([ + TextInput::make('config.heading') + ->label(__('Heading')) + ->live(onBlur: true), + TextInput::make('config.description') + ->label(__('Description')) + ->live(onBlur: true), + TextInput::make('config.icon') + ->label(__('Icon')) + ->placeholder('heroicon-m-') + ->live(onBlur: true), + ]), + Grid::make(2) + ->schema([ + Toggle::make('config.collapsible') + ->label(__('Collapsible')) + ->inline(false), + Toggle::make('config.collapsed') + ->label(__('Collapsed')) + ->inline(false) + ->visible(fn (Get $get): bool => $get('config.collapsible')), + Toggle::make('config.compact') + ->label(__('Compact')) + ->inline(false), + Toggle::make('config.aside') + ->label(__('Aside')) + ->inline(false), + ]), + ]; + } +} diff --git a/tests/SelectCascadingTest.php b/tests/SelectCascadingTest.php new file mode 100644 index 0000000..9ff3145 --- /dev/null +++ b/tests/SelectCascadingTest.php @@ -0,0 +1,151 @@ + 'Test Cascading Select', + 'field_type' => 'select', + 'config' => [ + 'parentField' => 'category_id', + 'parentRelationship' => 'categories', + 'childRelationship' => 'products', + 'parentKey' => 'id', + 'childKey' => 'id', + 'parentValue' => 'name', + 'childValue' => 'name', + ], + ]); + + $input = Select::make('test_field', $field); + + expect($input)->toBeInstanceOf(Input::class); + expect($input->getName())->toBe('test_field'); + expect($input->getLabel())->toBe('Test Cascading Select'); +}); + +it('creates a select field with live reactive options when cascading is configured', function () { + $field = new Field([ + 'name' => 'Test Cascading Select', + 'field_type' => 'select', + 'config' => [ + 'parentField' => 'category_id', + 'parentRelationship' => 'categories', + 'childRelationship' => 'products', + 'parentKey' => 'id', + 'childKey' => 'id', + 'parentValue' => 'name', + 'childValue' => 'name', + ], + ]); + + $input = Select::make('test_field', $field); + + // Check if the field has live() method applied + $reflection = new ReflectionClass($input); + $liveProperty = $reflection->getProperty('isLive'); + $liveProperty->setAccessible(true); + + expect($liveProperty->getValue($input))->toBeTrue(); +}); + +it('creates a regular select field when no cascading is configured', function () { + $field = new Field([ + 'name' => 'Test Regular Select', + 'field_type' => 'select', + 'config' => [ + 'options' => ['option1' => 'Option 1', 'option2' => 'Option 2'], + ], + ]); + + $input = Select::make('test_field', $field); + + // Check if the field has live() method applied + $reflection = new ReflectionClass($input); + $liveProperty = $reflection->getProperty('isLive'); + $liveProperty->setAccessible(true); + + $isLive = $liveProperty->getValue($input); + expect($isLive)->toBeTrue(); // All fields have live() applied in Base::applyDefaultSettings() +}); + +it('normalizes select values correctly for single selection', function () { + $field = new Field([ + 'ulid' => 'test_field', + 'config' => ['multiple' => false], + ]); + + $record = new class extends Model + { + public $valueColumn = 'values'; + + public $values = ['test_field' => 'single_value']; + }; + + $data = ['values' => []]; + $data = Select::mutateFormDataCallback($record, $field, $data); + + expect($data['values']['test_field'])->toBe('single_value'); +}); + +it('normalizes select values correctly for multiple selection', function () { + $field = new Field([ + 'ulid' => 'test_field', + 'config' => ['multiple' => true], + ]); + + $record = new class extends Model + { + public $valueColumn = 'values'; + + public $values = ['test_field' => '["value1", "value2"]']; + }; + + $data = ['values' => []]; + $data = Select::mutateFormDataCallback($record, $field, $data); + + expect($data['values']['test_field'])->toBe(['value1', 'value2']); +}); + +it('handles null values correctly', function () { + $field = new Field([ + 'ulid' => 'test_field', + 'config' => ['multiple' => false], + ]); + + $record = new class extends Model + { + public $valueColumn = 'values'; + + public $values = ['test_field' => null]; + }; + + $data = ['values' => []]; + $data = Select::mutateFormDataCallback($record, $field, $data); + + expect($data['values'])->toHaveKey('test_field'); + expect($data['values']['test_field'])->toBeNull(); +}); + +it('handles empty arrays for multiple selection', function () { + $field = new Field([ + 'ulid' => 'test_field', + 'config' => ['multiple' => true], + ]); + + $record = new class extends Model + { + public $valueColumn = 'values'; + + public $values = ['test_field' => null]; + }; + + $data = ['values' => []]; + $data = Select::mutateFormDataCallback($record, $field, $data); + + expect($data['values'])->toHaveKey('test_field'); + expect($data['values']['test_field'])->toBe([]); +});