diff --git a/README.md b/README.md index c06a46c..e6a788d 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,48 @@ Combine multiple conditions with logical operators: The visibility system works seamlessly with validation rules to create intelligent, user-friendly forms that adapt to your data and user interactions. +#### Dynamic Values (Text Fields) + +Text fields support dynamic value calculation, allowing them to automatically populate based on other fields in the form. + +##### Relation Prefill Mode + +Automatically prefill a text field based on a selection in another field (typically a Select field). + +- **Source Field**: The field to watch (e.g., a `building` select field). +- **Relation Column**: The column from the related model to fetch (e.g., `city`). + +*Example*: When a user selects a "Building", the "City" text field automatically updates to show the city associated with that building record. + +##### Calculation Mode + +Calculate the field's value using a mathematical formula based on other fields. + +- **Formula**: Enter a formula using field values. +- **Syntax**: Use field IDs or keys in curly braces, e.g., `{price} * {quantity}`. +- **Supported Operations**: Standard math operators (`+`, `-`, `*`, `/`, `(`, `)`). + +*Example*: A "Total" field can automatically calculate `{price} * {tax_rate}`. + +##### Advanced Use Cases + +**Using Slugs**: You can use the field slug instead of the ULID for better readability: `"{price} * {quantity}"`. + +**Conditional Logic**: The formula engine supports conditional operations similar to Excel or PHP ternary operators. This is useful for Repeater rows where you want calculations to apply only when specific criteria are met. + +*Syntax*: `condition ? value_if_true : value_if_false` + +*Example*: +``` +"{type}" == "Premium" ? {price} * 1.2 : {price} +``` + +*Nested Example* (Excel-like IF/ELSE): +``` +"{status}" == "Paid" ? 0 : ("{status}" == "Pending" ? {total} : null) +``` +**Note**: Returning `null` effectively skips the calculation, allowing the field to be manually edited or remain empty. + ### Making a resource page configurable To make a resource page configurable, you need to add the `CanMapDynamicFields` trait to your page. For this example, we'll make a `EditContent` page configurable. @@ -492,6 +534,20 @@ The package includes a powerful Rich Editor with custom plugins: - **[Visibility Rules](docs/visibility-rules.md)** - Comprehensive guide to controlling field visibility based on conditions and record properties + +## Fields + +### Repeater + +### Repeater + +The Repeater field includes a robust implementation to handle default value hydration. It uses a custom `NormalizedRepeater` component (extending `Filament\Forms\Components\Repeater`) to ensure that data hydration from JSON strings (common in database storage) is correctly decoded and normalized into a UUID-keyed array before the component renders. + +This prevents common "string given" errors in loops and ensures that fields are correctly populated even when the initial state is a flat list or single object. + +- **Default Values**: Can be entered as JSON in the field configuration. +- **JSON Editor**: Use the JSON editor to enter default values. + ## Testing ```bash 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/Components/NormalizedRepeater.php b/src/Components/NormalizedRepeater.php new file mode 100644 index 0000000..543be35 --- /dev/null +++ b/src/Components/NormalizedRepeater.php @@ -0,0 +1,50 @@ +toString()] = $item; + } + $state = $keyedState; + + // Persist the normalized state so keys don't rotate on every call + $this->rawState($state); + } + + return $state; + } + + return []; + } +} 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..9b13794 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; @@ -25,6 +26,8 @@ protected function getFieldTypeFormSchema(?string $fieldType): array return app($className)->getForm(); } catch (Exception $e) { + \Illuminate\Support\Facades\Log::error("FieldTypeResolver failed for {$fieldType}: " . $e->getMessage(), ['trace' => $e->getTraceAsString()]); + throw new Exception(message: "Failed to resolve field type class for '{$fieldType}'", code: 0, previous: $e); } } @@ -35,10 +38,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..3e4fd07 100644 --- a/src/Concerns/HasSelectableValues.php +++ b/src/Concerns/HasSelectableValues.php @@ -15,9 +15,10 @@ trait HasSelectableValues { - protected static function resolveResourceModel(string $tableName): ?object + public 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']); @@ -156,10 +158,17 @@ protected static function mergeRelationshipOptions(array $allOptions, array $rel // If both types are selected, group relationship options by resource if (isset($field->config[$type]) && (is_array($field->config[$type]) && in_array('array', $field->config[$type]))) { - return array_merge($allOptions, $relationshipOptions); + return $allOptions + $relationshipOptions; } else { // For single relationship type, merge all options without grouping - return array_merge($allOptions, ...array_values($relationshipOptions)); + $flatOptions = []; + foreach ($relationshipOptions as $resourceOptions) { + foreach ($resourceOptions as $id => $label) { + $flatOptions[$id] = $label; + } + } + + return $allOptions + $flatOptions; } } 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 @@ +helperText($field->config['helperText'] ?? self::getDefaultConfig()['helperText']) ->hint($field->config['hint'] ?? self::getDefaultConfig()['hint']) ->hintIcon($field->config['hintIcon'] ?? self::getDefaultConfig()['hintIcon']) - ->live(); + ->live() + ->afterStateUpdated(function ($state, Set $set, Get $get) use ($field) { + if (! $field) { + return; + } + + // Find fields that depend on this field + // We match fields where either the direct source matches this field ULID (relation mode) + // OR the formula contains this field ULID (calculation mode) + $dependents = \Backstage\Fields\Models\Field::where('model_type', $field->model_type) + ->where('model_key', $field->model_key) + ->where(function ($query) use ($field) { + $query->where('config->dynamic_source_field', $field->ulid) + ->orWhere('config->dynamic_formula', 'LIKE', "%{{$field->ulid}}%"); + }) + ->get(); + + foreach ($dependents as $dependent) { + if ($dependent->field_type === 'text') { + $newValue = \Backstage\Fields\Fields\Text::calculateDynamicValue($dependent, $state, $get); + if ($newValue !== null) { + $set("values.{$dependent->ulid}", $newValue); + } + } + } + }); if (isset($field->config['hintColor']) && $field->config['hintColor']) { $input->hintColor(Color::generateV3Palette($field->config['hintColor'])); diff --git a/src/Fields/Repeater.php b/src/Fields/Repeater.php index b7ee36b..c9f880f 100644 --- a/src/Fields/Repeater.php +++ b/src/Fields/Repeater.php @@ -2,6 +2,7 @@ namespace Backstage\Fields\Fields; +use Backstage\Fields\Components\NormalizedRepeater; use Backstage\Fields\Concerns\HasConfigurableFields; use Backstage\Fields\Concerns\HasFieldTypeResolver; use Backstage\Fields\Concerns\HasOptions; @@ -9,18 +10,20 @@ use Backstage\Fields\Enums\Field as FieldEnum; use Backstage\Fields\Facades\Fields; use Backstage\Fields\Models\Field; +use Filament\Forms; +use Filament\Forms\Components\CodeEditor\Enums\Language; use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Repeater as Input; use Filament\Forms\Components\Repeater\TableColumn; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Set; +use Filament\Support\Enums\Alignment; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Saade\FilamentAdjacencyList\Forms\Components\AdjacencyList; @@ -50,14 +53,22 @@ public static function getDefaultConfig(): array 'cloneable' => false, 'columns' => 1, 'form' => [], - 'table' => false, + 'tableMode' => false, + 'tableColumns' => [], 'compact' => false, ]; } public static function make(string $name, ?Field $field = null): Input { - $input = self::applyDefaultSettings(Input::make($name), $field); + // Create an anonymous class extending the Filament Repeater to intercept the state + // This is necessary because standard Filament hooks (like formatStateUsing) + // are bypassed by the Repeater's internal rendering logic. + // We use NormalizedRepeater (separate class) because standard anonymous classes + // cannot be serialized by Livewire. + $input = self::applyDefaultSettings(NormalizedRepeater::make($name), $field); + + $input->configure(); $isReorderable = $field->config['reorderable'] ?? self::getDefaultConfig()['reorderable']; $isReorderableWithButtons = $field->config['reorderableWithButtons'] ?? self::getDefaultConfig()['reorderableWithButtons']; @@ -108,14 +119,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 +145,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 +254,20 @@ function () { )) ->visible(fn (Get $get) => filled($get('field_type'))), ]), - ]), + Forms\Components\CodeEditor::make('config.defaultValue') + ->label(__('Default Items (JSON)')) + ->language(Language::Json) + ->formatStateUsing(function ($state) { + if (is_array($state)) { + return json_encode($state, JSON_PRETTY_PRINT); + } + + return $state; + }) + ->rules('json') + ->helperText(__('Array of objects for default rows. Example: [{"slug": "value"}]')) + ->columnSpanFull(), + ])->columns(2), ])->columnSpanFull(), ]; } @@ -250,37 +277,128 @@ 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 = []; + $dependencyMap = []; // source_slug => [dependent_child_1, ... ] + $ulidToSlug = []; $children = $children->sortBy('position'); + // First pass: Build dependency map + foreach ($children as $child) { + $ulidToSlug[$child['ulid']] = $child['slug']; + + $config = $child['config'] ?? []; + $mode = $config['dynamic_mode'] ?? 'none'; + + if ($mode === 'relation') { + $sourceUlid = $config['dynamic_source_field'] ?? null; + if ($sourceUlid) { + $dependencyMap[$sourceUlid][] = $child; + } + } elseif ($mode === 'calculation') { + $formula = $config['dynamic_formula'] ?? ''; + preg_match_all('/\{([a-zA-Z0-9-]+)\}/', $formula, $matches); + foreach ($matches[1] as $sourceUlid) { + $dependencyMap[$sourceUlid][] = $child; + } + } + } + foreach ($children as $child) { - $columns[] = TableColumn::make($child['slug']); + $fieldType = $child['field_type']; + $fieldClass = self::resolveFieldTypeClassName($fieldType); + + if ($fieldClass === null) { + continue; + } + + $component = $fieldClass::make($child['slug'], $child); + + // Check if this field is a source for others + if (isset($dependencyMap[$child['ulid']])) { + $dependents = $dependencyMap[$child['ulid']]; + + $component->live(onBlur: true) + ->afterStateUpdated(function (Get $get, Set $set, $state) use ($dependents) { + foreach ($dependents as $dependent) { + $targetSlug = $dependent['slug']; + + // We need to pass the dependent Field model to calculateDynamicValue + // Since $dependent is likely the model instance itself (from $children collection) + // we can pass it directly. + + // Determine source value. + // For 'relation', the $state of the current field IS the source value. + + // Note: Text::calculateDynamicValue is static and stateless, + // it just needs the config from the field. + + $newValue = \Backstage\Fields\Fields\Text::calculateDynamicValue($dependent, $state, $get); + + if ($newValue !== null) { + // Relative path set + // Since we are inside a Repeater row, $set('slug', val) works for sibling fields + // BUT check if $get/set context is correct. + // In a Repeater item, Get/Set operate relative to the item. + // So $set($targetSlug, $newValue) should work. + $set($targetSlug, $newValue); + } + } + }); + } + + $schema[] = $component; } - 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/Fields/Text.php b/src/Fields/Text.php index b934a18..a44a394 100644 --- a/src/Fields/Text.php +++ b/src/Fields/Text.php @@ -13,6 +13,7 @@ use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; class Text extends Base implements FieldContract { @@ -37,6 +38,10 @@ public static function getDefaultConfig(): array 'inputMode' => null, 'telRegex' => null, 'revealable' => false, + 'dynamic_mode' => 'none', + 'dynamic_source_field' => null, + 'dynamic_relation_column' => null, + 'dynamic_formula' => null, ]; } @@ -80,12 +85,139 @@ public static function make(string $name, ?Field $field = null): Input $input->integer(); } + $input = self::applyDynamicSettings($input, $field); $input = self::addAffixesToInput($input, $field); $input = self::addDatalistToInput($input, $field); return $input; } + public static function calculateDynamicValue(Field $field, $sourceValue, ?Get $get = null) + { + $mode = $field->config['dynamic_mode'] ?? 'none'; + + if ($mode === 'relation') { + if (empty($sourceValue)) { + return null; + } + + $sourceUlid = $field->config['dynamic_source_field'] ?? null; + if (! $sourceUlid) { + return null; + } + + $sourceField = \Backstage\Fields\Models\Field::find($sourceUlid); + if (! $sourceField) { + return null; + } + + $relations = $sourceField->config['relations'] ?? []; + $relationConfig = reset($relations); + if (! $relationConfig || empty($relationConfig['resource'])) { + return null; + } + + $modelInstance = \Backstage\Fields\Fields\Select::resolveResourceModel($relationConfig['resource']); + if (! $modelInstance) { + return null; + } + + $relatedRecord = $modelInstance::find($sourceValue); + if (! $relatedRecord) { + return null; + } + + $targetColumn = $field->config['dynamic_relation_column'] ?? null; + if ($targetColumn && isset($relatedRecord->$targetColumn)) { + return $relatedRecord->$targetColumn; + } + } + + if ($mode === 'calculation') { + if (! $get) { + return null; + } + + $formula = $field->config['dynamic_formula'] ?? null; + if (! $formula) { + return null; + } + + // Regex to find {ulid} patterns + $parsedFormula = preg_replace_callback('/\{([a-zA-Z0-9-]+)\}/', function ($matches) use ($get) { + $ulid = $matches[1]; + + // Try to find the field to get its slug for relative lookup + // This allows calculations to work inside Repeaters where fields are named by slug + $referencedField = \Backstage\Fields\Models\Field::find($ulid); + $val = null; + + if ($referencedField) { + // Try relative path first (e.g. inside Repeater) using slug + $val = $get($referencedField->slug); + } + + // Try direct slug/key access (simplifies Repeater usage) + if ($val === null) { + $val = $get($ulid); + } + + // Fallback to absolute path (e.g. top level) + if ($val === null) { + $val = $get("values.{$ulid}"); + } + + if (is_numeric($val)) { + return $val; + } + + // If not numeric, encode as JSON to allow string comparisons in formula + // e.g. "My Value" + return json_encode($val); + }, $formula); + + // Safety: Allow numbers, math operators, comparisons, logic, and ternary + if (preg_match('/^[0-9\.\+\-\*\/\(\)\s\!\=\<\>\&\|\?\:\'\"\,]+$/', $parsedFormula)) { + try { + $result = @eval("return {$parsedFormula};"); + + return $result; + } catch (\Throwable $e) { + return null; + } + } + } + + return null; + } + + protected static function applyDynamicSettings(Input $input, ?Field $field = null): Input + { + if (! $field || empty($field->config['dynamic_mode']) || $field->config['dynamic_mode'] === 'none') { + return $input; + } + + return $input + // We keep afterStateHydrated for initial load, + // but we remove the `key` hack as we use "Push" model for updates. + ->afterStateHydrated(function (Input $component, Get $get, Set $set) use ($field) { + $mode = $field->config['dynamic_mode'] ?? 'none'; + + // Use the shared calculation logic + // But we need to resolve sourceValue from $get + $sourceUlid = $field->config['dynamic_source_field'] ?? null; + if ($sourceUlid) { + $sourceValue = $get("values.{$sourceUlid}"); + $newValue = self::calculateDynamicValue($field, $sourceValue); // We need to update this sig for calc? + + if ($newValue !== null && $component->getState() !== $newValue) { + $component->state($newValue); + $set($component->getStatePath(), $newValue); + } + } + }); + } + public function getForm(): array { return [ @@ -167,6 +299,99 @@ public function getForm(): array ->visible(fn (Get $get): bool => $get('config.type') === 'tel'), ]), ]), + Tab::make('Dynamic Values') + ->label(__('Dynamic Values')) + ->schema([ + Grid::make(1) + ->schema([ + Select::make('config.dynamic_mode') + ->label(__('Mode')) + ->options([ + 'none' => __('None'), + 'relation' => __('Relation Prefill'), + 'calculation' => __('Calculation'), + ]) + ->live(), + Select::make('config.dynamic_source_field') + ->label(__('Source Field')) + ->helperText(__('Select the field to use as source.')) + ->options(function ($record, $component) { + $formSlug = null; + + if ($record && isset($record->model_key)) { + $formSlug = $record->model_key; + } + + if (! $formSlug) { + $routeParams = request()->route()?->parameters() ?? []; + $formSlug = $routeParams['record'] ?? $routeParams['form'] ?? $routeParams['id'] ?? null; + } + + if (! $formSlug && method_exists($component, 'getOwnerRecord')) { + $ownerRecord = $component->getOwnerRecord(); + if ($ownerRecord) { + $formSlug = $ownerRecord->getKey(); + } + } + + if (! $formSlug) { + return []; + } + + $fields = \Backstage\Fields\Models\Field::where('model_type', 'App\Models\Form') + ->where('model_key', $formSlug) + ->when($record && isset($record->ulid), function ($query) use ($record) { + return $query->where('ulid', '!=', $record->ulid); + }) + ->orderBy('name') + ->pluck('name', 'ulid') + ->toArray(); + + return $fields; + }) + ->searchable() + ->visible(fn (Get $get): bool => $get('config.dynamic_mode') === 'relation'), + \Filament\Forms\Components\Select::make('config.dynamic_relation_column') + ->label(__('Relation Column')) + ->helperText(__('The column to pluck from the related model.')) + ->visible(fn (Get $get): bool => $get('config.dynamic_mode') === 'relation') + ->searchable() + ->options(function (Get $get) { + $sourceUlid = $get('config.dynamic_source_field'); + if (! $sourceUlid) { + return []; + } + + $sourceField = \Backstage\Fields\Models\Field::find($sourceUlid); + if (! $sourceField) { + return []; + } + + $relations = $sourceField->config['relations'] ?? []; + $relationConfig = reset($relations); + + if (! $relationConfig || empty($relationConfig['resource'])) { + return []; + } + + $modelInstance = \Backstage\Fields\Fields\Select::resolveResourceModel($relationConfig['resource']); + if (! $modelInstance) { + return []; + } + + $columns = \Illuminate\Support\Facades\Schema::getColumnListing($modelInstance->getTable()); + + return collect($columns)->mapWithKeys(function ($column) { + return [$column => $column]; + })->toArray(); + }), + \Filament\Forms\Components\Textarea::make('config.dynamic_formula') + ->label(__('Formula')) + ->rows(3) + ->helperText(__('Use field names as variables. Example: "price * quantity". Use {field_slug} or {field_ulid} for specific fields.')) + ->visible(fn (Get $get): bool => $get('config.dynamic_mode') === 'calculation'), + ]), + ]), 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..85b9a6a 100644 --- a/src/Filament/RelationManagers/FieldsRelationManager.php +++ b/src/Filament/RelationManagers/FieldsRelationManager.php @@ -7,6 +7,7 @@ use Backstage\Fields\Enums\Field as FieldEnum; use Backstage\Fields\Facades\Fields; use Backstage\Fields\Models\Field; +use Filament\Actions\Action; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; @@ -48,6 +49,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 +122,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 +150,115 @@ 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([ + Action::make('copy_fields') + ->label(__('Copy Fields')) + ->icon('heroicon-o-document-duplicate') + ->slideOver() + ->schema([ + Select::make('source_record_id') + ->label(__('Source Record')) + ->options(function () { + return $this->ownerRecord::query() + ->whereKeyNot($this->ownerRecord->getKey()) + ->get() + ->pluck('name', $this->ownerRecord->getKeyName()) + ->toArray(); + }) + ->searchable() + ->required() + ->live() + ->afterStateUpdated(function (Set $set) { + $set('fields_to_copy', []); + }), + \Filament\Forms\Components\CheckboxList::make('fields_to_copy') + ->label(__('Fields to Copy')) + ->options(function (Get $get) { + $sourceRecordId = $get('source_record_id'); + if (! $sourceRecordId) { + return []; + } + + return Field::where('model_type', get_class($this->ownerRecord)) + ->where('model_key', $sourceRecordId) + ->pluck('name', 'ulid') + ->toArray(); + }) + ->required() + ->columns(2) + ->bulkToggleable() + ->visible(fn (Get $get) => filled($get('source_record_id'))), + ]) + ->action(function (array $data, Component $livewire) { + $fields = Field::whereIn('ulid', $data['fields_to_copy'])->get(); + + foreach ($fields as $field) { + $newField = $field->replicate(); + $newField->model_key = $this->ownerRecord->getKey(); + $newField->schema_id = null; + $newField->save(); + } + + $livewire->dispatch('refreshFields'); + + \Filament\Notifications\Notification::make() + ->title(__('Fields copied successfully')) + ->success() + ->send(); + }), 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(), ]; }) @@ -171,20 +269,37 @@ public function table(Table $table): Table ->recordActions([ EditAction::make() ->slideOver() + ->hiddenLabel() ->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) { + $livewire->dispatch('refreshFields'); + }), + Action::make('replicate') + ->tooltip(__('Duplicate')) + ->hiddenLabel() + ->icon('heroicon-o-document-duplicate') + ->action(function (Field $record, Component $livewire) { + $replica = $record->replicate(); + $replica->ulid = (string) Str::ulid(); + $replica->name = $record->name . ' (' . __('Copy') . ')'; + $replica->slug = Str::slug($replica->name); + $replica->position = Field::where('model_key', $this->ownerRecord->getKey()) + ->where('model_type', get_class($this->ownerRecord)) + ->max('position') + 1; + $replica->save(); + $livewire->dispatch('refreshFields'); }), DeleteAction::make() + ->hiddenLabel() + ->tooltip(__('Delete')) ->after(function (Component $livewire, array $data, Model $record, array $arguments) { if ( isset($record->valueColumn) && $this->ownerRecord->getConnection() @@ -227,4 +342,27 @@ public static function getPluralModelLabel(): string { return __('Fields'); } + + protected function getSchemaOptions(): array + { + $schemas = \Backstage\Fields\Models\Schema::where('model_key', $this->ownerRecord->getKey()) + ->where('model_type', get_class($this->ownerRecord)) + ->orderBy('position') + ->get(); + + return $this->buildSchemaTree($schemas); + } + + protected function buildSchemaTree($schemas, $parentId = null, $depth = 0): array + { + $options = []; + $children = $schemas->where('parent_ulid', $parentId); + + foreach ($children as $schema) { + $options[$schema->ulid] = str_repeat('— ', $depth) . $schema->name; + $options = $options + $this->buildSchemaTree($schemas, $schema->ulid, $depth + 1); + } + + return $options; + } } diff --git a/src/Filament/RelationManagers/SchemaRelationManager.php b/src/Filament/RelationManagers/SchemaRelationManager.php new file mode 100644 index 0000000..223ff26 --- /dev/null +++ b/src/Filament/RelationManagers/SchemaRelationManager.php @@ -0,0 +1,229 @@ +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, ?SchemaModel $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)')) + ->options(function (?SchemaModel $record) { + $query = SchemaModel::query() + ->where('model_key', $this->ownerRecord->getKey()) + ->where('model_type', get_class($this->ownerRecord)) + ->orderBy('position'); + + if ($record) { + $query->where('ulid', '!=', $record->ulid); + } + + return $query->pluck('name', 'ulid')->toArray(); + }) + ->searchable() + ->preload() + ->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([]); +});