diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5f81597..0ecbf96 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -14,11 +14,11 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] php: [8.3] - laravel: [12.*] + laravel: [11.*] include: - - laravel: 12.* - testbench: 10.* - carbon: 3.* + - laravel: 11.* + testbench: 9.* + carbon: 2.*|3.* name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.os }} diff --git a/composer.json b/composer.json index fc3bc93..f687b8b 100644 --- a/composer.json +++ b/composer.json @@ -31,10 +31,10 @@ "laravel/pint": "^1.14", "nunomaduro/collision": "^8.1.1||^7.10.0", "nunomaduro/larastan": "^3.0", - "orchestra/testbench": "^9.0.0", - "pestphp/pest": "^4.1", - "pestphp/pest-plugin-arch": "^4.0", - "pestphp/pest-plugin-laravel": "^4.0", + "orchestra/testbench": "^9.0.0|^10.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-arch": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.0", "phpstan/extension-installer": "^1.1", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2.0" 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/database/migrations/migrate_model_type_to_class_names.php.stub b/database/migrations/migrate_model_type_to_class_names.php.stub new file mode 100644 index 0000000..aa7493e --- /dev/null +++ b/database/migrations/migrate_model_type_to_class_names.php.stub @@ -0,0 +1,90 @@ +where('model_type', 'setting') + ->get(); + + foreach ($fields as $field) { + // Try to determine the actual model class + // You may need to customize this logic based on your application + $modelClass = $this->resolveModelClass($field); + + if ($modelClass) { + DB::table('fields') + ->where('ulid', $field->ulid) + ->update(['model_type' => $modelClass]); + } + } + + // Do the same for schemas table if it exists + if (DB::getSchemaBuilder()->hasTable('schemas')) { + $schemas = DB::table('schemas') + ->where('model_type', 'setting') + ->get(); + + foreach ($schemas as $schema) { + $modelClass = $this->resolveModelClass($schema); + + if ($modelClass) { + DB::table('schemas') + ->where('ulid', $schema->ulid) + ->update(['model_type' => $modelClass]); + } + } + } + } + + public function down(): void + { + // Revert to 'setting' for fields that were migrated + // This is a simple revert - you may want to customize based on your needs + + DB::table('fields') + ->where('model_type', 'like', '%\\\\%') // Contains namespace separator + ->update(['model_type' => 'setting']); + + if (DB::getSchemaBuilder()->hasTable('schemas')) { + DB::table('schemas') + ->where('model_type', 'like', '%\\\\%') + ->update(['model_type' => 'setting']); + } + } + + /** + * Resolve the actual model class from the field/schema record + * + * Customize this method based on your application's model structure + */ + private function resolveModelClass(object $record): ?string + { + // Check if model_key matches common patterns + // This is a basic example - adjust to your app's structure + + // If you have a specific model that fields/schemas attach to, use it + // For example, if all fields belong to forms: + if (class_exists('App\\Models\\Form')) { + return 'App\\Models\\Form'; + } + + // If you have a settings model: + if (class_exists('App\\Models\\Setting')) { + return 'App\\Models\\Setting'; + } + + // Try to infer from model_key if it contains model information + // You may need to add custom logic here based on your app + + // Return null if we can't determine - will skip this record + return null; + } +}; diff --git a/database/migrations/migrate_repeater_table_config_to_table_mode.php.stub b/database/migrations/migrate_repeater_table_config_to_table_mode.php.stub new file mode 100644 index 0000000..ac20919 --- /dev/null +++ b/database/migrations/migrate_repeater_table_config_to_table_mode.php.stub @@ -0,0 +1,67 @@ +where('field_type', 'repeater') + ->whereNotNull('config') + ->get(); + + foreach ($repeaterFields as $field) { + $config = json_decode($field->config, true); + + if (! is_array($config)) { + continue; + } + + // If 'table' config exists and 'tableMode' doesn't, migrate it + if (isset($config['table']) && ! isset($config['tableMode'])) { + $config['tableMode'] = $config['table']; + + // Optionally remove the old 'table' key to clean up + // Uncomment the next line if you want to remove the old key + // unset($config['table']); + + DB::table('fields') + ->where('ulid', $field->ulid) + ->update(['config' => json_encode($config)]); + } + } + } + + public function down(): void + { + // Revert 'tableMode' back to 'table' for backward compatibility + + $repeaterFields = DB::table('fields') + ->where('field_type', 'repeater') + ->whereNotNull('config') + ->get(); + + foreach ($repeaterFields as $field) { + $config = json_decode($field->config, true); + + if (! is_array($config)) { + continue; + } + + // If 'tableMode' exists, migrate it back to 'table' + if (isset($config['tableMode'])) { + $config['table'] = $config['tableMode']; + unset($config['tableMode']); + + DB::table('fields') + ->where('ulid', $field->ulid) + ->update(['config' => json_encode($config)]); + } + } + } +}; diff --git a/src/Concerns/CanMapDynamicFields.php b/src/Concerns/CanMapDynamicFields.php index b8474d7..64a4893 100644 --- a/src/Concerns/CanMapDynamicFields.php +++ b/src/Concerns/CanMapDynamicFields.php @@ -29,7 +29,7 @@ */ trait CanMapDynamicFields { - private FieldInspector $fieldInspector; + private ?FieldInspector $fieldInspector = null; private const FIELD_TYPE_MAP = [ 'text' => Text::class, @@ -49,15 +49,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 } protected function mutateBeforeFill(array $data): array @@ -72,8 +68,6 @@ protected function mutateBeforeFill(array $data): array return $this->mutateFormData($data, $allFields, function ($field, $fieldConfig, $fieldInstance, $data) use ($containerData) { return $this->applyFieldFillMutation($field, $fieldConfig, $fieldInstance, $data, $containerData); }); - - return $mutatedData; } protected function mutateBeforeSave(array $data): array @@ -93,23 +87,25 @@ protected function mutateBeforeSave(array $data): array return $this->mutateFormData($data, $allFields, function ($field, $fieldConfig, $fieldInstance, $data) { return $this->applyFieldSaveMutation($field, $fieldConfig, $fieldInstance, $data); }); - - return $mutatedData; } 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] : []; } private function extractContainerData(array $values): array @@ -126,6 +122,10 @@ private function extractContainerData(array $values): array private function getAllFieldsIncludingNested(array $containerData): Collection { + if (! property_exists($this, 'record') || ! $this->record) { + return collect(); + } + return $this->record->fields->merge( $this->getNestedFieldsFromContainerData($containerData) )->unique('ulid'); @@ -140,7 +140,11 @@ private function applyFieldFillMutation(Model $field, array $fieldConfig, object return $this->processContainerFieldFillMutation($field, $fieldInstance, $data, $fieldLocation); } - return $fieldInstance->mutateFormDataCallback($this->record, $field, $data); + if (property_exists($this, 'record') && $this->record) { + return $fieldInstance->mutateFormDataCallback($this->record, $field, $data); + } + + return $data; } $data[$this->record->valueColumn][$field->ulid] = $fieldInstance->getFieldValueFromRecord($this->record, $field); @@ -150,7 +154,7 @@ private function applyFieldFillMutation(Model $field, array $fieldConfig, object private function extractContainerDataFromRecord(): 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 []; } @@ -182,17 +186,31 @@ private function processContainerFieldFillMutation(Model $field, object $fieldIn 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; return $mockRecord; } + private function getFieldInspector(): FieldInspector + { + if ($this->fieldInspector === null) { + $this->fieldInspector = app(FieldInspector::class); + } + + return $this->fieldInspector; + } + private function resolveFieldConfigAndInstance(Model $field): array { + $inspector = $this->getFieldInspector(); + $fieldConfig = Fields::resolveField($field->field_type) ? - $this->fieldInspector->initializeCustomField($field->field_type) : - $this->fieldInspector->initializeDefaultField($field->field_type); + $inspector->initializeCustomField($field->field_type) : + $inspector->initializeDefaultField($field->field_type); return [ 'config' => $fieldConfig, @@ -255,9 +273,9 @@ protected function mutateFormData(array $data, Collection $fields, callable $mut 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 []; } @@ -273,7 +291,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]); } private function resolveFieldInput(Model $field, Collection $customFields, mixed $record = null, bool $isNested = false): ?object @@ -286,7 +304,9 @@ private function resolveFieldInput(Model $field, Collection $customFields, mixed } 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; @@ -294,7 +314,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; } private function applyFieldSaveMutation(Model $field, array $fieldConfig, object $fieldInstance, array $data): array 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, ]; } @@ -163,14 +165,15 @@ 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 (backward compatible with 'table' config key) + $tableMode = $field->config['tableMode'] ?? $field->config['table'] ?? self::getDefaultConfig()['tableMode']; + if ($tableMode) { + $tableColumns = self::generateTableColumnsFromChildren($field->children, $field->config['tableColumns'] ?? []); + if (! empty($tableColumns)) { + $input = $input->table($tableColumns); + } } } @@ -189,39 +192,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')) @@ -295,7 +301,7 @@ function () { )) ->visible(fn (Get $get) => filled($get('field_type'))), ]), - ]), + ])->columns(2), ])->columnSpanFull(), ]; } @@ -305,37 +311,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 fbf8f95..a08c167 100644 --- a/src/Fields/Select.php +++ b/src/Fields/Select.php @@ -46,6 +46,7 @@ public static function getDefaultConfig(): array 'optionsLimit' => null, 'minItemsForSearch' => null, 'maxItemsForSearch' => null, + 'dependsOnField' => null, // Simple field dependency ]; } @@ -54,7 +55,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']) @@ -65,9 +65,22 @@ public static function make(string $name, ?Field $field = null): Input ->searchPrompt($field->config['searchPrompt'] ?? self::getDefaultConfig()['searchPrompt']) ->searchingMessage($field->config['searchingMessage'] ?? self::getDefaultConfig()['searchingMessage']); - $input = self::addAffixesToInput($input, $field); + // Handle field dependencies - only add live/reactive when needed + if (isset($field->config['dependsOnField']) && $field->config['dependsOnField']) { + $input = $input->live()->dehydrated()->reactive(); + $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']); } @@ -91,6 +104,20 @@ 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->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')) { @@ -99,10 +126,6 @@ public static function mutateFormDataCallback(Model $record, Field $field, array $value = self::getFieldValueFromRecord($record, $field); - if ($value === null) { - return $data; - } - $data[$record->valueColumn][$field->ulid] = self::normalizeSelectValue($value, $field); return $data; @@ -236,6 +259,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 3d22079..81945a6 100644 --- a/src/FieldsServiceProvider.php +++ b/src/FieldsServiceProvider.php @@ -172,6 +172,10 @@ 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', + 'migrate_model_type_to_class_names', + 'migrate_repeater_table_config_to_table_mode', '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..10a0d84 --- /dev/null +++ b/src/Models/Schema.php @@ -0,0 +1,75 @@ +|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 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([]); +});