From c2e23a2786fe627d122c866be51769b5a7b87155 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:12:25 +0200 Subject: [PATCH 001/105] initial commit --- packages/builder/.gitignore | 3 + packages/builder/README.md | 492 ++++++++++++++++++ packages/builder/composer.json | 85 +++ packages/builder/config/builder.php | 36 ++ ...create_builder_field_groups_table.php.stub | 29 ++ ...reate_builder_field_options_table.php.stub | 26 + ...create_builder_field_values_table.php.stub | 39 ++ .../create_builder_fields_table.php.stub | 35 ++ .../database/seeders/BuilderSeeder.php | 405 ++++++++++++++ .../builder/resources/lang/de/builder.php | 79 +++ .../builder/resources/lang/en/builder.php | 79 +++ .../builder/src/BuilderServiceProvider.php | 100 ++++ .../builder/src/Compiler/LocationMatcher.php | 67 +++ .../builder/src/Compiler/SchemaCompiler.php | 129 +++++ .../builder/src/Concerns/HasCustomFields.php | 81 +++ packages/builder/src/Data/FieldDefinition.php | 77 +++ .../builder/src/Data/FieldGroupDefinition.php | 82 +++ packages/builder/src/Data/LocationContext.php | 12 + .../Exceptions/UnknownFieldTypeException.php | 15 + .../FieldTypes/Capabilities/Capability.php | 38 ++ .../FieldTypes/Capabilities/DefaultValue.php | 38 ++ .../FieldTypes/Capabilities/DisplayFormat.php | 31 ++ .../FieldTypes/Capabilities/HelperText.php | 33 ++ .../src/FieldTypes/Capabilities/MaxLength.php | 45 ++ .../src/FieldTypes/Capabilities/MaxValue.php | 39 ++ .../src/FieldTypes/Capabilities/MinValue.php | 39 ++ .../FieldTypes/Capabilities/Placeholder.php | 34 ++ .../FieldTypes/Capabilities/PrefixSuffix.php | 39 ++ .../src/FieldTypes/Capabilities/Rows.php | 33 ++ .../src/FieldTypes/Capabilities/Step.php | 30 ++ .../Concerns/BuildsOptionComponents.php | 39 ++ packages/builder/src/FieldTypes/FieldType.php | 72 +++ .../Types/CheckboxListFieldType.php | 53 ++ .../src/FieldTypes/Types/ColorFieldType.php | 34 ++ .../src/FieldTypes/Types/DateFieldType.php | 36 ++ .../FieldTypes/Types/DatetimeFieldType.php | 36 ++ .../src/FieldTypes/Types/EmailFieldType.php | 44 ++ .../FieldTypes/Types/MultiselectFieldType.php | 54 ++ .../src/FieldTypes/Types/NumberFieldType.php | 56 ++ .../FieldTypes/Types/PasswordFieldType.php | 37 ++ .../src/FieldTypes/Types/RadioFieldType.php | 44 ++ .../src/FieldTypes/Types/SelectFieldType.php | 46 ++ .../src/FieldTypes/Types/TextFieldType.php | 42 ++ .../FieldTypes/Types/TextareaFieldType.php | 43 ++ .../src/FieldTypes/Types/TimeFieldType.php | 34 ++ .../src/FieldTypes/Types/ToggleFieldType.php | 41 ++ .../src/FieldTypes/Types/UrlFieldType.php | 42 ++ .../src/Listeners/PersistCustomFields.php | 38 ++ packages/builder/src/Models/Field.php | 71 +++ packages/builder/src/Models/FieldGroup.php | 65 +++ packages/builder/src/Models/FieldOption.php | 43 ++ packages/builder/src/Models/FieldValue.php | 46 ++ .../InvalidateDefinitionCacheObserver.php | 20 + .../builder/src/Plugins/BuilderPlugin.php | 37 ++ .../src/Registry/DefinitionRegistry.php | 74 +++ .../builder/src/Registry/EntityRegistry.php | 89 ++++ .../src/Registry/FieldTypeRegistry.php | 54 ++ .../src/Resources/FieldGroupResource.php | 297 +++++++++++ .../Pages/CreateFieldGroup.php | 23 + .../Pages/EditFieldGroup.php | 66 +++ .../Pages/ListFieldGroups.php | 21 + .../src/Services/CustomFieldsManager.php | 111 ++++ .../src/Services/FieldGroupPersistence.php | 224 ++++++++ .../builder/src/Storage/TypedValueDriver.php | 68 +++ packages/builder/src/Storage/ValueStore.php | 24 + .../src/Storage/ValueStoreResolver.php | 26 + .../builder/src/Support/TypedValueColumns.php | 66 +++ .../tests/Feature/HasCustomFieldsTest.php | 122 +++++ packages/builder/tests/Pest.php | 9 + packages/builder/tests/Support/TestItem.php | 14 + .../tests/Support/TestItemResource.php | 15 + packages/builder/tests/TestCase.php | 76 +++ .../builder/tests/Unit/CapabilitiesTest.php | 60 +++ .../tests/Unit/DefinitionRegistryTest.php | 108 ++++ .../builder/tests/Unit/EntityRegistryTest.php | 37 ++ .../tests/Unit/FieldGroupPersistenceTest.php | 35 ++ .../tests/Unit/FieldTypeRegistryTest.php | 40 ++ .../tests/Unit/LocationMatcherTest.php | 57 ++ .../builder/tests/Unit/SchemaCompilerTest.php | 63 +++ .../tests/Unit/TypedValueDriverTest.php | 74 +++ .../tests/Unit/ValueStoreResolverTest.php | 20 + packages/builder/tests/bootstrap.php | 20 + packages/devlink/config/devlink.php | 5 + packages/full/composer.json | 1 + packages/item/composer.json | 3 +- packages/item/src/ItemServiceProvider.php | 11 + packages/item/src/Resources/ItemResource.php | 4 + 87 files changed, 5259 insertions(+), 1 deletion(-) create mode 100644 packages/builder/.gitignore create mode 100644 packages/builder/README.md create mode 100644 packages/builder/composer.json create mode 100644 packages/builder/config/builder.php create mode 100644 packages/builder/database/migrations/create_builder_field_groups_table.php.stub create mode 100644 packages/builder/database/migrations/create_builder_field_options_table.php.stub create mode 100644 packages/builder/database/migrations/create_builder_field_values_table.php.stub create mode 100644 packages/builder/database/migrations/create_builder_fields_table.php.stub create mode 100644 packages/builder/database/seeders/BuilderSeeder.php create mode 100644 packages/builder/resources/lang/de/builder.php create mode 100644 packages/builder/resources/lang/en/builder.php create mode 100644 packages/builder/src/BuilderServiceProvider.php create mode 100644 packages/builder/src/Compiler/LocationMatcher.php create mode 100644 packages/builder/src/Compiler/SchemaCompiler.php create mode 100644 packages/builder/src/Concerns/HasCustomFields.php create mode 100644 packages/builder/src/Data/FieldDefinition.php create mode 100644 packages/builder/src/Data/FieldGroupDefinition.php create mode 100644 packages/builder/src/Data/LocationContext.php create mode 100644 packages/builder/src/Exceptions/UnknownFieldTypeException.php create mode 100644 packages/builder/src/FieldTypes/Capabilities/Capability.php create mode 100644 packages/builder/src/FieldTypes/Capabilities/DefaultValue.php create mode 100644 packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php create mode 100644 packages/builder/src/FieldTypes/Capabilities/HelperText.php create mode 100644 packages/builder/src/FieldTypes/Capabilities/MaxLength.php create mode 100644 packages/builder/src/FieldTypes/Capabilities/MaxValue.php create mode 100644 packages/builder/src/FieldTypes/Capabilities/MinValue.php create mode 100644 packages/builder/src/FieldTypes/Capabilities/Placeholder.php create mode 100644 packages/builder/src/FieldTypes/Capabilities/PrefixSuffix.php create mode 100644 packages/builder/src/FieldTypes/Capabilities/Rows.php create mode 100644 packages/builder/src/FieldTypes/Capabilities/Step.php create mode 100644 packages/builder/src/FieldTypes/Concerns/BuildsOptionComponents.php create mode 100644 packages/builder/src/FieldTypes/FieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/CheckboxListFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/ColorFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/DateFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/DatetimeFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/EmailFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/MultiselectFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/NumberFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/PasswordFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/RadioFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/SelectFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/TextFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/TextareaFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/TimeFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/ToggleFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/UrlFieldType.php create mode 100644 packages/builder/src/Listeners/PersistCustomFields.php create mode 100644 packages/builder/src/Models/Field.php create mode 100644 packages/builder/src/Models/FieldGroup.php create mode 100644 packages/builder/src/Models/FieldOption.php create mode 100644 packages/builder/src/Models/FieldValue.php create mode 100644 packages/builder/src/Observers/InvalidateDefinitionCacheObserver.php create mode 100644 packages/builder/src/Plugins/BuilderPlugin.php create mode 100644 packages/builder/src/Registry/DefinitionRegistry.php create mode 100644 packages/builder/src/Registry/EntityRegistry.php create mode 100644 packages/builder/src/Registry/FieldTypeRegistry.php create mode 100644 packages/builder/src/Resources/FieldGroupResource.php create mode 100644 packages/builder/src/Resources/FieldGroupResource/Pages/CreateFieldGroup.php create mode 100644 packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php create mode 100644 packages/builder/src/Resources/FieldGroupResource/Pages/ListFieldGroups.php create mode 100644 packages/builder/src/Services/CustomFieldsManager.php create mode 100644 packages/builder/src/Services/FieldGroupPersistence.php create mode 100644 packages/builder/src/Storage/TypedValueDriver.php create mode 100644 packages/builder/src/Storage/ValueStore.php create mode 100644 packages/builder/src/Storage/ValueStoreResolver.php create mode 100644 packages/builder/src/Support/TypedValueColumns.php create mode 100644 packages/builder/tests/Feature/HasCustomFieldsTest.php create mode 100644 packages/builder/tests/Pest.php create mode 100644 packages/builder/tests/Support/TestItem.php create mode 100644 packages/builder/tests/Support/TestItemResource.php create mode 100644 packages/builder/tests/TestCase.php create mode 100644 packages/builder/tests/Unit/CapabilitiesTest.php create mode 100644 packages/builder/tests/Unit/DefinitionRegistryTest.php create mode 100644 packages/builder/tests/Unit/EntityRegistryTest.php create mode 100644 packages/builder/tests/Unit/FieldGroupPersistenceTest.php create mode 100644 packages/builder/tests/Unit/FieldTypeRegistryTest.php create mode 100644 packages/builder/tests/Unit/LocationMatcherTest.php create mode 100644 packages/builder/tests/Unit/SchemaCompilerTest.php create mode 100644 packages/builder/tests/Unit/TypedValueDriverTest.php create mode 100644 packages/builder/tests/Unit/ValueStoreResolverTest.php create mode 100644 packages/builder/tests/bootstrap.php diff --git a/packages/builder/.gitignore b/packages/builder/.gitignore new file mode 100644 index 0000000000..83713b9eec --- /dev/null +++ b/packages/builder/.gitignore @@ -0,0 +1,3 @@ +/vendor +.phpunit.result.cache +composer.lock diff --git a/packages/builder/README.md b/packages/builder/README.md new file mode 100644 index 0000000000..851f425810 --- /dev/null +++ b/packages/builder/README.md @@ -0,0 +1,492 @@ +# Moox Builder + +Runtime-Feldgruppen für Filament-Resources — ACF-ähnlich, aber rein Laravel/Filament. + +Admins definieren Felder im Panel. Werte werden in typisierten `builder_field_values`-Zeilen gespeichert (kein JSON-Blob am Model, kein WordPress/postmeta). + +--- + +## Inhaltsverzeichnis + +1. [Architektur-Überblick](#architektur-überblick) +2. [Die zwei Schichten](#die-zwei-schichten) +3. [Datenbank](#datenbank) +4. [Runtime-Ablauf](#runtime-ablauf) +5. [Installation](#installation) +6. [Resource anbinden](#resource-anbinden) +7. [Feldgruppen im Admin](#feldgruppen-im-admin) +8. [Feldtypen & Capabilities](#feldtypen--capabilities) +9. [Konfiguration](#konfiguration) +10. [Erweiterung](#erweiterung) +11. [Paketstruktur](#paketstruktur) +12. [Testen](#testen) +13. [Grenzen & Roadmap](#grenzen--roadmap) + +--- + +## Architektur-Überblick + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ ADMIN (Definition) │ +│ Filament → Felder → Feldgruppen (FieldGroupResource) │ +│ ↓ │ +│ builder_field_groups / builder_fields / builder_field_options │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + DefinitionRegistry (Cache) + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ RUNTIME (Consumer-Resources) │ +│ ItemResource + HasCustomFields │ +│ ↓ │ +│ EntityRegistry → LocationMatcher → SchemaCompiler → Filament-Sections │ +│ ↓ │ +│ PersistCustomFields (RecordSaved) → TypedValueDriver │ +│ ↓ │ +│ builder_field_values │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +**Kernprinzip:** Definition und Speicherung sind strikt getrennt. + +| Schicht | Frage | Wo | +|---------|-------|-----| +| **Definition** | Welche Felder gibt es? | `builder_field_*` Tabellen + Admin-UI | +| **Speicher** | Wo liegen die Werte? | `builder_field_values` + `TypedValueDriver` | + +--- + +## Die zwei Schichten + +### 1. Definition Layer + +Verantwortlich für **was** angezeigt wird und **wo** (Location). + +| Komponente | Aufgabe | +|------------|---------| +| `FieldGroupResource` | Filament-CRUD für Feldgruppen | +| `FieldGroupPersistence` | Speichert Gruppen, Felder, Optionen, Location Rules | +| `DefinitionRegistry` | Lädt aktive Gruppen, cached als Arrays | +| `LocationMatcher` | Prüft `location_rules` gegen `LocationContext` | +| `EntityRegistry` | Mappt registrierte Filament-Resources → Entity-Keys | +| `SchemaCompiler` | Baut Filament-`Section`s aus Definitionen | + +Definitionen werden als **DTOs** (`FieldGroupDefinition`, `FieldDefinition`) transportiert — nicht als lose Eloquent-Models im Runtime-Pfad. + +### 2. Storage Layer + +Verantwortlich für **Werte** pro Datensatz. + +| Komponente | Aufgabe | +|------------|---------| +| `ValueStore` (Interface) | `load()` / `save()` Vertrag | +| `TypedValueDriver` | Einziger Treiber: typisierte Spalten | +| `ValueStoreResolver` | Löst Treiber aus `config/builder.php` auf | +| `TypedValueColumns` | Mapping Feldtyp → DB-Spalte | +| `CustomFieldsManager` | Orchestriert Laden/Speichern für Resources | +| `PersistCustomFields` | Listener auf Filament `RecordSaved` | + +Werte hängen **nicht** am Eloquent-Model (keine `custom_fields`-JSON-Spalte nötig). + +--- + +## Datenbank + +### `builder_field_groups` + +| Spalte | Bedeutung | +|--------|-----------| +| `name` | Anzeigename (= Section-Titel im Formular) | +| `slug` | Technischer Schlüssel der Gruppe | +| `location_rules` | JSON: wo die Gruppe erscheint (siehe unten) | +| `placement` | Reserviert (`default`) | +| `settings` | Reserviert für Gruppen-Einstellungen | +| `sort` | Reihenfolge mehrerer Gruppen | +| `active` | Nur aktive Gruppen werden gerendert | + +### `builder_fields` + +Felder einer Gruppe: `name` (Feldschlüssel), `label`, `type`, `config`, `validation`, `sort`. + +`parent_field_id` ist in der Migration vorgesehen (Repeater/Flexible Content — noch nicht implementiert). + +### `builder_field_options` + +Optionen für `select`, `radio`, `multiselect`, `checkbox_list`. + +### `builder_field_values` + +Eine Zeile pro Wert: + +| Spalte | Feldtypen | +|--------|-----------| +| `entity` | z. B. `item` | +| `record_id` | ID des Datensatzes | +| `field_name` | Feldschlüssel | +| `value_string` | text, email, url, select, … | +| `value_text` | textarea | +| `value_decimal` | number | +| `value_date` | date | +| `value_datetime` | datetime | +| `value_boolean` | toggle | +| `value_json` | multiselect, checkbox_list | + +Unique: `(entity, record_id, field_name)`. + +### Location Rules (intern) + +Im Admin wählst du **„Anzeigen bei“** (Multi-Select). Intern wird das zu: + +```json +[ + [{ "param": "entity", "operator": "==", "value": "item" }], + [{ "param": "entity", "operator": "==", "value": "product" }] +] +``` + +Jede innere Liste = AND-Gruppe, mehrere Gruppen = OR. Aktuell unterstützt der Matcher nur `param: entity` mit `==` / `!=`. + +--- + +## Runtime-Ablauf + +### Formular öffnen (Create/Edit) + +``` +1. Resource::form() enthält ...static::customFieldComponents() + +2. HasCustomFields prüft EntityRegistry + → nicht registriert? → leeres Array (keine Sections) + +3. DefinitionRegistry::fieldGroupsFor(LocationContext) + → lädt gecachte Gruppen + → LocationMatcher filtert nach entity + +4. SchemaCompiler::compile() + → pro Gruppe eine Filament-Section + → pro Feld eine Form-Komponente (TextInput, Select, …) + → afterStateHydrated lädt Werte aus TypedValueDriver (nur Edit) +``` + +### Speichern + +``` +1. Filament speichert das Model (title, description, …) + +2. Event RecordSaved wird gefeuert + +3. PersistCustomFields::handle($record, $data, $page) + → prüft: Resource nutzt HasCustomFields + ist registriert? + +4. CustomFieldsManager::saveFromFormData() + → extrahiert nur bekannte Feld-Keys aus $data + → TypedValueDriver::save() → builder_field_values +``` + +**Wichtig:** Keine Page-Hooks (`afterCreate`, `mutateFormDataBeforeSave`) nötig. + +### Cache + +`DefinitionRegistry` cached unter `builder.definitions` als **PHP-Arrays** (keine serialisierten Objekte). + +Invalidierung automatisch via `InvalidateDefinitionCacheObserver` bei Änderungen an Gruppen/Feldern/Optionen. + +Manuell: `php artisan cache:forget builder.definitions` + +--- + +## Installation + +### Via Moox Installer + +```bash +composer require moox/builder +php artisan moox:install +``` + +Migrations, Config, Seeder und `BuilderPlugin` auswählen. + +### Manuell + +```bash +composer require moox/builder +php artisan vendor:publish --tag=builder-config +php artisan vendor:publish --tag=builder-migrations +php artisan migrate +php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" +``` + +Plugin im Panel: + +```php +use Moox\Builder\Plugins\BuilderPlugin; + +$panel->plugins([ + BuilderPlugin::make(), +]); +``` + +--- + +## Resource anbinden + +### Schritt 1: Entity registrieren + +Der Array-Key in `config('builder.entities')` ist der Entity-Identifier für Location Rules und Storage. + +**Option A — Config** (`config/builder.php`): + +```php +'entities' => [ + 'item' => [ + 'resource' => \Moox\Item\Resources\ItemResource::class, + 'label' => 'Items', + ], +], +``` + +**Option B — Service Provider** (empfohlen für Moox-Packages): + +```php +// ItemServiceProvider::packageBooted() +config([ + 'builder.entities.item' => [ + 'resource' => ItemResource::class, + 'label' => 'Items', + ], +]); +``` + +### Schritt 2: Trait in der Filament-Resource + +```php +use Moox\Builder\Concerns\HasCustomFields; + +class ItemResource extends Resource +{ + use HasCustomFields; + + public static function form(Schema $schema): Schema + { + return $schema->components([ + // eigene Felder … + ...static::customFieldComponents(), + ]); + } +} +``` + +**Nicht nötig:** + +- Trait oder Spalte am Eloquent-Model +- `$customFieldsEntity` (entfernt — kommt aus Config) +- Eigene Listener oder Page-Hooks +- Migration für JSON am Model + +### Schritt 3: Feldgruppe im Admin + +**Felder → Feldgruppen → Erstellen** + +- **Anzeigen bei:** `Items` (oder andere registrierte Entity) +- Felder definieren +- Aktiv lassen + +--- + +## Feldgruppen im Admin + +Navigation: **Felder → Feldgruppen** + +| Bereich | Inhalt | +|---------|--------| +| **Allgemein** | Name, technischer Schlüssel, aktiv, Reihenfolge | +| **Zuordnung** | Multi-Select „Anzeigen bei“ (registrierte Resources) | +| **Felder** | Repeater: Bezeichnung, Typ, Feldschlüssel, Pflichtfeld, Einstellungen, Optionen | + +Übersetzungen: `resources/lang/de/builder.php`, `en/builder.php`. + +--- + +## Feldtypen & Capabilities + +### Eingebaute Feldtypen (15) + +| Key | Filament-Komponente | +|-----|---------------------| +| `text` | TextInput | +| `textarea` | Textarea | +| `number` | TextInput (numeric) | +| `email` | TextInput (email) | +| `url` | TextInput (url) | +| `password` | TextInput (password) | +| `select` | Select | +| `multiselect` | Select (multiple) | +| `checkbox_list` | CheckboxList | +| `radio` | Radio | +| `toggle` | Toggle | +| `date` | DatePicker | +| `datetime` | DateTimePicker | +| `time` | TimePicker | +| `color` | ColorPicker | + +### Capabilities (pro Typ konfigurierbar) + +| Capability | Wirkung | +|------------|---------| +| `MaxLength` | max. Zeichenlänge | +| `Placeholder` | Platzhaltertext | +| `PrefixSuffix` | Präfix/Suffix | +| `DefaultValue` | Standardwert | +| `HelperText` | Hilfetext unter dem Feld | +| `MinValue` / `MaxValue` / `Step` | Zahlenfelder | +| `Rows` | Textarea-Zeilen | +| `DisplayFormat` | Datumsformat | + +Jeder Feldtyp implementiert `FieldType`: `key()`, `formComponent()`, `capabilities()`, optional `castValue()` und `hasOptions()`. + +--- + +## Konfiguration + +`config/builder.php`: + +| Key | Default | Beschreibung | +|-----|---------|--------------| +| `default_driver` | `typed` | Aktiver Value-Store-Treiber | +| `drivers.typed` | `TypedValueDriver` | Klassenname des Treibers | +| `navigation_group` | `Felder` | Filament-Navigationsgruppe | +| `entities` | `[]` | Registrierte Resources (siehe oben) | + +Env-Variablen: `BUILDER_DRIVER`, `BUILDER_NAVIGATION_GROUP`. + +--- + +## Erweiterung + +### Eigenen Feldtyp registrieren + +```php +use Moox\Builder\FieldTypes\FieldType; +use Moox\Builder\Registry\FieldTypeRegistry; + +$this->app->afterResolving(FieldTypeRegistry::class, function (FieldTypeRegistry $registry) { + $registry->register(new MyCustomFieldType); +}); +``` + +### Eigenen Storage-Treiber (optional) + +Interface `ValueStore` implementieren, in `config/builder.php` unter `drivers` eintragen, `default_driver` setzen. + +Aktuell ist nur `typed` produktiv im Einsatz. + +### Validierungsregeln abfragen + +```php +ItemResource::customFieldRules(); +// ['feld-name' => ['required', 'max:255'], ...] +``` + +--- + +## Paketstruktur + +``` +packages/builder/ +├── config/builder.php +├── database/ +│ ├── migrations/ # 4 Tabellen +│ └── seeders/BuilderSeeder.php +├── resources/lang/{de,en}/builder.php +└── src/ + ├── BuilderServiceProvider.php + ├── Concerns/HasCustomFields.php # Consumer-Trait + ├── Compiler/ + │ ├── LocationMatcher.php + │ └── SchemaCompiler.php + ├── Data/ + │ ├── FieldDefinition.php + │ ├── FieldGroupDefinition.php + │ └── LocationContext.php + ├── FieldTypes/ + │ ├── FieldType.php + │ ├── Capabilities/ # Wiederverwendbare Feld-Einstellungen + │ └── Types/ # 15 Feldtypen + ├── Listeners/PersistCustomFields.php + ├── Models/ # FieldGroup, Field, FieldOption, FieldValue + ├── Observers/InvalidateDefinitionCacheObserver.php + ├── Plugins/BuilderPlugin.php + ├── Registry/ + │ ├── DefinitionRegistry.php + │ ├── EntityRegistry.php + │ └── FieldTypeRegistry.php + ├── Resources/FieldGroupResource.php # Admin-UI + ├── Services/ + │ ├── CustomFieldsManager.php + │ └── FieldGroupPersistence.php + ├── Storage/ + │ ├── TypedValueDriver.php + │ ├── ValueStore.php + │ └── ValueStoreResolver.php + └── Support/TypedValueColumns.php +``` + +--- + +## Testen + +### Package-Tests + +```bash +cd packages/builder && composer test +``` + +### Manuell im Panel + +1. **Felder → Feldgruppen** — Demo-Gruppe „Fahrzeugdaten“ (nach Seeder) +2. **Items → Erstellen/Bearbeiten** — Section mit Custom Fields +3. Speichern, dann DB prüfen: + +```sql +SELECT entity, record_id, field_name, value_string, value_decimal, value_date +FROM builder_field_values +WHERE entity = 'item'; +``` + +4. Item erneut öffnen — Werte müssen geladen sein. + +### Checkliste + +| Check | Erwartung | +|-------|-----------| +| 4 Builder-Tabellen existieren | nach `migrate` | +| Entity in `builder.entities` registriert | z. B. via ItemServiceProvider | +| `BuilderPlugin` im Panel | Nav „Felder“ sichtbar | +| Feldgruppe mit „Anzeigen bei: Items“ | Section auf Item-Form | +| Werte in `builder_field_values` | typisierte Spalten befüllt | + +--- + +## Grenzen & Roadmap + +**Aktuell nicht implementiert:** + +- Repeater / Flexible Content (`parent_field_id` reserviert) +- Query-Scopes auf Consumer-Models (`whereCustomField()` o. ä.) +- Tier-1 Auto-Discovery von Entities +- Location-Params über `entity` hinaus +- `placement`-Steuerung (Tab, Sidebar, …) + +**Bewusst entfernt / nicht Ziel:** + +- WordPress/postmeta-Treiber +- JSON-Spalte am Model +- Press/WP-Integration + +--- + +## Lizenz + +MIT diff --git a/packages/builder/composer.json b/packages/builder/composer.json new file mode 100644 index 0000000000..1c13a8ef1a --- /dev/null +++ b/packages/builder/composer.json @@ -0,0 +1,85 @@ +{ + "name": "moox/builder", + "description": "Runtime custom field groups for Filament resources with typed value storage.", + "keywords": [ + "Moox", + "Laravel", + "Filament", + "Moox package", + "Laravel package", + "Custom fields" + ], + "homepage": "https://moox.org/docs/builder", + "license": "MIT", + "authors": [ + { + "name": "Moox Developer", + "email": "dev@moox.org", + "role": "Developer" + } + ], + "require": { + "moox/core": "dev-main" + }, + "autoload": { + "psr-4": { + "Moox\\Builder\\": "src", + "Moox\\Builder\\Database\\Factories\\": "database/factories", + "Moox\\Builder\\Database\\Seeders\\": "database/seeders" + } + }, + "autoload-dev": { + "psr-4": { + "Moox\\Builder\\Tests\\": "tests/", + "Moox\\Builder\\Tests\\Support\\": "tests/Support/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Moox\\Builder\\BuilderServiceProvider" + ] + }, + "moox": { + "name": "Moox Builder", + "stability": "stable", + "type": "moox-plugin", + "install": { + "auto_migrate": "database/migrations", + "seed": "database/seeders/BuilderSeeder.php", + "auto_publish": "builder-config", + "plugins": [ + "Moox\\Builder\\Plugins\\BuilderPlugin" + ] + }, + "update": { + "migrate": "database/migrations", + "merge": "builder-config" + }, + "uninstall": { + "migrate": "database/migrations", + "remove": "builder-config" + } + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "require-dev": { + "moox/devtools": "dev-main", + "pestphp/pest": "^4.7", + "pestphp/pest-plugin-livewire": "^4.0" + }, + "scripts": { + "test": [ + "@php ../../vendor/bin/pest" + ], + "format": [ + "@php ../../vendor/bin/pint" + ] + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/packages/builder/config/builder.php b/packages/builder/config/builder.php new file mode 100644 index 0000000000..2859e68e46 --- /dev/null +++ b/packages/builder/config/builder.php @@ -0,0 +1,36 @@ + env('BUILDER_DRIVER', 'typed'), + 'drivers' => [ + 'typed' => TypedValueDriver::class, + ], + 'navigation_group' => env('BUILDER_NAVIGATION_GROUP', 'Felder'), + + /* + |-------------------------------------------------------------------------- + | Builder entities + |-------------------------------------------------------------------------- + | + | Register Filament resources that may use custom field groups. The array + | key is the entity identifier used in location rules and storage. + | + | Packages can merge additional entries in their service provider: + | + | config(['builder.entities.product' => [ + | 'resource' => ProductResource::class, + | 'label' => 'Products', + | ]]); + | + */ + 'entities' => [ + // 'item' => [ + // 'resource' => \Moox\Item\Resources\ItemResource::class, + // 'label' => 'Items', + // ], + ], +]; diff --git a/packages/builder/database/migrations/create_builder_field_groups_table.php.stub b/packages/builder/database/migrations/create_builder_field_groups_table.php.stub new file mode 100644 index 0000000000..6ed5f14105 --- /dev/null +++ b/packages/builder/database/migrations/create_builder_field_groups_table.php.stub @@ -0,0 +1,29 @@ +id(); + $table->ulid('ulid')->unique(); + $table->string('name'); + $table->string('slug')->unique(); + $table->json('location_rules'); + $table->string('placement')->default('default'); + $table->json('settings')->nullable(); + $table->integer('sort')->default(0); + $table->boolean('active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('builder_field_groups'); + } +}; diff --git a/packages/builder/database/migrations/create_builder_field_options_table.php.stub b/packages/builder/database/migrations/create_builder_field_options_table.php.stub new file mode 100644 index 0000000000..d58afe6709 --- /dev/null +++ b/packages/builder/database/migrations/create_builder_field_options_table.php.stub @@ -0,0 +1,26 @@ +id(); + $table->ulid('ulid')->unique(); + $table->foreignId('field_id')->constrained('builder_fields')->cascadeOnDelete(); + $table->string('label'); + $table->string('value'); + $table->integer('sort')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('builder_field_options'); + } +}; diff --git a/packages/builder/database/migrations/create_builder_field_values_table.php.stub b/packages/builder/database/migrations/create_builder_field_values_table.php.stub new file mode 100644 index 0000000000..63f98725da --- /dev/null +++ b/packages/builder/database/migrations/create_builder_field_values_table.php.stub @@ -0,0 +1,39 @@ +id(); + $table->string('entity'); + $table->unsignedBigInteger('record_id'); + $table->string('field_name'); + $table->string('value_string')->nullable(); + $table->text('value_text')->nullable(); + $table->decimal('value_decimal', 20, 6)->nullable(); + $table->date('value_date')->nullable(); + $table->dateTime('value_datetime')->nullable(); + $table->boolean('value_boolean')->nullable(); + $table->json('value_json')->nullable(); + $table->timestamps(); + + $table->unique(['entity', 'record_id', 'field_name']); + $table->index(['entity', 'field_name', 'value_decimal']); + $table->index(['entity', 'field_name', 'value_date']); + $table->index(['entity', 'field_name', 'value_datetime']); + $table->index(['entity', 'record_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('builder_field_values'); + } +}; diff --git a/packages/builder/database/migrations/create_builder_fields_table.php.stub b/packages/builder/database/migrations/create_builder_fields_table.php.stub new file mode 100644 index 0000000000..9edc7d6b73 --- /dev/null +++ b/packages/builder/database/migrations/create_builder_fields_table.php.stub @@ -0,0 +1,35 @@ +id(); + $table->ulid('ulid')->unique(); + $table->foreignId('field_group_id')->constrained('builder_field_groups')->cascadeOnDelete(); + $table->foreignId('parent_field_id')->nullable()->constrained('builder_fields')->nullOnDelete(); + $table->string('name'); + $table->string('label'); + $table->string('type'); + $table->json('config')->nullable(); + $table->json('validation')->nullable(); + $table->json('conditional_logic')->nullable(); + $table->json('settings')->nullable(); + $table->integer('sort')->default(0); + $table->string('width')->nullable(); + $table->timestamps(); + + $table->unique(['field_group_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('builder_fields'); + } +}; diff --git a/packages/builder/database/seeders/BuilderSeeder.php b/packages/builder/database/seeders/BuilderSeeder.php new file mode 100644 index 0000000000..a6ce7abcd2 --- /dev/null +++ b/packages/builder/database/seeders/BuilderSeeder.php @@ -0,0 +1,405 @@ +seedFahrzeugdatenGroup(); + $this->seedFieldTypeShowcaseGroup(); + $this->seedDemoValuesForFirstItem(); + + app(DefinitionRegistry::class)->forget(); + } + + protected function seedFahrzeugdatenGroup(): void + { + $group = $this->upsertGroup( + slug: 'fahrzeugdaten', + name: 'Fahrzeugdaten', + sort: 0, + ); + + $this->upsertField($group, [ + 'name' => 'fahrzeugtyp-modell', + 'label' => 'Fahrzeugtyp / Modell', + 'type' => 'text', + 'sort' => 0, + 'config' => [ + 'placeholder' => 'z. B. Golf GTI', + 'maxLength' => 120, + ], + ]); + + $this->upsertField($group, [ + 'name' => 'bruttolistenpreis', + 'label' => 'Bruttolistenpreis', + 'type' => 'number', + 'sort' => 1, + 'config' => [ + 'suffix' => '€', + 'min' => 0, + 'step' => 100, + ], + ]); + + $this->upsertField($group, [ + 'name' => 'erstzulassung', + 'label' => 'Erstzulassung', + 'type' => 'date', + 'sort' => 2, + 'config' => [ + 'displayFormat' => 'd.m.Y', + ], + ]); + } + + protected function seedFieldTypeShowcaseGroup(): void + { + $group = $this->upsertGroup( + slug: 'feldtyp-showcase', + name: 'Feldtyp-Showcase (alle Typen)', + sort: 10, + ); + + $this->upsertField($group, [ + 'name' => 'demo-text', + 'label' => 'Text (kurz)', + 'type' => 'text', + 'sort' => 0, + 'required' => true, + 'config' => [ + 'placeholder' => 'Kurzer Text …', + 'prefix' => 'REF-', + 'maxLength' => 50, + 'default' => 'Standardtext', + 'helperText' => 'value_string in der Datenbank', + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-textarea', + 'label' => 'Text (mehrzeilig)', + 'type' => 'textarea', + 'sort' => 1, + 'config' => [ + 'rows' => 4, + 'placeholder' => 'Mehrzeilige Beschreibung …', + 'helperText' => 'value_text in der Datenbank', + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-number', + 'label' => 'Zahl', + 'type' => 'number', + 'sort' => 2, + 'config' => [ + 'prefix' => '€', + 'suffix' => 'brutto', + 'min' => 0, + 'max' => 500000, + 'step' => 0.01, + 'placeholder' => '0,00', + 'helperText' => 'value_decimal in der Datenbank', + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-email', + 'label' => 'E-Mail', + 'type' => 'email', + 'sort' => 3, + 'config' => [ + 'placeholder' => 'name@beispiel.de', + 'helperText' => 'value_string + email-Validierung', + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-url', + 'label' => 'URL', + 'type' => 'url', + 'sort' => 4, + 'config' => [ + 'placeholder' => 'https://moox.org', + 'helperText' => 'value_string + url-Validierung', + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-password', + 'label' => 'Passwort', + 'type' => 'password', + 'sort' => 5, + 'config' => [ + 'maxLength' => 64, + 'helperText' => 'Wird nur gespeichert wenn ausgefüllt (value_string)', + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-select', + 'label' => 'Auswahl (Dropdown)', + 'type' => 'select', + 'sort' => 6, + 'config' => [ + 'default' => 'bmw', + 'helperText' => 'value_string — gewählter Options-Wert', + ], + 'options' => [ + ['label' => 'BMW', 'value' => 'bmw'], + ['label' => 'Audi', 'value' => 'audi'], + ['label' => 'Mercedes', 'value' => 'mercedes'], + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-multiselect', + 'label' => 'Mehrfachauswahl', + 'type' => 'multiselect', + 'sort' => 7, + 'config' => [ + 'helperText' => 'value_json — Array der Options-Werte', + ], + 'options' => [ + ['label' => 'Sportpaket', 'value' => 'sport'], + ['label' => 'Komfortpaket', 'value' => 'comfort'], + ['label' => 'Assistenzpaket', 'value' => 'assist'], + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-checkbox-list', + 'label' => 'Checkbox-Liste', + 'type' => 'checkbox_list', + 'sort' => 8, + 'required' => true, + 'config' => [ + 'helperText' => 'value_json — Array der angehakten Options-Werte', + ], + 'options' => [ + ['label' => 'LED-Scheinwerfer', 'value' => 'led'], + ['label' => 'Navigationssystem', 'value' => 'navi'], + ['label' => 'Sitzheizung', 'value' => 'heating'], + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-radio', + 'label' => 'Optionsfelder (Radio)', + 'type' => 'radio', + 'sort' => 9, + 'config' => [ + 'helperText' => 'value_string — ein Options-Wert', + ], + 'options' => [ + ['label' => 'Benzin', 'value' => 'petrol'], + ['label' => 'Diesel', 'value' => 'diesel'], + ['label' => 'Elektro', 'value' => 'electric'], + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-toggle', + 'label' => 'Schalter', + 'type' => 'toggle', + 'sort' => 10, + 'config' => [ + 'default' => '1', + 'helperText' => 'value_boolean in der Datenbank', + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-date', + 'label' => 'Datum', + 'type' => 'date', + 'sort' => 11, + 'config' => [ + 'displayFormat' => 'd.m.Y', + 'helperText' => 'value_date in der Datenbank', + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-datetime', + 'label' => 'Datum & Uhrzeit', + 'type' => 'datetime', + 'sort' => 12, + 'config' => [ + 'displayFormat' => 'd.m.Y H:i', + 'helperText' => 'value_datetime in der Datenbank', + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-time', + 'label' => 'Uhrzeit', + 'type' => 'time', + 'sort' => 13, + 'config' => [ + 'default' => '09:00', + 'helperText' => 'value_string (TimePicker)', + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-color', + 'label' => 'Farbe', + 'type' => 'color', + 'sort' => 14, + 'config' => [ + 'default' => '#3b82f6', + 'helperText' => 'value_string — Hex-Farbwert', + ], + ]); + } + + /** + * Demo-Werte für Item #1 — nur wenn die items-Tabelle und Datensatz 1 existieren. + */ + protected function seedDemoValuesForFirstItem(): void + { + if (! Schema::hasTable('items') || ! DB::table('items')->where('id', 1)->exists()) { + return; + } + + $demos = [ + 'fahrzeugtyp-modell' => ['type' => 'text', 'value' => 'Golf GTI'], + 'bruttolistenpreis' => ['type' => 'number', 'value' => 32990], + 'erstzulassung' => ['type' => 'date', 'value' => '2022-06-15'], + 'demo-text' => ['type' => 'text', 'value' => 'REF-DEMO-001'], + 'demo-textarea' => ['type' => 'textarea', 'value' => "Zeile 1: Ausstattung\nZeile 2: Zustand sehr gut\nZeile 3: Scheckheftgepflegt"], + 'demo-number' => ['type' => 'number', 'value' => 28990.50], + 'demo-email' => ['type' => 'email', 'value' => 'demo@moox.org'], + 'demo-url' => ['type' => 'url', 'value' => 'https://moox.org'], + 'demo-select' => ['type' => 'select', 'value' => 'audi'], + 'demo-multiselect' => ['type' => 'multiselect', 'value' => ['sport', 'comfort']], + 'demo-checkbox-list' => ['type' => 'checkbox_list', 'value' => ['led', 'navi']], + 'demo-radio' => ['type' => 'radio', 'value' => 'diesel'], + 'demo-toggle' => ['type' => 'toggle', 'value' => true], + 'demo-date' => ['type' => 'date', 'value' => '2024-03-15'], + 'demo-datetime' => ['type' => 'datetime', 'value' => '2024-03-15 14:30:00'], + 'demo-time' => ['type' => 'time', 'value' => '14:30'], + 'demo-color' => ['type' => 'color', 'value' => '#22c55e'], + ]; + + foreach ($demos as $fieldName => $demo) { + $this->upsertFieldValue('item', 1, $fieldName, $demo['type'], $demo['value']); + } + } + + protected function upsertGroup(string $slug, string $name, int $sort): FieldGroup + { + return FieldGroup::query()->updateOrCreate( + ['slug' => $slug], + [ + 'name' => $name, + 'location_rules' => [ + [ + [ + 'param' => 'entity', + 'operator' => '==', + 'value' => 'item', + ], + ], + ], + 'placement' => 'default', + 'sort' => $sort, + 'active' => true, + ], + ); + } + + /** + * @param array{ + * name: string, + * label: string, + * type: string, + * sort: int, + * config?: array, + * required?: bool, + * options?: list + * } $attributes + */ + protected function upsertField(FieldGroup $group, array $attributes): Field + { + $field = Field::query()->updateOrCreate( + [ + 'field_group_id' => $group->getKey(), + 'name' => $attributes['name'], + ], + [ + 'label' => $attributes['label'], + 'type' => $attributes['type'], + 'config' => $attributes['config'] ?? [], + 'validation' => [ + 'required' => (bool) ($attributes['required'] ?? false), + 'rules' => [], + ], + 'sort' => $attributes['sort'], + ], + ); + + if (isset($attributes['options'])) { + $this->syncFieldOptions($field, $attributes['options']); + } else { + $field->options()->delete(); + } + + return $field; + } + + /** + * @param list $options + */ + protected function syncFieldOptions(Field $field, array $options): void + { + $retainedIds = []; + + foreach ($options as $index => $option) { + $model = FieldOption::query()->updateOrCreate( + [ + 'field_id' => $field->getKey(), + 'value' => $option['value'], + ], + [ + 'label' => $option['label'], + 'sort' => $index, + ], + ); + + $retainedIds[] = $model->getKey(); + } + + $field->options() + ->whereNotIn('id', $retainedIds) + ->delete(); + } + + protected function upsertFieldValue(string $entity, int $recordId, string $fieldName, string $type, mixed $value): void + { + FieldValue::query()->updateOrCreate( + [ + 'entity' => $entity, + 'record_id' => $recordId, + 'field_name' => $fieldName, + ], + TypedValueColumns::attributesFor($type, $value), + ); + } +} diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php new file mode 100644 index 0000000000..7c79623655 --- /dev/null +++ b/packages/builder/resources/lang/de/builder.php @@ -0,0 +1,79 @@ + 'Felder', + + 'field_group' => [ + 'single' => 'Feldgruppe', + 'plural' => 'Feldgruppen', + 'general' => 'Allgemein', + 'assignment' => 'Zuordnung', + 'fields' => 'Felder', + 'name' => 'Name', + 'name_helper' => 'Wird als Überschrift im Formular angezeigt.', + 'slug' => 'Technischer Schlüssel', + 'slug_helper' => 'Eindeutiger Bezeichner für diese Gruppe. Wird automatisch aus dem Namen erzeugt.', + 'active' => 'Aktiv', + 'active_helper' => 'Nur aktive Gruppen erscheinen in den Formularen.', + 'sort' => 'Reihenfolge', + 'sort_helper' => 'Niedrigere Werte werden weiter oben angezeigt.', + 'target_entities' => 'Anzeigen bei', + 'target_entities_helper' => 'Diese Feldgruppe erscheint in den Formularen der ausgewählten Ressourcen.', + 'target_entities_placeholder' => 'Ressource auswählen…', + 'no_entities_registered' => 'Noch keine Ressourcen registriert. Trage sie unter config(builder.entities) ein.', + 'fields_empty' => 'Noch keine Felder definiert.', + 'field_item' => 'Feld', + 'fields_count' => 'Felder', + 'assigned_to' => 'Zugeordnet zu', + ], + + 'field' => [ + 'label' => 'Bezeichnung', + 'label_helper' => 'Sichtbarer Name im Formular.', + 'name' => 'Feldschlüssel', + 'name_helper' => 'Technischer Name für Speicherung und Abfragen. Nur Kleinbuchstaben, Zahlen und Bindestriche.', + 'type' => 'Feldtyp', + 'required' => 'Pflichtfeld', + 'settings' => 'Einstellungen', + 'options' => 'Auswahloptionen', + 'option_label' => 'Anzeigetext', + 'option_value' => 'Wert', + ], + + 'field_types' => [ + 'text' => 'Text (kurz)', + 'textarea' => 'Text (mehrzeilig)', + 'number' => 'Zahl', + 'email' => 'E-Mail', + 'url' => 'URL', + 'password' => 'Passwort', + 'select' => 'Auswahl (Dropdown)', + 'multiselect' => 'Mehrfachauswahl', + 'checkbox_list' => 'Checkbox-Liste', + 'radio' => 'Optionsfelder', + 'toggle' => 'Schalter', + 'date' => 'Datum', + 'datetime' => 'Datum & Uhrzeit', + 'time' => 'Uhrzeit', + 'color' => 'Farbe', + ], + + 'capabilities' => [ + 'max_length' => 'Maximale Länge', + 'placeholder' => 'Platzhalter', + 'prefix' => 'Präfix', + 'suffix' => 'Suffix', + 'default_value' => 'Standardwert', + 'helper_text' => 'Hilfetext', + 'min_value' => 'Mindestwert', + 'max_value' => 'Höchstwert', + 'step' => 'Schrittweite', + 'rows' => 'Zeilen', + 'display_format' => 'Anzeigeformat', + ], + + 'repeater' => [ + 'collapse_all' => 'Alle einklappen', + 'expand_all' => 'Alle ausklappen', + ], +]; diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php new file mode 100644 index 0000000000..6acfc7bb22 --- /dev/null +++ b/packages/builder/resources/lang/en/builder.php @@ -0,0 +1,79 @@ + 'Fields', + + 'field_group' => [ + 'single' => 'Field group', + 'plural' => 'Field groups', + 'general' => 'General', + 'assignment' => 'Assignment', + 'fields' => 'Fields', + 'name' => 'Name', + 'name_helper' => 'Shown as the section heading in forms.', + 'slug' => 'Technical key', + 'slug_helper' => 'Unique identifier for this group. Generated from the name automatically.', + 'active' => 'Active', + 'active_helper' => 'Only active groups appear in forms.', + 'sort' => 'Sort order', + 'sort_helper' => 'Lower values appear higher in the form.', + 'target_entities' => 'Show on', + 'target_entities_helper' => 'This field group appears in the forms of the selected resources.', + 'target_entities_placeholder' => 'Select a resource…', + 'no_entities_registered' => 'No resources registered yet. Add them under config(builder.entities).', + 'fields_empty' => 'No fields defined yet.', + 'field_item' => 'Field', + 'fields_count' => 'Fields', + 'assigned_to' => 'Assigned to', + ], + + 'field' => [ + 'label' => 'Label', + 'label_helper' => 'Visible name in the form.', + 'name' => 'Field key', + 'name_helper' => 'Technical name for storage and queries. Lowercase letters, numbers, and hyphens only.', + 'type' => 'Field type', + 'required' => 'Required', + 'settings' => 'Settings', + 'options' => 'Options', + 'option_label' => 'Display text', + 'option_value' => 'Value', + ], + + 'field_types' => [ + 'text' => 'Text (short)', + 'textarea' => 'Text (multiline)', + 'number' => 'Number', + 'email' => 'Email', + 'url' => 'URL', + 'password' => 'Password', + 'select' => 'Select (dropdown)', + 'multiselect' => 'Multi select', + 'checkbox_list' => 'Checkbox list', + 'radio' => 'Radio buttons', + 'toggle' => 'Toggle', + 'date' => 'Date', + 'datetime' => 'Date & time', + 'time' => 'Time', + 'color' => 'Color', + ], + + 'capabilities' => [ + 'max_length' => 'Maximum length', + 'placeholder' => 'Placeholder', + 'prefix' => 'Prefix', + 'suffix' => 'Suffix', + 'default_value' => 'Default value', + 'helper_text' => 'Helper text', + 'min_value' => 'Minimum value', + 'max_value' => 'Maximum value', + 'step' => 'Step', + 'rows' => 'Rows', + 'display_format' => 'Display format', + ], + + 'repeater' => [ + 'collapse_all' => 'Collapse all', + 'expand_all' => 'Expand all', + ], +]; diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php new file mode 100644 index 0000000000..f49c7b3cc5 --- /dev/null +++ b/packages/builder/src/BuilderServiceProvider.php @@ -0,0 +1,100 @@ +name('builder') + ->hasConfigFile() + ->hasTranslations() + ->hasMigrations([ + 'create_builder_field_groups_table', + 'create_builder_fields_table', + 'create_builder_field_options_table', + 'create_builder_field_values_table', + ]); + } + + public function packageRegistered(): void + { + $this->app->singleton(FieldTypeRegistry::class, function (): FieldTypeRegistry { + $registry = new FieldTypeRegistry; + + foreach ($this->defaultFieldTypes() as $type) { + $registry->register($type); + } + + return $registry; + }); + + $this->app->singleton(ValueStoreResolver::class); + $this->app->singleton(EntityRegistry::class); + } + + public function packageBooted(): void + { + FieldGroup::observe(InvalidateDefinitionCacheObserver::class); + Field::observe(InvalidateDefinitionCacheObserver::class); + FieldOption::observe(InvalidateDefinitionCacheObserver::class); + + Event::listen(RecordSaved::class, PersistCustomFields::class); + } + + /** + * @return list + */ + protected function defaultFieldTypes(): array + { + return [ + new TextFieldType, + new TextareaFieldType, + new NumberFieldType, + new EmailFieldType, + new UrlFieldType, + new PasswordFieldType, + new SelectFieldType, + new MultiselectFieldType, + new CheckboxListFieldType, + new RadioFieldType, + new ToggleFieldType, + new DateFieldType, + new DatetimeFieldType, + new TimeFieldType, + new ColorFieldType, + ]; + } +} diff --git a/packages/builder/src/Compiler/LocationMatcher.php b/packages/builder/src/Compiler/LocationMatcher.php new file mode 100644 index 0000000000..daedf79a2f --- /dev/null +++ b/packages/builder/src/Compiler/LocationMatcher.php @@ -0,0 +1,67 @@ +> $locationRules + */ + public function matches(array $locationRules, LocationContext $context): bool + { + if ($locationRules === []) { + return true; + } + + foreach ($locationRules as $andGroup) { + if ($this->matchesAndGroup($andGroup, $context)) { + return true; + } + } + + return false; + } + + /** + * @param list $andGroup + */ + protected function matchesAndGroup(array $andGroup, LocationContext $context): bool + { + foreach ($andGroup as $rule) { + if (! $this->matchesRule($rule, $context)) { + return false; + } + } + + return $andGroup !== []; + } + + /** + * @param array{param: string, operator: string, value: mixed} $rule + */ + protected function matchesRule(array $rule, LocationContext $context): bool + { + $param = $rule['param'] ?? ''; + $operator = $rule['operator'] ?? '=='; + $value = $rule['value'] ?? null; + + if ($param !== 'entity') { + if (app()->bound('log')) { + Log::debug('Builder location rule skipped: unknown param.', ['param' => $param]); + } + + return false; + } + + return match ($operator) { + '==' => (string) $context->entity === (string) $value, + '!=' => (string) $context->entity !== (string) $value, + default => false, + }; + } +} diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php new file mode 100644 index 0000000000..6899e24baa --- /dev/null +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -0,0 +1,129 @@ + $fieldGroups + * @param class-string|null $resourceClass + * @return list
+ */ + public function compile(Collection $fieldGroups, ?string $resourceClass = null): array + { + $sections = []; + + foreach ($fieldGroups as $group) { + $components = $group->fields + ->sortBy(fn (FieldDefinition $field): int => $field->sort) + ->values() + ->map(fn (FieldDefinition $field): Component => $this->compileField($field, $resourceClass)) + ->all(); + + if ($components === []) { + continue; + } + + $sections[] = Section::make($group->name) + ->schema($components); + } + + return $sections; + } + + /** + * @param Collection $fieldGroups + * @return array> + */ + public function rules(Collection $fieldGroups): array + { + $rules = []; + + foreach ($fieldGroups as $group) { + foreach ($group->fields as $field) { + $fieldRules = $this->rulesForField($field); + if ($fieldRules !== []) { + $rules[$field->name] = $fieldRules; + } + } + } + + return $rules; + } + + /** + * @return list + */ + protected function rulesForField(FieldDefinition $field): array + { + $fieldType = $this->fieldTypeRegistry->get($field->type); + $rules = []; + + foreach ($fieldType->capabilities() as $capabilityClass) { + $rules = array_merge($rules, app($capabilityClass)->rules($field)); + } + + if (($field->validation['required'] ?? false) === true) { + $rules[] = 'required'; + } + + $rules = array_merge($rules, $field->validation['rules'] ?? []); + + if ($field->type === 'email') { + $rules[] = 'email'; + } + + if ($field->type === 'url') { + $rules[] = 'url'; + } + + return array_values(array_unique($rules)); + } + + /** + * @param class-string|null $resourceClass + */ + protected function compileField(FieldDefinition $field, ?string $resourceClass = null): Component + { + $fieldType = $this->fieldTypeRegistry->get($field->type); + $component = $fieldType->formComponent($field); + $valueStore = $this->valueStoreResolver->for(); + $entity = $resourceClass !== null + ? $this->customFieldsManager->locationContextForResource($resourceClass)->entity + : null; + + return $component->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $valueStore, $entity): void { + if (filled($state) || $record === null || $entity === null) { + return; + } + + $values = $valueStore->load( + $entity, + $record, + collect([$field]), + ); + + if (array_key_exists($field->name, $values)) { + $component->state($values[$field->name]); + } + }); + } +} diff --git a/packages/builder/src/Concerns/HasCustomFields.php b/packages/builder/src/Concerns/HasCustomFields.php new file mode 100644 index 0000000000..c9bfece2b7 --- /dev/null +++ b/packages/builder/src/Concerns/HasCustomFields.php @@ -0,0 +1,81 @@ + + */ + public static function customFieldComponents(): array + { + if (! static::isRegisteredForCustomFields()) { + return []; + } + + $groups = app(DefinitionRegistry::class)->fieldGroupsFor( + static::customFieldsLocationContext(), + ); + + if ($groups->isEmpty()) { + return []; + } + + return app(SchemaCompiler::class)->compile($groups, static::class); + } + + public static function customFieldsLocationContext(): LocationContext + { + return new LocationContext(static::resolveCustomFieldsEntityIdentifier()); + } + + public static function resolveCustomFieldsEntityIdentifier(): string + { + $entity = app(EntityRegistry::class)->resolveForResource(static::class); + + if ($entity === null) { + throw new \LogicException(sprintf( + 'Resource [%s] uses HasCustomFields but is not registered in config(builder.entities).', + static::class, + )); + } + + return $entity; + } + + public static function isRegisteredForCustomFields(): bool + { + return app(EntityRegistry::class)->isRegisteredResource(static::class); + } + + /** + * @return array> + */ + public static function customFieldRules(): array + { + $groups = app(DefinitionRegistry::class)->fieldGroupsFor( + static::customFieldsLocationContext(), + ); + + return app(SchemaCompiler::class)->rules($groups); + } +} diff --git a/packages/builder/src/Data/FieldDefinition.php b/packages/builder/src/Data/FieldDefinition.php new file mode 100644 index 0000000000..18cb965ec3 --- /dev/null +++ b/packages/builder/src/Data/FieldDefinition.php @@ -0,0 +1,77 @@ + $config + * @param array $validation + * @param list $options + */ + public function __construct( + public string $name, + public string $label, + public string $type, + public int $sort = 0, + public array $config = [], + public array $validation = [], + public array $options = [], + ) {} + + public static function fromModel(Field $field): self + { + $options = $field->relationLoaded('options') + ? $field->options->map(fn ($option): array => [ + 'label' => $option->label, + 'value' => $option->value, + ])->values()->all() + : []; + + return new self( + name: $field->name, + label: $field->label, + type: $field->type, + sort: (int) $field->sort, + config: $field->config ?? [], + validation: $field->validation ?? [], + options: $options, + ); + } + + /** + * @return array{name: string, label: string, type: string, config: array, validation: array, options: list} + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'label' => $this->label, + 'type' => $this->type, + 'sort' => $this->sort, + 'config' => $this->config, + 'validation' => $this->validation, + 'options' => $this->options, + ]; + } + + /** + * @param array{name: string, label: string, type: string, config?: array, validation?: array, options?: list} $data + */ + public static function fromArray(array $data): self + { + return new self( + name: $data['name'], + label: $data['label'], + type: $data['type'], + sort: (int) ($data['sort'] ?? 0), + config: $data['config'] ?? [], + validation: $data['validation'] ?? [], + options: $data['options'] ?? [], + ); + } +} diff --git a/packages/builder/src/Data/FieldGroupDefinition.php b/packages/builder/src/Data/FieldGroupDefinition.php new file mode 100644 index 0000000000..cb7ff07eb2 --- /dev/null +++ b/packages/builder/src/Data/FieldGroupDefinition.php @@ -0,0 +1,82 @@ + $fields + * @param list> $locationRules + * @param array $settings + */ + public function __construct( + public string $name, + public string $slug, + public string $placement, + public Collection $fields, + public array $locationRules = [], + public array $settings = [], + ) {} + + public static function fromModel(FieldGroup $group): self + { + $fields = $group->relationLoaded('fields') + ? $group->fields + ->map(fn ($field): FieldDefinition => FieldDefinition::fromModel($field)) + ->sortBy(fn (FieldDefinition $field): int => $field->sort) + ->values() + : collect(); + + return new self( + name: $group->name, + slug: $group->slug, + placement: $group->placement, + fields: $fields, + locationRules: $group->location_rules ?? [], + settings: $group->settings ?? [], + ); + } + + /** + * @return array{name: string, slug: string, placement: string, fields: list>, locationRules: list>, settings: array} + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'slug' => $this->slug, + 'placement' => $this->placement, + 'fields' => $this->fields + ->map(fn (FieldDefinition $field): array => $field->toArray()) + ->values() + ->all(), + 'locationRules' => $this->locationRules, + 'settings' => $this->settings, + ]; + } + + /** + * @param array{name: string, slug: string, placement: string, fields?: list>, locationRules?: list>, settings?: array} $data + */ + public static function fromArray(array $data): self + { + $fields = collect($data['fields'] ?? []) + ->map(fn (array $field): FieldDefinition => FieldDefinition::fromArray($field)) + ->sortBy(fn (FieldDefinition $field): int => $field->sort) + ->values(); + + return new self( + name: $data['name'], + slug: $data['slug'], + placement: $data['placement'], + fields: $fields, + locationRules: $data['locationRules'] ?? [], + settings: $data['settings'] ?? [], + ); + } +} diff --git a/packages/builder/src/Data/LocationContext.php b/packages/builder/src/Data/LocationContext.php new file mode 100644 index 0000000000..5d496859af --- /dev/null +++ b/packages/builder/src/Data/LocationContext.php @@ -0,0 +1,12 @@ + + */ + abstract public function builderFields(): array; + + /** + * @return list + */ + public function rules(FieldDefinition $field): array + { + return []; + } + + abstract public function apply(Component $component, FieldDefinition $field): Component; + + /** + * @param list> $capabilities + */ + public static function applyAll(array $capabilities, Component $component, FieldDefinition $field): Component + { + foreach ($capabilities as $class) { + $component = app($class)->apply($component, $field); + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php new file mode 100644 index 0000000000..7a90373be9 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -0,0 +1,38 @@ +label(__('builder::builder.capabilities.default_value')), + ]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if (! array_key_exists('default', $field->config)) { + return $component; + } + + $default = $field->config['default']; + + if ($component instanceof Toggle) { + $component->default(filter_var($default, FILTER_VALIDATE_BOOLEAN)); + } else { + $component->default($default); + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php b/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php new file mode 100644 index 0000000000..25afbb71e8 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php @@ -0,0 +1,31 @@ +label(__('builder::builder.capabilities.display_format')) + ->placeholder('d.m.Y'), + ]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if ($component instanceof DateTimePicker && filled($field->config['displayFormat'] ?? null)) { + $component->displayFormat($field->config['displayFormat']); + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/Capabilities/HelperText.php b/packages/builder/src/FieldTypes/Capabilities/HelperText.php new file mode 100644 index 0000000000..9a6083f9b2 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/HelperText.php @@ -0,0 +1,33 @@ +label(__('builder::builder.capabilities.helper_text')), + ]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if (! filled($field->config['helperText'] ?? null)) { + return $component; + } + + if (method_exists($component, 'helperText')) { + $component->helperText($field->config['helperText']); + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/Capabilities/MaxLength.php b/packages/builder/src/FieldTypes/Capabilities/MaxLength.php new file mode 100644 index 0000000000..5d72a3dd20 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/MaxLength.php @@ -0,0 +1,45 @@ +label(__('builder::builder.capabilities.max_length')) + ->numeric() + ->minValue(1), + ]; + } + + public function rules(FieldDefinition $field): array + { + if (! isset($field->config['maxLength'])) { + return []; + } + + return ['max:'.$field->config['maxLength']]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if (! isset($field->config['maxLength'])) { + return $component; + } + + if ($component instanceof TextInput || $component instanceof Textarea) { + $component->maxLength((int) $field->config['maxLength']); + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/Capabilities/MaxValue.php b/packages/builder/src/FieldTypes/Capabilities/MaxValue.php new file mode 100644 index 0000000000..1f737095b4 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/MaxValue.php @@ -0,0 +1,39 @@ +label(__('builder::builder.capabilities.max_value')) + ->numeric(), + ]; + } + + public function rules(FieldDefinition $field): array + { + if (! isset($field->config['max'])) { + return []; + } + + return ['max:'.$field->config['max']]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if ($component instanceof TextInput && isset($field->config['max'])) { + $component->maxValue($field->config['max']); + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/Capabilities/MinValue.php b/packages/builder/src/FieldTypes/Capabilities/MinValue.php new file mode 100644 index 0000000000..a71a5ba106 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/MinValue.php @@ -0,0 +1,39 @@ +label(__('builder::builder.capabilities.min_value')) + ->numeric(), + ]; + } + + public function rules(FieldDefinition $field): array + { + if (! isset($field->config['min'])) { + return []; + } + + return ['min:'.$field->config['min']]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if ($component instanceof TextInput && isset($field->config['min'])) { + $component->minValue($field->config['min']); + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/Capabilities/Placeholder.php b/packages/builder/src/FieldTypes/Capabilities/Placeholder.php new file mode 100644 index 0000000000..09053c4f91 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/Placeholder.php @@ -0,0 +1,34 @@ +label(__('builder::builder.capabilities.placeholder')), + ]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if (! isset($field->config['placeholder'])) { + return $component; + } + + if ($component instanceof TextInput || $component instanceof Textarea) { + $component->placeholder($field->config['placeholder']); + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/Capabilities/PrefixSuffix.php b/packages/builder/src/FieldTypes/Capabilities/PrefixSuffix.php new file mode 100644 index 0000000000..bfaed2de91 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/PrefixSuffix.php @@ -0,0 +1,39 @@ +label(__('builder::builder.capabilities.prefix')), + TextInput::make('config.suffix') + ->label(__('builder::builder.capabilities.suffix')), + ]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if (! $component instanceof TextInput) { + return $component; + } + + if (filled($field->config['prefix'] ?? null)) { + $component->prefix($field->config['prefix']); + } + + if (filled($field->config['suffix'] ?? null)) { + $component->suffix($field->config['suffix']); + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/Capabilities/Rows.php b/packages/builder/src/FieldTypes/Capabilities/Rows.php new file mode 100644 index 0000000000..4a41ded2c9 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/Rows.php @@ -0,0 +1,33 @@ +label(__('builder::builder.capabilities.rows')) + ->numeric() + ->default(3) + ->minValue(1), + ]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if ($component instanceof Textarea && isset($field->config['rows'])) { + $component->rows((int) $field->config['rows']); + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/Capabilities/Step.php b/packages/builder/src/FieldTypes/Capabilities/Step.php new file mode 100644 index 0000000000..ae05962dad --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/Step.php @@ -0,0 +1,30 @@ +label(__('builder::builder.capabilities.step')) + ->numeric(), + ]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if ($component instanceof TextInput && isset($field->config['step'])) { + $component->step($field->config['step']); + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/Concerns/BuildsOptionComponents.php b/packages/builder/src/FieldTypes/Concerns/BuildsOptionComponents.php new file mode 100644 index 0000000000..1d5f23d341 --- /dev/null +++ b/packages/builder/src/FieldTypes/Concerns/BuildsOptionComponents.php @@ -0,0 +1,39 @@ + + */ + protected function optionsMap(FieldDefinition $field): array + { + $options = []; + + foreach ($field->options as $option) { + $options[$option['value']] = $option['label']; + } + + return $options; + } + + protected function applyOptions(Component $component, FieldDefinition $field): Component + { + $options = $this->optionsMap($field); + + if ($component instanceof Select || $component instanceof Radio || $component instanceof CheckboxList) { + $component->options($options); + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/FieldType.php b/packages/builder/src/FieldTypes/FieldType.php new file mode 100644 index 0000000000..87eb42fef7 --- /dev/null +++ b/packages/builder/src/FieldTypes/FieldType.php @@ -0,0 +1,72 @@ +> + */ + public function capabilities(): array + { + return []; + } + + public function castValue(mixed $raw): mixed + { + return $raw; + } + + public function hasOptions(): bool + { + return false; + } + + public function label(): string + { + $key = 'builder::builder.field_types.'.str_replace('-', '_', static::key()); + + return __($key) !== $key ? __($key) : ucfirst(str_replace('_', ' ', static::key())); + } + + protected function applyCapabilitiesAndValidation(Component $component, FieldDefinition $field): Component + { + $component = Capability::applyAll($this->capabilities(), $component, $field); + + $rules = []; + + foreach ($this->capabilities() as $capabilityClass) { + $rules = array_merge($rules, app($capabilityClass)->rules($field)); + } + + $rules = array_merge($rules, $this->additionalRules($field)); + + if ($rules !== []) { + $component->rules($rules); + } + + if (($field->validation['required'] ?? false) === true) { + $component->required(); + } + + return $component; + } + + /** + * @return list + */ + protected function additionalRules(FieldDefinition $field): array + { + return $field->validation['rules'] ?? []; + } +} diff --git a/packages/builder/src/FieldTypes/Types/CheckboxListFieldType.php b/packages/builder/src/FieldTypes/Types/CheckboxListFieldType.php new file mode 100644 index 0000000000..2034c0f0a6 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/CheckboxListFieldType.php @@ -0,0 +1,53 @@ +name) + ->label($field->label); + + $component = $this->applyOptions($component, $field); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/ColorFieldType.php b/packages/builder/src/FieldTypes/Types/ColorFieldType.php new file mode 100644 index 0000000000..7a3236673d --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/ColorFieldType.php @@ -0,0 +1,34 @@ +name) + ->label($field->label); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/DateFieldType.php b/packages/builder/src/FieldTypes/Types/DateFieldType.php new file mode 100644 index 0000000000..b9a24733ad --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/DateFieldType.php @@ -0,0 +1,36 @@ +name) + ->label($field->label); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/DatetimeFieldType.php b/packages/builder/src/FieldTypes/Types/DatetimeFieldType.php new file mode 100644 index 0000000000..0c263e541a --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/DatetimeFieldType.php @@ -0,0 +1,36 @@ +name) + ->label($field->label); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/EmailFieldType.php b/packages/builder/src/FieldTypes/Types/EmailFieldType.php new file mode 100644 index 0000000000..b48a2f53a7 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/EmailFieldType.php @@ -0,0 +1,44 @@ +name) + ->label($field->label) + ->email(); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/MultiselectFieldType.php b/packages/builder/src/FieldTypes/Types/MultiselectFieldType.php new file mode 100644 index 0000000000..bc0392904a --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/MultiselectFieldType.php @@ -0,0 +1,54 @@ +name) + ->label($field->label) + ->multiple(); + + $component = $this->applyOptions($component, $field); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/NumberFieldType.php b/packages/builder/src/FieldTypes/Types/NumberFieldType.php new file mode 100644 index 0000000000..51306f2457 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/NumberFieldType.php @@ -0,0 +1,56 @@ +name) + ->label($field->label) + ->numeric(); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/PasswordFieldType.php b/packages/builder/src/FieldTypes/Types/PasswordFieldType.php new file mode 100644 index 0000000000..8919eff70f --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/PasswordFieldType.php @@ -0,0 +1,37 @@ +name) + ->label($field->label) + ->password() + ->revealable(false) + ->dehydrated(fn (?string $state): bool => filled($state)); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/RadioFieldType.php b/packages/builder/src/FieldTypes/Types/RadioFieldType.php new file mode 100644 index 0000000000..af59e74b5e --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/RadioFieldType.php @@ -0,0 +1,44 @@ +name) + ->label($field->label); + + $component = $this->applyOptions($component, $field); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/SelectFieldType.php b/packages/builder/src/FieldTypes/Types/SelectFieldType.php new file mode 100644 index 0000000000..07f5a8bfaf --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/SelectFieldType.php @@ -0,0 +1,46 @@ +name) + ->label($field->label); + + $component = $this->applyOptions($component, $field); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/TextFieldType.php b/packages/builder/src/FieldTypes/Types/TextFieldType.php new file mode 100644 index 0000000000..d1f5df620b --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/TextFieldType.php @@ -0,0 +1,42 @@ +name) + ->label($field->label); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/TextareaFieldType.php b/packages/builder/src/FieldTypes/Types/TextareaFieldType.php new file mode 100644 index 0000000000..5e5b5e34f0 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/TextareaFieldType.php @@ -0,0 +1,43 @@ +name) + ->label($field->label) + ->rows((int) ($field->config['rows'] ?? 3)); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/TimeFieldType.php b/packages/builder/src/FieldTypes/Types/TimeFieldType.php new file mode 100644 index 0000000000..c334603958 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/TimeFieldType.php @@ -0,0 +1,34 @@ +name) + ->label($field->label); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/ToggleFieldType.php b/packages/builder/src/FieldTypes/Types/ToggleFieldType.php new file mode 100644 index 0000000000..924e655af0 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/ToggleFieldType.php @@ -0,0 +1,41 @@ +name) + ->label($field->label); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/UrlFieldType.php b/packages/builder/src/FieldTypes/Types/UrlFieldType.php new file mode 100644 index 0000000000..4bd433b1db --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/UrlFieldType.php @@ -0,0 +1,42 @@ +name) + ->label($field->label) + ->url(); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/Listeners/PersistCustomFields.php b/packages/builder/src/Listeners/PersistCustomFields.php new file mode 100644 index 0000000000..a79b048a14 --- /dev/null +++ b/packages/builder/src/Listeners/PersistCustomFields.php @@ -0,0 +1,38 @@ + $data + */ + public function handle(Model $record, array $data, Page $page): void + { + $resourceClass = $page::getResource(); + + if (! is_subclass_of($resourceClass, Resource::class)) { + return; + } + + if (! $this->customFieldsManager->usesCustomFields($resourceClass)) { + return; + } + + $this->customFieldsManager->saveFromFormData($resourceClass, $record, $data); + } +} diff --git a/packages/builder/src/Models/Field.php b/packages/builder/src/Models/Field.php new file mode 100644 index 0000000000..50cf234f6d --- /dev/null +++ b/packages/builder/src/Models/Field.php @@ -0,0 +1,71 @@ + 'array', + 'validation' => 'array', + 'conditional_logic' => 'array', + 'settings' => 'array', + 'sort' => 'integer', + ]; + + protected static function booted(): void + { + static::creating(function (Field $field): void { + if (blank($field->ulid)) { + $field->ulid = (string) Str::ulid(); + } + }); + } + + /** + * @return BelongsTo + */ + public function fieldGroup(): BelongsTo + { + return $this->belongsTo(FieldGroup::class); + } + + /** + * @return BelongsTo + */ + public function parentField(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_field_id'); + } + + /** + * @return HasMany + */ + public function options(): HasMany + { + return $this->hasMany(FieldOption::class)->orderBy('sort'); + } +} diff --git a/packages/builder/src/Models/FieldGroup.php b/packages/builder/src/Models/FieldGroup.php new file mode 100644 index 0000000000..a7e0032a57 --- /dev/null +++ b/packages/builder/src/Models/FieldGroup.php @@ -0,0 +1,65 @@ + 'default', + 'sort' => 0, + 'active' => true, + ]; + + protected $fillable = [ + 'ulid', + 'name', + 'slug', + 'location_rules', + 'placement', + 'settings', + 'sort', + 'active', + ]; + + protected $casts = [ + 'location_rules' => 'array', + 'settings' => 'array', + 'sort' => 'integer', + 'active' => 'boolean', + ]; + + protected static function booted(): void + { + static::creating(function (FieldGroup $group): void { + if (blank($group->ulid)) { + $group->ulid = (string) Str::ulid(); + } + }); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('active', true); + } + + /** + * @return HasMany + */ + public function fields(): HasMany + { + return $this->hasMany(Field::class)->orderBy('sort'); + } +} diff --git a/packages/builder/src/Models/FieldOption.php b/packages/builder/src/Models/FieldOption.php new file mode 100644 index 0000000000..cd501ab680 --- /dev/null +++ b/packages/builder/src/Models/FieldOption.php @@ -0,0 +1,43 @@ + 'integer', + ]; + + protected static function booted(): void + { + static::creating(function (FieldOption $option): void { + if (blank($option->ulid)) { + $option->ulid = (string) Str::ulid(); + } + }); + } + + /** + * @return BelongsTo + */ + public function field(): BelongsTo + { + return $this->belongsTo(Field::class); + } +} diff --git a/packages/builder/src/Models/FieldValue.php b/packages/builder/src/Models/FieldValue.php new file mode 100644 index 0000000000..c681aeb8bc --- /dev/null +++ b/packages/builder/src/Models/FieldValue.php @@ -0,0 +1,46 @@ + 'integer', + 'value_decimal' => 'decimal:6', + 'value_date' => 'date', + 'value_datetime' => 'datetime', + 'value_boolean' => 'boolean', + 'value_json' => 'array', + ]; + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForRecord(Builder $query, string $entity, int|string $recordId): Builder + { + return $query + ->where('entity', $entity) + ->where('record_id', $recordId); + } +} diff --git a/packages/builder/src/Observers/InvalidateDefinitionCacheObserver.php b/packages/builder/src/Observers/InvalidateDefinitionCacheObserver.php new file mode 100644 index 0000000000..3afc09d150 --- /dev/null +++ b/packages/builder/src/Observers/InvalidateDefinitionCacheObserver.php @@ -0,0 +1,20 @@ +forget(); + } + + public function deleted(object $model): void + { + app(DefinitionRegistry::class)->forget(); + } +} diff --git a/packages/builder/src/Plugins/BuilderPlugin.php b/packages/builder/src/Plugins/BuilderPlugin.php new file mode 100644 index 0000000000..a626560de6 --- /dev/null +++ b/packages/builder/src/Plugins/BuilderPlugin.php @@ -0,0 +1,37 @@ +resources([ + FieldGroupResource::class, + ]); + } + + public function boot(Panel $panel): void + { + // + } + + public static function make(): static + { + return app(static::class); + } +} diff --git a/packages/builder/src/Registry/DefinitionRegistry.php b/packages/builder/src/Registry/DefinitionRegistry.php new file mode 100644 index 0000000000..db0bcc98ee --- /dev/null +++ b/packages/builder/src/Registry/DefinitionRegistry.php @@ -0,0 +1,74 @@ + + */ + public function fieldGroupsFor(LocationContext $context): Collection + { + return $this->allActiveGroups() + ->filter(fn (FieldGroupDefinition $group): bool => $this->locationMatcher->matches( + $group->locationRules, + $context, + )) + ->values(); + } + + /** + * @return Collection + */ + public function allActiveGroups(): Collection + { + $cached = Cache::get(self::CACHE_KEY); + + if (! is_array($cached)) { + if ($cached !== null) { + Cache::forget(self::CACHE_KEY); + } + + $cached = $this->loadActiveGroupsAsArrays(); + Cache::forever(self::CACHE_KEY, $cached); + } + + return collect($cached) + ->map(fn (array $data): FieldGroupDefinition => FieldGroupDefinition::fromArray($data)) + ->values(); + } + + /** + * @return list> + */ + protected function loadActiveGroupsAsArrays(): array + { + return FieldGroup::query() + ->active() + ->with(['fields.options']) + ->orderBy('sort') + ->get() + ->map(fn (FieldGroup $group): array => FieldGroupDefinition::fromModel($group)->toArray()) + ->all(); + } + + public function forget(): void + { + Cache::forget(self::CACHE_KEY); + } +} diff --git a/packages/builder/src/Registry/EntityRegistry.php b/packages/builder/src/Registry/EntityRegistry.php new file mode 100644 index 0000000000..e647fab6f7 --- /dev/null +++ b/packages/builder/src/Registry/EntityRegistry.php @@ -0,0 +1,89 @@ + + */ + public function all(): array + { + return config('builder.entities', []); + } + + /** + * @param class-string $resourceClass + */ + public function resolveForResource(string $resourceClass): ?string + { + foreach ($this->all() as $key => $definition) { + if (($definition['resource'] ?? null) === $resourceClass) { + return (string) $key; + } + } + + return null; + } + + /** + * @return class-string|null + */ + public function resourceFor(string $entity): ?string + { + $resource = $this->all()[$entity]['resource'] ?? null; + + return is_string($resource) ? $resource : null; + } + + public function labelFor(string $entity): string + { + $definition = $this->all()[$entity] ?? []; + + if (filled($definition['label'] ?? null)) { + return (string) $definition['label']; + } + + return Str::headline($entity); + } + + /** + * @param list $entities + */ + public function labelsFor(array $entities): string + { + if ($entities === []) { + return '—'; + } + + return collect($entities) + ->map(fn (string $entity): string => $this->labelFor($entity)) + ->implode(', '); + } + + /** + * @return array + */ + public function optionsForSelect(): array + { + $options = []; + + foreach (array_keys($this->all()) as $entity) { + $options[$entity] = $this->labelFor($entity); + } + + return $options; + } + + /** + * @param class-string $resourceClass + */ + public function isRegisteredResource(string $resourceClass): bool + { + return $this->resolveForResource($resourceClass) !== null; + } +} diff --git a/packages/builder/src/Registry/FieldTypeRegistry.php b/packages/builder/src/Registry/FieldTypeRegistry.php new file mode 100644 index 0000000000..8df2bf774d --- /dev/null +++ b/packages/builder/src/Registry/FieldTypeRegistry.php @@ -0,0 +1,54 @@ + + */ + protected array $types = []; + + public function register(FieldType $type): void + { + $this->types[$type::key()] = $type; + } + + public function get(string $key): FieldType + { + if (! isset($this->types[$key])) { + throw UnknownFieldTypeException::forKey($key); + } + + return $this->types[$key]; + } + + /** + * @return array + */ + public function all(): array + { + return $this->types; + } + + /** + * @return array + */ + public function optionsForSelect(): array + { + $options = []; + + foreach ($this->types as $key => $type) { + $options[$key] = $type->label(); + } + + asort($options); + + return $options; + } +} diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php new file mode 100644 index 0000000000..4c3f517c82 --- /dev/null +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -0,0 +1,297 @@ +optionsForSelect(); + + return $schema->components([ + Grid::make() + ->schema([ + Grid::make() + ->schema([ + Section::make(__('builder::builder.field_group.general')) + ->schema([ + TextInput::make('name') + ->label(__('builder::builder.field_group.name')) + ->helperText(__('builder::builder.field_group.name_helper')) + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function ($state, callable $set, ?FieldGroup $record): void { + if ($record !== null) { + return; + } + + $set('slug', Str::slug((string) $state)); + }), + TextInput::make('slug') + ->label(__('builder::builder.field_group.slug')) + ->helperText(__('builder::builder.field_group.slug_helper')) + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true) + ->alphaDash(), + Toggle::make('active') + ->label(__('builder::builder.field_group.active')) + ->helperText(__('builder::builder.field_group.active_helper')) + ->default(true) + ->inline(false), + TextInput::make('sort') + ->label(__('builder::builder.field_group.sort')) + ->helperText(__('builder::builder.field_group.sort_helper')) + ->numeric() + ->default(0) + ->minValue(0), + ]), + Section::make(__('builder::builder.field_group.assignment')) + ->schema([ + Select::make('target_entities') + ->label(__('builder::builder.field_group.target_entities')) + ->helperText( + $entityOptions === [] + ? __('builder::builder.field_group.no_entities_registered') + : __('builder::builder.field_group.target_entities_helper'), + ) + ->options($entityOptions) + ->multiple() + ->searchable() + ->preload() + ->placeholder(__('builder::builder.field_group.target_entities_placeholder')) + ->required() + ->disabled($entityOptions === []) + ->native(false), + ]), + ]) + ->columnSpan(1) + ->columns(1), + Section::make(__('builder::builder.field_group.fields')) + ->columnSpan(2) + ->headerActions(static::fieldRepeaterHeaderActions()) + ->schema([ + Repeater::make('fields') + ->hiddenLabel() + ->orderColumn('sort') + ->reorderable() + ->collapsible() + ->cloneable() + ->collapseAllAction(fn (Action $action): Action => $action->hidden()) + ->expandAllAction(fn (Action $action): Action => $action->hidden()) + ->itemLabel(fn (array $state): ?string => filled($state['label'] ?? null) + ? (string) $state['label'] + : __('builder::builder.field_group.field_item')) + ->schema([ + Hidden::make('id'), + Hidden::make('sort'), + Grid::make(2) + ->schema([ + TextInput::make('label') + ->label(__('builder::builder.field.label')) + ->helperText(__('builder::builder.field.label_helper')) + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function ($state, callable $set, callable $get): void { + if (blank($get('name'))) { + $set('name', Str::slug((string) $state, '-')); + } + }), + Select::make('type') + ->label(__('builder::builder.field.type')) + ->options($registry->optionsForSelect()) + ->required() + ->searchable() + ->live() + ->native(false), + ]), + TextInput::make('name') + ->label(__('builder::builder.field.name')) + ->helperText(__('builder::builder.field.name_helper')) + ->required() + ->maxLength(255) + ->regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/'), + Toggle::make('required') + ->label(__('builder::builder.field.required')) + ->inline(false), + Section::make(__('builder::builder.field.settings')) + ->collapsed() + ->schema(fn (callable $get): array => static::typeSettingsSchema($get('type'))) + ->visible(fn (callable $get): bool => filled($get('type'))), + Section::make(__('builder::builder.field.options')) + ->collapsed() + ->schema([ + Repeater::make('options') + ->label(__('builder::builder.field.options')) + ->orderColumn('sort') + ->reorderable() + ->schema([ + Hidden::make('id'), + TextInput::make('label') + ->label(__('builder::builder.field.option_label')) + ->required(), + TextInput::make('value') + ->label(__('builder::builder.field.option_value')) + ->required(), + ]) + ->columns(2) + ->defaultItems(1), + ]) + ->visible(fn (callable $get): bool => filled($get('type')) && $registry->get($get('type'))->hasOptions()), + ]), + ]), + ]) + ->columns(3) + ->columnSpanFull(), + ]); + } + + /** + * @return list + */ + protected static function typeSettingsSchema(?string $type): array + { + if (blank($type)) { + return []; + } + + $fieldType = app(FieldTypeRegistry::class)->get($type); + $components = []; + + foreach ($fieldType->capabilities() as $capabilityClass) { + $components = array_merge($components, app($capabilityClass)->builderFields()); + } + + return $components; + } + + /** + * Collapse/expand controls live on the section header (Filament-native, right-aligned). + * + * @return list + */ + protected static function fieldRepeaterHeaderActions(): array + { + $visible = fn (Get $get): bool => count($get('fields') ?? []) >= 2; + + return [ + Action::make('collapseAllFields') + ->label('') + ->icon(Heroicon::ArrowsPointingIn) + ->iconButton() + ->tooltip(__('builder::builder.repeater.collapse_all')) + ->color('gray') + ->size(Size::Small) + ->visible($visible) + ->alpineClickHandler("\$dispatch('repeater-collapse', 'data.fields')"), + Action::make('expandAllFields') + ->label('') + ->icon(Heroicon::ArrowsPointingOut) + ->iconButton() + ->tooltip(__('builder::builder.repeater.expand_all')) + ->color('gray') + ->size(Size::Small) + ->visible($visible) + ->alpineClickHandler("\$dispatch('repeater-expand', 'data.fields')"), + ]; + } + + public static function table(Table $table): Table + { + $persistence = app(FieldGroupPersistence::class); + $entityRegistry = app(EntityRegistry::class); + + return $table + ->columns([ + TextColumn::make('name') + ->label(__('builder::builder.field_group.name')) + ->searchable() + ->sortable() + ->description(fn (FieldGroup $record): ?string => $record->slug), + TextColumn::make('location_rules') + ->label(__('builder::builder.field_group.assigned_to')) + ->formatStateUsing(fn (?array $state): string => $entityRegistry->labelsFor( + $persistence->entitiesFromLocationRules($state ?? []), + )) + ->wrap(), + TextColumn::make('fields_count') + ->counts('fields') + ->label(__('builder::builder.field_group.fields_count')) + ->alignCenter(), + IconColumn::make('active') + ->label(__('builder::builder.field_group.active')) + ->boolean() + ->alignCenter(), + TextColumn::make('sort') + ->label(__('builder::builder.field_group.sort')) + ->sortable() + ->alignCenter() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('sort') + ->reorderable('sort'); + } + + public static function getPages(): array + { + return [ + 'index' => ListFieldGroups::route('/'), + 'create' => CreateFieldGroup::route('/create'), + 'edit' => EditFieldGroup::route('/{record}/edit'), + ]; + } +} diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/CreateFieldGroup.php b/packages/builder/src/Resources/FieldGroupResource/Pages/CreateFieldGroup.php new file mode 100644 index 0000000000..2ea21f7ed2 --- /dev/null +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/CreateFieldGroup.php @@ -0,0 +1,23 @@ +sync($group, $data); + + return $group->fresh(['fields.options']); + } +} diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php new file mode 100644 index 0000000000..d292f224bd --- /dev/null +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php @@ -0,0 +1,66 @@ + + */ + protected function mutateFormDataBeforeFill(array $data): array + { + /** @var FieldGroup $record */ + $record = $this->getRecord(); + $record->load(['fields.options']); + + $data['location_rules'] = app(FieldGroupPersistence::class)->flattenLocationRulesForForm( + $record->location_rules ?? [], + ); + + $data['target_entities'] = app(FieldGroupPersistence::class)->entitiesFromLocationRules( + $record->location_rules ?? [], + ); + + $data['fields'] = $record->fields->map(fn ($field): array => [ + 'id' => $field->getKey(), + 'label' => $field->label, + 'name' => $field->name, + 'type' => $field->type, + 'required' => (bool) ($field->validation['required'] ?? false), + 'config' => $field->config ?? [], + 'sort' => $field->sort, + 'options' => $field->options->map(fn ($option): array => [ + 'id' => $option->getKey(), + 'label' => $option->label, + 'value' => $option->value, + 'sort' => $option->sort, + ])->values()->all(), + ])->values()->all(); + + return $data; + } + + protected function handleRecordUpdate($record, array $data): FieldGroup + { + app(FieldGroupPersistence::class)->sync($record, $data); + + return $record->fresh(['fields.options']); + } +} diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/ListFieldGroups.php b/packages/builder/src/Resources/FieldGroupResource/Pages/ListFieldGroups.php new file mode 100644 index 0000000000..a8ec0f3c62 --- /dev/null +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/ListFieldGroups.php @@ -0,0 +1,21 @@ + + */ + public function fieldsForResource(string $resourceClass): Collection + { + $groups = $this->definitionRegistry->fieldGroupsFor( + $this->locationContextForResource($resourceClass), + ); + + return $groups->flatMap(fn ($group) => $group->fields)->values(); + } + + /** + * @param class-string $resourceClass + * @return array + */ + public function loadFormData(string $resourceClass, Model $record): array + { + $fields = $this->fieldsForResource($resourceClass); + + if ($fields->isEmpty()) { + return []; + } + + return $this->valueStoreResolver->for()->load( + $this->locationContextForResource($resourceClass)->entity, + $record, + $fields, + ); + } + + /** + * @param class-string $resourceClass + * @param array $data + */ + public function saveFromFormData(string $resourceClass, Model $record, array $data): void + { + $fields = $this->fieldsForResource($resourceClass); + + if ($fields->isEmpty()) { + return; + } + + $values = []; + + foreach ($fields as $field) { + if (array_key_exists($field->name, $data)) { + $values[$field->name] = $data[$field->name]; + } + } + + if ($values === []) { + return; + } + + $this->valueStoreResolver->for()->save( + $this->locationContextForResource($resourceClass)->entity, + $record, + $values, + $fields, + ); + } + + /** + * @param class-string $resourceClass + */ + public function usesCustomFields(string $resourceClass): bool + { + return in_array(HasCustomFields::class, class_uses_recursive($resourceClass), true) + && $this->entityRegistry->isRegisteredResource($resourceClass); + } +} diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php new file mode 100644 index 0000000000..631c97d4d7 --- /dev/null +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -0,0 +1,224 @@ + $data + */ + public function sync(FieldGroup $group, array $data): void + { + $group->fill(Arr::only($data, [ + 'name', + 'slug', + 'active', + 'sort', + 'placement', + 'settings', + ])); + + $group->location_rules = $this->resolveLocationRules($data); + $group->save(); + + $this->syncFields($group, $data['fields'] ?? []); + } + + /** + * @param array $data + * @return list> + */ + public function resolveLocationRules(array $data): array + { + if (array_key_exists('target_entities', $data)) { + return $this->locationRulesFromEntities($data['target_entities'] ?? []); + } + + return $this->normalizeLocationRules($data['location_rules'] ?? []); + } + + /** + * @param list|string|null $entities + * @return list> + */ + public function locationRulesFromEntities(array|string|null $entities): array + { + $entities = is_array($entities) ? $entities : (filled($entities) ? [(string) $entities] : []); + + $rules = []; + + foreach ($entities as $entity) { + if (blank($entity)) { + continue; + } + + $rules[] = [[ + 'param' => 'entity', + 'operator' => '==', + 'value' => (string) $entity, + ]]; + } + + return $rules; + } + + /** + * @param list> $rules + * @return list + */ + public function entitiesFromLocationRules(array $rules): array + { + $entities = []; + + foreach ($rules as $andGroup) { + foreach ($andGroup as $rule) { + if (($rule['param'] ?? null) !== 'entity') { + continue; + } + + if (($rule['operator'] ?? '==') !== '==') { + continue; + } + + if (filled($rule['value'] ?? null)) { + $entities[] = (string) $rule['value']; + } + } + } + + return array_values(array_unique($entities)); + } + + /** + * @param list> $rows + * @return list> + */ + public function normalizeLocationRules(array $rows): array + { + $rules = []; + + foreach ($rows as $row) { + if (blank($row['param'] ?? null)) { + continue; + } + + $rules[] = [[ + 'param' => (string) $row['param'], + 'operator' => (string) ($row['operator'] ?? '=='), + 'value' => $row['value'] ?? null, + ]]; + } + + return $rules; + } + + /** + * @param list> $rules + * @return list + */ + public function flattenLocationRulesForForm(array $rules): array + { + $rows = []; + + foreach ($rules as $andGroup) { + foreach ($andGroup as $rule) { + $rows[] = $rule; + } + } + + return $rows; + } + + /** + * @param list> $rows + */ + protected function syncFields(FieldGroup $group, array $rows): void + { + $existing = $group->fields()->with('options')->get()->keyBy('id'); + $retainedIds = []; + + foreach ($rows as $index => $row) { + if (blank($row['name'] ?? null) || blank($row['type'] ?? null)) { + continue; + } + + $fieldId = $row['id'] ?? null; + $field = $fieldId ? $existing->get((int) $fieldId) : new Field; + + if (! $field instanceof Field) { + $field = new Field; + } + + $field->fill([ + 'field_group_id' => $group->getKey(), + 'name' => (string) $row['name'], + 'label' => (string) ($row['label'] ?? $row['name']), + 'type' => (string) $row['type'], + 'config' => $row['config'] ?? [], + 'validation' => [ + 'required' => (bool) ($row['required'] ?? false), + 'rules' => $row['validation']['rules'] ?? [], + ], + 'sort' => $index, + ]); + + $field->save(); + $retainedIds[] = $field->getKey(); + + $this->syncOptions($field, $row['options'] ?? []); + } + + $group->fields() + ->whereNotIn('id', $retainedIds) + ->each(fn (Field $field) => $field->delete()); + } + + /** + * @param list> $rows + */ + protected function syncOptions(Field $field, array $rows): void + { + $existing = $field->options()->get()->keyBy('id'); + $retainedIds = []; + + foreach ($rows as $index => $row) { + if (blank($row['value'] ?? null)) { + continue; + } + + $optionId = $row['id'] ?? null; + $option = $optionId ? $existing->get((int) $optionId) : new FieldOption; + + if (! $option instanceof FieldOption) { + $option = new FieldOption; + } + + $option->fill([ + 'field_id' => $field->getKey(), + 'label' => (string) ($row['label'] ?? $row['value']), + 'value' => (string) $row['value'], + 'sort' => (int) ($row['sort'] ?? $index), + ]); + + $option->save(); + $retainedIds[] = $option->getKey(); + } + + $field->options() + ->whereNotIn('id', $retainedIds) + ->each(fn (FieldOption $option) => $option->delete()); + } + + public function slugFromName(string $name): string + { + return Str::slug($name); + } +} diff --git a/packages/builder/src/Storage/TypedValueDriver.php b/packages/builder/src/Storage/TypedValueDriver.php new file mode 100644 index 0000000000..e20e205c95 --- /dev/null +++ b/packages/builder/src/Storage/TypedValueDriver.php @@ -0,0 +1,68 @@ +isEmpty()) { + return []; + } + + $rows = FieldValue::query() + ->forRecord($entity, $record->getKey()) + ->whereIn('field_name', $fields->pluck('name')) + ->get() + ->keyBy('field_name'); + + $values = []; + + foreach ($fields as $field) { + $row = $rows->get($field->name); + + if ($row === null) { + continue; + } + + $raw = TypedValueColumns::read($row, $field->type); + $values[$field->name] = $this->fieldTypeRegistry->get($field->type)->castValue($raw); + } + + return $values; + } + + public function save(string $entity, Model $record, array $values, Collection $fields): void + { + foreach ($fields as $field) { + if (! array_key_exists($field->name, $values)) { + continue; + } + + $cast = $this->fieldTypeRegistry->get($field->type)->castValue($values[$field->name]); + $columns = TypedValueColumns::attributesFor($field->type, $cast); + + FieldValue::query()->updateOrCreate( + [ + 'entity' => $entity, + 'record_id' => $record->getKey(), + 'field_name' => $field->name, + ], + $columns, + ); + } + } +} diff --git a/packages/builder/src/Storage/ValueStore.php b/packages/builder/src/Storage/ValueStore.php new file mode 100644 index 0000000000..b82a1af0b7 --- /dev/null +++ b/packages/builder/src/Storage/ValueStore.php @@ -0,0 +1,24 @@ + $fields + * @return array + */ + public function load(string $entity, Model $record, Collection $fields): array; + + /** + * @param array $values + * @param Collection $fields + */ + public function save(string $entity, Model $record, array $values, Collection $fields): void; +} diff --git a/packages/builder/src/Storage/ValueStoreResolver.php b/packages/builder/src/Storage/ValueStoreResolver.php new file mode 100644 index 0000000000..53f64fc99c --- /dev/null +++ b/packages/builder/src/Storage/ValueStoreResolver.php @@ -0,0 +1,26 @@ +container->make($class); + } +} diff --git a/packages/builder/src/Support/TypedValueColumns.php b/packages/builder/src/Support/TypedValueColumns.php new file mode 100644 index 0000000000..10b8690753 --- /dev/null +++ b/packages/builder/src/Support/TypedValueColumns.php @@ -0,0 +1,66 @@ + + */ + public static function valueColumns(): array + { + return [ + 'value_string', + 'value_text', + 'value_decimal', + 'value_date', + 'value_datetime', + 'value_boolean', + 'value_json', + ]; + } + + public static function columnForType(string $type): string + { + return match ($type) { + 'textarea' => 'value_text', + 'number' => 'value_decimal', + 'date' => 'value_date', + 'datetime' => 'value_datetime', + 'toggle' => 'value_boolean', + 'multiselect', 'checkbox_list' => 'value_json', + default => 'value_string', + }; + } + + /** + * @return array + */ + public static function emptyColumns(): array + { + return array_fill_keys(self::valueColumns(), null); + } + + /** + * @return array + */ + public static function attributesFor(string $type, mixed $value): array + { + $columns = self::emptyColumns(); + $column = self::columnForType($type); + $columns[$column] = $value; + + return $columns; + } + + public static function read(FieldValue $row, string $type): mixed + { + $column = self::columnForType($type); + + return $row->getAttribute($column); + } +} diff --git a/packages/builder/tests/Feature/HasCustomFieldsTest.php b/packages/builder/tests/Feature/HasCustomFieldsTest.php new file mode 100644 index 0000000000..ccedb50879 --- /dev/null +++ b/packages/builder/tests/Feature/HasCustomFieldsTest.php @@ -0,0 +1,122 @@ +createItemsTable(); + + config()->set('builder.entities', [ + 'item' => [ + 'resource' => TestItemResource::class, + 'label' => 'Item', + ], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Vehicle data', + 'slug' => 'vehicle-data', + 'location_rules' => [ + [['param' => 'entity', 'operator' => '==', 'value' => 'item']], + ], + 'active' => true, + ]); + + foreach ([ + ['name' => 'fahrzeugtyp-modell', 'label' => 'Model', 'type' => 'text'], + ['name' => 'bruttolistenpreis', 'label' => 'Price', 'type' => 'number'], + ] as $index => $field) { + Field::query()->create([ + 'field_group_id' => $group->getKey(), + ...$field, + 'sort' => $index, + 'validation' => ['required' => false, 'rules' => []], + ]); + } +}); + +it('exposes compiled custom field components for matching resources', function (): void { + $components = TestItemResource::customFieldComponents(); + + expect($components)->toHaveCount(1) + ->and($components[0])->toBeInstanceOf(Section::class); +}); + +it('returns no components when the resource is not registered', function (): void { + config()->set('builder.entities', []); + + expect(TestItemResource::customFieldComponents())->toBe([]); +}); + +it('loads and saves custom field values for a record', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + $manager = app(CustomFieldsManager::class); + + $manager->saveFromFormData(TestItemResource::class, $record, [ + 'fahrzeugtyp-modell' => 'Golf GTI', + 'bruttolistenpreis' => '32990', + ]); + + $values = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->get() + ->keyBy('field_name'); + + expect($values['fahrzeugtyp-modell']->value_string)->toBe('Golf GTI') + ->and((float) $values['bruttolistenpreis']->value_decimal)->toBe(32990.0); + + $loaded = $manager->loadFormData(TestItemResource::class, $record); + + expect($loaded)->toMatchArray([ + 'fahrzeugtyp-modell' => 'Golf GTI', + 'bruttolistenpreis' => 32990, + ]); +}); + +it('persists custom fields when filament dispatches record saved', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + $page = new class extends Page + { + protected static string $resource = TestItemResource::class; + }; + + Event::dispatch(RecordSaved::class, [ + 'record' => $record, + 'data' => [ + 'fahrzeugtyp-modell' => 'Polo', + 'bruttolistenpreis' => '24990', + ], + 'page' => $page, + ]); + + $values = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->get() + ->keyBy('field_name'); + + expect($values['fahrzeugtyp-modell']->value_string)->toBe('Polo') + ->and((float) $values['bruttolistenpreis']->value_decimal)->toBe(24990.0); +}); diff --git a/packages/builder/tests/Pest.php b/packages/builder/tests/Pest.php new file mode 100644 index 0000000000..4f017508bc --- /dev/null +++ b/packages/builder/tests/Pest.php @@ -0,0 +1,9 @@ +in(__DIR__.'/Unit', __DIR__.'/Feature'); diff --git a/packages/builder/tests/Support/TestItem.php b/packages/builder/tests/Support/TestItem.php new file mode 100644 index 0000000000..173bd1dbd0 --- /dev/null +++ b/packages/builder/tests/Support/TestItem.php @@ -0,0 +1,14 @@ +set('cache.default', 'array'); + } + + protected function getPackageProviders($app): array + { + return [ + BuilderServiceProvider::class, + ]; + } + + protected function getEnvironmentSetUp($app): void + { + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ]); + $app['config']->set('cache.default', 'array'); + $app['config']->set('app.key', 'base64:'.base64_encode(random_bytes(32))); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadPackageMigrations(); + } + + protected function loadPackageMigrations(): void + { + foreach ([ + 'create_builder_field_groups_table', + 'create_builder_fields_table', + 'create_builder_field_options_table', + 'create_builder_field_values_table', + ] as $migration) { + $path = dirname(__DIR__)."/database/migrations/{$migration}.php.stub"; + + if (is_file($path)) { + $instance = include $path; + $instance->up(); + } + } + } + + protected function createItemsTable(): void + { + Schema::dropIfExists('items'); + + Schema::create('items', function (Blueprint $table): void { + $table->id(); + $table->string('title')->nullable(); + $table->timestamps(); + }); + } +} diff --git a/packages/builder/tests/Unit/CapabilitiesTest.php b/packages/builder/tests/Unit/CapabilitiesTest.php new file mode 100644 index 0000000000..efea68649f --- /dev/null +++ b/packages/builder/tests/Unit/CapabilitiesTest.php @@ -0,0 +1,60 @@ + 120], + ); + + $component = app(MaxLength::class)->apply(TextInput::make('title'), $field); + + expect($component->getMaxLength())->toBe(120) + ->and(app(MaxLength::class)->rules($field))->toBe(['max:120']); +}); + +it('applies numeric min and max capabilities', function (): void { + $field = new FieldDefinition( + name: 'price', + label: 'Price', + type: 'number', + config: ['min' => 1, 'max' => 99], + ); + + $component = TextInput::make('price')->numeric(); + $component = app(MinValue::class)->apply($component, $field); + $component = app(MaxValue::class)->apply($component, $field); + + expect($component->getMinValue())->toBe(1) + ->and($component->getMaxValue())->toBe(99) + ->and(app(MinValue::class)->rules($field))->toBe(['min:1']) + ->and(app(MaxValue::class)->rules($field))->toBe(['max:99']); +}); + +it('applies placeholder and prefix suffix capabilities', function (): void { + $field = new FieldDefinition( + name: 'amount', + label: 'Amount', + type: 'number', + config: ['placeholder' => '0.00', 'prefix' => '€', 'suffix' => 'EUR'], + ); + + $component = TextInput::make('amount'); + $component = app(Placeholder::class)->apply($component, $field); + $component = app(PrefixSuffix::class)->apply($component, $field); + + expect($component->getPlaceholder())->toBe('0.00') + ->and($component->getPrefixLabel())->toBe('€') + ->and($component->getSuffixLabel())->toBe('EUR'); +}); diff --git a/packages/builder/tests/Unit/DefinitionRegistryTest.php b/packages/builder/tests/Unit/DefinitionRegistryTest.php new file mode 100644 index 0000000000..b2ad047d64 --- /dev/null +++ b/packages/builder/tests/Unit/DefinitionRegistryTest.php @@ -0,0 +1,108 @@ +create([ + 'name' => 'Demo', + 'slug' => 'demo', + 'location_rules' => [ + [['param' => 'entity', 'operator' => '==', 'value' => 'item']], + ], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'title', + 'label' => 'Title', + 'type' => 'text', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + $registry = app(DefinitionRegistry::class); + + expect(Cache::has(DefinitionRegistry::CACHE_KEY))->toBeFalse(); + + $groups = $registry->fieldGroupsFor(new LocationContext('item')); + expect($groups)->toHaveCount(1) + ->and(Cache::has(DefinitionRegistry::CACHE_KEY))->toBeTrue(); + + $group->update(['name' => 'Updated']); + + expect(Cache::has(DefinitionRegistry::CACHE_KEY))->toBeFalse(); +}); + +it('hydrates definitions from cached arrays', function (): void { + Cache::forget(DefinitionRegistry::CACHE_KEY); + + FieldGroup::query()->create([ + 'name' => 'Cached', + 'slug' => 'cached', + 'location_rules' => [ + [['param' => 'entity', 'operator' => '==', 'value' => 'item']], + ], + 'active' => true, + ]); + + $registry = app(DefinitionRegistry::class); + $registry->fieldGroupsFor(new LocationContext('item')); + + $cached = Cache::get(DefinitionRegistry::CACHE_KEY); + expect($cached)->toBeArray() + ->and($cached[0])->toBeArray() + ->and($cached[0]['name'])->toBe('Cached'); + + $groups = $registry->fieldGroupsFor(new LocationContext('item')); + expect($groups)->toHaveCount(1) + ->and($groups->first()->name)->toBe('Cached'); +}); + +it('preserves field sort order when hydrating from cache', function (): void { + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Ordered', + 'slug' => 'ordered', + 'location_rules' => [ + [['param' => 'entity', 'operator' => '==', 'value' => 'item']], + ], + 'active' => true, + ]); + + foreach ([ + ['name' => 'third', 'label' => 'Third', 'sort' => 2], + ['name' => 'first', 'label' => 'First', 'sort' => 0], + ['name' => 'second', 'label' => 'Second', 'sort' => 1], + ] as $field) { + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => $field['name'], + 'label' => $field['label'], + 'type' => 'text', + 'sort' => $field['sort'], + 'validation' => ['required' => false, 'rules' => []], + ]); + } + + $registry = app(DefinitionRegistry::class); + $registry->fieldGroupsFor(new LocationContext('item')); + + $groups = $registry->fieldGroupsFor(new LocationContext('item')); + + expect($groups->first()->fields->pluck('name')->all())->toBe(['first', 'second', 'third']); +}); diff --git a/packages/builder/tests/Unit/EntityRegistryTest.php b/packages/builder/tests/Unit/EntityRegistryTest.php new file mode 100644 index 0000000000..4f04f00813 --- /dev/null +++ b/packages/builder/tests/Unit/EntityRegistryTest.php @@ -0,0 +1,37 @@ +set('builder.entities', [ + 'item' => [ + 'resource' => TestItemResource::class, + 'label' => 'Items', + ], + ]); + + $registry = app(EntityRegistry::class); + + expect($registry->resolveForResource(TestItemResource::class))->toBe('item') + ->and($registry->resourceFor('item'))->toBe(TestItemResource::class) + ->and($registry->optionsForSelect())->toBe(['item' => 'Items']) + ->and($registry->isRegisteredResource(TestItemResource::class))->toBeTrue(); +}); + +it('returns null for unregistered resources', function (): void { + config()->set('builder.entities', []); + + $registry = app(EntityRegistry::class); + + expect($registry->resolveForResource(TestItemResource::class))->toBeNull() + ->and($registry->isRegisteredResource(TestItemResource::class))->toBeFalse(); +}); diff --git a/packages/builder/tests/Unit/FieldGroupPersistenceTest.php b/packages/builder/tests/Unit/FieldGroupPersistenceTest.php new file mode 100644 index 0000000000..6bbcd38dd8 --- /dev/null +++ b/packages/builder/tests/Unit/FieldGroupPersistenceTest.php @@ -0,0 +1,35 @@ +locationRulesFromEntities(['item', 'product']); + + expect($rules)->toHaveCount(2) + ->and($rules[0][0])->toMatchArray(['param' => 'entity', 'operator' => '==', 'value' => 'item']) + ->and($rules[1][0])->toMatchArray(['param' => 'entity', 'operator' => '==', 'value' => 'product']) + ->and($persistence->entitiesFromLocationRules($rules))->toBe(['item', 'product']); +}); + +it('prefers target entities when resolving location rules', function (): void { + $persistence = app(FieldGroupPersistence::class); + + $rules = $persistence->resolveLocationRules([ + 'target_entities' => ['item'], + 'location_rules' => [ + ['param' => 'entity', 'operator' => '==', 'value' => 'ignored'], + ], + ]); + + expect($rules)->toHaveCount(1) + ->and($rules[0][0]['value'])->toBe('item'); +}); diff --git a/packages/builder/tests/Unit/FieldTypeRegistryTest.php b/packages/builder/tests/Unit/FieldTypeRegistryTest.php new file mode 100644 index 0000000000..237bff85c4 --- /dev/null +++ b/packages/builder/tests/Unit/FieldTypeRegistryTest.php @@ -0,0 +1,40 @@ +register(new TextFieldType); + + expect($registry->get('text'))->toBeInstanceOf(TextFieldType::class) + ->and($registry->all())->toHaveKey('text'); +}); + +it('throws for unknown field type keys', function (): void { + $registry = new FieldTypeRegistry; + + $registry->get('missing'); +})->throws(UnknownFieldTypeException::class); + +it('resolves all fifteen default field types', function (): void { + $registry = app(FieldTypeRegistry::class); + + $keys = [ + 'text', 'textarea', 'number', 'email', 'url', 'password', + 'select', 'multiselect', 'checkbox_list', 'radio', 'toggle', + 'date', 'datetime', 'time', 'color', + ]; + + foreach ($keys as $key) { + expect($registry->get($key)::key())->toBe($key); + } +}); diff --git a/packages/builder/tests/Unit/LocationMatcherTest.php b/packages/builder/tests/Unit/LocationMatcherTest.php new file mode 100644 index 0000000000..2f9e8489d8 --- /dev/null +++ b/packages/builder/tests/Unit/LocationMatcherTest.php @@ -0,0 +1,57 @@ + 'entity', 'operator' => '==', 'value' => 'page'], + ], + [ + ['param' => 'entity', 'operator' => '==', 'value' => 'item'], + ], + ]; + + expect($matcher->matches($rules, $context))->toBeTrue(); + + $notRules = [ + [ + ['param' => 'entity', 'operator' => '!=', 'value' => 'item'], + ], + ]; + + expect($matcher->matches($notRules, $context))->toBeFalse(); +}); + +it('fails closed for unknown location params', function (): void { + $matcher = new LocationMatcher; + $context = new LocationContext('item'); + + $rules = [ + [ + ['param' => 'taxonomy', 'operator' => '==', 'value' => 'news'], + ], + ]; + + expect($matcher->matches($rules, $context))->toBeFalse(); +}); + +it('requires all rules in an AND group to match', function (): void { + $matcher = new LocationMatcher; + $context = new LocationContext('item'); + + $rules = [ + [ + ['param' => 'entity', 'operator' => '==', 'value' => 'item'], + ['param' => 'entity', 'operator' => '==', 'value' => 'page'], + ], + ]; + + expect($matcher->matches($rules, $context))->toBeFalse(); +}); diff --git a/packages/builder/tests/Unit/SchemaCompilerTest.php b/packages/builder/tests/Unit/SchemaCompilerTest.php new file mode 100644 index 0000000000..4ffc610193 --- /dev/null +++ b/packages/builder/tests/Unit/SchemaCompilerTest.php @@ -0,0 +1,63 @@ +create([ + 'name' => 'Vehicle data', + 'slug' => 'vehicle-data', + 'location_rules' => [], + 'active' => true, + ]); + + $group->fields()->createMany([ + [ + 'name' => 'third', + 'label' => 'Third', + 'type' => 'text', + 'sort' => 2, + 'validation' => ['required' => false, 'rules' => []], + ], + [ + 'name' => 'first', + 'label' => 'First', + 'type' => 'text', + 'sort' => 0, + 'validation' => ['required' => true, 'rules' => []], + ], + [ + 'name' => 'second', + 'label' => 'Second', + 'type' => 'number', + 'sort' => 1, + 'config' => ['min' => 1], + 'validation' => ['required' => false, 'rules' => []], + ], + ]); + + $group->load('fields.options'); + $definition = FieldGroupDefinition::fromModel($group); + + $compiler = app(SchemaCompiler::class); + $sections = $compiler->compile(collect([$definition])); + + expect($sections)->toHaveCount(1) + ->and($sections[0])->toBeInstanceOf(Section::class) + ->and($definition->fields)->toHaveCount(3) + ->and($definition->fields->pluck('name')->all())->toBe(['first', 'second', 'third']); + + $rules = $compiler->rules(collect([$definition])); + + expect($rules['first'])->toContain('required') + ->and($rules['second'])->toContain('min:1'); +}); diff --git a/packages/builder/tests/Unit/TypedValueDriverTest.php b/packages/builder/tests/Unit/TypedValueDriverTest.php new file mode 100644 index 0000000000..98f8e596e3 --- /dev/null +++ b/packages/builder/tests/Unit/TypedValueDriverTest.php @@ -0,0 +1,74 @@ +createItemsTable(); +}); + +it('roundtrips values through typed columns', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + $driver = app(TypedValueDriver::class); + $fields = collect([ + new FieldDefinition('fahrzeugtyp-modell', 'Model', 'text'), + new FieldDefinition('bruttolistenpreis', 'Price', 'number'), + new FieldDefinition('erstzulassung', 'Registered', 'date'), + ]); + + $driver->save('item', $record, [ + 'fahrzeugtyp-modell' => 'Golf GTI', + 'bruttolistenpreis' => '32990', + 'erstzulassung' => '2020-03-15', + ], $fields); + + expect(FieldValue::query()->forRecord('item', $record->getKey())->count())->toBe(3); + + $loaded = $driver->load('item', $record->fresh(), $fields); + + expect($loaded['fahrzeugtyp-modell'])->toBe('Golf GTI') + ->and($loaded['bruttolistenpreis'])->toEqual(32990) + ->and($loaded['erstzulassung']->format('Y-m-d'))->toBe('2020-03-15'); +}); + +it('clears stale typed columns when field type changes', function (): void { + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => 1, + 'field_name' => 'price', + 'value_string' => 'old', + 'value_decimal' => null, + ]); + + $record = TestItem::query()->create(['title' => 'Demo']); + $driver = app(TypedValueDriver::class); + + $driver->save('item', $record, ['price' => 19999], collect([ + new FieldDefinition('price', 'Price', 'number'), + ])); + + $row = FieldValue::query()->forRecord('item', $record->getKey())->first(); + + expect($row?->value_string)->toBeNull() + ->and((float) $row?->value_decimal)->toBe(19999.0); +}); + +it('maps field types to typed columns', function (): void { + expect(TypedValueColumns::columnForType('number'))->toBe('value_decimal') + ->and(TypedValueColumns::columnForType('date'))->toBe('value_date') + ->and(TypedValueColumns::columnForType('multiselect'))->toBe('value_json') + ->and(TypedValueColumns::columnForType('text'))->toBe('value_string'); +}); diff --git a/packages/builder/tests/Unit/ValueStoreResolverTest.php b/packages/builder/tests/Unit/ValueStoreResolverTest.php new file mode 100644 index 0000000000..2e7138b770 --- /dev/null +++ b/packages/builder/tests/Unit/ValueStoreResolverTest.php @@ -0,0 +1,20 @@ +for())->toBeInstanceOf(TypedValueDriver::class) + ->and($resolver->for())->toBeInstanceOf(TypedValueDriver::class); +}); diff --git a/packages/builder/tests/bootstrap.php b/packages/builder/tests/bootstrap.php new file mode 100644 index 0000000000..147c3fc39d --- /dev/null +++ b/packages/builder/tests/bootstrap.php @@ -0,0 +1,20 @@ + $public_base_path.'/build', 'type' => 'public', ], + 'builder' => [ + 'active' => false, + 'path' => $public_base_path.'/builder', + 'type' => 'public', + ], 'cache' => [ 'active' => false, 'path' => $public_base_path.'/cache', diff --git a/packages/full/composer.json b/packages/full/composer.json index c57511ced9..760b027e34 100644 --- a/packages/full/composer.json +++ b/packages/full/composer.json @@ -24,6 +24,7 @@ "moox/backup-server": "dev-main", "moox/brand": "dev-main", "moox/build": "dev-main", + "moox/builder": "dev-main", "moox/cart": "dev-main", "moox/category": "dev-main", "moox/clipboard": "dev-main", diff --git a/packages/item/composer.json b/packages/item/composer.json index 41474b3759..d9467f3bba 100644 --- a/packages/item/composer.json +++ b/packages/item/composer.json @@ -18,7 +18,8 @@ } ], "require": { - "moox/core": "self.version" + "moox/builder": "dev-main", + "moox/core": "dev-main" }, "autoload": { "psr-4": { diff --git a/packages/item/src/ItemServiceProvider.php b/packages/item/src/ItemServiceProvider.php index 72c5c18205..58f828636d 100644 --- a/packages/item/src/ItemServiceProvider.php +++ b/packages/item/src/ItemServiceProvider.php @@ -4,6 +4,7 @@ namespace Moox\Item; +use Moox\Item\Resources\ItemResource; use Moox\Core\MooxServiceProvider; use Spatie\LaravelPackageTools\Package; @@ -67,4 +68,14 @@ public function configureMoox(Package $package): void '', ]); } + + public function packageBooted(): void + { + config([ + 'builder.entities.item' => [ + 'resource' => ItemResource::class, + 'label' => 'Items', + ], + ]); + } } diff --git a/packages/item/src/Resources/ItemResource.php b/packages/item/src/Resources/ItemResource.php index 47cb2101fa..343a2b4c88 100644 --- a/packages/item/src/Resources/ItemResource.php +++ b/packages/item/src/Resources/ItemResource.php @@ -11,6 +11,7 @@ use Filament\Schemas\Schema; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Moox\Builder\Concerns\HasCustomFields; use Moox\Core\Entities\Items\Item\BaseItemResource; use Moox\Item\Models\Item; use Moox\Item\Resources\ItemResource\Pages\CreateItem; @@ -20,6 +21,8 @@ class ItemResource extends BaseItemResource { + use HasCustomFields; + protected static ?string $model = Item::class; protected static string|\BackedEnum|null $navigationIcon = 'gmdi-local-offer'; @@ -60,6 +63,7 @@ public static function form(Schema $form): Schema ->label(__('core::core.title')), MarkdownEditor::make('description') ->label(__('core::core.description')), + ...static::customFieldComponents(), Grid::make(2) ->schema([ static::getFooterActions()->columnSpan(1), From 574b043b10e419678fbf74f5b8b2ac0aedc7f39f Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:13:49 +0000 Subject: [PATCH 002/105] Fix styling --- packages/builder/src/BuilderServiceProvider.php | 2 +- packages/builder/src/Resources/FieldGroupResource.php | 2 +- packages/builder/src/Storage/TypedValueDriver.php | 1 - packages/builder/tests/Feature/HasCustomFieldsTest.php | 2 +- packages/builder/tests/Unit/DefinitionRegistryTest.php | 2 +- packages/builder/tests/Unit/EntityRegistryTest.php | 2 +- packages/builder/tests/Unit/FieldGroupPersistenceTest.php | 2 +- packages/builder/tests/Unit/FieldTypeRegistryTest.php | 2 +- packages/builder/tests/Unit/SchemaCompilerTest.php | 2 +- packages/builder/tests/Unit/TypedValueDriverTest.php | 2 +- packages/builder/tests/Unit/ValueStoreResolverTest.php | 3 +-- packages/item/src/ItemServiceProvider.php | 2 +- 12 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index f49c7b3cc5..d9c683f12d 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -27,8 +27,8 @@ use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldOption; use Moox\Builder\Observers\InvalidateDefinitionCacheObserver; -use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Registry\EntityRegistry; +use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Storage\ValueStoreResolver; use Moox\Core\MooxServiceProvider; use Spatie\LaravelPackageTools\Package; diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 4c3f517c82..8b960ed231 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -4,13 +4,13 @@ namespace Moox\Builder\Resources; +use Filament\Actions\Action; use Filament\Forms\Components\Component; use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; -use Filament\Actions\Action; use Filament\Resources\Resource; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; diff --git a/packages/builder/src/Storage/TypedValueDriver.php b/packages/builder/src/Storage/TypedValueDriver.php index e20e205c95..5c2d086eee 100644 --- a/packages/builder/src/Storage/TypedValueDriver.php +++ b/packages/builder/src/Storage/TypedValueDriver.php @@ -6,7 +6,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; -use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\TypedValueColumns; diff --git a/packages/builder/tests/Feature/HasCustomFieldsTest.php b/packages/builder/tests/Feature/HasCustomFieldsTest.php index ccedb50879..988be569de 100644 --- a/packages/builder/tests/Feature/HasCustomFieldsTest.php +++ b/packages/builder/tests/Feature/HasCustomFieldsTest.php @@ -6,7 +6,7 @@ require_once __DIR__.'/../Support/TestItem.php'; require_once __DIR__.'/../Support/TestItemResource.php'; -uses(Moox\Builder\Tests\TestCase::class); +uses(TestCase::class); use Filament\Resources\Events\RecordSaved; use Filament\Resources\Pages\Page; diff --git a/packages/builder/tests/Unit/DefinitionRegistryTest.php b/packages/builder/tests/Unit/DefinitionRegistryTest.php index b2ad047d64..132bbd6ffd 100644 --- a/packages/builder/tests/Unit/DefinitionRegistryTest.php +++ b/packages/builder/tests/Unit/DefinitionRegistryTest.php @@ -4,7 +4,7 @@ require_once __DIR__.'/../TestCase.php'; -uses(Moox\Builder\Tests\TestCase::class); +uses(TestCase::class); use Illuminate\Support\Facades\Cache; use Moox\Builder\Data\LocationContext; diff --git a/packages/builder/tests/Unit/EntityRegistryTest.php b/packages/builder/tests/Unit/EntityRegistryTest.php index 4f04f00813..6735065163 100644 --- a/packages/builder/tests/Unit/EntityRegistryTest.php +++ b/packages/builder/tests/Unit/EntityRegistryTest.php @@ -5,7 +5,7 @@ require_once __DIR__.'/../TestCase.php'; require_once __DIR__.'/../Support/TestItemResource.php'; -uses(Moox\Builder\Tests\TestCase::class); +uses(TestCase::class); use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Tests\Support\TestItemResource; diff --git a/packages/builder/tests/Unit/FieldGroupPersistenceTest.php b/packages/builder/tests/Unit/FieldGroupPersistenceTest.php index 6bbcd38dd8..65966c54ce 100644 --- a/packages/builder/tests/Unit/FieldGroupPersistenceTest.php +++ b/packages/builder/tests/Unit/FieldGroupPersistenceTest.php @@ -4,7 +4,7 @@ require_once __DIR__.'/../TestCase.php'; -uses(Moox\Builder\Tests\TestCase::class); +uses(TestCase::class); use Moox\Builder\Services\FieldGroupPersistence; use Moox\Builder\Tests\TestCase; diff --git a/packages/builder/tests/Unit/FieldTypeRegistryTest.php b/packages/builder/tests/Unit/FieldTypeRegistryTest.php index 237bff85c4..84ccb08462 100644 --- a/packages/builder/tests/Unit/FieldTypeRegistryTest.php +++ b/packages/builder/tests/Unit/FieldTypeRegistryTest.php @@ -4,7 +4,7 @@ require_once __DIR__.'/../TestCase.php'; -uses(Moox\Builder\Tests\TestCase::class); +uses(TestCase::class); use Moox\Builder\Exceptions\UnknownFieldTypeException; use Moox\Builder\FieldTypes\Types\TextFieldType; diff --git a/packages/builder/tests/Unit/SchemaCompilerTest.php b/packages/builder/tests/Unit/SchemaCompilerTest.php index 4ffc610193..6c32275405 100644 --- a/packages/builder/tests/Unit/SchemaCompilerTest.php +++ b/packages/builder/tests/Unit/SchemaCompilerTest.php @@ -4,7 +4,7 @@ require_once __DIR__.'/../TestCase.php'; -uses(Moox\Builder\Tests\TestCase::class); +uses(TestCase::class); use Filament\Schemas\Components\Section; use Moox\Builder\Compiler\SchemaCompiler; diff --git a/packages/builder/tests/Unit/TypedValueDriverTest.php b/packages/builder/tests/Unit/TypedValueDriverTest.php index 98f8e596e3..9ad1260b02 100644 --- a/packages/builder/tests/Unit/TypedValueDriverTest.php +++ b/packages/builder/tests/Unit/TypedValueDriverTest.php @@ -6,7 +6,7 @@ require_once __DIR__.'/../Support/TestItem.php'; require_once __DIR__.'/../Support/TestItemResource.php'; -uses(Moox\Builder\Tests\TestCase::class); +uses(TestCase::class); use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Models\FieldValue; diff --git a/packages/builder/tests/Unit/ValueStoreResolverTest.php b/packages/builder/tests/Unit/ValueStoreResolverTest.php index 2e7138b770..904a43bad3 100644 --- a/packages/builder/tests/Unit/ValueStoreResolverTest.php +++ b/packages/builder/tests/Unit/ValueStoreResolverTest.php @@ -5,11 +5,10 @@ require_once __DIR__.'/../TestCase.php'; require_once __DIR__.'/../Support/TestItemResource.php'; -uses(Moox\Builder\Tests\TestCase::class); +uses(TestCase::class); use Moox\Builder\Storage\TypedValueDriver; use Moox\Builder\Storage\ValueStoreResolver; -use Moox\Builder\Tests\Support\TestItemResource; use Moox\Builder\Tests\TestCase; it('resolves the typed value driver', function (): void { diff --git a/packages/item/src/ItemServiceProvider.php b/packages/item/src/ItemServiceProvider.php index 58f828636d..edc4f6da8a 100644 --- a/packages/item/src/ItemServiceProvider.php +++ b/packages/item/src/ItemServiceProvider.php @@ -4,8 +4,8 @@ namespace Moox\Item; -use Moox\Item\Resources\ItemResource; use Moox\Core\MooxServiceProvider; +use Moox\Item\Resources\ItemResource; use Spatie\LaravelPackageTools\Package; class ItemServiceProvider extends MooxServiceProvider From 5b2926ed643bbfd7b5c63e213ea36cd0f21995d2 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:12:03 +0200 Subject: [PATCH 003/105] validation --- .../builder/resources/lang/de/builder.php | 6 + .../builder/resources/lang/en/builder.php | 6 + .../builder/src/BuilderServiceProvider.php | 9 +- .../builder/src/Compiler/SchemaCompiler.php | 9 +- packages/builder/src/FieldTypes/FieldType.php | 5 + .../FieldTypes/Types/PasswordFieldType.php | 16 ++ .../Observers/PurgeFieldValuesObserver.php | 32 +++ .../builder/src/Registry/EntityRegistry.php | 36 ++++ .../src/Services/CustomFieldsManager.php | 10 +- .../src/Services/FieldGroupPersistence.php | 14 ++ .../src/Services/FieldGroupValidator.php | 121 ++++++++++++ .../builder/src/Services/FieldValuePurger.php | 33 ++++ .../builder/src/Storage/TypedValueDriver.php | 11 +- .../Support/EntityModelDeletionRegistrar.php | 37 ++++ .../builder/src/Support/OptionValueRules.php | 123 ++++++++++++ .../tests/Unit/FieldValueIntegrityTest.php | 183 ++++++++++++++++++ .../tests/Unit/OptionValueRulesTest.php | 57 ++++++ 17 files changed, 701 insertions(+), 7 deletions(-) create mode 100644 packages/builder/src/Observers/PurgeFieldValuesObserver.php create mode 100644 packages/builder/src/Services/FieldGroupValidator.php create mode 100644 packages/builder/src/Services/FieldValuePurger.php create mode 100644 packages/builder/src/Support/EntityModelDeletionRegistrar.php create mode 100644 packages/builder/src/Support/OptionValueRules.php create mode 100644 packages/builder/tests/Unit/FieldValueIntegrityTest.php create mode 100644 packages/builder/tests/Unit/OptionValueRulesTest.php diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 7c79623655..50f41c79bb 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -76,4 +76,10 @@ 'collapse_all' => 'Alle einklappen', 'expand_all' => 'Alle ausklappen', ], + + 'validation' => [ + 'invalid_option' => 'Der gewählte Wert ist keine gültige Option.', + 'duplicate_field_name' => 'Der Feldschlüssel „:name“ wird bereits in der Gruppe „:group“ verwendet.', + 'duplicate_field_name_internal' => 'Der Feldschlüssel „:name“ ist in dieser Gruppe mehrfach vergeben.', + ], ]; diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 6acfc7bb22..e449bc6933 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -76,4 +76,10 @@ 'collapse_all' => 'Collapse all', 'expand_all' => 'Expand all', ], + + 'validation' => [ + 'invalid_option' => 'The selected value is not a valid option.', + 'duplicate_field_name' => 'The field key ":name" is already used in the group ":group".', + 'duplicate_field_name_internal' => 'The field key ":name" is assigned more than once in this group.', + ], ]; diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index d9c683f12d..3370409fb7 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -27,8 +27,10 @@ use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldOption; use Moox\Builder\Observers\InvalidateDefinitionCacheObserver; -use Moox\Builder\Registry\EntityRegistry; +use Moox\Builder\Observers\PurgeFieldValuesObserver; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Registry\EntityRegistry; +use Moox\Builder\Support\EntityModelDeletionRegistrar; use Moox\Builder\Storage\ValueStoreResolver; use Moox\Core\MooxServiceProvider; use Spatie\LaravelPackageTools\Package; @@ -69,9 +71,14 @@ public function packageBooted(): void { FieldGroup::observe(InvalidateDefinitionCacheObserver::class); Field::observe(InvalidateDefinitionCacheObserver::class); + Field::observe(PurgeFieldValuesObserver::class); FieldOption::observe(InvalidateDefinitionCacheObserver::class); Event::listen(RecordSaved::class, PersistCustomFields::class); + + $this->app->booted(function (): void { + app(EntityModelDeletionRegistrar::class)->register(); + }); } /** diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 6899e24baa..ffd691e65a 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -12,6 +12,7 @@ use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Services\CustomFieldsManager; +use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Storage\ValueStoreResolver; class SchemaCompiler @@ -95,7 +96,11 @@ protected function rulesForField(FieldDefinition $field): array $rules[] = 'url'; } - return array_values(array_unique($rules)); + if ($fieldType->hasOptions()) { + $rules = array_merge($rules, OptionValueRules::forField($field)); + } + + return array_values(array_unique($rules, SORT_REGULAR)); } /** @@ -111,7 +116,7 @@ protected function compileField(FieldDefinition $field, ?string $resourceClass = : null; return $component->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $valueStore, $entity): void { - if (filled($state) || $record === null || $entity === null) { + if ($field->type === 'password' || filled($state) || $record === null || $entity === null) { return; } diff --git a/packages/builder/src/FieldTypes/FieldType.php b/packages/builder/src/FieldTypes/FieldType.php index 87eb42fef7..962b50109e 100644 --- a/packages/builder/src/FieldTypes/FieldType.php +++ b/packages/builder/src/FieldTypes/FieldType.php @@ -7,6 +7,7 @@ use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\FieldTypes\Capabilities\Capability; +use Moox\Builder\Support\OptionValueRules; abstract class FieldType { @@ -51,6 +52,10 @@ protected function applyCapabilitiesAndValidation(Component $component, FieldDef $rules = array_merge($rules, $this->additionalRules($field)); + if ($this->hasOptions()) { + $rules = array_merge($rules, OptionValueRules::forField($field)); + } + if ($rules !== []) { $component->rules($rules); } diff --git a/packages/builder/src/FieldTypes/Types/PasswordFieldType.php b/packages/builder/src/FieldTypes/Types/PasswordFieldType.php index 8919eff70f..0687bb7156 100644 --- a/packages/builder/src/FieldTypes/Types/PasswordFieldType.php +++ b/packages/builder/src/FieldTypes/Types/PasswordFieldType.php @@ -6,6 +6,7 @@ use Filament\Forms\Components\TextInput; use Filament\Schemas\Components\Component; +use Illuminate\Support\Facades\Hash; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\FieldTypes\Capabilities\MaxLength; use Moox\Builder\FieldTypes\FieldType; @@ -34,4 +35,19 @@ public function formComponent(FieldDefinition $field): Component return $this->applyCapabilitiesAndValidation($component, $field); } + + public function castValue(mixed $raw): mixed + { + if ($raw === null || $raw === '') { + return null; + } + + $value = (string) $raw; + + if (Hash::isHashed($value)) { + return $value; + } + + return Hash::make($value); + } } diff --git a/packages/builder/src/Observers/PurgeFieldValuesObserver.php b/packages/builder/src/Observers/PurgeFieldValuesObserver.php new file mode 100644 index 0000000000..0f4d128448 --- /dev/null +++ b/packages/builder/src/Observers/PurgeFieldValuesObserver.php @@ -0,0 +1,32 @@ +loadMissing('fieldGroup'); + + if ($field->fieldGroup === null) { + return; + } + + $entities = $this->fieldGroupPersistence->entitiesFromLocationRules( + $field->fieldGroup->location_rules ?? [], + ); + + $this->purger->purgeForFieldName($field->name, $entities); + } +} diff --git a/packages/builder/src/Registry/EntityRegistry.php b/packages/builder/src/Registry/EntityRegistry.php index e647fab6f7..e5182001fb 100644 --- a/packages/builder/src/Registry/EntityRegistry.php +++ b/packages/builder/src/Registry/EntityRegistry.php @@ -40,6 +40,42 @@ public function resourceFor(string $entity): ?string return is_string($resource) ? $resource : null; } + /** + * @return class-string|null + */ + public function modelFor(string $entity): ?string + { + $resource = $this->resourceFor($entity); + + if ($resource === null || ! method_exists($resource, 'getModel')) { + return null; + } + + $model = $resource::getModel(); + + return is_string($model) ? $model : null; + } + + /** + * @param class-string $modelClass + */ + public function entityForModel(string $modelClass): ?string + { + foreach ($this->all() as $key => $definition) { + $resource = $definition['resource'] ?? null; + + if (! is_string($resource) || ! method_exists($resource, 'getModel')) { + continue; + } + + if ($resource::getModel() === $modelClass) { + return (string) $key; + } + } + + return null; + } + public function labelFor(string $entity): string { $definition = $this->all()[$entity] ?? []; diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index 73ebc4a9a2..7dbaf20b02 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -83,9 +83,15 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da $values = []; foreach ($fields as $field) { - if (array_key_exists($field->name, $data)) { - $values[$field->name] = $data[$field->name]; + if (! array_key_exists($field->name, $data)) { + continue; } + + if ($field->type === 'password' && blank($data[$field->name])) { + continue; + } + + $values[$field->name] = $data[$field->name]; } if ($values === []) { diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 631c97d4d7..5607b7adfb 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -12,11 +12,17 @@ class FieldGroupPersistence { + public function __construct( + protected FieldValuePurger $fieldValuePurger, + ) {} + /** * @param array $data */ public function sync(FieldGroup $group, array $data): void { + app(FieldGroupValidator::class)->validate($group, $data); + $group->fill(Arr::only($data, [ 'name', 'slug', @@ -157,6 +163,8 @@ protected function syncFields(FieldGroup $group, array $rows): void $field = new Field; } + $previousName = $field->exists ? $field->name : null; + $field->fill([ 'field_group_id' => $group->getKey(), 'name' => (string) $row['name'], @@ -171,6 +179,12 @@ protected function syncFields(FieldGroup $group, array $rows): void ]); $field->save(); + + if ($previousName !== null && $previousName !== $field->name) { + $entities = $this->entitiesFromLocationRules($group->location_rules ?? []); + $this->fieldValuePurger->purgeForFieldName($previousName, $entities); + } + $retainedIds[] = $field->getKey(); $this->syncOptions($field, $row['options'] ?? []); diff --git a/packages/builder/src/Services/FieldGroupValidator.php b/packages/builder/src/Services/FieldGroupValidator.php new file mode 100644 index 0000000000..7566c0c1c5 --- /dev/null +++ b/packages/builder/src/Services/FieldGroupValidator.php @@ -0,0 +1,121 @@ + $data + */ + public function validate(FieldGroup $group, array $data): void + { + $locationRules = $this->fieldGroupPersistence->resolveLocationRules($data); + $entities = $this->fieldGroupPersistence->entitiesFromLocationRules($locationRules); + $fieldRows = $data['fields'] ?? []; + + if ($entities === [] || $fieldRows === []) { + return; + } + + $messages = []; + + $messages = array_merge($messages, $this->internalDuplicateMessages($fieldRows)); + $messages = array_merge($messages, $this->externalConflictMessages($group, $entities, $fieldRows)); + + if ($messages !== []) { + throw ValidationException::withMessages($messages); + } + } + + /** + * @param list> $fieldRows + * @return array> + */ + protected function internalDuplicateMessages(array $fieldRows): array + { + $seen = []; + $messages = []; + + foreach ($fieldRows as $index => $row) { + $name = (string) ($row['name'] ?? ''); + + if ($name === '') { + continue; + } + + if (isset($seen[$name])) { + $messages["fields.{$index}.name"] = [ + __('builder::builder.validation.duplicate_field_name_internal', ['name' => $name]), + ]; + + continue; + } + + $seen[$name] = $index; + } + + return $messages; + } + + /** + * @param list $entities + * @param list> $fieldRows + * @return array> + */ + protected function externalConflictMessages(FieldGroup $group, array $entities, array $fieldRows): array + { + $namesByIndex = []; + + foreach ($fieldRows as $index => $row) { + if (filled($row['name'] ?? null)) { + $namesByIndex[$index] = (string) $row['name']; + } + } + + if ($namesByIndex === []) { + return []; + } + + $otherGroups = FieldGroup::query() + ->active() + ->when($group->exists, fn ($query) => $query->whereKeyNot($group->getKey())) + ->with('fields') + ->get(); + + $messages = []; + + foreach ($otherGroups as $otherGroup) { + $otherEntities = $this->fieldGroupPersistence->entitiesFromLocationRules($otherGroup->location_rules ?? []); + + if (array_intersect($entities, $otherEntities) === []) { + continue; + } + + foreach ($otherGroup->fields as $otherField) { + foreach ($namesByIndex as $index => $name) { + if ($name !== $otherField->name) { + continue; + } + + $messages["fields.{$index}.name"] = [ + __('builder::builder.validation.duplicate_field_name', [ + 'name' => $name, + 'group' => $otherGroup->name, + ]), + ]; + } + } + } + + return $messages; + } +} diff --git a/packages/builder/src/Services/FieldValuePurger.php b/packages/builder/src/Services/FieldValuePurger.php new file mode 100644 index 0000000000..e8580fbbed --- /dev/null +++ b/packages/builder/src/Services/FieldValuePurger.php @@ -0,0 +1,33 @@ +where('entity', $entity) + ->where('record_id', $recordId) + ->delete(); + } + + /** + * @param list $entities + */ + public function purgeForFieldName(string $fieldName, array $entities): void + { + if ($entities === []) { + return; + } + + FieldValue::query() + ->where('field_name', $fieldName) + ->whereIn('entity', $entities) + ->delete(); + } +} diff --git a/packages/builder/src/Storage/TypedValueDriver.php b/packages/builder/src/Storage/TypedValueDriver.php index 5c2d086eee..0019fd541d 100644 --- a/packages/builder/src/Storage/TypedValueDriver.php +++ b/packages/builder/src/Storage/TypedValueDriver.php @@ -8,6 +8,7 @@ use Illuminate\Support\Collection; use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Support\TypedValueColumns; class TypedValueDriver implements ValueStore @@ -33,7 +34,7 @@ public function load(string $entity, Model $record, Collection $fields): array foreach ($fields as $field) { $row = $rows->get($field->name); - if ($row === null) { + if ($row === null || $field->type === 'password') { continue; } @@ -51,7 +52,13 @@ public function save(string $entity, Model $record, array $values, Collection $f continue; } - $cast = $this->fieldTypeRegistry->get($field->type)->castValue($values[$field->name]); + $value = $values[$field->name]; + + if ($field->type !== 'password') { + OptionValueRules::assertValid($field, $value); + } + + $cast = $this->fieldTypeRegistry->get($field->type)->castValue($value); $columns = TypedValueColumns::attributesFor($field->type, $cast); FieldValue::query()->updateOrCreate( diff --git a/packages/builder/src/Support/EntityModelDeletionRegistrar.php b/packages/builder/src/Support/EntityModelDeletionRegistrar.php new file mode 100644 index 0000000000..a5b911ec03 --- /dev/null +++ b/packages/builder/src/Support/EntityModelDeletionRegistrar.php @@ -0,0 +1,37 @@ +> */ + protected static array $registeredModels = []; + + public function __construct( + protected EntityRegistry $entityRegistry, + protected FieldValuePurger $purger, + ) {} + + public function register(): void + { + foreach ($this->entityRegistry->all() as $entity => $definition) { + $modelClass = $this->entityRegistry->modelFor($entity); + + if ($modelClass === null || in_array($modelClass, self::$registeredModels, true)) { + continue; + } + + $modelClass::deleted(function (Model $record) use ($entity): void { + app(FieldValuePurger::class)->purgeForRecord($entity, $record->getKey()); + }); + + self::$registeredModels[] = $modelClass; + } + } +} diff --git a/packages/builder/src/Support/OptionValueRules.php b/packages/builder/src/Support/OptionValueRules.php new file mode 100644 index 0000000000..a02b2f9644 --- /dev/null +++ b/packages/builder/src/Support/OptionValueRules.php @@ -0,0 +1,123 @@ + + */ + public static function forField(FieldDefinition $field): array + { + if (! in_array($field->type, ['select', 'radio', 'multiselect', 'checkbox_list'], true)) { + return []; + } + + $allowed = self::allowedValues($field); + + if ($allowed === []) { + return []; + } + + if (in_array($field->type, ['multiselect', 'checkbox_list'], true)) { + return [ + 'array', + self::arrayValuesRule($allowed), + ]; + } + + return [ + self::scalarValueRule($allowed), + ]; + } + + public static function assertValid(FieldDefinition $field, mixed $value): void + { + if ($value === null || $value === '' || $value === []) { + return; + } + + $allowed = self::allowedValues($field); + + if ($allowed === []) { + return; + } + + if (in_array($field->type, ['multiselect', 'checkbox_list'], true)) { + if (! is_array($value)) { + throw new \InvalidArgumentException(__('builder::builder.validation.invalid_option')); + } + + foreach ($value as $item) { + if (! in_array((string) $item, $allowed, true)) { + throw new \InvalidArgumentException(__('builder::builder.validation.invalid_option')); + } + } + + return; + } + + if (! in_array((string) $value, $allowed, true)) { + throw new \InvalidArgumentException(__('builder::builder.validation.invalid_option')); + } + } + + /** + * @return list + */ + public static function allowedValues(FieldDefinition $field): array + { + return collect($field->options) + ->pluck('value') + ->map(fn (mixed $value): string => (string) $value) + ->values() + ->all(); + } + + /** + * @param list $allowed + */ + public static function scalarValueRule(array $allowed): Closure + { + return function (string $attribute, mixed $value, Closure $fail) use ($allowed): void { + if ($value === null || $value === '') { + return; + } + + if (! in_array((string) $value, $allowed, true)) { + $fail(__('builder::builder.validation.invalid_option')); + } + }; + } + + /** + * @param list $allowed + */ + public static function arrayValuesRule(array $allowed): Closure + { + return function (string $attribute, mixed $value, Closure $fail) use ($allowed): void { + if ($value === null) { + return; + } + + if (! is_array($value)) { + $fail(__('builder::builder.validation.invalid_option')); + + return; + } + + foreach ($value as $item) { + if (! in_array((string) $item, $allowed, true)) { + $fail(__('builder::builder.validation.invalid_option')); + + return; + } + } + }; + } +} diff --git a/packages/builder/tests/Unit/FieldValueIntegrityTest.php b/packages/builder/tests/Unit/FieldValueIntegrityTest.php new file mode 100644 index 0000000000..b942b4865c --- /dev/null +++ b/packages/builder/tests/Unit/FieldValueIntegrityTest.php @@ -0,0 +1,183 @@ +createItemsTable(); + + config()->set('builder.entities', [ + 'item' => ['resource' => TestItemResource::class], + ]); +}); + +it('purges values when a record is deleted', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + 'field_name' => 'color', + 'value_string' => 'red', + ]); + + app(\Moox\Builder\Support\EntityModelDeletionRegistrar::class)->register(); + + $record->delete(); + + expect(FieldValue::query()->count())->toBe(0); +}); + +it('purges values when a field is deleted', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Specs', + 'slug' => 'specs', + 'active' => true, + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + ]); + + $field = Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'color', + 'label' => 'Color', + 'type' => 'text', + ]); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => 1, + 'field_name' => 'color', + 'value_string' => 'red', + ]); + + $field->delete(); + + expect(FieldValue::query()->count())->toBe(0); +}); + +it('purges values when a field is renamed during sync', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Specs', + 'slug' => 'specs', + 'active' => true, + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + ]); + + $field = Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'color', + 'label' => 'Color', + 'type' => 'text', + ]); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => 1, + 'field_name' => 'color', + 'value_string' => 'red', + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Specs', + 'slug' => 'specs', + 'active' => true, + 'target_entities' => ['item'], + 'fields' => [[ + 'id' => $field->getKey(), + 'name' => 'farbe', + 'label' => 'Farbe', + 'type' => 'text', + ]], + ]); + + expect(FieldValue::query()->where('field_name', 'color')->count())->toBe(0); +}); + +it('rejects duplicate field names across groups for the same entity', function (): void { + FieldGroup::query()->create([ + 'name' => 'Existing', + 'slug' => 'existing', + 'active' => true, + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + ])->fields()->create([ + 'name' => 'vin', + 'label' => 'VIN', + 'type' => 'text', + ]); + + $group = new FieldGroup; + + expect(fn () => app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'New', + 'slug' => 'new', + 'active' => true, + 'target_entities' => ['item'], + 'fields' => [[ + 'name' => 'vin', + 'label' => 'VIN duplicate', + 'type' => 'text', + ]], + ]))->toThrow(ValidationException::class); +}); + +it('hashes password values on save', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + $driver = app(\Moox\Builder\Storage\TypedValueDriver::class); + + $driver->save('item', $record, [ + 'secret' => 'plain-text', + ], collect([ + new \Moox\Builder\Data\FieldDefinition('secret', 'Secret', 'password'), + ])); + + $stored = FieldValue::query()->forRecord('item', $record->getKey())->first(); + + expect($stored?->value_string)->not->toBe('plain-text') + ->and(password_verify('plain-text', (string) $stored?->value_string))->toBeTrue(); +}); + +it('does not load password values back into forms', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + 'field_name' => 'secret', + 'value_string' => bcrypt('hidden'), + ]); + + $loaded = app(\Moox\Builder\Storage\TypedValueDriver::class)->load( + 'item', + $record, + collect([new \Moox\Builder\Data\FieldDefinition('secret', 'Secret', 'password')]), + ); + + expect($loaded)->toBe([]); +}); + +it('purges values for a record via purger service', function (): void { + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => 99, + 'field_name' => 'color', + 'value_string' => 'red', + ]); + + app(FieldValuePurger::class)->purgeForRecord('item', 99); + + expect(FieldValue::query()->count())->toBe(0); +}); diff --git a/packages/builder/tests/Unit/OptionValueRulesTest.php b/packages/builder/tests/Unit/OptionValueRulesTest.php new file mode 100644 index 0000000000..2b1e408ebb --- /dev/null +++ b/packages/builder/tests/Unit/OptionValueRulesTest.php @@ -0,0 +1,57 @@ + 'A', 'value' => '0'], + ['label' => 'B', 'value' => 'kein value'], + ], + ); + + OptionValueRules::assertValid($field, '0'); + + expect(fn () => OptionValueRules::assertValid($field, 'invalid')) + ->toThrow(InvalidArgumentException::class); +}); + +it('rejects invalid array option values', function (): void { + $field = new FieldDefinition( + name: 'tags', + label: 'Tags', + type: 'checkbox_list', + options: [ + ['label' => 'A', 'value' => '0'], + ['label' => 'B', 'value' => 'kein value'], + ], + ); + + OptionValueRules::assertValid($field, ['0', 'kein value']); + + expect(fn () => OptionValueRules::assertValid($field, ['value'])) + ->toThrow(InvalidArgumentException::class); +}); + +it('builds validation rules for option fields', function (): void { + $field = new FieldDefinition( + name: 'tags', + label: 'Tags', + type: 'multiselect', + options: [ + ['label' => 'A', 'value' => 'a'], + ], + ); + + expect(OptionValueRules::forField($field))->toHaveCount(2); +}); From dcbd5ca24c4d7be4da1c717dea7bf4c281ac346d Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:13:12 +0000 Subject: [PATCH 004/105] Fix styling --- packages/builder/src/BuilderServiceProvider.php | 4 ++-- packages/builder/src/Compiler/SchemaCompiler.php | 2 +- .../tests/Unit/FieldValueIntegrityTest.php | 16 ++++++++++------ .../builder/tests/Unit/OptionValueRulesTest.php | 3 ++- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index 3370409fb7..ab6b255655 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -28,10 +28,10 @@ use Moox\Builder\Models\FieldOption; use Moox\Builder\Observers\InvalidateDefinitionCacheObserver; use Moox\Builder\Observers\PurgeFieldValuesObserver; -use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Registry\EntityRegistry; -use Moox\Builder\Support\EntityModelDeletionRegistrar; +use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Storage\ValueStoreResolver; +use Moox\Builder\Support\EntityModelDeletionRegistrar; use Moox\Core\MooxServiceProvider; use Spatie\LaravelPackageTools\Package; diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index ffd691e65a..8232fa6c8c 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -12,8 +12,8 @@ use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Services\CustomFieldsManager; -use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Storage\ValueStoreResolver; +use Moox\Builder\Support\OptionValueRules; class SchemaCompiler { diff --git a/packages/builder/tests/Unit/FieldValueIntegrityTest.php b/packages/builder/tests/Unit/FieldValueIntegrityTest.php index b942b4865c..c360b1a724 100644 --- a/packages/builder/tests/Unit/FieldValueIntegrityTest.php +++ b/packages/builder/tests/Unit/FieldValueIntegrityTest.php @@ -6,16 +6,20 @@ require_once __DIR__.'/../Support/TestItem.php'; require_once __DIR__.'/../Support/TestItemResource.php'; -uses(Moox\Builder\Tests\TestCase::class); +uses(TestCase::class); use Illuminate\Validation\ValidationException; +use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldValue; use Moox\Builder\Services\FieldGroupPersistence; use Moox\Builder\Services\FieldValuePurger; +use Moox\Builder\Storage\TypedValueDriver; +use Moox\Builder\Support\EntityModelDeletionRegistrar; use Moox\Builder\Tests\Support\TestItem; use Moox\Builder\Tests\Support\TestItemResource; +use Moox\Builder\Tests\TestCase; beforeEach(function (): void { $this->createItemsTable(); @@ -35,7 +39,7 @@ 'value_string' => 'red', ]); - app(\Moox\Builder\Support\EntityModelDeletionRegistrar::class)->register(); + app(EntityModelDeletionRegistrar::class)->register(); $record->delete(); @@ -136,12 +140,12 @@ it('hashes password values on save', function (): void { $record = TestItem::query()->create(['title' => 'Demo']); - $driver = app(\Moox\Builder\Storage\TypedValueDriver::class); + $driver = app(TypedValueDriver::class); $driver->save('item', $record, [ 'secret' => 'plain-text', ], collect([ - new \Moox\Builder\Data\FieldDefinition('secret', 'Secret', 'password'), + new FieldDefinition('secret', 'Secret', 'password'), ])); $stored = FieldValue::query()->forRecord('item', $record->getKey())->first(); @@ -160,10 +164,10 @@ 'value_string' => bcrypt('hidden'), ]); - $loaded = app(\Moox\Builder\Storage\TypedValueDriver::class)->load( + $loaded = app(TypedValueDriver::class)->load( 'item', $record, - collect([new \Moox\Builder\Data\FieldDefinition('secret', 'Secret', 'password')]), + collect([new FieldDefinition('secret', 'Secret', 'password')]), ); expect($loaded)->toBe([]); diff --git a/packages/builder/tests/Unit/OptionValueRulesTest.php b/packages/builder/tests/Unit/OptionValueRulesTest.php index 2b1e408ebb..7abb182894 100644 --- a/packages/builder/tests/Unit/OptionValueRulesTest.php +++ b/packages/builder/tests/Unit/OptionValueRulesTest.php @@ -4,10 +4,11 @@ require_once __DIR__.'/../TestCase.php'; -uses(Moox\Builder\Tests\TestCase::class); +uses(TestCase::class); use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Support\OptionValueRules; +use Moox\Builder\Tests\TestCase; it('rejects invalid scalar option values', function (): void { $field = new FieldDefinition( From ff28baf36a0fa265539265bec48154ee8d36edd6 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:24:08 +0200 Subject: [PATCH 005/105] new fieldtypes --- packages/builder/config/builder.php | 6 - .../database/seeders/BuilderSeeder.php | 129 +++++++++++++++++- .../builder/resources/lang/de/builder.php | 10 ++ .../builder/resources/lang/en/builder.php | 10 ++ .../builder/src/BuilderServiceProvider.php | 9 +- .../builder/src/Compiler/SchemaCompiler.php | 9 +- .../src/FieldTypes/Capabilities/MaxLength.php | 2 +- .../src/FieldTypes/Capabilities/MaxValue.php | 3 +- .../src/FieldTypes/Capabilities/MinValue.php | 3 +- .../src/FieldTypes/Capabilities/Step.php | 3 +- .../Concerns/BuildsOptionComponents.php | 7 +- packages/builder/src/FieldTypes/FieldType.php | 5 - .../FieldTypes/Types/ButtonGroupFieldType.php | 54 ++++++++ .../src/FieldTypes/Types/LinkFieldType.php | 102 ++++++++++++++ .../src/FieldTypes/Types/RangeFieldType.php | 83 +++++++++++ .../FieldTypes/Types/RichTextFieldType.php | 38 ++++++ .../src/Services/CustomFieldsManager.php | 74 +++++++++- .../builder/src/Storage/TypedValueDriver.php | 74 ---------- packages/builder/src/Storage/ValueStore.php | 24 ---- .../src/Storage/ValueStoreResolver.php | 26 ---- .../builder/src/Support/OptionValueRules.php | 2 +- .../builder/src/Support/TypedValueColumns.php | 7 +- packages/item/database/seeders/ItemSeeder.php | 5 + 23 files changed, 529 insertions(+), 156 deletions(-) create mode 100644 packages/builder/src/FieldTypes/Types/ButtonGroupFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/LinkFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/RangeFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/RichTextFieldType.php delete mode 100644 packages/builder/src/Storage/TypedValueDriver.php delete mode 100644 packages/builder/src/Storage/ValueStore.php delete mode 100644 packages/builder/src/Storage/ValueStoreResolver.php diff --git a/packages/builder/config/builder.php b/packages/builder/config/builder.php index 2859e68e46..37cd82399b 100644 --- a/packages/builder/config/builder.php +++ b/packages/builder/config/builder.php @@ -2,13 +2,7 @@ declare(strict_types=1); -use Moox\Builder\Storage\TypedValueDriver; - return [ - 'default_driver' => env('BUILDER_DRIVER', 'typed'), - 'drivers' => [ - 'typed' => TypedValueDriver::class, - ], 'navigation_group' => env('BUILDER_NAVIGATION_GROUP', 'Felder'), /* diff --git a/packages/builder/database/seeders/BuilderSeeder.php b/packages/builder/database/seeders/BuilderSeeder.php index a6ce7abcd2..03b78f08f8 100644 --- a/packages/builder/database/seeders/BuilderSeeder.php +++ b/packages/builder/database/seeders/BuilderSeeder.php @@ -65,13 +65,62 @@ protected function seedFahrzeugdatenGroup(): void 'displayFormat' => 'd.m.Y', ], ]); + + $this->upsertField($group, [ + 'name' => 'motorleistung', + 'label' => 'Motorleistung', + 'type' => 'range', + 'sort' => 3, + 'config' => [ + 'min' => 50, + 'max' => 500, + 'step' => 5, + 'suffix' => 'PS', + 'helperText' => 'value_decimal — Schieberegler für Leistung', + ], + ]); + + $this->upsertField($group, [ + 'name' => 'getriebe', + 'label' => 'Getriebe', + 'type' => 'button_group', + 'sort' => 4, + 'config' => [ + 'default' => 'automatic', + 'helperText' => 'value_string — Button-Gruppe', + ], + 'options' => [ + ['label' => 'Schaltgetriebe', 'value' => 'manual'], + ['label' => 'Automatik', 'value' => 'automatic'], + ], + ]); + + $this->upsertField($group, [ + 'name' => 'inserat-url', + 'label' => 'Inserat-Link', + 'type' => 'link', + 'sort' => 5, + 'config' => [ + 'helperText' => 'value_json — URL, Bezeichnung und Ziel', + ], + ]); + + $this->upsertField($group, [ + 'name' => 'fahrzeugbeschreibung', + 'label' => 'Fahrzeugbeschreibung', + 'type' => 'rich_text', + 'sort' => 6, + 'config' => [ + 'helperText' => 'value_text — formatierter Langtext', + ], + ]); } protected function seedFieldTypeShowcaseGroup(): void { $group = $this->upsertGroup( slug: 'feldtyp-showcase', - name: 'Feldtyp-Showcase (alle Typen)', + name: 'Feldtyp-Showcase (alle 19 Typen)', sort: 10, ); @@ -267,6 +316,56 @@ protected function seedFieldTypeShowcaseGroup(): void 'helperText' => 'value_string — Hex-Farbwert', ], ]); + + $this->upsertField($group, [ + 'name' => 'demo-range', + 'label' => 'Bereich (Slider)', + 'type' => 'range', + 'sort' => 15, + 'config' => [ + 'min' => 0, + 'max' => 100, + 'step' => 5, + 'default' => '50', + 'helperText' => 'value_decimal — Schieberegler', + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-button-group', + 'label' => 'Button-Gruppe', + 'type' => 'button_group', + 'sort' => 16, + 'config' => [ + 'default' => 'used', + 'helperText' => 'value_string — Toggle-Buttons', + ], + 'options' => [ + ['label' => 'Neuwagen', 'value' => 'new'], + ['label' => 'Gebraucht', 'value' => 'used'], + ['label' => 'Vorführwagen', 'value' => 'demo'], + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-link', + 'label' => 'Link', + 'type' => 'link', + 'sort' => 17, + 'config' => [ + 'helperText' => 'value_json — URL, Bezeichnung und Ziel', + ], + ]); + + $this->upsertField($group, [ + 'name' => 'demo-rich-text', + 'label' => 'Rich Text', + 'type' => 'rich_text', + 'sort' => 18, + 'config' => [ + 'helperText' => 'value_text — formatierter HTML-Text', + ], + ]); } /** @@ -282,6 +381,20 @@ protected function seedDemoValuesForFirstItem(): void 'fahrzeugtyp-modell' => ['type' => 'text', 'value' => 'Golf GTI'], 'bruttolistenpreis' => ['type' => 'number', 'value' => 32990], 'erstzulassung' => ['type' => 'date', 'value' => '2022-06-15'], + 'motorleistung' => ['type' => 'range', 'value' => 245], + 'getriebe' => ['type' => 'button_group', 'value' => 'automatic'], + 'inserat-url' => [ + 'type' => 'link', + 'value' => [ + 'url' => 'https://moox.org/items/golf-gti', + 'label' => 'Inserat online ansehen', + 'target' => '_blank', + ], + ], + 'fahrzeugbeschreibung' => [ + 'type' => 'rich_text', + 'value' => '

Golf GTI in sehr gutem Zustand mit vollständiger Ausstattung.

  • LED-Scheinwerfer
  • Navigationssystem
  • Sitzheizung
', + ], 'demo-text' => ['type' => 'text', 'value' => 'REF-DEMO-001'], 'demo-textarea' => ['type' => 'textarea', 'value' => "Zeile 1: Ausstattung\nZeile 2: Zustand sehr gut\nZeile 3: Scheckheftgepflegt"], 'demo-number' => ['type' => 'number', 'value' => 28990.50], @@ -296,6 +409,20 @@ protected function seedDemoValuesForFirstItem(): void 'demo-datetime' => ['type' => 'datetime', 'value' => '2024-03-15 14:30:00'], 'demo-time' => ['type' => 'time', 'value' => '14:30'], 'demo-color' => ['type' => 'color', 'value' => '#22c55e'], + 'demo-range' => ['type' => 'range', 'value' => 75], + 'demo-button-group' => ['type' => 'button_group', 'value' => 'used'], + 'demo-link' => [ + 'type' => 'link', + 'value' => [ + 'url' => 'https://moox.org', + 'label' => 'Mehr auf moox.org', + 'target' => '_blank', + ], + ], + 'demo-rich-text' => [ + 'type' => 'rich_text', + 'value' => '

Dies ist ein formatierter Demo-Text mit Rich-Text-Editor.

', + ], ]; foreach ($demos as $fieldName => $demo) { diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 50f41c79bb..38b92d9ed3 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -44,6 +44,7 @@ 'text' => 'Text (kurz)', 'textarea' => 'Text (mehrzeilig)', 'number' => 'Zahl', + 'range' => 'Bereich', 'email' => 'E-Mail', 'url' => 'URL', 'password' => 'Passwort', @@ -51,11 +52,20 @@ 'multiselect' => 'Mehrfachauswahl', 'checkbox_list' => 'Checkbox-Liste', 'radio' => 'Optionsfelder', + 'button_group' => 'Button-Gruppe', 'toggle' => 'Schalter', 'date' => 'Datum', 'datetime' => 'Datum & Uhrzeit', 'time' => 'Uhrzeit', 'color' => 'Farbe', + 'link' => 'Link', + 'rich_text' => 'Rich Text', + ], + + 'link' => [ + 'url' => 'URL', + 'label' => 'Bezeichnung', + 'opens_in_new_tab' => 'In neuem Tab öffnen', ], 'capabilities' => [ diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index e449bc6933..8af72b6427 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -44,6 +44,7 @@ 'text' => 'Text (short)', 'textarea' => 'Text (multiline)', 'number' => 'Number', + 'range' => 'Range', 'email' => 'Email', 'url' => 'URL', 'password' => 'Password', @@ -51,11 +52,20 @@ 'multiselect' => 'Multi select', 'checkbox_list' => 'Checkbox list', 'radio' => 'Radio buttons', + 'button_group' => 'Button group', 'toggle' => 'Toggle', 'date' => 'Date', 'datetime' => 'Date & time', 'time' => 'Time', 'color' => 'Color', + 'link' => 'Link', + 'rich_text' => 'Rich text', + ], + + 'link' => [ + 'url' => 'URL', + 'label' => 'Label', + 'opens_in_new_tab' => 'Open in new tab', ], 'capabilities' => [ diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index ab6b255655..56379be212 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -8,11 +8,14 @@ use Illuminate\Support\Facades\Event; use Moox\Builder\FieldTypes\FieldType; use Moox\Builder\FieldTypes\Types\CheckboxListFieldType; +use Moox\Builder\FieldTypes\Types\ButtonGroupFieldType; use Moox\Builder\FieldTypes\Types\ColorFieldType; use Moox\Builder\FieldTypes\Types\DateFieldType; use Moox\Builder\FieldTypes\Types\DatetimeFieldType; use Moox\Builder\FieldTypes\Types\EmailFieldType; +use Moox\Builder\FieldTypes\Types\LinkFieldType; use Moox\Builder\FieldTypes\Types\MultiselectFieldType; +use Moox\Builder\FieldTypes\Types\RangeFieldType; use Moox\Builder\FieldTypes\Types\NumberFieldType; use Moox\Builder\FieldTypes\Types\PasswordFieldType; use Moox\Builder\FieldTypes\Types\RadioFieldType; @@ -21,6 +24,7 @@ use Moox\Builder\FieldTypes\Types\TextFieldType; use Moox\Builder\FieldTypes\Types\TimeFieldType; use Moox\Builder\FieldTypes\Types\ToggleFieldType; +use Moox\Builder\FieldTypes\Types\RichTextFieldType; use Moox\Builder\FieldTypes\Types\UrlFieldType; use Moox\Builder\Listeners\PersistCustomFields; use Moox\Builder\Models\Field; @@ -63,7 +67,6 @@ public function packageRegistered(): void return $registry; }); - $this->app->singleton(ValueStoreResolver::class); $this->app->singleton(EntityRegistry::class); } @@ -102,6 +105,10 @@ protected function defaultFieldTypes(): array new DatetimeFieldType, new TimeFieldType, new ColorFieldType, + new RangeFieldType, + new ButtonGroupFieldType, + new LinkFieldType, + new RichTextFieldType, ]; } } diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 8232fa6c8c..d42b53d6c5 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -12,14 +12,12 @@ use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Services\CustomFieldsManager; -use Moox\Builder\Storage\ValueStoreResolver; use Moox\Builder\Support\OptionValueRules; class SchemaCompiler { public function __construct( protected FieldTypeRegistry $fieldTypeRegistry, - protected ValueStoreResolver $valueStoreResolver, protected CustomFieldsManager $customFieldsManager, ) {} @@ -110,17 +108,16 @@ protected function compileField(FieldDefinition $field, ?string $resourceClass = { $fieldType = $this->fieldTypeRegistry->get($field->type); $component = $fieldType->formComponent($field); - $valueStore = $this->valueStoreResolver->for(); $entity = $resourceClass !== null ? $this->customFieldsManager->locationContextForResource($resourceClass)->entity : null; - return $component->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $valueStore, $entity): void { - if ($field->type === 'password' || filled($state) || $record === null || $entity === null) { + return $component->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $entity): void { + if ($field->type === 'password' || $record === null || $entity === null) { return; } - $values = $valueStore->load( + $values = $this->customFieldsManager->loadValues( $entity, $record, collect([$field]), diff --git a/packages/builder/src/FieldTypes/Capabilities/MaxLength.php b/packages/builder/src/FieldTypes/Capabilities/MaxLength.php index 5d72a3dd20..14e196cfae 100644 --- a/packages/builder/src/FieldTypes/Capabilities/MaxLength.php +++ b/packages/builder/src/FieldTypes/Capabilities/MaxLength.php @@ -36,7 +36,7 @@ public function apply(Component $component, FieldDefinition $field): Component return $component; } - if ($component instanceof TextInput || $component instanceof Textarea) { + if ($component instanceof TextInput || $component instanceof Textarea || method_exists($component, 'maxLength')) { $component->maxLength((int) $field->config['maxLength']); } diff --git a/packages/builder/src/FieldTypes/Capabilities/MaxValue.php b/packages/builder/src/FieldTypes/Capabilities/MaxValue.php index 1f737095b4..7be758ed83 100644 --- a/packages/builder/src/FieldTypes/Capabilities/MaxValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/MaxValue.php @@ -5,6 +5,7 @@ namespace Moox\Builder\FieldTypes\Capabilities; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Slider; use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; @@ -30,7 +31,7 @@ public function rules(FieldDefinition $field): array public function apply(Component $component, FieldDefinition $field): Component { - if ($component instanceof TextInput && isset($field->config['max'])) { + if (isset($field->config['max']) && ($component instanceof TextInput || $component instanceof Slider)) { $component->maxValue($field->config['max']); } diff --git a/packages/builder/src/FieldTypes/Capabilities/MinValue.php b/packages/builder/src/FieldTypes/Capabilities/MinValue.php index a71a5ba106..c97e8a7e62 100644 --- a/packages/builder/src/FieldTypes/Capabilities/MinValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/MinValue.php @@ -5,6 +5,7 @@ namespace Moox\Builder\FieldTypes\Capabilities; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Slider; use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; @@ -30,7 +31,7 @@ public function rules(FieldDefinition $field): array public function apply(Component $component, FieldDefinition $field): Component { - if ($component instanceof TextInput && isset($field->config['min'])) { + if (isset($field->config['min']) && ($component instanceof TextInput || $component instanceof Slider)) { $component->minValue($field->config['min']); } diff --git a/packages/builder/src/FieldTypes/Capabilities/Step.php b/packages/builder/src/FieldTypes/Capabilities/Step.php index ae05962dad..561f7de6fa 100644 --- a/packages/builder/src/FieldTypes/Capabilities/Step.php +++ b/packages/builder/src/FieldTypes/Capabilities/Step.php @@ -5,6 +5,7 @@ namespace Moox\Builder\FieldTypes\Capabilities; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Slider; use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; @@ -21,7 +22,7 @@ public function builderFields(): array public function apply(Component $component, FieldDefinition $field): Component { - if ($component instanceof TextInput && isset($field->config['step'])) { + if (isset($field->config['step']) && ($component instanceof TextInput || $component instanceof Slider)) { $component->step($field->config['step']); } diff --git a/packages/builder/src/FieldTypes/Concerns/BuildsOptionComponents.php b/packages/builder/src/FieldTypes/Concerns/BuildsOptionComponents.php index 1d5f23d341..ca6f15dcfb 100644 --- a/packages/builder/src/FieldTypes/Concerns/BuildsOptionComponents.php +++ b/packages/builder/src/FieldTypes/Concerns/BuildsOptionComponents.php @@ -7,6 +7,7 @@ use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\Radio; use Filament\Forms\Components\Select; +use Filament\Forms\Components\ToggleButtons; use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; @@ -30,8 +31,12 @@ protected function applyOptions(Component $component, FieldDefinition $field): C { $options = $this->optionsMap($field); - if ($component instanceof Select || $component instanceof Radio || $component instanceof CheckboxList) { + if ($component instanceof Select || $component instanceof Radio || $component instanceof CheckboxList || $component instanceof ToggleButtons) { $component->options($options); + + if ($options !== []) { + $component->in(array_keys($options)); + } } return $component; diff --git a/packages/builder/src/FieldTypes/FieldType.php b/packages/builder/src/FieldTypes/FieldType.php index 962b50109e..87eb42fef7 100644 --- a/packages/builder/src/FieldTypes/FieldType.php +++ b/packages/builder/src/FieldTypes/FieldType.php @@ -7,7 +7,6 @@ use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\FieldTypes\Capabilities\Capability; -use Moox\Builder\Support\OptionValueRules; abstract class FieldType { @@ -52,10 +51,6 @@ protected function applyCapabilitiesAndValidation(Component $component, FieldDef $rules = array_merge($rules, $this->additionalRules($field)); - if ($this->hasOptions()) { - $rules = array_merge($rules, OptionValueRules::forField($field)); - } - if ($rules !== []) { $component->rules($rules); } diff --git a/packages/builder/src/FieldTypes/Types/ButtonGroupFieldType.php b/packages/builder/src/FieldTypes/Types/ButtonGroupFieldType.php new file mode 100644 index 0000000000..7ea7138db8 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/ButtonGroupFieldType.php @@ -0,0 +1,54 @@ +name) + ->label($field->label) + ->inline(); + + $component = $this->applyOptions($component, $field); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/LinkFieldType.php b/packages/builder/src/FieldTypes/Types/LinkFieldType.php new file mode 100644 index 0000000000..72ffba0175 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/LinkFieldType.php @@ -0,0 +1,102 @@ + $url, + 'label' => $label, + 'opens_in_new_tab' => $opensInNewTab, + ]; + } + + public function capabilities(): array + { + return [ + HelperText::class, + ]; + } + + public function formComponent(FieldDefinition $field): Component + { + $url = TextInput::make('url') + ->label(__('builder::builder.link.url')) + ->url() + ->nullable(); + + if (($field->validation['required'] ?? false) === true) { + $url->required(); + } else { + $url->nullable(); + } + + $fieldset = Fieldset::make($field->label) + ->schema([ + $url, + TextInput::make('label') + ->label(__('builder::builder.link.label')), + Toggle::make('opens_in_new_tab') + ->label(__('builder::builder.link.opens_in_new_tab')), + ]) + ->statePath($field->name) + ->columnSpanFull(); + + return $fieldset; + } + + protected function additionalRules(FieldDefinition $field): array + { + // Validation rules in `$field->validation['rules']` target the field state, + // which for `link` is an array. We validate the nested URL component instead. + return []; + } +} diff --git a/packages/builder/src/FieldTypes/Types/RangeFieldType.php b/packages/builder/src/FieldTypes/Types/RangeFieldType.php new file mode 100644 index 0000000000..aac86ca892 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/RangeFieldType.php @@ -0,0 +1,83 @@ +name) + ->label($field->label) + ->tooltips(true) + ->required(false) + ->decimalPlaces($this->decimalPlacesForStep($field)); + + return $this->applyCapabilitiesAndValidation($component, $field); + } + + protected function decimalPlacesForStep(FieldDefinition $field): int + { + $step = $field->config['step'] ?? null; + + if ($step === null || $step === '') { + return 0; + } + + $step = (float) $step; + + if ($step === floor($step)) { + return 0; + } + + $fraction = explode('.', (string) $step)[1] ?? ''; + + return strlen(rtrim($fraction, '0')) ?: strlen($fraction); + } +} diff --git a/packages/builder/src/FieldTypes/Types/RichTextFieldType.php b/packages/builder/src/FieldTypes/Types/RichTextFieldType.php new file mode 100644 index 0000000000..5662d0399d --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/RichTextFieldType.php @@ -0,0 +1,38 @@ +name) + ->label($field->label); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index 7dbaf20b02..337cd02dbc 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -10,15 +10,18 @@ use Moox\Builder\Concerns\HasCustomFields; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Data\LocationContext; +use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Registry\EntityRegistry; -use Moox\Builder\Storage\ValueStoreResolver; +use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Support\OptionValueRules; +use Moox\Builder\Support\TypedValueColumns; class CustomFieldsManager { public function __construct( protected DefinitionRegistry $definitionRegistry, - protected ValueStoreResolver $valueStoreResolver, + protected FieldTypeRegistry $fieldTypeRegistry, protected EntityRegistry $entityRegistry, ) {} @@ -61,13 +64,45 @@ public function loadFormData(string $resourceClass, Model $record): array return []; } - return $this->valueStoreResolver->for()->load( + return $this->loadValues( $this->locationContextForResource($resourceClass)->entity, $record, $fields, ); } + /** + * @param Collection $fields + * @return array + */ + public function loadValues(string $entity, Model $record, Collection $fields): array + { + if ($fields->isEmpty()) { + return []; + } + + $rows = FieldValue::query() + ->forRecord($entity, $record->getKey()) + ->whereIn('field_name', $fields->pluck('name')) + ->get() + ->keyBy('field_name'); + + $values = []; + + foreach ($fields as $field) { + $row = $rows->get($field->name); + + if ($row === null || $field->type === 'password') { + continue; + } + + $raw = TypedValueColumns::read($row, $field->type); + $values[$field->name] = $this->fieldTypeRegistry->get($field->type)->castValue($raw); + } + + return $values; + } + /** * @param class-string $resourceClass * @param array $data @@ -98,7 +133,7 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da return; } - $this->valueStoreResolver->for()->save( + $this->saveValues( $this->locationContextForResource($resourceClass)->entity, $record, $values, @@ -106,6 +141,37 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da ); } + /** + * @param array $values + * @param Collection $fields + */ + public function saveValues(string $entity, Model $record, array $values, Collection $fields): void + { + foreach ($fields as $field) { + if (! array_key_exists($field->name, $values)) { + continue; + } + + $value = $values[$field->name]; + + if ($field->type !== 'password') { + OptionValueRules::assertValid($field, $value); + } + + $cast = $this->fieldTypeRegistry->get($field->type)->castValue($value); + $columns = TypedValueColumns::attributesFor($field->type, $cast); + + FieldValue::query()->updateOrCreate( + [ + 'entity' => $entity, + 'record_id' => $record->getKey(), + 'field_name' => $field->name, + ], + $columns, + ); + } + } + /** * @param class-string $resourceClass */ diff --git a/packages/builder/src/Storage/TypedValueDriver.php b/packages/builder/src/Storage/TypedValueDriver.php deleted file mode 100644 index 0019fd541d..0000000000 --- a/packages/builder/src/Storage/TypedValueDriver.php +++ /dev/null @@ -1,74 +0,0 @@ -isEmpty()) { - return []; - } - - $rows = FieldValue::query() - ->forRecord($entity, $record->getKey()) - ->whereIn('field_name', $fields->pluck('name')) - ->get() - ->keyBy('field_name'); - - $values = []; - - foreach ($fields as $field) { - $row = $rows->get($field->name); - - if ($row === null || $field->type === 'password') { - continue; - } - - $raw = TypedValueColumns::read($row, $field->type); - $values[$field->name] = $this->fieldTypeRegistry->get($field->type)->castValue($raw); - } - - return $values; - } - - public function save(string $entity, Model $record, array $values, Collection $fields): void - { - foreach ($fields as $field) { - if (! array_key_exists($field->name, $values)) { - continue; - } - - $value = $values[$field->name]; - - if ($field->type !== 'password') { - OptionValueRules::assertValid($field, $value); - } - - $cast = $this->fieldTypeRegistry->get($field->type)->castValue($value); - $columns = TypedValueColumns::attributesFor($field->type, $cast); - - FieldValue::query()->updateOrCreate( - [ - 'entity' => $entity, - 'record_id' => $record->getKey(), - 'field_name' => $field->name, - ], - $columns, - ); - } - } -} diff --git a/packages/builder/src/Storage/ValueStore.php b/packages/builder/src/Storage/ValueStore.php deleted file mode 100644 index b82a1af0b7..0000000000 --- a/packages/builder/src/Storage/ValueStore.php +++ /dev/null @@ -1,24 +0,0 @@ - $fields - * @return array - */ - public function load(string $entity, Model $record, Collection $fields): array; - - /** - * @param array $values - * @param Collection $fields - */ - public function save(string $entity, Model $record, array $values, Collection $fields): void; -} diff --git a/packages/builder/src/Storage/ValueStoreResolver.php b/packages/builder/src/Storage/ValueStoreResolver.php deleted file mode 100644 index 53f64fc99c..0000000000 --- a/packages/builder/src/Storage/ValueStoreResolver.php +++ /dev/null @@ -1,26 +0,0 @@ -container->make($class); - } -} diff --git a/packages/builder/src/Support/OptionValueRules.php b/packages/builder/src/Support/OptionValueRules.php index a02b2f9644..ae6621d71c 100644 --- a/packages/builder/src/Support/OptionValueRules.php +++ b/packages/builder/src/Support/OptionValueRules.php @@ -14,7 +14,7 @@ final class OptionValueRules */ public static function forField(FieldDefinition $field): array { - if (! in_array($field->type, ['select', 'radio', 'multiselect', 'checkbox_list'], true)) { + if (! in_array($field->type, ['select', 'radio', 'button_group', 'multiselect', 'checkbox_list'], true)) { return []; } diff --git a/packages/builder/src/Support/TypedValueColumns.php b/packages/builder/src/Support/TypedValueColumns.php index 10b8690753..5797663ed2 100644 --- a/packages/builder/src/Support/TypedValueColumns.php +++ b/packages/builder/src/Support/TypedValueColumns.php @@ -27,12 +27,13 @@ public static function valueColumns(): array public static function columnForType(string $type): string { return match ($type) { - 'textarea' => 'value_text', - 'number' => 'value_decimal', + 'textarea', 'rich_text' => 'value_text', + 'number', 'range' => 'value_decimal', 'date' => 'value_date', 'datetime' => 'value_datetime', 'toggle' => 'value_boolean', - 'multiselect', 'checkbox_list' => 'value_json', + 'multiselect', 'checkbox_list', 'link' => 'value_json', + 'button_group' => 'value_string', default => 'value_string', }; } diff --git a/packages/item/database/seeders/ItemSeeder.php b/packages/item/database/seeders/ItemSeeder.php index 4526ee4f63..56bdd481bf 100644 --- a/packages/item/database/seeders/ItemSeeder.php +++ b/packages/item/database/seeders/ItemSeeder.php @@ -9,6 +9,7 @@ use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use Moox\Builder\Database\Seeders\BuilderSeeder; use Moox\Demo\Seeding\FormatsFakerLocaleText; use Moox\Demo\Seeding\ReportsMooxSeederProgress; use Moox\Demo\Seeding\RunsMooxDemoAssets; @@ -32,6 +33,10 @@ public function run(): void { $this->seed(); + if (class_exists(BuilderSeeder::class)) { + $this->call(BuilderSeeder::class); + } + if (class_exists(RunsMooxDemoAssets::class)) { RunsMooxDemoAssets::invoke($this); } From 429b88aa400c7d7e68045a6389e845a195961902 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:25:29 +0000 Subject: [PATCH 006/105] Fix styling --- packages/builder/src/BuilderServiceProvider.php | 7 +++---- packages/builder/src/FieldTypes/Capabilities/MaxValue.php | 2 +- packages/builder/src/FieldTypes/Capabilities/MinValue.php | 2 +- packages/builder/src/FieldTypes/Capabilities/Step.php | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index 56379be212..35a640734c 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -7,24 +7,24 @@ use Filament\Resources\Events\RecordSaved; use Illuminate\Support\Facades\Event; use Moox\Builder\FieldTypes\FieldType; -use Moox\Builder\FieldTypes\Types\CheckboxListFieldType; use Moox\Builder\FieldTypes\Types\ButtonGroupFieldType; +use Moox\Builder\FieldTypes\Types\CheckboxListFieldType; use Moox\Builder\FieldTypes\Types\ColorFieldType; use Moox\Builder\FieldTypes\Types\DateFieldType; use Moox\Builder\FieldTypes\Types\DatetimeFieldType; use Moox\Builder\FieldTypes\Types\EmailFieldType; use Moox\Builder\FieldTypes\Types\LinkFieldType; use Moox\Builder\FieldTypes\Types\MultiselectFieldType; -use Moox\Builder\FieldTypes\Types\RangeFieldType; use Moox\Builder\FieldTypes\Types\NumberFieldType; use Moox\Builder\FieldTypes\Types\PasswordFieldType; use Moox\Builder\FieldTypes\Types\RadioFieldType; +use Moox\Builder\FieldTypes\Types\RangeFieldType; +use Moox\Builder\FieldTypes\Types\RichTextFieldType; use Moox\Builder\FieldTypes\Types\SelectFieldType; use Moox\Builder\FieldTypes\Types\TextareaFieldType; use Moox\Builder\FieldTypes\Types\TextFieldType; use Moox\Builder\FieldTypes\Types\TimeFieldType; use Moox\Builder\FieldTypes\Types\ToggleFieldType; -use Moox\Builder\FieldTypes\Types\RichTextFieldType; use Moox\Builder\FieldTypes\Types\UrlFieldType; use Moox\Builder\Listeners\PersistCustomFields; use Moox\Builder\Models\Field; @@ -34,7 +34,6 @@ use Moox\Builder\Observers\PurgeFieldValuesObserver; use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Registry\FieldTypeRegistry; -use Moox\Builder\Storage\ValueStoreResolver; use Moox\Builder\Support\EntityModelDeletionRegistrar; use Moox\Core\MooxServiceProvider; use Spatie\LaravelPackageTools\Package; diff --git a/packages/builder/src/FieldTypes/Capabilities/MaxValue.php b/packages/builder/src/FieldTypes/Capabilities/MaxValue.php index 7be758ed83..0b0975507b 100644 --- a/packages/builder/src/FieldTypes/Capabilities/MaxValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/MaxValue.php @@ -4,8 +4,8 @@ namespace Moox\Builder\FieldTypes\Capabilities; -use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Slider; +use Filament\Forms\Components\TextInput; use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; diff --git a/packages/builder/src/FieldTypes/Capabilities/MinValue.php b/packages/builder/src/FieldTypes/Capabilities/MinValue.php index c97e8a7e62..6c2cfd0a2e 100644 --- a/packages/builder/src/FieldTypes/Capabilities/MinValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/MinValue.php @@ -4,8 +4,8 @@ namespace Moox\Builder\FieldTypes\Capabilities; -use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Slider; +use Filament\Forms\Components\TextInput; use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; diff --git a/packages/builder/src/FieldTypes/Capabilities/Step.php b/packages/builder/src/FieldTypes/Capabilities/Step.php index 561f7de6fa..9f6184f383 100644 --- a/packages/builder/src/FieldTypes/Capabilities/Step.php +++ b/packages/builder/src/FieldTypes/Capabilities/Step.php @@ -4,8 +4,8 @@ namespace Moox\Builder\FieldTypes\Capabilities; -use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Slider; +use Filament\Forms\Components\TextInput; use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; From 34864395cea9a6add125d1bcb9b8b6d6af2ab810 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:26:26 +0200 Subject: [PATCH 007/105] Purge custom field values when a field group is deleted --- .../builder/src/BuilderServiceProvider.php | 1 + .../Observers/PurgeFieldValuesObserver.php | 27 ++++++++++++++++++- .../builder/src/Services/FieldValuePurger.php | 13 +++++++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index 35a640734c..216a486f2c 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -72,6 +72,7 @@ public function packageRegistered(): void public function packageBooted(): void { FieldGroup::observe(InvalidateDefinitionCacheObserver::class); + FieldGroup::observe(PurgeFieldValuesObserver::class); Field::observe(InvalidateDefinitionCacheObserver::class); Field::observe(PurgeFieldValuesObserver::class); FieldOption::observe(InvalidateDefinitionCacheObserver::class); diff --git a/packages/builder/src/Observers/PurgeFieldValuesObserver.php b/packages/builder/src/Observers/PurgeFieldValuesObserver.php index 0f4d128448..355359e136 100644 --- a/packages/builder/src/Observers/PurgeFieldValuesObserver.php +++ b/packages/builder/src/Observers/PurgeFieldValuesObserver.php @@ -5,6 +5,7 @@ namespace Moox\Builder\Observers; use Moox\Builder\Models\Field; +use Moox\Builder\Models\FieldGroup; use Moox\Builder\Services\FieldGroupPersistence; use Moox\Builder\Services\FieldValuePurger; @@ -15,8 +16,32 @@ public function __construct( protected FieldGroupPersistence $fieldGroupPersistence, ) {} - public function deleted(Field $field): void + public function deleting(FieldGroup|Field $model): void { + if (! $model instanceof FieldGroup) { + return; + } + + $group = $model; + $group->loadMissing('fields'); + + $entities = $this->fieldGroupPersistence->entitiesFromLocationRules( + $group->location_rules ?? [], + ); + + $this->purger->purgeForFieldNames( + $group->fields->pluck('name')->all(), + $entities, + ); + } + + public function deleted(Field|FieldGroup $model): void + { + if (! $model instanceof Field) { + return; + } + + $field = $model; $field->loadMissing('fieldGroup'); if ($field->fieldGroup === null) { diff --git a/packages/builder/src/Services/FieldValuePurger.php b/packages/builder/src/Services/FieldValuePurger.php index e8580fbbed..e4a0d92d1a 100644 --- a/packages/builder/src/Services/FieldValuePurger.php +++ b/packages/builder/src/Services/FieldValuePurger.php @@ -21,12 +21,21 @@ public function purgeForRecord(string $entity, int|string $recordId): void */ public function purgeForFieldName(string $fieldName, array $entities): void { - if ($entities === []) { + $this->purgeForFieldNames([$fieldName], $entities); + } + + /** + * @param list $fieldNames + * @param list $entities + */ + public function purgeForFieldNames(array $fieldNames, array $entities): void + { + if ($fieldNames === [] || $entities === []) { return; } FieldValue::query() - ->where('field_name', $fieldName) + ->whereIn('field_name', $fieldNames) ->whereIn('entity', $entities) ->delete(); } From 727ebe07459e46a4c4e39d5d5d64c458f7af6741 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:56:15 +0200 Subject: [PATCH 008/105] new fields --- packages/builder/composer.json | 1 - packages/builder/config/builder.php | 23 -- .../database/seeders/BuilderSeeder.php | 340 ------------------ .../builder/resources/lang/de/builder.php | 13 +- .../builder/resources/lang/en/builder.php | 13 +- .../builder/src/BuilderServiceProvider.php | 4 + .../builder/src/Compiler/SchemaCompiler.php | 11 +- .../builder/src/Concerns/HasCustomFields.php | 33 +- .../FieldTypes/Capabilities/MessageBody.php | 26 ++ packages/builder/src/FieldTypes/FieldType.php | 5 + .../src/FieldTypes/Types/MessageFieldType.php | 38 ++ .../src/FieldTypes/Types/OembedFieldType.php | 45 +++ .../builder/src/Registry/EntityRegistry.php | 117 ++++-- .../src/Services/CustomFieldsManager.php | 34 +- packages/item/composer.json | 1 - packages/item/src/ItemServiceProvider.php | 11 - packages/item/src/Resources/ItemResource.php | 4 - 17 files changed, 269 insertions(+), 450 deletions(-) create mode 100644 packages/builder/src/FieldTypes/Capabilities/MessageBody.php create mode 100644 packages/builder/src/FieldTypes/Types/MessageFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/OembedFieldType.php diff --git a/packages/builder/composer.json b/packages/builder/composer.json index 1c13a8ef1a..f1436df92a 100644 --- a/packages/builder/composer.json +++ b/packages/builder/composer.json @@ -24,7 +24,6 @@ "autoload": { "psr-4": { "Moox\\Builder\\": "src", - "Moox\\Builder\\Database\\Factories\\": "database/factories", "Moox\\Builder\\Database\\Seeders\\": "database/seeders" } }, diff --git a/packages/builder/config/builder.php b/packages/builder/config/builder.php index 37cd82399b..2fe3587cb1 100644 --- a/packages/builder/config/builder.php +++ b/packages/builder/config/builder.php @@ -4,27 +4,4 @@ return [ 'navigation_group' => env('BUILDER_NAVIGATION_GROUP', 'Felder'), - - /* - |-------------------------------------------------------------------------- - | Builder entities - |-------------------------------------------------------------------------- - | - | Register Filament resources that may use custom field groups. The array - | key is the entity identifier used in location rules and storage. - | - | Packages can merge additional entries in their service provider: - | - | config(['builder.entities.product' => [ - | 'resource' => ProductResource::class, - | 'label' => 'Products', - | ]]); - | - */ - 'entities' => [ - // 'item' => [ - // 'resource' => \Moox\Item\Resources\ItemResource::class, - // 'label' => 'Items', - // ], - ], ]; diff --git a/packages/builder/database/seeders/BuilderSeeder.php b/packages/builder/database/seeders/BuilderSeeder.php index 03b78f08f8..d4b8994909 100644 --- a/packages/builder/database/seeders/BuilderSeeder.php +++ b/packages/builder/database/seeders/BuilderSeeder.php @@ -5,22 +5,16 @@ namespace Moox\Builder\Database\Seeders; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Schema; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldOption; -use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; -use Moox\Builder\Support\TypedValueColumns; class BuilderSeeder extends Seeder { public function run(): void { $this->seedFahrzeugdatenGroup(); - $this->seedFieldTypeShowcaseGroup(); - $this->seedDemoValuesForFirstItem(); app(DefinitionRegistry::class)->forget(); } @@ -76,7 +70,6 @@ protected function seedFahrzeugdatenGroup(): void 'max' => 500, 'step' => 5, 'suffix' => 'PS', - 'helperText' => 'value_decimal — Schieberegler für Leistung', ], ]); @@ -87,7 +80,6 @@ protected function seedFahrzeugdatenGroup(): void 'sort' => 4, 'config' => [ 'default' => 'automatic', - 'helperText' => 'value_string — Button-Gruppe', ], 'options' => [ ['label' => 'Schaltgetriebe', 'value' => 'manual'], @@ -100,9 +92,6 @@ protected function seedFahrzeugdatenGroup(): void 'label' => 'Inserat-Link', 'type' => 'link', 'sort' => 5, - 'config' => [ - 'helperText' => 'value_json — URL, Bezeichnung und Ziel', - ], ]); $this->upsertField($group, [ @@ -110,326 +99,9 @@ protected function seedFahrzeugdatenGroup(): void 'label' => 'Fahrzeugbeschreibung', 'type' => 'rich_text', 'sort' => 6, - 'config' => [ - 'helperText' => 'value_text — formatierter Langtext', - ], ]); } - protected function seedFieldTypeShowcaseGroup(): void - { - $group = $this->upsertGroup( - slug: 'feldtyp-showcase', - name: 'Feldtyp-Showcase (alle 19 Typen)', - sort: 10, - ); - - $this->upsertField($group, [ - 'name' => 'demo-text', - 'label' => 'Text (kurz)', - 'type' => 'text', - 'sort' => 0, - 'required' => true, - 'config' => [ - 'placeholder' => 'Kurzer Text …', - 'prefix' => 'REF-', - 'maxLength' => 50, - 'default' => 'Standardtext', - 'helperText' => 'value_string in der Datenbank', - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-textarea', - 'label' => 'Text (mehrzeilig)', - 'type' => 'textarea', - 'sort' => 1, - 'config' => [ - 'rows' => 4, - 'placeholder' => 'Mehrzeilige Beschreibung …', - 'helperText' => 'value_text in der Datenbank', - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-number', - 'label' => 'Zahl', - 'type' => 'number', - 'sort' => 2, - 'config' => [ - 'prefix' => '€', - 'suffix' => 'brutto', - 'min' => 0, - 'max' => 500000, - 'step' => 0.01, - 'placeholder' => '0,00', - 'helperText' => 'value_decimal in der Datenbank', - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-email', - 'label' => 'E-Mail', - 'type' => 'email', - 'sort' => 3, - 'config' => [ - 'placeholder' => 'name@beispiel.de', - 'helperText' => 'value_string + email-Validierung', - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-url', - 'label' => 'URL', - 'type' => 'url', - 'sort' => 4, - 'config' => [ - 'placeholder' => 'https://moox.org', - 'helperText' => 'value_string + url-Validierung', - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-password', - 'label' => 'Passwort', - 'type' => 'password', - 'sort' => 5, - 'config' => [ - 'maxLength' => 64, - 'helperText' => 'Wird nur gespeichert wenn ausgefüllt (value_string)', - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-select', - 'label' => 'Auswahl (Dropdown)', - 'type' => 'select', - 'sort' => 6, - 'config' => [ - 'default' => 'bmw', - 'helperText' => 'value_string — gewählter Options-Wert', - ], - 'options' => [ - ['label' => 'BMW', 'value' => 'bmw'], - ['label' => 'Audi', 'value' => 'audi'], - ['label' => 'Mercedes', 'value' => 'mercedes'], - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-multiselect', - 'label' => 'Mehrfachauswahl', - 'type' => 'multiselect', - 'sort' => 7, - 'config' => [ - 'helperText' => 'value_json — Array der Options-Werte', - ], - 'options' => [ - ['label' => 'Sportpaket', 'value' => 'sport'], - ['label' => 'Komfortpaket', 'value' => 'comfort'], - ['label' => 'Assistenzpaket', 'value' => 'assist'], - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-checkbox-list', - 'label' => 'Checkbox-Liste', - 'type' => 'checkbox_list', - 'sort' => 8, - 'required' => true, - 'config' => [ - 'helperText' => 'value_json — Array der angehakten Options-Werte', - ], - 'options' => [ - ['label' => 'LED-Scheinwerfer', 'value' => 'led'], - ['label' => 'Navigationssystem', 'value' => 'navi'], - ['label' => 'Sitzheizung', 'value' => 'heating'], - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-radio', - 'label' => 'Optionsfelder (Radio)', - 'type' => 'radio', - 'sort' => 9, - 'config' => [ - 'helperText' => 'value_string — ein Options-Wert', - ], - 'options' => [ - ['label' => 'Benzin', 'value' => 'petrol'], - ['label' => 'Diesel', 'value' => 'diesel'], - ['label' => 'Elektro', 'value' => 'electric'], - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-toggle', - 'label' => 'Schalter', - 'type' => 'toggle', - 'sort' => 10, - 'config' => [ - 'default' => '1', - 'helperText' => 'value_boolean in der Datenbank', - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-date', - 'label' => 'Datum', - 'type' => 'date', - 'sort' => 11, - 'config' => [ - 'displayFormat' => 'd.m.Y', - 'helperText' => 'value_date in der Datenbank', - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-datetime', - 'label' => 'Datum & Uhrzeit', - 'type' => 'datetime', - 'sort' => 12, - 'config' => [ - 'displayFormat' => 'd.m.Y H:i', - 'helperText' => 'value_datetime in der Datenbank', - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-time', - 'label' => 'Uhrzeit', - 'type' => 'time', - 'sort' => 13, - 'config' => [ - 'default' => '09:00', - 'helperText' => 'value_string (TimePicker)', - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-color', - 'label' => 'Farbe', - 'type' => 'color', - 'sort' => 14, - 'config' => [ - 'default' => '#3b82f6', - 'helperText' => 'value_string — Hex-Farbwert', - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-range', - 'label' => 'Bereich (Slider)', - 'type' => 'range', - 'sort' => 15, - 'config' => [ - 'min' => 0, - 'max' => 100, - 'step' => 5, - 'default' => '50', - 'helperText' => 'value_decimal — Schieberegler', - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-button-group', - 'label' => 'Button-Gruppe', - 'type' => 'button_group', - 'sort' => 16, - 'config' => [ - 'default' => 'used', - 'helperText' => 'value_string — Toggle-Buttons', - ], - 'options' => [ - ['label' => 'Neuwagen', 'value' => 'new'], - ['label' => 'Gebraucht', 'value' => 'used'], - ['label' => 'Vorführwagen', 'value' => 'demo'], - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-link', - 'label' => 'Link', - 'type' => 'link', - 'sort' => 17, - 'config' => [ - 'helperText' => 'value_json — URL, Bezeichnung und Ziel', - ], - ]); - - $this->upsertField($group, [ - 'name' => 'demo-rich-text', - 'label' => 'Rich Text', - 'type' => 'rich_text', - 'sort' => 18, - 'config' => [ - 'helperText' => 'value_text — formatierter HTML-Text', - ], - ]); - } - - /** - * Demo-Werte für Item #1 — nur wenn die items-Tabelle und Datensatz 1 existieren. - */ - protected function seedDemoValuesForFirstItem(): void - { - if (! Schema::hasTable('items') || ! DB::table('items')->where('id', 1)->exists()) { - return; - } - - $demos = [ - 'fahrzeugtyp-modell' => ['type' => 'text', 'value' => 'Golf GTI'], - 'bruttolistenpreis' => ['type' => 'number', 'value' => 32990], - 'erstzulassung' => ['type' => 'date', 'value' => '2022-06-15'], - 'motorleistung' => ['type' => 'range', 'value' => 245], - 'getriebe' => ['type' => 'button_group', 'value' => 'automatic'], - 'inserat-url' => [ - 'type' => 'link', - 'value' => [ - 'url' => 'https://moox.org/items/golf-gti', - 'label' => 'Inserat online ansehen', - 'target' => '_blank', - ], - ], - 'fahrzeugbeschreibung' => [ - 'type' => 'rich_text', - 'value' => '

Golf GTI in sehr gutem Zustand mit vollständiger Ausstattung.

  • LED-Scheinwerfer
  • Navigationssystem
  • Sitzheizung
', - ], - 'demo-text' => ['type' => 'text', 'value' => 'REF-DEMO-001'], - 'demo-textarea' => ['type' => 'textarea', 'value' => "Zeile 1: Ausstattung\nZeile 2: Zustand sehr gut\nZeile 3: Scheckheftgepflegt"], - 'demo-number' => ['type' => 'number', 'value' => 28990.50], - 'demo-email' => ['type' => 'email', 'value' => 'demo@moox.org'], - 'demo-url' => ['type' => 'url', 'value' => 'https://moox.org'], - 'demo-select' => ['type' => 'select', 'value' => 'audi'], - 'demo-multiselect' => ['type' => 'multiselect', 'value' => ['sport', 'comfort']], - 'demo-checkbox-list' => ['type' => 'checkbox_list', 'value' => ['led', 'navi']], - 'demo-radio' => ['type' => 'radio', 'value' => 'diesel'], - 'demo-toggle' => ['type' => 'toggle', 'value' => true], - 'demo-date' => ['type' => 'date', 'value' => '2024-03-15'], - 'demo-datetime' => ['type' => 'datetime', 'value' => '2024-03-15 14:30:00'], - 'demo-time' => ['type' => 'time', 'value' => '14:30'], - 'demo-color' => ['type' => 'color', 'value' => '#22c55e'], - 'demo-range' => ['type' => 'range', 'value' => 75], - 'demo-button-group' => ['type' => 'button_group', 'value' => 'used'], - 'demo-link' => [ - 'type' => 'link', - 'value' => [ - 'url' => 'https://moox.org', - 'label' => 'Mehr auf moox.org', - 'target' => '_blank', - ], - ], - 'demo-rich-text' => [ - 'type' => 'rich_text', - 'value' => '

Dies ist ein formatierter Demo-Text mit Rich-Text-Editor.

', - ], - ]; - - foreach ($demos as $fieldName => $demo) { - $this->upsertFieldValue('item', 1, $fieldName, $demo['type'], $demo['value']); - } - } - protected function upsertGroup(string $slug, string $name, int $sort): FieldGroup { return FieldGroup::query()->updateOrCreate( @@ -517,16 +189,4 @@ protected function syncFieldOptions(Field $field, array $options): void ->whereNotIn('id', $retainedIds) ->delete(); } - - protected function upsertFieldValue(string $entity, int $recordId, string $fieldName, string $type, mixed $value): void - { - FieldValue::query()->updateOrCreate( - [ - 'entity' => $entity, - 'record_id' => $recordId, - 'field_name' => $fieldName, - ], - TypedValueColumns::attributesFor($type, $value), - ); - } } diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 38b92d9ed3..09b2042da2 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -20,8 +20,7 @@ 'target_entities' => 'Anzeigen bei', 'target_entities_helper' => 'Diese Feldgruppe erscheint in den Formularen der ausgewählten Ressourcen.', 'target_entities_placeholder' => 'Ressource auswählen…', - 'no_entities_registered' => 'Noch keine Ressourcen registriert. Trage sie unter config(builder.entities) ein.', - 'fields_empty' => 'Noch keine Felder definiert.', + 'no_entities_registered' => 'Noch keine Ressource mit HasCustomFields. Trait an einer Filament-Resource hinzufügen.', 'field_item' => 'Feld', 'fields_count' => 'Felder', 'assigned_to' => 'Zugeordnet zu', @@ -60,6 +59,16 @@ 'color' => 'Farbe', 'link' => 'Link', 'rich_text' => 'Rich Text', + 'message' => 'Hinweis', + 'oembed' => 'oEmbed', + ], + + 'message' => [ + 'body' => 'Hinweistext', + ], + + 'oembed' => [ + 'helper' => 'Video- oder Embed-URL einfügen (YouTube, Vimeo, …).', ], 'link' => [ diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 8af72b6427..12ca136e15 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -20,8 +20,7 @@ 'target_entities' => 'Show on', 'target_entities_helper' => 'This field group appears in the forms of the selected resources.', 'target_entities_placeholder' => 'Select a resource…', - 'no_entities_registered' => 'No resources registered yet. Add them under config(builder.entities).', - 'fields_empty' => 'No fields defined yet.', + 'no_entities_registered' => 'No resources use HasCustomFields yet. Add the trait to a Filament resource.', 'field_item' => 'Field', 'fields_count' => 'Fields', 'assigned_to' => 'Assigned to', @@ -60,6 +59,16 @@ 'color' => 'Color', 'link' => 'Link', 'rich_text' => 'Rich text', + 'message' => 'Message', + 'oembed' => 'oEmbed', + ], + + 'message' => [ + 'body' => 'Message', + ], + + 'oembed' => [ + 'helper' => 'Paste a video or embed URL (YouTube, Vimeo, etc.).', ], 'link' => [ diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index 216a486f2c..a39f3b3701 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -14,8 +14,10 @@ use Moox\Builder\FieldTypes\Types\DatetimeFieldType; use Moox\Builder\FieldTypes\Types\EmailFieldType; use Moox\Builder\FieldTypes\Types\LinkFieldType; +use Moox\Builder\FieldTypes\Types\MessageFieldType; use Moox\Builder\FieldTypes\Types\MultiselectFieldType; use Moox\Builder\FieldTypes\Types\NumberFieldType; +use Moox\Builder\FieldTypes\Types\OembedFieldType; use Moox\Builder\FieldTypes\Types\PasswordFieldType; use Moox\Builder\FieldTypes\Types\RadioFieldType; use Moox\Builder\FieldTypes\Types\RangeFieldType; @@ -109,6 +111,8 @@ protected function defaultFieldTypes(): array new ButtonGroupFieldType, new LinkFieldType, new RichTextFieldType, + new MessageFieldType, + new OembedFieldType, ]; } } diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index d42b53d6c5..20197b4959 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -74,6 +74,11 @@ public function rules(Collection $fieldGroups): array protected function rulesForField(FieldDefinition $field): array { $fieldType = $this->fieldTypeRegistry->get($field->type); + + if (! $fieldType->storesValue()) { + return []; + } + $rules = []; foreach ($fieldType->capabilities() as $capabilityClass) { @@ -90,7 +95,7 @@ protected function rulesForField(FieldDefinition $field): array $rules[] = 'email'; } - if ($field->type === 'url') { + if (in_array($field->type, ['url', 'oembed'], true)) { $rules[] = 'url'; } @@ -112,8 +117,8 @@ protected function compileField(FieldDefinition $field, ?string $resourceClass = ? $this->customFieldsManager->locationContextForResource($resourceClass)->entity : null; - return $component->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $entity): void { - if ($field->type === 'password' || $record === null || $entity === null) { + return $component->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $entity, $fieldType): void { + if (! $fieldType->storesValue() || $field->type === 'password' || $record === null || $entity === null) { return; } diff --git a/packages/builder/src/Concerns/HasCustomFields.php b/packages/builder/src/Concerns/HasCustomFields.php index c9bfece2b7..196701a51d 100644 --- a/packages/builder/src/Concerns/HasCustomFields.php +++ b/packages/builder/src/Concerns/HasCustomFields.php @@ -5,10 +5,10 @@ namespace Moox\Builder\Concerns; use Filament\Schemas\Components\Section; +use Illuminate\Support\Str; use Moox\Builder\Compiler\SchemaCompiler; use Moox\Builder\Data\LocationContext; use Moox\Builder\Registry\DefinitionRegistry; -use Moox\Builder\Registry\EntityRegistry; /** * Add custom field group sections to a Filament resource form. @@ -17,9 +17,9 @@ * * ...static::customFieldComponents(), * - * The resource must be registered in config('builder.entities'). Loading and - * saving is handled automatically via Filament record events registered by - * the Builder service provider — no page-level hooks required. + * Field groups are matched by entity key (model basename in kebab-case, e.g. + * Item → item). Override with customFieldsEntity() when needed. Loading and + * saving is handled automatically via Filament record events. */ trait HasCustomFields { @@ -28,10 +28,6 @@ trait HasCustomFields */ public static function customFieldComponents(): array { - if (! static::isRegisteredForCustomFields()) { - return []; - } - $groups = app(DefinitionRegistry::class)->fieldGroupsFor( static::customFieldsLocationContext(), ); @@ -50,21 +46,24 @@ public static function customFieldsLocationContext(): LocationContext public static function resolveCustomFieldsEntityIdentifier(): string { - $entity = app(EntityRegistry::class)->resolveForResource(static::class); + $entity = static::customFieldsEntity(); - if ($entity === null) { - throw new \LogicException(sprintf( - 'Resource [%s] uses HasCustomFields but is not registered in config(builder.entities).', - static::class, - )); + if ($entity !== null) { + return $entity; } - return $entity; + $model = static::getModel(); + + return Str::kebab(class_basename($model)); } - public static function isRegisteredForCustomFields(): bool + /** + * Override the storage / location-rule entity key when it should differ + * from the model basename (e.g. "item" instead of "test-item"). + */ + protected static function customFieldsEntity(): ?string { - return app(EntityRegistry::class)->isRegisteredResource(static::class); + return null; } /** diff --git a/packages/builder/src/FieldTypes/Capabilities/MessageBody.php b/packages/builder/src/FieldTypes/Capabilities/MessageBody.php new file mode 100644 index 0000000000..8bf72cd4a8 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/MessageBody.php @@ -0,0 +1,26 @@ +label(__('builder::builder.message.body')) + ->rows(4), + ]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/FieldType.php b/packages/builder/src/FieldTypes/FieldType.php index 87eb42fef7..5bb9e840e4 100644 --- a/packages/builder/src/FieldTypes/FieldType.php +++ b/packages/builder/src/FieldTypes/FieldType.php @@ -32,6 +32,11 @@ public function hasOptions(): bool return false; } + public function storesValue(): bool + { + return true; + } + public function label(): string { $key = 'builder::builder.field_types.'.str_replace('-', '_', static::key()); diff --git a/packages/builder/src/FieldTypes/Types/MessageFieldType.php b/packages/builder/src/FieldTypes/Types/MessageFieldType.php new file mode 100644 index 0000000000..6453b03faf --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/MessageFieldType.php @@ -0,0 +1,38 @@ +name) + ->label($field->label) + ->content($field->config['message'] ?? ''); + } +} diff --git a/packages/builder/src/FieldTypes/Types/OembedFieldType.php b/packages/builder/src/FieldTypes/Types/OembedFieldType.php new file mode 100644 index 0000000000..1d57170e51 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/OembedFieldType.php @@ -0,0 +1,45 @@ +name) + ->label($field->label) + ->url() + ->helperText(__('builder::builder.oembed.helper')); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} diff --git a/packages/builder/src/Registry/EntityRegistry.php b/packages/builder/src/Registry/EntityRegistry.php index e5182001fb..a70371a799 100644 --- a/packages/builder/src/Registry/EntityRegistry.php +++ b/packages/builder/src/Registry/EntityRegistry.php @@ -4,7 +4,9 @@ namespace Moox\Builder\Registry; +use Filament\Facades\Filament; use Illuminate\Support\Str; +use Moox\Builder\Concerns\HasCustomFields; class EntityRegistry { @@ -13,7 +15,22 @@ class EntityRegistry */ public function all(): array { - return config('builder.entities', []); + $entities = []; + + foreach ($this->traitResources() as $resourceClass) { + $entity = $this->resolveEntityKey($resourceClass); + + if ($entity === null) { + continue; + } + + $entities[$entity] = [ + 'resource' => $resourceClass, + 'label' => $this->labelForResource($resourceClass), + ]; + } + + return $entities; } /** @@ -21,13 +38,11 @@ public function all(): array */ public function resolveForResource(string $resourceClass): ?string { - foreach ($this->all() as $key => $definition) { - if (($definition['resource'] ?? null) === $resourceClass) { - return (string) $key; - } + if (! $this->usesCustomFields($resourceClass)) { + return null; } - return null; + return $this->resolveEntityKey($resourceClass); } /** @@ -35,9 +50,15 @@ public function resolveForResource(string $resourceClass): ?string */ public function resourceFor(string $entity): ?string { - $resource = $this->all()[$entity]['resource'] ?? null; + foreach ($this->all() as $key => $definition) { + if ($key === $entity) { + $resource = $definition['resource'] ?? null; + + return is_string($resource) ? $resource : null; + } + } - return is_string($resource) ? $resource : null; + return null; } /** @@ -56,26 +77,6 @@ public function modelFor(string $entity): ?string return is_string($model) ? $model : null; } - /** - * @param class-string $modelClass - */ - public function entityForModel(string $modelClass): ?string - { - foreach ($this->all() as $key => $definition) { - $resource = $definition['resource'] ?? null; - - if (! is_string($resource) || ! method_exists($resource, 'getModel')) { - continue; - } - - if ($resource::getModel() === $modelClass) { - return (string) $key; - } - } - - return null; - } - public function labelFor(string $entity): string { $definition = $this->all()[$entity] ?? []; @@ -108,8 +109,8 @@ public function optionsForSelect(): array { $options = []; - foreach (array_keys($this->all()) as $entity) { - $options[$entity] = $this->labelFor($entity); + foreach ($this->all() as $entity => $definition) { + $options[$entity] = $definition['label'] ?? $this->labelFor($entity); } return $options; @@ -118,8 +119,60 @@ public function optionsForSelect(): array /** * @param class-string $resourceClass */ - public function isRegisteredResource(string $resourceClass): bool + public function usesCustomFields(string $resourceClass): bool { - return $this->resolveForResource($resourceClass) !== null; + return in_array(HasCustomFields::class, class_uses_recursive($resourceClass), true); + } + + /** + * @param class-string $resourceClass + */ + protected function resolveEntityKey(string $resourceClass): ?string + { + if (! method_exists($resourceClass, 'resolveCustomFieldsEntityIdentifier')) { + return null; + } + + return $resourceClass::resolveCustomFieldsEntityIdentifier(); + } + + /** + * @return list + */ + protected function traitResources(): array + { + if (! class_exists(Filament::class)) { + return []; + } + + try { + $panels = Filament::getPanels(); + } catch (\Throwable) { + return []; + } + + $resources = []; + + foreach ($panels as $panel) { + foreach ($panel->getResources() as $resourceClass) { + if ($this->usesCustomFields($resourceClass)) { + $resources[] = $resourceClass; + } + } + } + + return array_values(array_unique($resources)); + } + + /** + * @param class-string $resourceClass + */ + protected function labelForResource(string $resourceClass): string + { + if (method_exists($resourceClass, 'getPluralModelLabel')) { + return (string) $resourceClass::getPluralModelLabel(); + } + + return Str::headline(class_basename($resourceClass)); } } diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index 337cd02dbc..100989daf2 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -6,13 +6,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; -use Illuminate\Support\Str; use Moox\Builder\Concerns\HasCustomFields; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Data\LocationContext; use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; -use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Support\TypedValueColumns; @@ -22,7 +20,6 @@ class CustomFieldsManager public function __construct( protected DefinitionRegistry $definitionRegistry, protected FieldTypeRegistry $fieldTypeRegistry, - protected EntityRegistry $entityRegistry, ) {} /** @@ -30,13 +27,7 @@ public function __construct( */ public function locationContextForResource(string $resourceClass): LocationContext { - if (in_array(HasCustomFields::class, class_uses_recursive($resourceClass), true)) { - return $resourceClass::customFieldsLocationContext(); - } - - $modelClass = $resourceClass::getModel(); - - return new LocationContext(Str::kebab(class_basename($modelClass))); + return new LocationContext($resourceClass::resolveCustomFieldsEntityIdentifier()); } /** @@ -90,6 +81,12 @@ public function loadValues(string $entity, Model $record, Collection $fields): a $values = []; foreach ($fields as $field) { + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if (! $fieldType->storesValue()) { + continue; + } + $row = $rows->get($field->name); if ($row === null || $field->type === 'password') { @@ -97,7 +94,7 @@ public function loadValues(string $entity, Model $record, Collection $fields): a } $raw = TypedValueColumns::read($row, $field->type); - $values[$field->name] = $this->fieldTypeRegistry->get($field->type)->castValue($raw); + $values[$field->name] = $fieldType->castValue($raw); } return $values; @@ -122,6 +119,10 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da continue; } + if (! $this->fieldTypeRegistry->get($field->type)->storesValue()) { + continue; + } + if ($field->type === 'password' && blank($data[$field->name])) { continue; } @@ -152,13 +153,19 @@ public function saveValues(string $entity, Model $record, array $values, Collect continue; } + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if (! $fieldType->storesValue()) { + continue; + } + $value = $values[$field->name]; if ($field->type !== 'password') { OptionValueRules::assertValid($field, $value); } - $cast = $this->fieldTypeRegistry->get($field->type)->castValue($value); + $cast = $fieldType->castValue($value); $columns = TypedValueColumns::attributesFor($field->type, $cast); FieldValue::query()->updateOrCreate( @@ -177,7 +184,6 @@ public function saveValues(string $entity, Model $record, array $values, Collect */ public function usesCustomFields(string $resourceClass): bool { - return in_array(HasCustomFields::class, class_uses_recursive($resourceClass), true) - && $this->entityRegistry->isRegisteredResource($resourceClass); + return in_array(HasCustomFields::class, class_uses_recursive($resourceClass), true); } } diff --git a/packages/item/composer.json b/packages/item/composer.json index d9467f3bba..72418296eb 100644 --- a/packages/item/composer.json +++ b/packages/item/composer.json @@ -18,7 +18,6 @@ } ], "require": { - "moox/builder": "dev-main", "moox/core": "dev-main" }, "autoload": { diff --git a/packages/item/src/ItemServiceProvider.php b/packages/item/src/ItemServiceProvider.php index edc4f6da8a..72c5c18205 100644 --- a/packages/item/src/ItemServiceProvider.php +++ b/packages/item/src/ItemServiceProvider.php @@ -5,7 +5,6 @@ namespace Moox\Item; use Moox\Core\MooxServiceProvider; -use Moox\Item\Resources\ItemResource; use Spatie\LaravelPackageTools\Package; class ItemServiceProvider extends MooxServiceProvider @@ -68,14 +67,4 @@ public function configureMoox(Package $package): void '', ]); } - - public function packageBooted(): void - { - config([ - 'builder.entities.item' => [ - 'resource' => ItemResource::class, - 'label' => 'Items', - ], - ]); - } } diff --git a/packages/item/src/Resources/ItemResource.php b/packages/item/src/Resources/ItemResource.php index 343a2b4c88..47cb2101fa 100644 --- a/packages/item/src/Resources/ItemResource.php +++ b/packages/item/src/Resources/ItemResource.php @@ -11,7 +11,6 @@ use Filament\Schemas\Schema; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; -use Moox\Builder\Concerns\HasCustomFields; use Moox\Core\Entities\Items\Item\BaseItemResource; use Moox\Item\Models\Item; use Moox\Item\Resources\ItemResource\Pages\CreateItem; @@ -21,8 +20,6 @@ class ItemResource extends BaseItemResource { - use HasCustomFields; - protected static ?string $model = Item::class; protected static string|\BackedEnum|null $navigationIcon = 'gmdi-local-offer'; @@ -63,7 +60,6 @@ public static function form(Schema $form): Schema ->label(__('core::core.title')), MarkdownEditor::make('description') ->label(__('core::core.description')), - ...static::customFieldComponents(), Grid::make(2) ->schema([ static::getFooterActions()->columnSpan(1), From 9cab163273ce650f917b32cda470162cd4d79b30 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:16:31 +0200 Subject: [PATCH 009/105] repeater, groups, tabs --- .../database/seeders/BuilderSeeder.php | 475 +++++++++++++----- .../builder/resources/lang/de/builder.php | 8 + .../builder/resources/lang/en/builder.php | 8 + .../builder/src/BuilderServiceProvider.php | 6 + .../builder/src/Compiler/SchemaCompiler.php | 125 ++++- packages/builder/src/Data/FieldDefinition.php | 25 +- .../builder/src/Data/FieldGroupDefinition.php | 4 +- .../FieldTypes/Capabilities/RepeaterItems.php | 47 ++ packages/builder/src/FieldTypes/FieldType.php | 10 + .../src/FieldTypes/Types/GroupFieldType.php | 67 +++ .../FieldTypes/Types/RepeaterFieldType.php | 55 ++ .../src/FieldTypes/Types/TabFieldType.php | 32 ++ packages/builder/src/Models/Field.php | 12 + .../src/Registry/DefinitionRegistry.php | 8 +- .../src/Registry/FieldTypeRegistry.php | 20 + .../src/Resources/FieldGroupResource.php | 150 +++++- .../Pages/EditFieldGroup.php | 16 +- .../src/Services/CustomFieldsManager.php | 29 +- .../src/Services/FieldGroupPersistence.php | 75 ++- .../src/Services/FieldGroupValidator.php | 22 +- .../builder/src/Support/TypedValueColumns.php | 2 +- packages/item/src/Resources/ItemResource.php | 3 + .../record/src/Resources/RecordResource.php | 3 + 23 files changed, 1021 insertions(+), 181 deletions(-) create mode 100644 packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php create mode 100644 packages/builder/src/FieldTypes/Types/GroupFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/RepeaterFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/TabFieldType.php diff --git a/packages/builder/database/seeders/BuilderSeeder.php b/packages/builder/database/seeders/BuilderSeeder.php index d4b8994909..99839d2318 100644 --- a/packages/builder/database/seeders/BuilderSeeder.php +++ b/packages/builder/database/seeders/BuilderSeeder.php @@ -5,118 +5,378 @@ namespace Moox\Builder\Database\Seeders; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; -use Moox\Builder\Models\FieldOption; +use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; +use Moox\Builder\Services\FieldGroupPersistence; +use Moox\Builder\Support\TypedValueColumns; class BuilderSeeder extends Seeder { public function run(): void { + $this->removeObsoleteGroups(); + $this->seedFahrzeugdatenGroup(); + $this->seedLayoutShowcaseGroup(); + $this->seedDemoValuesForFirstItem(); app(DefinitionRegistry::class)->forget(); } + protected function removeObsoleteGroups(): void + { + FieldGroup::query() + ->whereIn('slug', ['feldtyp-showcase']) + ->each(fn (FieldGroup $group) => $group->delete()); + } + protected function seedFahrzeugdatenGroup(): void { $group = $this->upsertGroup( slug: 'fahrzeugdaten', name: 'Fahrzeugdaten', sort: 0, + entities: ['item'], ); - $this->upsertField($group, [ - 'name' => 'fahrzeugtyp-modell', - 'label' => 'Fahrzeugtyp / Modell', - 'type' => 'text', + $this->resetGroupFields($group); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Fahrzeugdaten', + 'slug' => 'fahrzeugdaten', + 'active' => true, 'sort' => 0, - 'config' => [ - 'placeholder' => 'z. B. Golf GTI', - 'maxLength' => 120, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'fahrzeugtyp-modell', + 'label' => 'Fahrzeugtyp / Modell', + 'type' => 'text', + 'sort' => 0, + 'config' => [ + 'placeholder' => 'z. B. Golf GTI', + 'maxLength' => 120, + ], + ], + [ + 'name' => 'bruttolistenpreis', + 'label' => 'Bruttolistenpreis', + 'type' => 'number', + 'sort' => 1, + 'config' => [ + 'suffix' => '€', + 'min' => 0, + 'step' => 100, + ], + ], + [ + 'name' => 'erstzulassung', + 'label' => 'Erstzulassung', + 'type' => 'date', + 'sort' => 2, + 'config' => [ + 'displayFormat' => 'd.m.Y', + ], + ], + [ + 'name' => 'motorleistung', + 'label' => 'Motorleistung', + 'type' => 'range', + 'sort' => 3, + 'config' => [ + 'min' => 50, + 'max' => 500, + 'step' => 5, + 'suffix' => 'PS', + ], + ], + [ + 'name' => 'getriebe', + 'label' => 'Getriebe', + 'type' => 'button_group', + 'sort' => 4, + 'config' => [ + 'default' => 'automatic', + ], + 'options' => [ + ['label' => 'Schaltgetriebe', 'value' => 'manual'], + ['label' => 'Automatik', 'value' => 'automatic'], + ], + ], + [ + 'name' => 'inserat-url', + 'label' => 'Inserat-Link', + 'type' => 'link', + 'sort' => 5, + ], + [ + 'name' => 'fahrzeugbeschreibung', + 'label' => 'Fahrzeugbeschreibung', + 'type' => 'rich_text', + 'sort' => 6, + ], + [ + 'name' => 'standort', + 'label' => 'Standort', + 'type' => 'group', + 'sort' => 7, + 'children' => [ + [ + 'name' => 'stadt', + 'label' => 'Stadt', + 'type' => 'text', + 'sort' => 0, + 'config' => ['placeholder' => 'z. B. Berlin'], + ], + [ + 'name' => 'plz', + 'label' => 'PLZ', + 'type' => 'text', + 'sort' => 1, + 'config' => ['maxLength' => 10], + ], + ], + ], + [ + 'name' => 'ausstattung', + 'label' => 'Ausstattung', + 'type' => 'repeater', + 'sort' => 8, + 'children' => [ + [ + 'name' => 'merkmal', + 'label' => 'Merkmal', + 'type' => 'text', + 'sort' => 0, + ], + [ + 'name' => 'enthalten', + 'label' => 'Enthalten', + 'type' => 'toggle', + 'sort' => 1, + ], + ], + ], ], ]); + } - $this->upsertField($group, [ - 'name' => 'bruttolistenpreis', - 'label' => 'Bruttolistenpreis', - 'type' => 'number', - 'sort' => 1, - 'config' => [ - 'suffix' => '€', - 'min' => 0, - 'step' => 100, - ], - ]); + protected function seedLayoutShowcaseGroup(): void + { + $group = $this->upsertGroup( + slug: 'layout-showcase', + name: 'Layout-Showcase', + sort: 10, + entities: ['item', 'record'], + ); - $this->upsertField($group, [ - 'name' => 'erstzulassung', - 'label' => 'Erstzulassung', - 'type' => 'date', - 'sort' => 2, - 'config' => [ - 'displayFormat' => 'd.m.Y', - ], - ]); + $this->resetGroupFields($group); - $this->upsertField($group, [ - 'name' => 'motorleistung', - 'label' => 'Motorleistung', - 'type' => 'range', - 'sort' => 3, - 'config' => [ - 'min' => 50, - 'max' => 500, - 'step' => 5, - 'suffix' => 'PS', + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Layout-Showcase', + 'slug' => 'layout-showcase', + 'active' => true, + 'sort' => 10, + 'target_entities' => ['item', 'record'], + 'fields' => [ + [ + 'name' => 'layout-hinweis', + 'label' => 'Hinweis', + 'type' => 'message', + 'sort' => 0, + 'config' => [ + 'message' => 'Diese Gruppe demonstriert Tab, Group und Repeater.', + ], + ], + [ + 'name' => 'tab-allgemein', + 'label' => 'Allgemein', + 'type' => 'tab', + 'sort' => 1, + ], + [ + 'name' => 'demo-titel', + 'label' => 'Titel', + 'type' => 'text', + 'sort' => 2, + 'config' => ['placeholder' => 'Kurzer Titel'], + ], + [ + 'name' => 'demo-status', + 'label' => 'Status', + 'type' => 'select', + 'sort' => 3, + 'options' => [ + ['label' => 'Entwurf', 'value' => 'draft'], + ['label' => 'Veröffentlicht', 'value' => 'published'], + ], + ], + [ + 'name' => 'tab-details', + 'label' => 'Details', + 'type' => 'tab', + 'sort' => 4, + ], + [ + 'name' => 'demo-leistung', + 'label' => 'Leistung (PS)', + 'type' => 'number', + 'sort' => 6, + 'config' => ['suffix' => 'PS', 'min' => 0], + ], + [ + 'name' => 'demo-aktiv', + 'label' => 'Aktiv', + 'type' => 'toggle', + 'sort' => 7, + ], + [ + 'name' => 'kontakt', + 'label' => 'Kontakt', + 'type' => 'group', + 'sort' => 8, + 'children' => [ + [ + 'name' => 'name', + 'label' => 'Name', + 'type' => 'text', + 'sort' => 0, + ], + [ + 'name' => 'email', + 'label' => 'E-Mail', + 'type' => 'email', + 'sort' => 1, + ], + ], + ], + [ + 'name' => 'timeline', + 'label' => 'Timeline', + 'type' => 'repeater', + 'sort' => 9, + 'children' => [ + [ + 'name' => 'datum', + 'label' => 'Datum', + 'type' => 'date', + 'sort' => 0, + ], + [ + 'name' => 'ereignis', + 'label' => 'Ereignis', + 'type' => 'text', + 'sort' => 1, + ], + ], + ], + [ + 'name' => 'demo-oembed', + 'label' => 'Video (oEmbed)', + 'type' => 'oembed', + 'sort' => 10, + 'config' => [ + 'placeholder' => 'https://www.youtube.com/watch?v=…', + ], + ], ], ]); + } - $this->upsertField($group, [ - 'name' => 'getriebe', - 'label' => 'Getriebe', - 'type' => 'button_group', - 'sort' => 4, - 'config' => [ - 'default' => 'automatic', + /** + * Demo-Werte für Item #1 — nur wenn items-Tabelle und Datensatz existieren. + */ + protected function seedDemoValuesForFirstItem(): void + { + if (! Schema::hasTable('items') || ! DB::table('items')->where('id', 1)->exists()) { + return; + } + + $demos = [ + 'fahrzeugtyp-modell' => ['type' => 'text', 'value' => 'Golf GTI'], + 'bruttolistenpreis' => ['type' => 'number', 'value' => 32990], + 'erstzulassung' => ['type' => 'date', 'value' => '2022-06-15'], + 'motorleistung' => ['type' => 'range', 'value' => 245], + 'getriebe' => ['type' => 'button_group', 'value' => 'automatic'], + 'inserat-url' => [ + 'type' => 'link', + 'value' => [ + 'url' => 'https://moox.org/items/golf-gti', + 'label' => 'Inserat online ansehen', + 'target' => '_blank', + ], ], - 'options' => [ - ['label' => 'Schaltgetriebe', 'value' => 'manual'], - ['label' => 'Automatik', 'value' => 'automatic'], + 'fahrzeugbeschreibung' => [ + 'type' => 'rich_text', + 'value' => '

Golf GTI in sehr gutem Zustand.

', ], - ]); - - $this->upsertField($group, [ - 'name' => 'inserat-url', - 'label' => 'Inserat-Link', - 'type' => 'link', - 'sort' => 5, - ]); + 'standort' => [ + 'type' => 'group', + 'value' => [ + 'stadt' => 'Berlin', + 'plz' => '10115', + ], + ], + 'ausstattung' => [ + 'type' => 'repeater', + 'value' => [ + ['merkmal' => 'Sitzheizung', 'enthalten' => true], + ['merkmal' => 'Navigationssystem', 'enthalten' => true], + ['merkmal' => 'Schiebedach', 'enthalten' => false], + ], + ], + 'demo-titel' => ['type' => 'text', 'value' => 'Demo-Eintrag'], + 'demo-status' => ['type' => 'select', 'value' => 'published'], + 'demo-leistung' => ['type' => 'number', 'value' => 245], + 'demo-aktiv' => ['type' => 'toggle', 'value' => true], + 'kontakt' => [ + 'type' => 'group', + 'value' => [ + 'name' => 'Max Mustermann', + 'email' => 'max@moox.org', + ], + ], + 'timeline' => [ + 'type' => 'repeater', + 'value' => [ + ['datum' => '2022-06-15', 'ereignis' => 'Erstzulassung'], + ['datum' => '2024-03-01', 'ereignis' => 'Inspektion'], + ], + ], + 'demo-oembed' => [ + 'type' => 'oembed', + 'value' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ], + ]; - $this->upsertField($group, [ - 'name' => 'fahrzeugbeschreibung', - 'label' => 'Fahrzeugbeschreibung', - 'type' => 'rich_text', - 'sort' => 6, - ]); + foreach ($demos as $fieldName => $demo) { + $this->upsertFieldValue('item', 1, $fieldName, $demo['type'], $demo['value']); + } } - protected function upsertGroup(string $slug, string $name, int $sort): FieldGroup + /** + * @param list $entities + */ + protected function upsertGroup(string $slug, string $name, int $sort, array $entities): FieldGroup { + $locationRules = array_map( + fn (string $entity): array => [[ + 'param' => 'entity', + 'operator' => '==', + 'value' => $entity, + ]], + $entities, + ); + return FieldGroup::query()->updateOrCreate( ['slug' => $slug], [ 'name' => $name, - 'location_rules' => [ - [ - [ - 'param' => 'entity', - 'operator' => '==', - 'value' => 'item', - ], - ], - ], + 'location_rules' => $locationRules, 'placement' => 'default', 'sort' => $sort, 'active' => true, @@ -124,69 +384,20 @@ protected function upsertGroup(string $slug, string $name, int $sort): FieldGrou ); } - /** - * @param array{ - * name: string, - * label: string, - * type: string, - * sort: int, - * config?: array, - * required?: bool, - * options?: list - * } $attributes - */ - protected function upsertField(FieldGroup $group, array $attributes): Field + protected function resetGroupFields(FieldGroup $group): void { - $field = Field::query()->updateOrCreate( - [ - 'field_group_id' => $group->getKey(), - 'name' => $attributes['name'], - ], - [ - 'label' => $attributes['label'], - 'type' => $attributes['type'], - 'config' => $attributes['config'] ?? [], - 'validation' => [ - 'required' => (bool) ($attributes['required'] ?? false), - 'rules' => [], - ], - 'sort' => $attributes['sort'], - ], - ); - - if (isset($attributes['options'])) { - $this->syncFieldOptions($field, $attributes['options']); - } else { - $field->options()->delete(); - } - - return $field; + $group->fields()->each(fn (Field $field) => $field->delete()); } - /** - * @param list $options - */ - protected function syncFieldOptions(Field $field, array $options): void + protected function upsertFieldValue(string $entity, int $recordId, string $fieldName, string $type, mixed $value): void { - $retainedIds = []; - - foreach ($options as $index => $option) { - $model = FieldOption::query()->updateOrCreate( - [ - 'field_id' => $field->getKey(), - 'value' => $option['value'], - ], - [ - 'label' => $option['label'], - 'sort' => $index, - ], - ); - - $retainedIds[] = $model->getKey(); - } - - $field->options() - ->whereNotIn('id', $retainedIds) - ->delete(); + FieldValue::query()->updateOrCreate( + [ + 'entity' => $entity, + 'record_id' => $recordId, + 'field_name' => $fieldName, + ], + TypedValueColumns::attributesFor($type, $value), + ); } } diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 09b2042da2..ebecd96e1e 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -33,10 +33,13 @@ 'name_helper' => 'Technischer Name für Speicherung und Abfragen. Nur Kleinbuchstaben, Zahlen und Bindestriche.', 'type' => 'Feldtyp', 'required' => 'Pflichtfeld', + 'required_badge' => 'Pflicht', + 'subfields_count' => '{1} :count Unterfeld|[2,*] :count Unterfelder', 'settings' => 'Einstellungen', 'options' => 'Auswahloptionen', 'option_label' => 'Anzeigetext', 'option_value' => 'Wert', + 'subfields' => 'Unterfelder', ], 'field_types' => [ @@ -61,6 +64,9 @@ 'rich_text' => 'Rich Text', 'message' => 'Hinweis', 'oembed' => 'oEmbed', + 'tab' => 'Tab', + 'group' => 'Gruppe', + 'repeater' => 'Repeater', ], 'message' => [ @@ -86,6 +92,8 @@ 'helper_text' => 'Hilfetext', 'min_value' => 'Mindestwert', 'max_value' => 'Höchstwert', + 'min_items' => 'Mindestanzahl Einträge', + 'max_items' => 'Höchstanzahl Einträge', 'step' => 'Schrittweite', 'rows' => 'Zeilen', 'display_format' => 'Anzeigeformat', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 12ca136e15..5eb00b2837 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -33,10 +33,13 @@ 'name_helper' => 'Technical name for storage and queries. Lowercase letters, numbers, and hyphens only.', 'type' => 'Field type', 'required' => 'Required', + 'required_badge' => 'Required', + 'subfields_count' => '{1} :count subfield|[2,*] :count subfields', 'settings' => 'Settings', 'options' => 'Options', 'option_label' => 'Display text', 'option_value' => 'Value', + 'subfields' => 'Subfields', ], 'field_types' => [ @@ -61,6 +64,9 @@ 'rich_text' => 'Rich text', 'message' => 'Message', 'oembed' => 'oEmbed', + 'tab' => 'Tab', + 'group' => 'Group', + 'repeater' => 'Repeater', ], 'message' => [ @@ -86,6 +92,8 @@ 'helper_text' => 'Helper text', 'min_value' => 'Minimum value', 'max_value' => 'Maximum value', + 'min_items' => 'Minimum items', + 'max_items' => 'Maximum items', 'step' => 'Step', 'rows' => 'Rows', 'display_format' => 'Display format', diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index a39f3b3701..896472a09b 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -13,6 +13,7 @@ use Moox\Builder\FieldTypes\Types\DateFieldType; use Moox\Builder\FieldTypes\Types\DatetimeFieldType; use Moox\Builder\FieldTypes\Types\EmailFieldType; +use Moox\Builder\FieldTypes\Types\GroupFieldType; use Moox\Builder\FieldTypes\Types\LinkFieldType; use Moox\Builder\FieldTypes\Types\MessageFieldType; use Moox\Builder\FieldTypes\Types\MultiselectFieldType; @@ -21,8 +22,10 @@ use Moox\Builder\FieldTypes\Types\PasswordFieldType; use Moox\Builder\FieldTypes\Types\RadioFieldType; use Moox\Builder\FieldTypes\Types\RangeFieldType; +use Moox\Builder\FieldTypes\Types\RepeaterFieldType; use Moox\Builder\FieldTypes\Types\RichTextFieldType; use Moox\Builder\FieldTypes\Types\SelectFieldType; +use Moox\Builder\FieldTypes\Types\TabFieldType; use Moox\Builder\FieldTypes\Types\TextareaFieldType; use Moox\Builder\FieldTypes\Types\TextFieldType; use Moox\Builder\FieldTypes\Types\TimeFieldType; @@ -113,6 +116,9 @@ protected function defaultFieldTypes(): array new RichTextFieldType, new MessageFieldType, new OembedFieldType, + new TabFieldType, + new GroupFieldType, + new RepeaterFieldType, ]; } } diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 20197b4959..cefa13c647 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -6,6 +6,8 @@ use Filament\Schemas\Components\Component; use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs; +use Filament\Schemas\Components\Tabs\Tab; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Moox\Builder\Data\FieldDefinition; @@ -31,11 +33,10 @@ public function compile(Collection $fieldGroups, ?string $resourceClass = null): $sections = []; foreach ($fieldGroups as $group) { - $components = $group->fields - ->sortBy(fn (FieldDefinition $field): int => $field->sort) - ->values() - ->map(fn (FieldDefinition $field): Component => $this->compileField($field, $resourceClass)) - ->all(); + $components = $this->compileRootFields( + $group->fields->sortBy(fn (FieldDefinition $field): int => $field->sort)->values(), + $resourceClass, + ); if ($components === []) { continue; @@ -48,6 +49,64 @@ public function compile(Collection $fieldGroups, ?string $resourceClass = null): return $sections; } + /** + * @param Collection $fields + * @param class-string|null $resourceClass + * @return list + */ + public function compileSubFields(Collection $fields, ?string $resourceClass): array + { + return $fields + ->sortBy(fn (FieldDefinition $field): int => $field->sort) + ->values() + ->map(fn (FieldDefinition $field): Component => $this->compileField($field, $resourceClass)) + ->all(); + } + + /** + * @param Collection $fields + * @param class-string|null $resourceClass + * @return list + */ + protected function compileRootFields(Collection $fields, ?string $resourceClass): array + { + $components = []; + $beforeTabs = []; + $tabPanels = []; + $tabPanelIndex = null; + + foreach ($fields as $field) { + if ($field->type === 'tab') { + $tabPanels[] = ['label' => $field->label, 'fields' => []]; + $tabPanelIndex = count($tabPanels) - 1; + + continue; + } + + if ($tabPanelIndex !== null) { + $tabPanels[$tabPanelIndex]['fields'][] = $field; + + continue; + } + + $beforeTabs[] = $field; + } + + foreach ($beforeTabs as $field) { + $components[] = $this->compileField($field, $resourceClass); + } + + if ($tabPanels !== []) { + $components[] = Tabs::make('custom_fields_tabs') + ->tabs(collect($tabPanels)->map( + fn (array $panel): Tab => Tab::make($panel['label']) + ->schema($this->compileSubFields(collect($panel['fields']), $resourceClass)), + )->all()); + } + + return $components; + } + /** * @param Collection $fieldGroups * @return array> @@ -58,11 +117,37 @@ public function rules(Collection $fieldGroups): array foreach ($fieldGroups as $group) { foreach ($group->fields as $field) { - $fieldRules = $this->rulesForField($field); - if ($fieldRules !== []) { - $rules[$field->name] = $fieldRules; - } + $rules = array_merge($rules, $this->rulesForFieldTree($field)); + } + } + + return $rules; + } + + /** + * @return array> + */ + protected function rulesForFieldTree(FieldDefinition $field): array + { + $rules = []; + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if ($fieldType->isLayoutMarker()) { + return []; + } + + if ($fieldType->hasSubFields()) { + foreach ($field->children as $child) { + $rules = array_merge($rules, $this->rulesForFieldTree($child)); } + + return $rules; + } + + $fieldRules = $this->rulesForField($field); + + if ($fieldRules !== []) { + $rules[$field->name] = $fieldRules; } return $rules; @@ -117,8 +202,16 @@ protected function compileField(FieldDefinition $field, ?string $resourceClass = ? $this->customFieldsManager->locationContextForResource($resourceClass)->entity : null; + if (! $fieldType->storesValue() || $entity === null) { + return $component; + } + return $component->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $entity, $fieldType): void { - if (! $fieldType->storesValue() || $field->type === 'password' || $record === null || $entity === null) { + if ($record === null) { + return; + } + + if ($field->type === 'password') { return; } @@ -128,9 +221,17 @@ protected function compileField(FieldDefinition $field, ?string $resourceClass = collect([$field]), ); - if (array_key_exists($field->name, $values)) { - $component->state($values[$field->name]); + if (! array_key_exists($field->name, $values)) { + return; } + + $value = $values[$field->name]; + + if ($fieldType->hasSubFields() && method_exists($fieldType, 'normalizeForForm')) { + $value = $fieldType->normalizeForForm($value); + } + + $component->state($value); }); } } diff --git a/packages/builder/src/Data/FieldDefinition.php b/packages/builder/src/Data/FieldDefinition.php index 18cb965ec3..846fa11cf2 100644 --- a/packages/builder/src/Data/FieldDefinition.php +++ b/packages/builder/src/Data/FieldDefinition.php @@ -4,6 +4,7 @@ namespace Moox\Builder\Data; +use Illuminate\Support\Collection; use Moox\Builder\Models\Field; readonly class FieldDefinition @@ -12,6 +13,7 @@ * @param array $config * @param array $validation * @param list $options + * @param Collection $children */ public function __construct( public string $name, @@ -21,6 +23,7 @@ public function __construct( public array $config = [], public array $validation = [], public array $options = [], + public Collection $children = new Collection, ) {} public static function fromModel(Field $field): self @@ -32,6 +35,13 @@ public static function fromModel(Field $field): self ])->values()->all() : []; + $children = $field->relationLoaded('children') + ? $field->children + ->map(fn (Field $child): self => self::fromModel($child)) + ->sortBy(fn (self $child): int => $child->sort) + ->values() + : new Collection; + return new self( name: $field->name, label: $field->label, @@ -40,11 +50,12 @@ public static function fromModel(Field $field): self config: $field->config ?? [], validation: $field->validation ?? [], options: $options, + children: $children, ); } /** - * @return array{name: string, label: string, type: string, config: array, validation: array, options: list} + * @return array{name: string, label: string, type: string, sort: int, config: array, validation: array, options: list, children: list>} */ public function toArray(): array { @@ -56,14 +67,23 @@ public function toArray(): array 'config' => $this->config, 'validation' => $this->validation, 'options' => $this->options, + 'children' => $this->children + ->map(fn (self $child): array => $child->toArray()) + ->values() + ->all(), ]; } /** - * @param array{name: string, label: string, type: string, config?: array, validation?: array, options?: list} $data + * @param array{name: string, label: string, type: string, sort?: int, config?: array, validation?: array, options?: list, children?: list>} $data */ public static function fromArray(array $data): self { + $children = collect($data['children'] ?? []) + ->map(fn (array $child): self => self::fromArray($child)) + ->sortBy(fn (self $child): int => $child->sort) + ->values(); + return new self( name: $data['name'], label: $data['label'], @@ -72,6 +92,7 @@ public static function fromArray(array $data): self config: $data['config'] ?? [], validation: $data['validation'] ?? [], options: $data['options'] ?? [], + children: $children, ); } } diff --git a/packages/builder/src/Data/FieldGroupDefinition.php b/packages/builder/src/Data/FieldGroupDefinition.php index cb7ff07eb2..b9f6c1051f 100644 --- a/packages/builder/src/Data/FieldGroupDefinition.php +++ b/packages/builder/src/Data/FieldGroupDefinition.php @@ -5,6 +5,7 @@ namespace Moox\Builder\Data; use Illuminate\Support\Collection; +use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; readonly class FieldGroupDefinition @@ -27,7 +28,8 @@ public static function fromModel(FieldGroup $group): self { $fields = $group->relationLoaded('fields') ? $group->fields - ->map(fn ($field): FieldDefinition => FieldDefinition::fromModel($field)) + ->filter(fn (Field $field): bool => $field->parent_field_id === null) + ->map(fn (Field $field): FieldDefinition => FieldDefinition::fromModel($field)) ->sortBy(fn (FieldDefinition $field): int => $field->sort) ->values() : collect(); diff --git a/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php b/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php new file mode 100644 index 0000000000..39e2d73d8b --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php @@ -0,0 +1,47 @@ +label(__('builder::builder.capabilities.min_items')) + ->numeric() + ->minValue(0), + TextInput::make('config.max') + ->label(__('builder::builder.capabilities.max_items')) + ->numeric() + ->minValue(0), + ]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if (! $component instanceof Repeater) { + return $component; + } + + $min = $field->config['min'] ?? null; + $max = $field->config['max'] ?? null; + + if (is_numeric($min)) { + $component->minItems((int) $min); + } + + if (is_numeric($max)) { + $component->maxItems((int) $max); + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/FieldType.php b/packages/builder/src/FieldTypes/FieldType.php index 5bb9e840e4..e82307d27e 100644 --- a/packages/builder/src/FieldTypes/FieldType.php +++ b/packages/builder/src/FieldTypes/FieldType.php @@ -37,6 +37,16 @@ public function storesValue(): bool return true; } + public function isLayoutMarker(): bool + { + return false; + } + + public function hasSubFields(): bool + { + return false; + } + public function label(): string { $key = 'builder::builder.field_types.'.str_replace('-', '_', static::key()); diff --git a/packages/builder/src/FieldTypes/Types/GroupFieldType.php b/packages/builder/src/FieldTypes/Types/GroupFieldType.php new file mode 100644 index 0000000000..afe73a4cee --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/GroupFieldType.php @@ -0,0 +1,67 @@ +name) + ->label($field->label) + ->schema($compiler->compileSubFields($field->children, null)) + ->minItems(1) + ->maxItems(1) + ->defaultItems(1) + ->addable(false) + ->deletable(false) + ->reorderable(false) + ->collapsible(false) + ->hiddenLabel($field->label === ''); + } + + public function castValue(mixed $raw): mixed + { + if (! is_array($raw)) { + return []; + } + + if (array_is_list($raw) && isset($raw[0]) && is_array($raw[0])) { + return $raw[0]; + } + + return $raw; + } + + public function normalizeForForm(mixed $stored): array + { + if (! is_array($stored) || $stored === []) { + return [[]]; + } + + if (array_is_list($stored)) { + return $stored; + } + + return [$stored]; + } +} diff --git a/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php b/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php new file mode 100644 index 0000000000..e2aa4d944f --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php @@ -0,0 +1,55 @@ +name) + ->label($field->label) + ->schema($compiler->compileSubFields($field->children, null)) + ->collapsible() + ->defaultItems(0) + ->reorderable(); + + return $this->applyCapabilitiesAndValidation($component, $field); + } + + public function castValue(mixed $raw): mixed + { + if (! is_array($raw)) { + return []; + } + + return array_values($raw); + } +} diff --git a/packages/builder/src/FieldTypes/Types/TabFieldType.php b/packages/builder/src/FieldTypes/Types/TabFieldType.php new file mode 100644 index 0000000000..5a70ff1838 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/TabFieldType.php @@ -0,0 +1,32 @@ +ulid = (string) Str::ulid(); } }); + + static::deleting(function (Field $field): void { + $field->children()->each(fn (Field $child) => $child->delete()); + }); } /** @@ -61,6 +65,14 @@ public function parentField(): BelongsTo return $this->belongsTo(self::class, 'parent_field_id'); } + /** + * @return HasMany + */ + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_field_id')->orderBy('sort'); + } + /** * @return HasMany */ diff --git a/packages/builder/src/Registry/DefinitionRegistry.php b/packages/builder/src/Registry/DefinitionRegistry.php index db0bcc98ee..4dab418613 100644 --- a/packages/builder/src/Registry/DefinitionRegistry.php +++ b/packages/builder/src/Registry/DefinitionRegistry.php @@ -7,6 +7,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Moox\Builder\Compiler\LocationMatcher; +use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\Data\LocationContext; use Moox\Builder\Models\FieldGroup; @@ -60,7 +61,12 @@ protected function loadActiveGroupsAsArrays(): array { return FieldGroup::query() ->active() - ->with(['fields.options']) + ->with([ + 'fields' => fn ($query) => $query->whereNull('parent_field_id')->orderBy('sort'), + 'fields.options', + 'fields.children' => fn ($query) => $query->orderBy('sort'), + 'fields.children.options', + ]) ->orderBy('sort') ->get() ->map(fn (FieldGroup $group): array => FieldGroupDefinition::fromModel($group)->toArray()) diff --git a/packages/builder/src/Registry/FieldTypeRegistry.php b/packages/builder/src/Registry/FieldTypeRegistry.php index 8df2bf774d..734bbc7e82 100644 --- a/packages/builder/src/Registry/FieldTypeRegistry.php +++ b/packages/builder/src/Registry/FieldTypeRegistry.php @@ -51,4 +51,24 @@ public function optionsForSelect(): array return $options; } + + /** + * @return array + */ + public function optionsForSubFields(): array + { + $options = []; + + foreach ($this->types as $key => $type) { + if ($type->isLayoutMarker() || $type->hasSubFields()) { + continue; + } + + $options[$key] = $type->label(); + } + + asort($options); + + return $options; + } } diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 8b960ed231..32bea24290 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -22,6 +22,7 @@ use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Support\Str; +use Moox\Builder\Exceptions\UnknownFieldTypeException; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Registry\FieldTypeRegistry; @@ -129,12 +130,11 @@ public static function form(Schema $schema): Schema ->orderColumn('sort') ->reorderable() ->collapsible() + ->collapsed() ->cloneable() ->collapseAllAction(fn (Action $action): Action => $action->hidden()) ->expandAllAction(fn (Action $action): Action => $action->hidden()) - ->itemLabel(fn (array $state): ?string => filled($state['label'] ?? null) - ? (string) $state['label'] - : __('builder::builder.field_group.field_item')) + ->itemLabel(fn (array $state): string => static::fieldRepeaterItemLabel($registry, $state)) ->schema([ Hidden::make('id'), Hidden::make('sort'), @@ -164,14 +164,16 @@ public static function form(Schema $schema): Schema ->helperText(__('builder::builder.field.name_helper')) ->required() ->maxLength(255) - ->regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/'), + ->regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/') + ->live(onBlur: true), Toggle::make('required') ->label(__('builder::builder.field.required')) - ->inline(false), + ->inline(false) + ->live(), Section::make(__('builder::builder.field.settings')) ->collapsed() ->schema(fn (callable $get): array => static::typeSettingsSchema($get('type'))) - ->visible(fn (callable $get): bool => filled($get('type'))), + ->visible(fn (callable $get): bool => static::typeHasSettings($get('type'))), Section::make(__('builder::builder.field.options')) ->collapsed() ->schema([ @@ -192,6 +194,20 @@ public static function form(Schema $schema): Schema ->defaultItems(1), ]) ->visible(fn (callable $get): bool => filled($get('type')) && $registry->get($get('type'))->hasOptions()), + Section::make(__('builder::builder.field.subfields')) + ->collapsed() + ->schema([ + Repeater::make('children') + ->hiddenLabel() + ->orderColumn('sort') + ->reorderable() + ->collapsible() + ->collapsed() + ->itemLabel(fn (array $state): string => static::fieldRepeaterItemLabel($registry, $state)) + ->schema(static::subFieldSchema($registry)) + ->defaultItems(0), + ]) + ->visible(fn (callable $get): bool => filled($get('type')) && $registry->get($get('type'))->hasSubFields()), ]), ]), ]) @@ -200,6 +216,118 @@ public static function form(Schema $schema): Schema ]); } + /** + * @param array $state + */ + protected static function fieldRepeaterItemLabel(FieldTypeRegistry $registry, array $state): string + { + $parts = []; + + $parts[] = filled($state['label'] ?? null) + ? (string) $state['label'] + : __('builder::builder.field_group.field_item'); + + if (filled($state['type'] ?? null)) { + $parts[] = static::fieldTypeLabel($registry, (string) $state['type']); + } + + if (filled($state['name'] ?? null)) { + $parts[] = (string) $state['name']; + } + + if (($state['required'] ?? false) === true) { + $parts[] = __('builder::builder.field.required_badge'); + } + + $childrenCount = count($state['children'] ?? []); + if ($childrenCount > 0) { + $parts[] = trans_choice('builder::builder.field.subfields_count', $childrenCount, [ + 'count' => $childrenCount, + ]); + } + + return implode(' · ', $parts); + } + + protected static function fieldTypeLabel(FieldTypeRegistry $registry, string $type): string + { + try { + return $registry->get($type)->label(); + } catch (UnknownFieldTypeException) { + return $type; + } + } + + /** + * @return list + */ + protected static function subFieldSchema(FieldTypeRegistry $registry): array + { + return [ + Hidden::make('id'), + Hidden::make('sort'), + Grid::make(2) + ->schema([ + TextInput::make('label') + ->label(__('builder::builder.field.label')) + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function ($state, callable $set, callable $get): void { + if (blank($get('name'))) { + $set('name', Str::slug((string) $state, '-')); + } + }), + Select::make('type') + ->label(__('builder::builder.field.type')) + ->options($registry->optionsForSubFields()) + ->required() + ->searchable() + ->live() + ->native(false), + ]), + TextInput::make('name') + ->label(__('builder::builder.field.name')) + ->required() + ->maxLength(255) + ->regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/') + ->live(onBlur: true), + Toggle::make('required') + ->label(__('builder::builder.field.required')) + ->inline(false) + ->live(), + Section::make(__('builder::builder.field.settings')) + ->collapsed() + ->schema(fn (callable $get): array => static::typeSettingsSchema($get('type'))) + ->visible(fn (callable $get): bool => static::typeHasSettings($get('type'))), + Section::make(__('builder::builder.field.options')) + ->collapsed() + ->schema([ + Repeater::make('options') + ->label(__('builder::builder.field.options')) + ->orderColumn('sort') + ->reorderable() + ->schema([ + Hidden::make('id'), + TextInput::make('label') + ->label(__('builder::builder.field.option_label')) + ->required(), + TextInput::make('value') + ->label(__('builder::builder.field.option_value')) + ->required(), + ]) + ->columns(2) + ->defaultItems(1), + ]) + ->visible(fn (callable $get): bool => filled($get('type')) && $registry->get($get('type'))->hasOptions()), + ]; + } + + protected static function typeHasSettings(?string $type): bool + { + return static::typeSettingsSchema($type) !== []; + } + /** * @return list */ @@ -262,11 +390,13 @@ public static function table(Table $table): Table ->searchable() ->sortable() ->description(fn (FieldGroup $record): ?string => $record->slug), - TextColumn::make('location_rules') + TextColumn::make('assigned_entities') ->label(__('builder::builder.field_group.assigned_to')) - ->formatStateUsing(fn (?array $state): string => $entityRegistry->labelsFor( - $persistence->entitiesFromLocationRules($state ?? []), - )) + ->getStateUsing(function (FieldGroup $record) use ($entityRegistry, $persistence): string { + return $entityRegistry->labelsFor( + $persistence->entitiesFromLocationRules($record->location_rules ?? []), + ); + }) ->wrap(), TextColumn::make('fields_count') ->counts('fields') diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php index d292f224bd..879a10b1b8 100644 --- a/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php @@ -38,21 +38,7 @@ protected function mutateFormDataBeforeFill(array $data): array $record->location_rules ?? [], ); - $data['fields'] = $record->fields->map(fn ($field): array => [ - 'id' => $field->getKey(), - 'label' => $field->label, - 'name' => $field->name, - 'type' => $field->type, - 'required' => (bool) ($field->validation['required'] ?? false), - 'config' => $field->config ?? [], - 'sort' => $field->sort, - 'options' => $field->options->map(fn ($option): array => [ - 'id' => $option->getKey(), - 'label' => $option->label, - 'value' => $option->value, - 'sort' => $option->sort, - ])->values()->all(), - ])->values()->all(); + $data['fields'] = app(FieldGroupPersistence::class)->fieldRowsForForm($record); return $data; } diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index 100989daf2..b452e1514a 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -40,7 +40,34 @@ public function fieldsForResource(string $resourceClass): Collection $this->locationContextForResource($resourceClass), ); - return $groups->flatMap(fn ($group) => $group->fields)->values(); + return $groups->flatMap(fn ($group) => $this->storableFieldsFromList($group->fields))->values(); + } + + /** + * @param Collection $fields + * @return Collection + */ + protected function storableFieldsFromList(Collection $fields): Collection + { + return $fields->flatMap(fn (FieldDefinition $field): Collection => $this->storableFieldsFor($field)); + } + + /** + * @return Collection + */ + protected function storableFieldsFor(FieldDefinition $field): Collection + { + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if ($fieldType->isLayoutMarker()) { + return collect(); + } + + if ($fieldType->hasSubFields()) { + return collect([$field]); + } + + return collect([$field]); } /** diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 5607b7adfb..4caf7c9c53 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -5,10 +5,12 @@ namespace Moox\Builder\Services; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldOption; +use Moox\Builder\Registry\FieldTypeRegistry; class FieldGroupPersistence { @@ -143,12 +145,59 @@ public function flattenLocationRulesForForm(array $rules): array return $rows; } + /** + * @return list> + */ + public function fieldRowsForForm(FieldGroup $group): array + { + $group->load([ + 'fields' => fn ($query) => $query->whereNull('parent_field_id')->orderBy('sort'), + 'fields.options', + 'fields.children' => fn ($query) => $query->orderBy('sort'), + 'fields.children.options', + ]); + + return $this->mapFieldRows($group->fields); + } + + /** + * @param Collection $fields + * @return list> + */ + protected function mapFieldRows(Collection $fields): array + { + return $fields->map(fn (Field $field): array => [ + 'id' => $field->getKey(), + 'label' => $field->label, + 'name' => $field->name, + 'type' => $field->type, + 'required' => (bool) ($field->validation['required'] ?? false), + 'config' => $field->config ?? [], + 'sort' => $field->sort, + 'options' => $field->options->map(fn (FieldOption $option): array => [ + 'id' => $option->getKey(), + 'label' => $option->label, + 'value' => $option->value, + 'sort' => $option->sort, + ])->values()->all(), + 'children' => $this->mapFieldRows($field->children), + ])->values()->all(); + } + /** * @param list> $rows */ - protected function syncFields(FieldGroup $group, array $rows): void + protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldId = null): void { - $existing = $group->fields()->with('options')->get()->keyBy('id'); + $existingQuery = $group->fields()->with('options'); + + if ($parentFieldId === null) { + $existingQuery->whereNull('parent_field_id'); + } else { + $existingQuery->where('parent_field_id', $parentFieldId); + } + + $existing = $existingQuery->get()->keyBy('id'); $retainedIds = []; foreach ($rows as $index => $row) { @@ -167,6 +216,7 @@ protected function syncFields(FieldGroup $group, array $rows): void $field->fill([ 'field_group_id' => $group->getKey(), + 'parent_field_id' => $parentFieldId, 'name' => (string) $row['name'], 'label' => (string) ($row['label'] ?? $row['name']), 'type' => (string) $row['type'], @@ -188,13 +238,32 @@ protected function syncFields(FieldGroup $group, array $rows): void $retainedIds[] = $field->getKey(); $this->syncOptions($field, $row['options'] ?? []); + + if ($this->typeHasSubFields((string) $row['type'])) { + $this->syncFields($group, $row['children'] ?? [], $field->getKey()); + } else { + $field->children()->each(fn (Field $child) => $child->delete()); + } + } + + $deleteQuery = $group->fields(); + + if ($parentFieldId === null) { + $deleteQuery->whereNull('parent_field_id'); + } else { + $deleteQuery->where('parent_field_id', $parentFieldId); } - $group->fields() + $deleteQuery ->whereNotIn('id', $retainedIds) ->each(fn (Field $field) => $field->delete()); } + protected function typeHasSubFields(string $type): bool + { + return app(FieldTypeRegistry::class)->get($type)->hasSubFields(); + } + /** * @param list> $rows */ diff --git a/packages/builder/src/Services/FieldGroupValidator.php b/packages/builder/src/Services/FieldGroupValidator.php index 7566c0c1c5..307baab635 100644 --- a/packages/builder/src/Services/FieldGroupValidator.php +++ b/packages/builder/src/Services/FieldGroupValidator.php @@ -29,18 +29,27 @@ public function validate(FieldGroup $group, array $data): void $messages = []; $messages = array_merge($messages, $this->internalDuplicateMessages($fieldRows)); - $messages = array_merge($messages, $this->externalConflictMessages($group, $entities, $fieldRows)); + $messages = array_merge($messages, $this->externalConflictMessages($group, $entities, $this->rootFieldRows($fieldRows))); if ($messages !== []) { throw ValidationException::withMessages($messages); } } + /** + * @param list> $fieldRows + * @return list> + */ + protected function rootFieldRows(array $fieldRows): array + { + return $fieldRows; + } + /** * @param list> $fieldRows * @return array> */ - protected function internalDuplicateMessages(array $fieldRows): array + protected function internalDuplicateMessages(array $fieldRows, string $pathPrefix = 'fields'): array { $seen = []; $messages = []; @@ -53,7 +62,7 @@ protected function internalDuplicateMessages(array $fieldRows): array } if (isset($seen[$name])) { - $messages["fields.{$index}.name"] = [ + $messages["{$pathPrefix}.{$index}.name"] = [ __('builder::builder.validation.duplicate_field_name_internal', ['name' => $name]), ]; @@ -61,6 +70,13 @@ protected function internalDuplicateMessages(array $fieldRows): array } $seen[$name] = $index; + + if (isset($row['children']) && is_array($row['children'])) { + $messages = array_merge( + $messages, + $this->internalDuplicateMessages($row['children'], "{$pathPrefix}.{$index}.children"), + ); + } } return $messages; diff --git a/packages/builder/src/Support/TypedValueColumns.php b/packages/builder/src/Support/TypedValueColumns.php index 5797663ed2..fe0a46362d 100644 --- a/packages/builder/src/Support/TypedValueColumns.php +++ b/packages/builder/src/Support/TypedValueColumns.php @@ -32,7 +32,7 @@ public static function columnForType(string $type): string 'date' => 'value_date', 'datetime' => 'value_datetime', 'toggle' => 'value_boolean', - 'multiselect', 'checkbox_list', 'link' => 'value_json', + 'multiselect', 'checkbox_list', 'link', 'group', 'repeater' => 'value_json', 'button_group' => 'value_string', default => 'value_string', }; diff --git a/packages/item/src/Resources/ItemResource.php b/packages/item/src/Resources/ItemResource.php index 47cb2101fa..e44500bc1f 100644 --- a/packages/item/src/Resources/ItemResource.php +++ b/packages/item/src/Resources/ItemResource.php @@ -11,6 +11,7 @@ use Filament\Schemas\Schema; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Moox\Builder\Concerns\HasCustomFields; use Moox\Core\Entities\Items\Item\BaseItemResource; use Moox\Item\Models\Item; use Moox\Item\Resources\ItemResource\Pages\CreateItem; @@ -20,6 +21,7 @@ class ItemResource extends BaseItemResource { + use HasCustomFields; protected static ?string $model = Item::class; protected static string|\BackedEnum|null $navigationIcon = 'gmdi-local-offer'; @@ -60,6 +62,7 @@ public static function form(Schema $form): Schema ->label(__('core::core.title')), MarkdownEditor::make('description') ->label(__('core::core.description')), + ...static::customFieldComponents(), Grid::make(2) ->schema([ static::getFooterActions()->columnSpan(1), diff --git a/packages/record/src/Resources/RecordResource.php b/packages/record/src/Resources/RecordResource.php index c19b847285..72516c4e83 100644 --- a/packages/record/src/Resources/RecordResource.php +++ b/packages/record/src/Resources/RecordResource.php @@ -9,6 +9,7 @@ use Filament\Schemas\Schema; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Moox\Builder\Concerns\HasCustomFields; use Moox\Core\Entities\Items\Record\BaseRecordResource; use Moox\Core\Traits\Tabs\HasResourceTabs; use Moox\Core\Traits\Taxonomy\HasResourceTaxonomy; @@ -23,6 +24,7 @@ class RecordResource extends BaseRecordResource { use HasResourceTabs; + use HasCustomFields; use HasResourceTaxonomy; protected static ?string $model = Record::class; @@ -76,6 +78,7 @@ public static function form(Schema $form): Schema ), RichEditor::make('description') ->label(__('core::core.description')), + ...static::customFieldComponents(), Grid::make(2) ->schema([ static::getFooterActions()->columnSpan(1), From a36b14b4470fe232cf13c2f956768b0c0377f8b7 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:26:27 +0200 Subject: [PATCH 010/105] validation --- .../database/seeders/BuilderSeeder.php | 1 + .../builder/resources/lang/de/builder.php | 1 + .../builder/resources/lang/en/builder.php | 1 + .../builder/src/Compiler/SchemaCompiler.php | 13 +- packages/builder/src/FieldTypes/FieldType.php | 18 ++ .../src/FieldTypes/Types/GroupFieldType.php | 4 +- .../FieldTypes/Types/RepeaterFieldType.php | 5 +- .../src/Services/CustomFieldsManager.php | 4 + .../src/Services/FieldValueValidator.php | 186 ++++++++++++++++++ 9 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 packages/builder/src/Services/FieldValueValidator.php diff --git a/packages/builder/database/seeders/BuilderSeeder.php b/packages/builder/database/seeders/BuilderSeeder.php index 99839d2318..b9e52d917c 100644 --- a/packages/builder/database/seeders/BuilderSeeder.php +++ b/packages/builder/database/seeders/BuilderSeeder.php @@ -152,6 +152,7 @@ protected function seedFahrzeugdatenGroup(): void 'label' => 'Merkmal', 'type' => 'text', 'sort' => 0, + 'required' => true, ], [ 'name' => 'enthalten', diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index ebecd96e1e..6e41eee42d 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -108,5 +108,6 @@ 'invalid_option' => 'Der gewählte Wert ist keine gültige Option.', 'duplicate_field_name' => 'Der Feldschlüssel „:name“ wird bereits in der Gruppe „:group“ verwendet.', 'duplicate_field_name_internal' => 'Der Feldschlüssel „:name“ ist in dieser Gruppe mehrfach vergeben.', + 'empty_repeater_item' => 'Eintrag :position in „:field“ ist leer.', ], ]; diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 5eb00b2837..54630defd2 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -108,5 +108,6 @@ 'invalid_option' => 'The selected value is not a valid option.', 'duplicate_field_name' => 'The field key ":name" is already used in the group ":group".', 'duplicate_field_name_internal' => 'The field key ":name" is assigned more than once in this group.', + 'empty_repeater_item' => 'Row :position in ":field" is empty.', ], ]; diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index cefa13c647..69502aacf2 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -127,7 +127,7 @@ public function rules(Collection $fieldGroups): array /** * @return array> */ - protected function rulesForFieldTree(FieldDefinition $field): array + protected function rulesForFieldTree(FieldDefinition $field, string $prefix = ''): array { $rules = []; $fieldType = $this->fieldTypeRegistry->get($field->type); @@ -137,8 +137,14 @@ protected function rulesForFieldTree(FieldDefinition $field): array } if ($fieldType->hasSubFields()) { + $childPrefix = $prefix === '' ? $field->name : "{$prefix}.{$field->name}"; + + if ($field->type === 'repeater') { + $childPrefix .= '.*'; + } + foreach ($field->children as $child) { - $rules = array_merge($rules, $this->rulesForFieldTree($child)); + $rules = array_merge($rules, $this->rulesForFieldTree($child, $childPrefix)); } return $rules; @@ -147,7 +153,8 @@ protected function rulesForFieldTree(FieldDefinition $field): array $fieldRules = $this->rulesForField($field); if ($fieldRules !== []) { - $rules[$field->name] = $fieldRules; + $key = $prefix === '' ? $field->name : "{$prefix}.{$field->name}"; + $rules[$key] = $fieldRules; } return $rules; diff --git a/packages/builder/src/FieldTypes/FieldType.php b/packages/builder/src/FieldTypes/FieldType.php index e82307d27e..404175a818 100644 --- a/packages/builder/src/FieldTypes/FieldType.php +++ b/packages/builder/src/FieldTypes/FieldType.php @@ -7,6 +7,7 @@ use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\FieldTypes\Capabilities\Capability; +use Moox\Builder\Services\FieldValueValidator; abstract class FieldType { @@ -77,6 +78,23 @@ protected function applyCapabilitiesAndValidation(Component $component, FieldDef return $component; } + protected function applyNestedValueValidation(Component $component, FieldDefinition $field): Component + { + if (! $this->hasSubFields()) { + return $component; + } + + return $component->rules([ + fn (): \Closure => function (string $attribute, mixed $value, \Closure $fail) use ($field): void { + foreach (app(FieldValueValidator::class)->messagesFor($field, $value, $attribute) as $messages) { + foreach ($messages as $message) { + $fail($message); + } + } + }, + ]); + } + /** * @return list */ diff --git a/packages/builder/src/FieldTypes/Types/GroupFieldType.php b/packages/builder/src/FieldTypes/Types/GroupFieldType.php index afe73a4cee..afeac8978b 100644 --- a/packages/builder/src/FieldTypes/Types/GroupFieldType.php +++ b/packages/builder/src/FieldTypes/Types/GroupFieldType.php @@ -26,7 +26,7 @@ public function formComponent(FieldDefinition $field): Component { $compiler = app(SchemaCompiler::class); - return Repeater::make($field->name) + $component = Repeater::make($field->name) ->label($field->label) ->schema($compiler->compileSubFields($field->children, null)) ->minItems(1) @@ -37,6 +37,8 @@ public function formComponent(FieldDefinition $field): Component ->reorderable(false) ->collapsible(false) ->hiddenLabel($field->label === ''); + + return $this->applyNestedValueValidation($component, $field); } public function castValue(mixed $raw): mixed diff --git a/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php b/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php index e2aa4d944f..890fa661a7 100644 --- a/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php +++ b/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php @@ -41,7 +41,10 @@ public function formComponent(FieldDefinition $field): Component ->defaultItems(0) ->reorderable(); - return $this->applyCapabilitiesAndValidation($component, $field); + return $this->applyNestedValueValidation( + $this->applyCapabilitiesAndValidation($component, $field), + $field, + ); } public function castValue(mixed $raw): mixed diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index b452e1514a..321d1cf353 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -12,6 +12,7 @@ use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Services\FieldValueValidator; use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Support\TypedValueColumns; @@ -20,6 +21,7 @@ class CustomFieldsManager public function __construct( protected DefinitionRegistry $definitionRegistry, protected FieldTypeRegistry $fieldTypeRegistry, + protected FieldValueValidator $fieldValueValidator, ) {} /** @@ -192,6 +194,8 @@ public function saveValues(string $entity, Model $record, array $values, Collect OptionValueRules::assertValid($field, $value); } + $this->fieldValueValidator->assertValid($field, $value); + $cast = $fieldType->castValue($value); $columns = TypedValueColumns::attributesFor($field->type, $cast); diff --git a/packages/builder/src/Services/FieldValueValidator.php b/packages/builder/src/Services/FieldValueValidator.php new file mode 100644 index 0000000000..a121f3b7c3 --- /dev/null +++ b/packages/builder/src/Services/FieldValueValidator.php @@ -0,0 +1,186 @@ +> + */ + public function messagesFor(FieldDefinition $field, mixed $value, ?string $path = null): array + { + $path ??= $field->name; + + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if ($fieldType->isLayoutMarker()) { + return []; + } + + if ($fieldType->hasSubFields()) { + return $this->messagesForCompound($field, $value, $path); + } + + return $this->messagesForLeaf($field, $value, $path); + } + + public function assertValid(FieldDefinition $field, mixed $value): void + { + $messages = $this->messagesFor($field, $value); + + if ($messages !== []) { + throw ValidationException::withMessages($messages); + } + } + + /** + * @return array> + */ + protected function messagesForCompound(FieldDefinition $field, mixed $value, string $path): array + { + if ($field->type === 'group') { + return $this->messagesForRow($field, $this->normalizeGroupRow($value), $path); + } + + if (! is_array($value)) { + return []; + } + + $messages = []; + + foreach (array_values($value) as $index => $item) { + if (! is_array($item)) { + continue; + } + + $itemPath = "{$path}.{$index}"; + + if ($this->isRowEmpty($field, $item)) { + $messages[$itemPath] = [ + __('builder::builder.validation.empty_repeater_item', [ + 'position' => $index + 1, + 'field' => $field->label, + ]), + ]; + + continue; + } + + $messages = array_merge($messages, $this->messagesForRow($field, $item, $itemPath)); + } + + return $messages; + } + + /** + * @return array> + */ + protected function messagesForRow(FieldDefinition $parent, array $row, string $path): array + { + $messages = []; + + foreach ($parent->children as $child) { + $childPath = "{$path}.{$child->name}"; + $messages = array_merge( + $messages, + $this->messagesFor($child, $row[$child->name] ?? null, $childPath), + ); + } + + return $messages; + } + + /** + * @return array> + */ + protected function messagesForLeaf(FieldDefinition $field, mixed $value, string $path): array + { + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if (! $fieldType->storesValue()) { + return []; + } + + $messages = []; + + if (($field->validation['required'] ?? false) === true && $this->isEmptyValue($field->type, $value)) { + $messages[$path] = [ + __('validation.required', ['attribute' => $field->label]), + ]; + } + + if ($this->isEmptyValue($field->type, $value)) { + return $messages; + } + + try { + OptionValueRules::assertValid($field, $value); + } catch (ValidationException $exception) { + foreach ($exception->errors() as $key => $errorMessages) { + $messages[$key !== 'value' ? $key : $path] = $errorMessages; + } + } + + return $messages; + } + + /** + * @param array $row + */ + protected function isRowEmpty(FieldDefinition $parent, array $row): bool + { + foreach ($parent->children as $child) { + $fieldType = $this->fieldTypeRegistry->get($child->type); + + if (! $fieldType->storesValue()) { + continue; + } + + if (! $this->isEmptyValue($child->type, $row[$child->name] ?? null)) { + return false; + } + } + + return true; + } + + protected function isEmptyValue(string $type, mixed $value): bool + { + if ($type === 'toggle') { + return $value === null || $value === false; + } + + if (is_array($value)) { + return $value === []; + } + + return blank($value); + } + + /** + * @return array + */ + protected function normalizeGroupRow(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + if (array_is_list($value) && isset($value[0]) && is_array($value[0])) { + return $value[0]; + } + + return $value; + } +} From 97afebef05ff8cddbaa9889f94f7fac3b84681dc Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:17:47 +0000 Subject: [PATCH 011/105] Fix styling --- packages/builder/src/Registry/DefinitionRegistry.php | 1 - packages/item/src/Resources/ItemResource.php | 1 + packages/record/src/Resources/RecordResource.php | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/builder/src/Registry/DefinitionRegistry.php b/packages/builder/src/Registry/DefinitionRegistry.php index 4dab418613..7aad131c24 100644 --- a/packages/builder/src/Registry/DefinitionRegistry.php +++ b/packages/builder/src/Registry/DefinitionRegistry.php @@ -7,7 +7,6 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Moox\Builder\Compiler\LocationMatcher; -use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\Data\LocationContext; use Moox\Builder\Models\FieldGroup; diff --git a/packages/item/src/Resources/ItemResource.php b/packages/item/src/Resources/ItemResource.php index e44500bc1f..343a2b4c88 100644 --- a/packages/item/src/Resources/ItemResource.php +++ b/packages/item/src/Resources/ItemResource.php @@ -22,6 +22,7 @@ class ItemResource extends BaseItemResource { use HasCustomFields; + protected static ?string $model = Item::class; protected static string|\BackedEnum|null $navigationIcon = 'gmdi-local-offer'; diff --git a/packages/record/src/Resources/RecordResource.php b/packages/record/src/Resources/RecordResource.php index 72516c4e83..de3b01bbc6 100644 --- a/packages/record/src/Resources/RecordResource.php +++ b/packages/record/src/Resources/RecordResource.php @@ -23,8 +23,8 @@ class RecordResource extends BaseRecordResource { - use HasResourceTabs; use HasCustomFields; + use HasResourceTabs; use HasResourceTaxonomy; protected static ?string $model = Record::class; From 52b89ee40ad7186f60deb92a1fedae4dc3b13304 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:27:50 +0000 Subject: [PATCH 012/105] Fix styling --- packages/builder/src/Services/CustomFieldsManager.php | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index 321d1cf353..ca975f436c 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -12,7 +12,6 @@ use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Registry\FieldTypeRegistry; -use Moox\Builder\Services\FieldValueValidator; use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Support\TypedValueColumns; From b08e27d2250dd26af2ce688b4fbdc70323a17ea8 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:56:16 +0200 Subject: [PATCH 013/105] flexible layout --- .../database/seeders/BuilderSeeder.php | 61 +++++++++++- .../builder/resources/lang/de/builder.php | 14 +++ .../builder/resources/lang/en/builder.php | 14 +++ .../builder/src/BuilderServiceProvider.php | 4 + .../builder/src/Compiler/SchemaCompiler.php | 9 ++ packages/builder/src/Data/FieldDefinition.php | 11 +++ .../FieldTypes/Capabilities/RepeaterItems.php | 4 +- packages/builder/src/FieldTypes/FieldType.php | 10 ++ .../Types/FlexibleContentFieldType.php | 98 +++++++++++++++++++ .../Types/FlexibleLayoutFieldType.php | 37 +++++++ .../src/Registry/DefinitionRegistry.php | 2 + .../src/Registry/FieldTypeRegistry.php | 6 +- .../src/Resources/FieldGroupResource.php | 88 ++++++++++++++++- .../src/Services/FieldGroupPersistence.php | 74 ++++++++++---- .../src/Services/FieldGroupValidator.php | 7 ++ .../src/Services/FieldValueValidator.php | 61 ++++++++++++ .../builder/src/Support/TypedValueColumns.php | 2 +- 17 files changed, 480 insertions(+), 22 deletions(-) create mode 100644 packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php create mode 100644 packages/builder/src/FieldTypes/Types/FlexibleLayoutFieldType.php diff --git a/packages/builder/database/seeders/BuilderSeeder.php b/packages/builder/database/seeders/BuilderSeeder.php index b9e52d917c..185b634fba 100644 --- a/packages/builder/database/seeders/BuilderSeeder.php +++ b/packages/builder/database/seeders/BuilderSeeder.php @@ -275,11 +275,52 @@ protected function seedLayoutShowcaseGroup(): void ], ], ], + [ + 'name' => 'seiteninhalt', + 'label' => 'Seiteninhalt', + 'type' => 'flexible_content', + 'sort' => 10, + 'layouts' => [ + [ + 'name' => 'hero', + 'label' => 'Hero', + 'sort' => 0, + 'children' => [ + [ + 'name' => 'titel', + 'label' => 'Titel', + 'type' => 'text', + 'sort' => 0, + 'required' => true, + ], + [ + 'name' => 'text', + 'label' => 'Text', + 'type' => 'textarea', + 'sort' => 1, + ], + ], + ], + [ + 'name' => 'text-block', + 'label' => 'Textblock', + 'sort' => 1, + 'children' => [ + [ + 'name' => 'inhalt', + 'label' => 'Inhalt', + 'type' => 'rich_text', + 'sort' => 0, + ], + ], + ], + ], + ], [ 'name' => 'demo-oembed', 'label' => 'Video (oEmbed)', 'type' => 'oembed', - 'sort' => 10, + 'sort' => 11, 'config' => [ 'placeholder' => 'https://www.youtube.com/watch?v=…', ], @@ -348,6 +389,24 @@ protected function seedDemoValuesForFirstItem(): void ['datum' => '2024-03-01', 'ereignis' => 'Inspektion'], ], ], + 'seiteninhalt' => [ + 'type' => 'flexible_content', + 'value' => [ + [ + 'type' => 'hero', + 'data' => [ + 'titel' => 'Willkommen beim Layout-Showcase', + 'text' => 'Flexible Inhalte mit unterschiedlichen Layouts.', + ], + ], + [ + 'type' => 'text-block', + 'data' => [ + 'inhalt' => '

Dies ist ein Textblock im Flexible-Content-Feld.

', + ], + ], + ], + ], 'demo-oembed' => [ 'type' => 'oembed', 'value' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 6e41eee42d..ee1fce8e66 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -40,6 +40,12 @@ 'option_label' => 'Anzeigetext', 'option_value' => 'Wert', 'subfields' => 'Unterfelder', + 'layouts' => 'Layouts', + 'layout_label' => 'Layout-Bezeichnung', + 'layout_key' => 'Layout-Schlüssel', + 'layout_key_helper' => 'Technischer Schlüssel für gespeicherte Blöcke. Nur Kleinbuchstaben, Zahlen und Bindestriche.', + 'layout_item' => 'Layout', + 'layouts_count' => '{1} :count Layout|[2,*] :count Layouts', ], 'field_types' => [ @@ -67,6 +73,11 @@ 'tab' => 'Tab', 'group' => 'Gruppe', 'repeater' => 'Repeater', + 'flexible_content' => 'Flexible Inhalte', + ], + + 'flexible_content' => [ + 'add_layout' => 'Layout hinzufügen', ], 'message' => [ @@ -109,5 +120,8 @@ 'duplicate_field_name' => 'Der Feldschlüssel „:name“ wird bereits in der Gruppe „:group“ verwendet.', 'duplicate_field_name_internal' => 'Der Feldschlüssel „:name“ ist in dieser Gruppe mehrfach vergeben.', 'empty_repeater_item' => 'Eintrag :position in „:field“ ist leer.', + 'empty_flexible_item' => 'Block :position in „:field“ ist leer.', + 'empty_flexible_layout' => 'Bitte ein Layout für jeden Block wählen.', + 'unknown_flexible_layout' => 'Unbekanntes Layout „:layout“.', ], ]; diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 54630defd2..286ce3c5d8 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -40,6 +40,12 @@ 'option_label' => 'Display text', 'option_value' => 'Value', 'subfields' => 'Subfields', + 'layouts' => 'Layouts', + 'layout_label' => 'Layout label', + 'layout_key' => 'Layout key', + 'layout_key_helper' => 'Technical identifier used when storing blocks. Lowercase letters, numbers, and hyphens only.', + 'layout_item' => 'Layout', + 'layouts_count' => '{1} :count layout|[2,*] :count layouts', ], 'field_types' => [ @@ -67,6 +73,11 @@ 'tab' => 'Tab', 'group' => 'Group', 'repeater' => 'Repeater', + 'flexible_content' => 'Flexible content', + ], + + 'flexible_content' => [ + 'add_layout' => 'Add layout', ], 'message' => [ @@ -109,5 +120,8 @@ 'duplicate_field_name' => 'The field key ":name" is already used in the group ":group".', 'duplicate_field_name_internal' => 'The field key ":name" is assigned more than once in this group.', 'empty_repeater_item' => 'Row :position in ":field" is empty.', + 'empty_flexible_item' => 'Block :position in ":field" is empty.', + 'empty_flexible_layout' => 'Please choose a layout for each block.', + 'unknown_flexible_layout' => 'Unknown layout ":layout".', ], ]; diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index 896472a09b..b855934082 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -13,6 +13,8 @@ use Moox\Builder\FieldTypes\Types\DateFieldType; use Moox\Builder\FieldTypes\Types\DatetimeFieldType; use Moox\Builder\FieldTypes\Types\EmailFieldType; +use Moox\Builder\FieldTypes\Types\FlexibleContentFieldType; +use Moox\Builder\FieldTypes\Types\FlexibleLayoutFieldType; use Moox\Builder\FieldTypes\Types\GroupFieldType; use Moox\Builder\FieldTypes\Types\LinkFieldType; use Moox\Builder\FieldTypes\Types\MessageFieldType; @@ -119,6 +121,8 @@ protected function defaultFieldTypes(): array new TabFieldType, new GroupFieldType, new RepeaterFieldType, + new FlexibleContentFieldType, + new FlexibleLayoutFieldType, ]; } } diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 69502aacf2..cb42ae2ff4 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -4,6 +4,7 @@ namespace Moox\Builder\Compiler; +use Filament\Forms\Components\Builder; use Filament\Schemas\Components\Component; use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Tabs; @@ -137,6 +138,10 @@ protected function rulesForFieldTree(FieldDefinition $field, string $prefix = '' } if ($fieldType->hasSubFields()) { + if ($field->type === 'flexible_content') { + return $rules; + } + $childPrefix = $prefix === '' ? $field->name : "{$prefix}.{$field->name}"; if ($field->type === 'repeater') { @@ -239,6 +244,10 @@ protected function compileField(FieldDefinition $field, ?string $resourceClass = } $component->state($value); + + if ($component instanceof Builder) { + $component->hydrateItems(); + } }); } } diff --git a/packages/builder/src/Data/FieldDefinition.php b/packages/builder/src/Data/FieldDefinition.php index 846fa11cf2..628d083607 100644 --- a/packages/builder/src/Data/FieldDefinition.php +++ b/packages/builder/src/Data/FieldDefinition.php @@ -54,6 +54,17 @@ public static function fromModel(Field $field): self ); } + /** + * @return Collection + */ + public function layouts(): Collection + { + return $this->children + ->filter(fn (self $child): bool => $child->type === 'flexible_layout') + ->sortBy(fn (self $child): int => $child->sort) + ->values(); + } + /** * @return array{name: string, label: string, type: string, sort: int, config: array, validation: array, options: list, children: list>} */ diff --git a/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php b/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php index 39e2d73d8b..a4fe0dcc97 100644 --- a/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php +++ b/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php @@ -4,6 +4,7 @@ namespace Moox\Builder\FieldTypes\Capabilities; +use Filament\Forms\Components\Builder; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\TextInput; use Filament\Schemas\Components\Component; @@ -27,7 +28,7 @@ public function builderFields(): array public function apply(Component $component, FieldDefinition $field): Component { - if (! $component instanceof Repeater) { + if (! $component instanceof Repeater && ! $component instanceof Builder) { return $component; } @@ -45,3 +46,4 @@ public function apply(Component $component, FieldDefinition $field): Component return $component; } } + diff --git a/packages/builder/src/FieldTypes/FieldType.php b/packages/builder/src/FieldTypes/FieldType.php index 404175a818..e04208a051 100644 --- a/packages/builder/src/FieldTypes/FieldType.php +++ b/packages/builder/src/FieldTypes/FieldType.php @@ -48,6 +48,16 @@ public function hasSubFields(): bool return false; } + public function hasLayouts(): bool + { + return false; + } + + public function isInternal(): bool + { + return false; + } + public function label(): string { $key = 'builder::builder.field_types.'.str_replace('-', '_', static::key()); diff --git a/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php b/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php new file mode 100644 index 0000000000..5dad192585 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php @@ -0,0 +1,98 @@ +layouts() + ->map(fn (FieldDefinition $layout): Block => Block::make($layout->name) + ->label($layout->label) + ->schema($compiler->compileSubFields($layout->children, null))) + ->all(); + + $component = Builder::make($field->name) + ->label($field->label) + ->blocks($blocks) + ->addActionLabel(__('builder::builder.flexible_content.add_layout')) + ->collapsible() + ->collapsed(); + + return $this->applyNestedValueValidation( + $this->applyCapabilitiesAndValidation($component, $field), + $field, + ); + } + + public function castValue(mixed $raw): mixed + { + if (! is_array($raw)) { + return []; + } + + $items = []; + + foreach ($raw as $item) { + if (! is_array($item)) { + continue; + } + + if (isset($item['type'], $item['data']) && is_array($item['data'])) { + $items[] = [ + 'type' => (string) $item['type'], + 'data' => $item['data'], + ]; + + continue; + } + + if (isset($item['layout']) && is_array($item)) { + $layout = (string) $item['layout']; + unset($item['layout']); + $items[] = ['type' => $layout, 'data' => $item]; + } + } + + return array_values($items); + } + + public function normalizeForForm(mixed $stored): array + { + return $this->castValue($stored); + } +} diff --git a/packages/builder/src/FieldTypes/Types/FlexibleLayoutFieldType.php b/packages/builder/src/FieldTypes/Types/FlexibleLayoutFieldType.php new file mode 100644 index 0000000000..a1a0b7f758 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/FlexibleLayoutFieldType.php @@ -0,0 +1,37 @@ + fn ($query) => $query->orderBy('sort'), 'fields.children.options', + 'fields.children.children' => fn ($query) => $query->orderBy('sort'), + 'fields.children.children.options', ]) ->orderBy('sort') ->get() diff --git a/packages/builder/src/Registry/FieldTypeRegistry.php b/packages/builder/src/Registry/FieldTypeRegistry.php index 734bbc7e82..c87414c06b 100644 --- a/packages/builder/src/Registry/FieldTypeRegistry.php +++ b/packages/builder/src/Registry/FieldTypeRegistry.php @@ -44,6 +44,10 @@ public function optionsForSelect(): array $options = []; foreach ($this->types as $key => $type) { + if ($type->isInternal()) { + continue; + } + $options[$key] = $type->label(); } @@ -60,7 +64,7 @@ public function optionsForSubFields(): array $options = []; foreach ($this->types as $key => $type) { - if ($type->isLayoutMarker() || $type->hasSubFields()) { + if ($type->isLayoutMarker() || $type->hasSubFields() || $type->isInternal()) { continue; } diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 32bea24290..f9b0a99680 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -207,7 +207,21 @@ public static function form(Schema $schema): Schema ->schema(static::subFieldSchema($registry)) ->defaultItems(0), ]) - ->visible(fn (callable $get): bool => filled($get('type')) && $registry->get($get('type'))->hasSubFields()), + ->visible(fn (callable $get): bool => filled($get('type')) && $registry->get($get('type'))->hasSubFields() && $get('type') !== 'flexible_content'), + Section::make(__('builder::builder.field.layouts')) + ->collapsed() + ->schema([ + Repeater::make('layouts') + ->hiddenLabel() + ->orderColumn('sort') + ->reorderable() + ->collapsible() + ->collapsed() + ->itemLabel(fn (array $state): string => static::layoutRepeaterItemLabel($registry, $state)) + ->schema(static::layoutSchema($registry)) + ->defaultItems(0), + ]) + ->visible(fn (callable $get): bool => filled($get('type')) && $get('type') === 'flexible_content'), ]), ]), ]) @@ -246,6 +260,38 @@ protected static function fieldRepeaterItemLabel(FieldTypeRegistry $registry, ar ]); } + $layoutsCount = count($state['layouts'] ?? []); + if ($layoutsCount > 0) { + $parts[] = trans_choice('builder::builder.field.layouts_count', $layoutsCount, [ + 'count' => $layoutsCount, + ]); + } + + return implode(' · ', $parts); + } + + /** + * @param array $state + */ + protected static function layoutRepeaterItemLabel(FieldTypeRegistry $registry, array $state): string + { + $parts = []; + + $parts[] = filled($state['label'] ?? null) + ? (string) $state['label'] + : __('builder::builder.field.layout_item'); + + if (filled($state['name'] ?? null)) { + $parts[] = (string) $state['name']; + } + + $childrenCount = count($state['children'] ?? []); + if ($childrenCount > 0) { + $parts[] = trans_choice('builder::builder.field.subfields_count', $childrenCount, [ + 'count' => $childrenCount, + ]); + } + return implode(' · ', $parts); } @@ -258,6 +304,46 @@ protected static function fieldTypeLabel(FieldTypeRegistry $registry, string $ty } } + /** + * @return list + */ + protected static function layoutSchema(FieldTypeRegistry $registry): array + { + return [ + Hidden::make('id'), + Hidden::make('sort'), + Grid::make(2) + ->schema([ + TextInput::make('label') + ->label(__('builder::builder.field.layout_label')) + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function ($state, callable $set, callable $get): void { + if (blank($get('name'))) { + $set('name', Str::slug((string) $state, '-')); + } + }), + ]), + TextInput::make('name') + ->label(__('builder::builder.field.layout_key')) + ->helperText(__('builder::builder.field.layout_key_helper')) + ->required() + ->maxLength(255) + ->regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/') + ->live(onBlur: true), + Repeater::make('children') + ->label(__('builder::builder.field.subfields')) + ->orderColumn('sort') + ->reorderable() + ->collapsible() + ->collapsed() + ->itemLabel(fn (array $state): string => static::fieldRepeaterItemLabel($registry, $state)) + ->schema(static::subFieldSchema($registry)) + ->defaultItems(0), + ]; + } + /** * @return list */ diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 4caf7c9c53..fbcbec8850 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -155,6 +155,8 @@ public function fieldRowsForForm(FieldGroup $group): array 'fields.options', 'fields.children' => fn ($query) => $query->orderBy('sort'), 'fields.children.options', + 'fields.children.children' => fn ($query) => $query->orderBy('sort'), + 'fields.children.children.options', ]); return $this->mapFieldRows($group->fields); @@ -166,22 +168,50 @@ public function fieldRowsForForm(FieldGroup $group): array */ protected function mapFieldRows(Collection $fields): array { - return $fields->map(fn (Field $field): array => [ - 'id' => $field->getKey(), - 'label' => $field->label, - 'name' => $field->name, - 'type' => $field->type, - 'required' => (bool) ($field->validation['required'] ?? false), - 'config' => $field->config ?? [], - 'sort' => $field->sort, - 'options' => $field->options->map(fn (FieldOption $option): array => [ - 'id' => $option->getKey(), - 'label' => $option->label, - 'value' => $option->value, - 'sort' => $option->sort, - ])->values()->all(), - 'children' => $this->mapFieldRows($field->children), - ])->values()->all(); + return $fields->map(function (Field $field): array { + $row = [ + 'id' => $field->getKey(), + 'label' => $field->label, + 'name' => $field->name, + 'type' => $field->type, + 'required' => (bool) ($field->validation['required'] ?? false), + 'config' => $field->config ?? [], + 'sort' => $field->sort, + 'options' => $field->options->map(fn (FieldOption $option): array => [ + 'id' => $option->getKey(), + 'label' => $option->label, + 'value' => $option->value, + 'sort' => $option->sort, + ])->values()->all(), + ]; + + if ($field->type === 'flexible_content') { + $row['layouts'] = $this->mapLayoutRows($field->children); + } else { + $row['children'] = $this->mapFieldRows($field->children); + } + + return $row; + })->values()->all(); + } + + /** + * @param Collection $fields + * @return list> + */ + protected function mapLayoutRows(Collection $fields): array + { + return $fields + ->filter(fn (Field $field): bool => $field->type === 'flexible_layout') + ->map(fn (Field $layout): array => [ + 'id' => $layout->getKey(), + 'label' => $layout->label, + 'name' => $layout->name, + 'sort' => $layout->sort, + 'children' => $this->mapFieldRows($layout->children), + ]) + ->values() + ->all(); } /** @@ -239,7 +269,17 @@ protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldI $this->syncOptions($field, $row['options'] ?? []); - if ($this->typeHasSubFields((string) $row['type'])) { + if ((string) $row['type'] === 'flexible_content') { + $layoutRows = array_map( + fn (array $layout): array => [ + ...$layout, + 'type' => 'flexible_layout', + ], + $row['layouts'] ?? [], + ); + + $this->syncFields($group, $layoutRows, $field->getKey()); + } elseif ($this->typeHasSubFields((string) $row['type'])) { $this->syncFields($group, $row['children'] ?? [], $field->getKey()); } else { $field->children()->each(fn (Field $child) => $child->delete()); diff --git a/packages/builder/src/Services/FieldGroupValidator.php b/packages/builder/src/Services/FieldGroupValidator.php index 307baab635..29b5a44b01 100644 --- a/packages/builder/src/Services/FieldGroupValidator.php +++ b/packages/builder/src/Services/FieldGroupValidator.php @@ -71,6 +71,13 @@ protected function internalDuplicateMessages(array $fieldRows, string $pathPrefi $seen[$name] = $index; + if (isset($row['layouts']) && is_array($row['layouts'])) { + $messages = array_merge( + $messages, + $this->internalDuplicateMessages($row['layouts'], "{$pathPrefix}.{$index}.layouts"), + ); + } + if (isset($row['children']) && is_array($row['children'])) { $messages = array_merge( $messages, diff --git a/packages/builder/src/Services/FieldValueValidator.php b/packages/builder/src/Services/FieldValueValidator.php index a121f3b7c3..e8221bc738 100644 --- a/packages/builder/src/Services/FieldValueValidator.php +++ b/packages/builder/src/Services/FieldValueValidator.php @@ -53,6 +53,10 @@ protected function messagesForCompound(FieldDefinition $field, mixed $value, str return $this->messagesForRow($field, $this->normalizeGroupRow($value), $path); } + if ($field->type === 'flexible_content') { + return $this->messagesForFlexibleContent($field, $value, $path); + } + if (! is_array($value)) { return []; } @@ -83,6 +87,63 @@ protected function messagesForCompound(FieldDefinition $field, mixed $value, str return $messages; } + /** + * @return array> + */ + protected function messagesForFlexibleContent(FieldDefinition $field, mixed $value, string $path): array + { + if (! is_array($value)) { + return []; + } + + $messages = []; + + foreach (array_values($value) as $index => $item) { + if (! is_array($item)) { + continue; + } + + $layoutKey = (string) ($item['type'] ?? $item['layout'] ?? ''); + $data = is_array($item['data'] ?? null) ? $item['data'] : $item; + unset($data['type'], $data['layout'], $data['data']); + + $itemPath = "{$path}.{$index}"; + + if ($layoutKey === '') { + $messages[$itemPath] = [ + __('builder::builder.validation.empty_flexible_layout'), + ]; + + continue; + } + + $layout = $field->layouts()->firstWhere('name', $layoutKey); + + if ($layout === null) { + $messages[$itemPath] = [ + __('builder::builder.validation.unknown_flexible_layout', ['layout' => $layoutKey]), + ]; + + continue; + } + + if ($this->isRowEmpty($layout, $data)) { + $messages[$itemPath] = [ + __('builder::builder.validation.empty_flexible_item', [ + 'position' => $index + 1, + 'field' => $field->label, + ]), + ]; + + continue; + } + + $messages = array_merge($messages, $this->messagesForRow($layout, $data, "{$itemPath}.data")); + } + + return $messages; + } + /** * @return array> */ diff --git a/packages/builder/src/Support/TypedValueColumns.php b/packages/builder/src/Support/TypedValueColumns.php index fe0a46362d..c7a02690d5 100644 --- a/packages/builder/src/Support/TypedValueColumns.php +++ b/packages/builder/src/Support/TypedValueColumns.php @@ -32,7 +32,7 @@ public static function columnForType(string $type): string 'date' => 'value_date', 'datetime' => 'value_datetime', 'toggle' => 'value_boolean', - 'multiselect', 'checkbox_list', 'link', 'group', 'repeater' => 'value_json', + 'multiselect', 'checkbox_list', 'link', 'group', 'repeater', 'flexible_content' => 'value_json', 'button_group' => 'value_string', default => 'value_string', }; From 63efaef1441e5b3c277e2c126cacc66556a5011f Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:57:38 +0000 Subject: [PATCH 014/105] Fix styling --- packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php b/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php index a4fe0dcc97..7f14573001 100644 --- a/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php +++ b/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php @@ -46,4 +46,3 @@ public function apply(Component $component, FieldDefinition $field): Component return $component; } } - From 4f241d0455f698b11a07c0c68448ee477f88c0b3 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 23 Jun 2026 11:26:43 +0200 Subject: [PATCH 015/105] Update README --- packages/builder/README.md | 268 +++++++++++++++++++++---------------- 1 file changed, 149 insertions(+), 119 deletions(-) diff --git a/packages/builder/README.md b/packages/builder/README.md index 851f425810..8f26b003a9 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -43,9 +43,9 @@ Admins definieren Felder im Panel. Werte werden in typisierten `builder_field_va │ ↓ │ │ EntityRegistry → LocationMatcher → SchemaCompiler → Filament-Sections │ │ ↓ │ -│ PersistCustomFields (RecordSaved) → TypedValueDriver │ +│ PersistCustomFields (RecordSaved) → CustomFieldsManager │ │ ↓ │ -│ builder_field_values │ +│ builder_field_values (TypedValueColumns) │ └─────────────────────────────────────────────────────────────────────────┘ ``` @@ -54,7 +54,7 @@ Admins definieren Felder im Panel. Werte werden in typisierten `builder_field_va | Schicht | Frage | Wo | |---------|-------|-----| | **Definition** | Welche Felder gibt es? | `builder_field_*` Tabellen + Admin-UI | -| **Speicher** | Wo liegen die Werte? | `builder_field_values` + `TypedValueDriver` | +| **Speicher** | Wo liegen die Werte? | `builder_field_values` + `TypedValueColumns` | --- @@ -67,11 +67,12 @@ Verantwortlich für **was** angezeigt wird und **wo** (Location). | Komponente | Aufgabe | |------------|---------| | `FieldGroupResource` | Filament-CRUD für Feldgruppen | -| `FieldGroupPersistence` | Speichert Gruppen, Felder, Optionen, Location Rules | +| `FieldGroupPersistence` | Speichert Gruppen, Felder, Optionen, Location Rules, verschachtelte Felder | +| `FieldGroupValidator` | Prüft doppelte Feldschlüssel innerhalb und zwischen Gruppen | | `DefinitionRegistry` | Lädt aktive Gruppen, cached als Arrays | | `LocationMatcher` | Prüft `location_rules` gegen `LocationContext` | -| `EntityRegistry` | Mappt registrierte Filament-Resources → Entity-Keys | -| `SchemaCompiler` | Baut Filament-`Section`s aus Definitionen | +| `EntityRegistry` | Findet Filament-Resources mit `HasCustomFields` → Entity-Keys | +| `SchemaCompiler` | Baut Filament-Sections, Tabs, Layout-Felder aus Definitionen | Definitionen werden als **DTOs** (`FieldGroupDefinition`, `FieldDefinition`) transportiert — nicht als lose Eloquent-Models im Runtime-Pfad. @@ -81,11 +82,10 @@ Verantwortlich für **Werte** pro Datensatz. | Komponente | Aufgabe | |------------|---------| -| `ValueStore` (Interface) | `load()` / `save()` Vertrag | -| `TypedValueDriver` | Einziger Treiber: typisierte Spalten | -| `ValueStoreResolver` | Löst Treiber aus `config/builder.php` auf | -| `TypedValueColumns` | Mapping Feldtyp → DB-Spalte | -| `CustomFieldsManager` | Orchestriert Laden/Speichern für Resources | +| `TypedValueColumns` | Mapping Feldtyp → DB-Spalte (`value_string`, `value_json`, …) | +| `CustomFieldsManager` | Laden/Speichern für Resources, Option-Validierung | +| `FieldValueValidator` | Validierung verschachtelter Werte (Repeater, Group, Flexible Content) | +| `FieldValuePurger` | Löscht Werte bei Feld-/Gruppenänderungen | | `PersistCustomFields` | Listener auf Filament `RecordSaved` | Werte hängen **nicht** am Eloquent-Model (keine `custom_fields`-JSON-Spalte nötig). @@ -108,13 +108,13 @@ Werte hängen **nicht** am Eloquent-Model (keine `custom_fields`-JSON-Spalte nö ### `builder_fields` -Felder einer Gruppe: `name` (Feldschlüssel), `label`, `type`, `config`, `validation`, `sort`. +Felder einer Gruppe: `name`, `label`, `type`, `config`, `validation`, `sort`. -`parent_field_id` ist in der Migration vorgesehen (Repeater/Flexible Content — noch nicht implementiert). +**`parent_field_id`** verknüpft Unterfelder mit Layout-Feldern (Group, Repeater, Flexible-Content-Layouts). Die Baumstruktur wird rekursiv in `FieldGroupPersistence` synchronisiert. ### `builder_field_options` -Optionen für `select`, `radio`, `multiselect`, `checkbox_list`. +Optionen für `select`, `radio`, `multiselect`, `checkbox_list`, `button_group`. ### `builder_field_values` @@ -125,13 +125,13 @@ Eine Zeile pro Wert: | `entity` | z. B. `item` | | `record_id` | ID des Datensatzes | | `field_name` | Feldschlüssel | -| `value_string` | text, email, url, select, … | -| `value_text` | textarea | -| `value_decimal` | number | +| `value_string` | text, email, url, select, password, oembed, … | +| `value_text` | textarea, rich_text | +| `value_decimal` | number, range | | `value_date` | date | | `value_datetime` | datetime | | `value_boolean` | toggle | -| `value_json` | multiselect, checkbox_list | +| `value_json` | multiselect, checkbox_list, link, group, repeater, flexible_content | Unique: `(entity, record_id, field_name)`. @@ -142,7 +142,7 @@ Im Admin wählst du **„Anzeigen bei“** (Multi-Select). Intern wird das zu: ```json [ [{ "param": "entity", "operator": "==", "value": "item" }], - [{ "param": "entity", "operator": "==", "value": "product" }] + [{ "param": "entity", "operator": "==", "value": "record" }] ] ``` @@ -157,17 +157,15 @@ Jede innere Liste = AND-Gruppe, mehrere Gruppen = OR. Aktuell unterstützt der M ``` 1. Resource::form() enthält ...static::customFieldComponents() -2. HasCustomFields prüft EntityRegistry - → nicht registriert? → leeres Array (keine Sections) - -3. DefinitionRegistry::fieldGroupsFor(LocationContext) +2. HasCustomFields → DefinitionRegistry::fieldGroupsFor(LocationContext) → lädt gecachte Gruppen → LocationMatcher filtert nach entity -4. SchemaCompiler::compile() +3. SchemaCompiler::compile() → pro Gruppe eine Filament-Section - → pro Feld eine Form-Komponente (TextInput, Select, …) - → afterStateHydrated lädt Werte aus TypedValueDriver (nur Edit) + → Layout-Felder: Tabs, Group, Repeater, Flexible Content (Builder) + → afterStateHydrated lädt Werte via CustomFieldsManager (nur Edit) + → Filament Builder: hydrateItems() für UUID-basierte Block-Keys ``` ### Speichern @@ -178,18 +176,19 @@ Jede innere Liste = AND-Gruppe, mehrere Gruppen = OR. Aktuell unterstützt der M 2. Event RecordSaved wird gefeuert 3. PersistCustomFields::handle($record, $data, $page) - → prüft: Resource nutzt HasCustomFields + ist registriert? + → prüft: Resource nutzt HasCustomFields? 4. CustomFieldsManager::saveFromFormData() - → extrahiert nur bekannte Feld-Keys aus $data - → TypedValueDriver::save() → builder_field_values + → extrahiert bekannte Feld-Keys aus $data + → FieldValueValidator + OptionValueRules + → updateOrCreate in builder_field_values ``` **Wichtig:** Keine Page-Hooks (`afterCreate`, `mutateFormDataBeforeSave`) nötig. ### Cache -`DefinitionRegistry` cached unter `builder.definitions` als **PHP-Arrays** (keine serialisierten Objekte). +`DefinitionRegistry` cached unter `builder.definitions` als **PHP-Arrays**. Invalidierung automatisch via `InvalidateDefinitionCacheObserver` bei Änderungen an Gruppen/Feldern/Optionen. @@ -232,34 +231,7 @@ $panel->plugins([ ## Resource anbinden -### Schritt 1: Entity registrieren - -Der Array-Key in `config('builder.entities')` ist der Entity-Identifier für Location Rules und Storage. - -**Option A — Config** (`config/builder.php`): - -```php -'entities' => [ - 'item' => [ - 'resource' => \Moox\Item\Resources\ItemResource::class, - 'label' => 'Items', - ], -], -``` - -**Option B — Service Provider** (empfohlen für Moox-Packages): - -```php -// ItemServiceProvider::packageBooted() -config([ - 'builder.entities.item' => [ - 'resource' => ItemResource::class, - 'label' => 'Items', - ], -]); -``` - -### Schritt 2: Trait in der Filament-Resource +### Schritt 1: Trait in der Filament-Resource ```php use Moox\Builder\Concerns\HasCustomFields; @@ -278,21 +250,34 @@ class ItemResource extends Resource } ``` -**Nicht nötig:** +Der **Entity-Key** wird automatisch aus dem Model-Basename abgeleitet (`Item` → `item`). Abweichenden Key per `customFieldsEntity()` überschreiben: -- Trait oder Spalte am Eloquent-Model -- `$customFieldsEntity` (entfernt — kommt aus Config) -- Eigene Listener oder Page-Hooks -- Migration für JSON am Model +```php +protected static function customFieldsEntity(): ?string +{ + return 'item'; +} +``` + +### Schritt 2: Entity-Discovery + +`EntityRegistry` findet Resources **automatisch** über alle registrierten Filament-Panels — jede Resource mit `HasCustomFields` erscheint im Multi-Select „Anzeigen bei“. Keine manuelle Config-Registrierung nötig. ### Schritt 3: Feldgruppe im Admin **Felder → Feldgruppen → Erstellen** -- **Anzeigen bei:** `Items` (oder andere registrierte Entity) +- **Anzeigen bei:** z. B. `Items`, `Records` - Felder definieren - Aktiv lassen +**Nicht nötig:** + +- Trait oder Spalte am Eloquent-Model +- Eigene Listener oder Page-Hooks +- Migration für JSON am Model +- Manuelle Entity-Config + --- ## Feldgruppen im Admin @@ -302,8 +287,14 @@ Navigation: **Felder → Feldgruppen** | Bereich | Inhalt | |---------|--------| | **Allgemein** | Name, technischer Schlüssel, aktiv, Reihenfolge | -| **Zuordnung** | Multi-Select „Anzeigen bei“ (registrierte Resources) | -| **Felder** | Repeater: Bezeichnung, Typ, Feldschlüssel, Pflichtfeld, Einstellungen, Optionen | +| **Zuordnung** | Multi-Select „Anzeigen bei“ (auto-discovered Resources) | +| **Felder** | Repeater: Bezeichnung, Typ, Feldschlüssel, Pflichtfeld | +| **Einstellungen** | Capability-Felder (nur wenn der Typ welche hat) | +| **Optionen** | Für Select/Radio/Multiselect/Checkbox-Liste | +| **Unterfelder** | Für Group und Repeater | +| **Layouts** | Für Flexible Content (Layout-Schlüssel + Unterfelder) | + +Repeater-Zeilen sind standardmäßig eingeklappt und zeigen Typ, Schlüssel und Pflichtfeld im Label. Übersetzungen: `resources/lang/de/builder.php`, `en/builder.php`. @@ -311,25 +302,29 @@ Navigation: **Felder → Feldgruppen** ## Feldtypen & Capabilities -### Eingebaute Feldtypen (15) - -| Key | Filament-Komponente | -|-----|---------------------| -| `text` | TextInput | -| `textarea` | Textarea | -| `number` | TextInput (numeric) | -| `email` | TextInput (email) | -| `url` | TextInput (url) | -| `password` | TextInput (password) | -| `select` | Select | -| `multiselect` | Select (multiple) | -| `checkbox_list` | CheckboxList | -| `radio` | Radio | -| `toggle` | Toggle | -| `date` | DatePicker | -| `datetime` | DateTimePicker | -| `time` | TimePicker | -| `color` | ColorPicker | +### Eingebaute Feldtypen (25 wählbar) + +| Kategorie | Keys | +|-----------|------| +| **Text** | `text`, `textarea`, `email`, `url`, `password`, `rich_text` | +| **Zahl** | `number`, `range` | +| **Auswahl** | `select`, `multiselect`, `checkbox_list`, `radio`, `button_group`, `toggle` | +| **Datum** | `date`, `datetime`, `time` | +| **Sonstiges** | `color`, `link`, `message`, `oembed` | +| **Layout** | `tab`, `group`, `repeater`, `flexible_content` | + +Intern (nur in der DB, nicht wählbar): `flexible_layout` — definiert ein Layout innerhalb von Flexible Content. + +### Layout-Felder + +| Typ | Filament-Komponente | Speicher | +|-----|---------------------|----------| +| `tab` | `Tabs` / `Tab` (Marker, kein Wert) | — | +| `group` | `Repeater` (min/max 1) | `value_json` (Objekt) | +| `repeater` | `Repeater` | `value_json` (Array) | +| `flexible_content` | `Builder` mit Layout-Blöcken | `value_json` (Array mit `type` + `data`) | + +Flexible Content entspricht ACF **Flexible Content**: pro Zeile ein wählbares Layout mit eigenen Unterfeldern. ### Capabilities (pro Typ konfigurierbar) @@ -343,8 +338,15 @@ Navigation: **Felder → Feldgruppen** | `MinValue` / `MaxValue` / `Step` | Zahlenfelder | | `Rows` | Textarea-Zeilen | | `DisplayFormat` | Datumsformat | +| `MessageBody` | Hinweistext (message) | +| `RepeaterItems` | min/max Einträge (Repeater, Flexible Content) | + +Jeder Feldtyp implementiert `FieldType`: `key()`, `formComponent()`, `capabilities()`, optional `castValue()`, `hasSubFields()`, `hasLayouts()`. -Jeder Feldtyp implementiert `FieldType`: `key()`, `formComponent()`, `capabilities()`, optional `castValue()` und `hasOptions()`. +### Validierung + +- **Pflichtfelder** und **Capabilities** werden als Filament-Regeln auf die Komponente angewendet. +- **Verschachtelte Werte** (Repeater, Group, Flexible Content) werden zusätzlich durch `FieldValueValidator` geprüft — u. a. leere Repeater-Zeilen und unbekannte Layouts. --- @@ -354,12 +356,9 @@ Jeder Feldtyp implementiert `FieldType`: `key()`, `formComponent()`, `capabiliti | Key | Default | Beschreibung | |-----|---------|--------------| -| `default_driver` | `typed` | Aktiver Value-Store-Treiber | -| `drivers.typed` | `TypedValueDriver` | Klassenname des Treibers | -| `navigation_group` | `Felder` | Filament-Navigationsgruppe | -| `entities` | `[]` | Registrierte Resources (siehe oben) | +| `navigation_group` | `Felder` | Filament-Navigationsgruppe für Feldgruppen | -Env-Variablen: `BUILDER_DRIVER`, `BUILDER_NAVIGATION_GROUP`. +Env: `BUILDER_NAVIGATION_GROUP`. --- @@ -376,17 +375,24 @@ $this->app->afterResolving(FieldTypeRegistry::class, function (FieldTypeRegistry }); ``` -### Eigenen Storage-Treiber (optional) - -Interface `ValueStore` implementieren, in `config/builder.php` unter `drivers` eintragen, `default_driver` setzen. - -Aktuell ist nur `typed` produktiv im Einsatz. +Übersetzung unter `builder::builder.field_types.{key}` in `resources/lang`. ### Validierungsregeln abfragen ```php ItemResource::customFieldRules(); -// ['feld-name' => ['required', 'max:255'], ...] +// ['feld-name' => ['required', 'max:255'], 'repeater.*.unterfeld' => ['required'], ...] +``` + +### Werte programmatisch laden + +```php +use Moox\Builder\Services\CustomFieldsManager; + +$values = app(CustomFieldsManager::class)->loadFormData( + ItemResource::class, + $item, +); ``` --- @@ -402,7 +408,7 @@ packages/builder/ ├── resources/lang/{de,en}/builder.php └── src/ ├── BuilderServiceProvider.php - ├── Concerns/HasCustomFields.php # Consumer-Trait + ├── Concerns/HasCustomFields.php # Consumer-Trait ├── Compiler/ │ ├── LocationMatcher.php │ └── SchemaCompiler.php @@ -412,25 +418,29 @@ packages/builder/ │ └── LocationContext.php ├── FieldTypes/ │ ├── FieldType.php - │ ├── Capabilities/ # Wiederverwendbare Feld-Einstellungen - │ └── Types/ # 15 Feldtypen + │ ├── Capabilities/ + │ └── Types/ # 26 Feldtypen ├── Listeners/PersistCustomFields.php - ├── Models/ # FieldGroup, Field, FieldOption, FieldValue - ├── Observers/InvalidateDefinitionCacheObserver.php + ├── Models/ # FieldGroup, Field, FieldOption, FieldValue + ├── Observers/ + │ ├── InvalidateDefinitionCacheObserver.php + │ └── PurgeFieldValuesObserver.php ├── Plugins/BuilderPlugin.php ├── Registry/ │ ├── DefinitionRegistry.php │ ├── EntityRegistry.php │ └── FieldTypeRegistry.php - ├── Resources/FieldGroupResource.php # Admin-UI + ├── Resources/FieldGroupResource.php # Admin-UI ├── Services/ │ ├── CustomFieldsManager.php - │ └── FieldGroupPersistence.php - ├── Storage/ - │ ├── TypedValueDriver.php - │ ├── ValueStore.php - │ └── ValueStoreResolver.php - └── Support/TypedValueColumns.php + │ ├── FieldGroupPersistence.php + │ ├── FieldGroupValidator.php + │ ├── FieldValuePurger.php + │ └── FieldValueValidator.php + └── Support/ + ├── EntityModelDeletionRegistrar.php + ├── OptionValueRules.php + └── TypedValueColumns.php ``` --- @@ -443,47 +453,67 @@ packages/builder/ cd packages/builder && composer test ``` +46 Tests (Stand: Paket-intern). + ### Manuell im Panel -1. **Felder → Feldgruppen** — Demo-Gruppe „Fahrzeugdaten“ (nach Seeder) -2. **Items → Erstellen/Bearbeiten** — Section mit Custom Fields +1. **Felder → Feldgruppen** — Demo-Gruppen „Fahrzeugdaten“ und „Layout-Showcase“ (nach Seeder) +2. **Items → Bearbeiten** — Custom-Field-Sections inkl. Tabs, Group, Repeater, Flexible Content 3. Speichern, dann DB prüfen: ```sql -SELECT entity, record_id, field_name, value_string, value_decimal, value_date +SELECT entity, record_id, field_name, value_string, value_decimal, value_json FROM builder_field_values WHERE entity = 'item'; ``` 4. Item erneut öffnen — Werte müssen geladen sein. +### Seeder + +```bash +php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --force +``` + ### Checkliste | Check | Erwartung | |-------|-----------| | 4 Builder-Tabellen existieren | nach `migrate` | -| Entity in `builder.entities` registriert | z. B. via ItemServiceProvider | +| Resource nutzt `HasCustomFields` | erscheint unter „Anzeigen bei“ | | `BuilderPlugin` im Panel | Nav „Felder“ sichtbar | | Feldgruppe mit „Anzeigen bei: Items“ | Section auf Item-Form | | Werte in `builder_field_values` | typisierte Spalten befüllt | +| Flexible Content sortierbar | ohne Fehler nach Speichern/Laden | --- ## Grenzen & Roadmap +**Implementiert:** + +- Nested Fields via `parent_field_id` (Group, Repeater, Flexible Content) +- Layout-Felder: Tab, Group, Repeater, Flexible Content +- Entity-Discovery über `HasCustomFields` in Filament-Panels +- Verschachtelte Validierung (`FieldValueValidator`) +- Repeater min/max (`RepeaterItems` Capability) + **Aktuell nicht implementiert:** -- Repeater / Flexible Content (`parent_field_id` reserviert) -- Query-Scopes auf Consumer-Models (`whereCustomField()` o. ä.) -- Tier-1 Auto-Discovery von Entities +- Werte-API / `HasBuilderValues`-Trait fürs Frontend (nur `CustomFieldsManager::loadFormData()`) +- Query-Scopes auf Consumer-Models (`whereCustomField()`) +- Relational-Felder (Post Object, Relationship, User, Taxonomy) +- Media-Felder (Image, File, Gallery) +- Clone-Feldtyp (ACF) - Location-Params über `entity` hinaus -- `placement`-Steuerung (Tab, Sidebar, …) +- `placement`-Steuerung (Sidebar, …) +- Conditional Logic im Formular -**Bewusst entfernt / nicht Ziel:** +**Bewusst nicht Ziel:** - WordPress/postmeta-Treiber - JSON-Spalte am Model -- Press/WP-Integration +- Accordion als eigener Feldtyp (Tabs + Sections reichen) --- From fef75f3b17aee0c2dea54cd6e208b3d9f0aeedea Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:57:15 +0200 Subject: [PATCH 016/105] mprove builder MVP: tabs, validation, value migration, and admin UX --- packages/builder/README.md | 15 +- .../database/seeders/BuilderSeeder.php | 510 ++++++++---------- .../builder/resources/lang/de/builder.php | 12 + .../builder/resources/lang/en/builder.php | 12 + .../builder/src/Compiler/SchemaCompiler.php | 101 ++-- .../FieldTypes/Capabilities/Capability.php | 8 + .../FieldTypes/Capabilities/DefaultValue.php | 156 +++++- .../FieldTypes/Capabilities/DisplayFormat.php | 23 +- .../Types/CheckboxListFieldType.php | 2 + .../FieldTypes/Types/MultiselectFieldType.php | 2 + .../src/FieldTypes/Types/TabFieldType.php | 2 +- .../Observers/PurgeFieldValuesObserver.php | 8 + .../src/Registry/DefinitionRegistry.php | 10 +- .../src/Resources/FieldGroupResource.php | 9 +- .../Services/CompoundFieldValueMigrator.php | 312 +++++++++++ .../src/Services/CustomFieldsManager.php | 57 +- .../src/Services/FieldGroupPersistence.php | 17 +- .../src/Services/FieldGroupValidator.php | 107 ++-- .../src/Services/FieldValueValidator.php | 4 + .../src/Services/TabStructureMigrator.php | 108 ++++ .../builder/src/Support/FieldRelationTree.php | 31 ++ .../src/Support/StorableFieldCollector.php | 111 ++++ 22 files changed, 1177 insertions(+), 440 deletions(-) create mode 100644 packages/builder/src/Services/CompoundFieldValueMigrator.php create mode 100644 packages/builder/src/Services/TabStructureMigrator.php create mode 100644 packages/builder/src/Support/FieldRelationTree.php create mode 100644 packages/builder/src/Support/StorableFieldCollector.php diff --git a/packages/builder/README.md b/packages/builder/README.md index 8f26b003a9..cb2868a7f0 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -67,8 +67,8 @@ Verantwortlich für **was** angezeigt wird und **wo** (Location). | Komponente | Aufgabe | |------------|---------| | `FieldGroupResource` | Filament-CRUD für Feldgruppen | -| `FieldGroupPersistence` | Speichert Gruppen, Felder, Optionen, Location Rules, verschachtelte Felder | -| `FieldGroupValidator` | Prüft doppelte Feldschlüssel innerhalb und zwischen Gruppen | +| `FieldGroupPersistence` | Speichert Gruppen, Felder, Optionen, Location Rules, verschachtelte Felder; migriert JSON-Werte bei Umbenennung/Löschen verschachtelter Subfelder | +| `FieldGroupValidator` | Prüft doppelte **speicherbare** Feldschlüssel (global pro Gruppe, inkl. Tabs) und Konflikte zwischen Gruppen | | `DefinitionRegistry` | Lädt aktive Gruppen, cached als Arrays | | `LocationMatcher` | Prüft `location_rules` gegen `LocationContext` | | `EntityRegistry` | Findet Filament-Resources mit `HasCustomFields` → Entity-Keys | @@ -83,9 +83,10 @@ Verantwortlich für **Werte** pro Datensatz. | Komponente | Aufgabe | |------------|---------| | `TypedValueColumns` | Mapping Feldtyp → DB-Spalte (`value_string`, `value_json`, …) | -| `CustomFieldsManager` | Laden/Speichern für Resources, Option-Validierung | +| `CustomFieldsManager` | Laden/Speichern für Resources, Option-Validierung, Hydration-Cache | | `FieldValueValidator` | Validierung verschachtelter Werte (Repeater, Group, Flexible Content) | -| `FieldValuePurger` | Löscht Werte bei Feld-/Gruppenänderungen | +| `FieldValuePurger` | Löscht Werte bei Feld-/Gruppenänderungen (Root-Felder) | +| `CompoundFieldValueMigrator` | Benennt/entfernt verschachtelte Schlüssel in `value_json` (Group, Repeater, Flexible Content) | | `PersistCustomFields` | Listener auf Filament `RecordSaved` | Werte hängen **nicht** am Eloquent-Model (keine `custom_fields`-JSON-Spalte nötig). @@ -164,7 +165,7 @@ Jede innere Liste = AND-Gruppe, mehrere Gruppen = OR. Aktuell unterstützt der M 3. SchemaCompiler::compile() → pro Gruppe eine Filament-Section → Layout-Felder: Tabs, Group, Repeater, Flexible Content (Builder) - → afterStateHydrated lädt Werte via CustomFieldsManager (nur Edit) + → afterStateHydrated lädt Werte via CustomFieldsManager (ein Query pro Datensatz, gecacht) → Filament Builder: hydrateItems() für UUID-basierte Block-Keys ``` @@ -453,11 +454,11 @@ packages/builder/ cd packages/builder && composer test ``` -46 Tests (Stand: Paket-intern). +73 Tests (Stand: Paket-intern). ### Manuell im Panel -1. **Felder → Feldgruppen** — Demo-Gruppen „Fahrzeugdaten“ und „Layout-Showcase“ (nach Seeder) +1. **Felder → Feldgruppen** — Demo-Gruppe „Fahrzeugdaten“ (nach Seeder) 2. **Items → Bearbeiten** — Custom-Field-Sections inkl. Tabs, Group, Repeater, Flexible Content 3. Speichern, dann DB prüfen: diff --git a/packages/builder/database/seeders/BuilderSeeder.php b/packages/builder/database/seeders/BuilderSeeder.php index 185b634fba..fe8030e9ce 100644 --- a/packages/builder/database/seeders/BuilderSeeder.php +++ b/packages/builder/database/seeders/BuilderSeeder.php @@ -12,6 +12,7 @@ use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Services\FieldGroupPersistence; +use Moox\Builder\Services\TabStructureMigrator; use Moox\Builder\Support\TypedValueColumns; class BuilderSeeder extends Seeder @@ -19,18 +20,27 @@ class BuilderSeeder extends Seeder public function run(): void { $this->removeObsoleteGroups(); + $this->migrateLegacyTabStructures(); $this->seedFahrzeugdatenGroup(); - $this->seedLayoutShowcaseGroup(); $this->seedDemoValuesForFirstItem(); app(DefinitionRegistry::class)->forget(); } + protected function migrateLegacyTabStructures(): void + { + $migrator = app(TabStructureMigrator::class); + + FieldGroup::query()->each(function (FieldGroup $group) use ($migrator): void { + $migrator->migrateGroup($group); + }); + } + protected function removeObsoleteGroups(): void { FieldGroup::query() - ->whereIn('slug', ['feldtyp-showcase']) + ->whereIn('slug', ['feldtyp-showcase', 'layout-showcase']) ->each(fn (FieldGroup $group) => $group->delete()); } @@ -51,282 +61,203 @@ protected function seedFahrzeugdatenGroup(): void 'active' => true, 'sort' => 0, 'target_entities' => ['item'], - 'fields' => [ - [ - 'name' => 'fahrzeugtyp-modell', - 'label' => 'Fahrzeugtyp / Modell', - 'type' => 'text', - 'sort' => 0, - 'config' => [ - 'placeholder' => 'z. B. Golf GTI', - 'maxLength' => 120, - ], - ], - [ - 'name' => 'bruttolistenpreis', - 'label' => 'Bruttolistenpreis', - 'type' => 'number', - 'sort' => 1, - 'config' => [ - 'suffix' => '€', - 'min' => 0, - 'step' => 100, - ], - ], - [ - 'name' => 'erstzulassung', - 'label' => 'Erstzulassung', - 'type' => 'date', - 'sort' => 2, - 'config' => [ - 'displayFormat' => 'd.m.Y', - ], - ], - [ - 'name' => 'motorleistung', - 'label' => 'Motorleistung', - 'type' => 'range', - 'sort' => 3, - 'config' => [ - 'min' => 50, - 'max' => 500, - 'step' => 5, - 'suffix' => 'PS', - ], - ], - [ - 'name' => 'getriebe', - 'label' => 'Getriebe', - 'type' => 'button_group', - 'sort' => 4, - 'config' => [ - 'default' => 'automatic', - ], - 'options' => [ - ['label' => 'Schaltgetriebe', 'value' => 'manual'], - ['label' => 'Automatik', 'value' => 'automatic'], - ], - ], - [ - 'name' => 'inserat-url', - 'label' => 'Inserat-Link', - 'type' => 'link', - 'sort' => 5, - ], - [ - 'name' => 'fahrzeugbeschreibung', - 'label' => 'Fahrzeugbeschreibung', - 'type' => 'rich_text', - 'sort' => 6, - ], - [ - 'name' => 'standort', - 'label' => 'Standort', - 'type' => 'group', - 'sort' => 7, - 'children' => [ - [ - 'name' => 'stadt', - 'label' => 'Stadt', - 'type' => 'text', - 'sort' => 0, - 'config' => ['placeholder' => 'z. B. Berlin'], - ], - [ - 'name' => 'plz', - 'label' => 'PLZ', - 'type' => 'text', - 'sort' => 1, - 'config' => ['maxLength' => 10], - ], - ], - ], - [ - 'name' => 'ausstattung', - 'label' => 'Ausstattung', - 'type' => 'repeater', - 'sort' => 8, - 'children' => [ - [ - 'name' => 'merkmal', - 'label' => 'Merkmal', - 'type' => 'text', - 'sort' => 0, - 'required' => true, - ], - [ - 'name' => 'enthalten', - 'label' => 'Enthalten', - 'type' => 'toggle', - 'sort' => 1, - ], - ], - ], - ], + 'fields' => $this->fahrzeugdatenFields(), ]); } - protected function seedLayoutShowcaseGroup(): void + /** + * Alle wählbaren Feldtypen am Beispiel eines Fahrzeuginserats. + * + * @return list> + */ + protected function fahrzeugdatenFields(): array { - $group = $this->upsertGroup( - slug: 'layout-showcase', - name: 'Layout-Showcase', - sort: 10, - entities: ['item', 'record'], - ); - - $this->resetGroupFields($group); - - app(FieldGroupPersistence::class)->sync($group, [ - 'name' => 'Layout-Showcase', - 'slug' => 'layout-showcase', - 'active' => true, - 'sort' => 10, - 'target_entities' => ['item', 'record'], - 'fields' => [ - [ - 'name' => 'layout-hinweis', - 'label' => 'Hinweis', - 'type' => 'message', - 'sort' => 0, - 'config' => [ - 'message' => 'Diese Gruppe demonstriert Tab, Group und Repeater.', - ], - ], - [ - 'name' => 'tab-allgemein', - 'label' => 'Allgemein', - 'type' => 'tab', - 'sort' => 1, - ], - [ - 'name' => 'demo-titel', - 'label' => 'Titel', - 'type' => 'text', - 'sort' => 2, - 'config' => ['placeholder' => 'Kurzer Titel'], + return [ + [ + 'name' => 'hinweis', + 'label' => 'Hinweis', + 'type' => 'message', + 'sort' => 0, + 'config' => [ + 'message' => 'Stammdaten stehen direkt in der Section. Weitere Feldtypen findest du in den Tabs „Technik & Ausstattung“ und „Inserat & Medien“.', ], - [ - 'name' => 'demo-status', - 'label' => 'Status', - 'type' => 'select', - 'sort' => 3, - 'options' => [ - ['label' => 'Entwurf', 'value' => 'draft'], - ['label' => 'Veröffentlicht', 'value' => 'published'], - ], + ], + $this->field('fahrzeugtyp-modell', 'Fahrzeugtyp / Modell', 'text', 1, required: true, config: [ + 'placeholder' => 'z. B. Golf GTI', + 'maxLength' => 120, + 'default' => 'Golf GTI', + ]), + $this->field('kurzbeschreibung', 'Kurzbeschreibung', 'textarea', 2, config: [ + 'rows' => 3, + 'placeholder' => 'Einzeiler für Listenansicht …', + ]), + $this->field('karosserieform', 'Karosserieform', 'select', 3, config: ['default' => 'limousine'], options: [ + ['label' => 'Limousine', 'value' => 'limousine'], + ['label' => 'Kombi', 'value' => 'kombi'], + ['label' => 'SUV', 'value' => 'suv'], + ['label' => 'Coupé', 'value' => 'coupe'], + ]), + $this->field('bruttolistenpreis', 'Bruttolistenpreis', 'number', 4, config: [ + 'suffix' => '€', 'min' => 0, 'step' => 100, 'default' => 32990, + ]), + $this->field('motorleistung', 'Motorleistung', 'range', 5, config: [ + 'min' => 50, 'max' => 500, 'step' => 5, 'suffix' => 'PS', 'default' => 245, + ]), + $this->field('erstzulassung', 'Erstzulassung', 'date', 6, config: [ + 'displayFormat' => 'd.m.Y', 'default' => '2022-06-15', + ]), + $this->field('kraftstoff', 'Kraftstoff', 'radio', 7, config: ['default' => 'benzin'], options: [ + ['label' => 'Benzin', 'value' => 'benzin'], + ['label' => 'Diesel', 'value' => 'diesel'], + ['label' => 'Elektro', 'value' => 'elektro'], + ['label' => 'Hybrid', 'value' => 'hybrid'], + ]), + $this->field('getriebe', 'Getriebe', 'button_group', 8, config: ['default' => 'automatic'], options: [ + ['label' => 'Schaltgetriebe', 'value' => 'manual'], + ['label' => 'Automatik', 'value' => 'automatic'], + ]), + $this->field('lackfarbe', 'Lackfarbe', 'color', 9, config: ['default' => '#1a1a1a']), + $this->field('unfallfrei', 'Unfallfrei', 'toggle', 10, config: ['default' => true]), + [ + 'name' => 'standort', + 'label' => 'Standort', + 'type' => 'group', + 'sort' => 11, + 'children' => [ + $this->field('stadt', 'Stadt', 'text', 0, config: ['placeholder' => 'z. B. Berlin']), + $this->field('plz', 'PLZ', 'text', 1, config: ['maxLength' => 10]), ], + ], + $this->field('haendler-email', 'Händler-E-Mail', 'email', 12, config: [ + 'placeholder' => 'verkauf@autohaus.de', + ]), + $this->field('fahrzeugbeschreibung', 'Fahrzeugbeschreibung', 'rich_text', 13), + $this->field('inserat-url', 'Inserat-Link', 'link', 14), + $this->tabField('tab-technik', 'Technik & Ausstattung', 15, [ + $this->field('letzte-wartung', 'Letzte Wartung', 'datetime', 0, config: [ + 'displayFormat' => 'd.m.Y H:i', 'default' => '2024-03-01 09:30', + ]), + $this->field('besichtigungszeit', 'Bevorzugte Besichtigungszeit', 'time', 1, config: [ + 'default' => '14:00', + ]), + $this->field('zahlungsoptionen', 'Zahlungsoptionen', 'multiselect', 2, options: [ + ['label' => 'Barzahlung', 'value' => 'cash'], + ['label' => 'Finanzierung', 'value' => 'finance'], + ['label' => 'Leasing', 'value' => 'leasing'], + ['label' => 'Inzahlungnahme', 'value' => 'trade_in'], + ]), + $this->field('serienausstattung', 'Serienausstattung', 'checkbox_list', 3, options: [ + ['label' => 'Klimaautomatik', 'value' => 'climate'], + ['label' => 'Tempomat', 'value' => 'cruise'], + ['label' => 'Einparkhilfe', 'value' => 'parking'], + ['label' => 'LED-Scheinwerfer', 'value' => 'led'], + ]), [ - 'name' => 'tab-details', - 'label' => 'Details', - 'type' => 'tab', + 'name' => 'ausstattung', + 'label' => 'Zusatzausstattung', + 'type' => 'repeater', 'sort' => 4, - ], - [ - 'name' => 'demo-leistung', - 'label' => 'Leistung (PS)', - 'type' => 'number', - 'sort' => 6, - 'config' => ['suffix' => 'PS', 'min' => 0], - ], - [ - 'name' => 'demo-aktiv', - 'label' => 'Aktiv', - 'type' => 'toggle', - 'sort' => 7, - ], - [ - 'name' => 'kontakt', - 'label' => 'Kontakt', - 'type' => 'group', - 'sort' => 8, + 'config' => ['min' => 1, 'max' => 20], 'children' => [ - [ - 'name' => 'name', - 'label' => 'Name', - 'type' => 'text', - 'sort' => 0, - ], - [ - 'name' => 'email', - 'label' => 'E-Mail', - 'type' => 'email', - 'sort' => 1, - ], + $this->field('merkmal', 'Merkmal', 'text', 0, required: true), + $this->field('enthalten', 'Enthalten', 'toggle', 1, config: ['default' => true]), ], ], [ - 'name' => 'timeline', - 'label' => 'Timeline', + 'name' => 'wartungshistorie', + 'label' => 'Wartungshistorie', 'type' => 'repeater', - 'sort' => 9, + 'sort' => 5, 'children' => [ - [ - 'name' => 'datum', - 'label' => 'Datum', - 'type' => 'date', - 'sort' => 0, - ], - [ - 'name' => 'ereignis', - 'label' => 'Ereignis', - 'type' => 'text', - 'sort' => 1, - ], + $this->field('datum', 'Datum', 'date', 0), + $this->field('beschreibung', 'Beschreibung', 'text', 1), ], ], + ]), + $this->tabField('tab-inserat', 'Inserat & Medien', 16, [ + $this->field('hersteller-seite', 'Herstellerseite', 'url', 0, config: [ + 'placeholder' => 'https://www.volkswagen.de', + ]), + $this->field('interner-zugang', 'Interner Zugangscode', 'password', 1), + $this->field('fahrzeugvideo', 'Fahrzeugvideo (oEmbed)', 'oembed', 2, config: [ + 'placeholder' => 'https://www.youtube.com/watch?v=…', + ]), [ - 'name' => 'seiteninhalt', - 'label' => 'Seiteninhalt', + 'name' => 'inserat-inhalt', + 'label' => 'Inserat-Inhalt (Flexible Inhalte)', 'type' => 'flexible_content', - 'sort' => 10, + 'sort' => 3, + 'config' => ['min' => 1, 'max' => 10], 'layouts' => [ [ 'name' => 'hero', - 'label' => 'Hero', + 'label' => 'Hero-Bereich', 'sort' => 0, 'children' => [ - [ - 'name' => 'titel', - 'label' => 'Titel', - 'type' => 'text', - 'sort' => 0, - 'required' => true, - ], - [ - 'name' => 'text', - 'label' => 'Text', - 'type' => 'textarea', - 'sort' => 1, - ], + $this->field('titel', 'Titel', 'text', 0, required: true), + $this->field('text', 'Text', 'textarea', 1), ], ], [ - 'name' => 'text-block', - 'label' => 'Textblock', + 'name' => 'technik-block', + 'label' => 'Technik-Block', 'sort' => 1, 'children' => [ - [ - 'name' => 'inhalt', - 'label' => 'Inhalt', - 'type' => 'rich_text', - 'sort' => 0, - ], + $this->field('ueberschrift', 'Überschrift', 'text', 0), + $this->field('details', 'Details', 'rich_text', 1), ], ], ], ], - [ - 'name' => 'demo-oembed', - 'label' => 'Video (oEmbed)', - 'type' => 'oembed', - 'sort' => 11, - 'config' => [ - 'placeholder' => 'https://www.youtube.com/watch?v=…', - ], - ], - ], - ]); + ]), + ]; + } + + /** + * @param list> $children + * @return array + */ + protected function tabField(string $name, string $label, int $sort, array $children): array + { + return [ + 'name' => $name, + 'label' => $label, + 'type' => 'tab', + 'sort' => $sort, + 'children' => $children, + ]; + } + + /** + * @param list $options + * @return array + */ + protected function field( + string $name, + string $label, + string $type, + int $sort, + bool $required = false, + array $config = [], + array $options = [], + ): array { + $field = [ + 'name' => $name, + 'label' => $label, + 'type' => $type, + 'sort' => $sort, + 'required' => $required, + ]; + + if ($config !== []) { + $field['config'] = $config; + } + + if ($options !== []) { + $field['options'] = $options; + } + + return $field; } /** @@ -338,23 +269,34 @@ protected function seedDemoValuesForFirstItem(): void return; } + FieldValue::query() + ->where('entity', 'item') + ->where('record_id', 1) + ->delete(); + $demos = [ 'fahrzeugtyp-modell' => ['type' => 'text', 'value' => 'Golf GTI'], + 'kurzbeschreibung' => [ + 'type' => 'textarea', + 'value' => 'Sportlicher Kompaktwagen mit 245 PS, unfallfrei, scheckheftgepflegt.', + ], + 'karosserieform' => ['type' => 'select', 'value' => 'limousine'], 'bruttolistenpreis' => ['type' => 'number', 'value' => 32990], - 'erstzulassung' => ['type' => 'date', 'value' => '2022-06-15'], 'motorleistung' => ['type' => 'range', 'value' => 245], + 'lackfarbe' => ['type' => 'color', 'value' => '#1a1a1a'], + 'unfallfrei' => ['type' => 'toggle', 'value' => true], + 'kraftstoff' => ['type' => 'radio', 'value' => 'benzin'], 'getriebe' => ['type' => 'button_group', 'value' => 'automatic'], - 'inserat-url' => [ - 'type' => 'link', - 'value' => [ - 'url' => 'https://moox.org/items/golf-gti', - 'label' => 'Inserat online ansehen', - 'target' => '_blank', - ], + 'erstzulassung' => ['type' => 'date', 'value' => '2022-06-15'], + 'letzte-wartung' => ['type' => 'datetime', 'value' => '2024-03-01 09:30:00'], + 'besichtigungszeit' => ['type' => 'time', 'value' => '14:00'], + 'zahlungsoptionen' => [ + 'type' => 'multiselect', + 'value' => ['finance', 'trade_in'], ], - 'fahrzeugbeschreibung' => [ - 'type' => 'rich_text', - 'value' => '

Golf GTI in sehr gutem Zustand.

', + 'serienausstattung' => [ + 'type' => 'checkbox_list', + 'value' => ['climate', 'cruise', 'led'], ], 'standort' => [ 'type' => 'group', @@ -363,54 +305,62 @@ protected function seedDemoValuesForFirstItem(): void 'plz' => '10115', ], ], + 'haendler-email' => ['type' => 'email', 'value' => 'verkauf@moox-autohaus.de'], + 'hersteller-seite' => ['type' => 'url', 'value' => 'https://www.volkswagen.de'], + 'interner-zugang' => ['type' => 'password', 'value' => 'demo-geheim-2024'], 'ausstattung' => [ 'type' => 'repeater', 'value' => [ - ['merkmal' => 'Sitzheizung', 'enthalten' => true], - ['merkmal' => 'Navigationssystem', 'enthalten' => true], + ['merkmal' => 'Sitzheizung vorn', 'enthalten' => true], + ['merkmal' => 'Navigationssystem Discover Pro', 'enthalten' => true], ['merkmal' => 'Schiebedach', 'enthalten' => false], + ['merkmal' => 'Sportsitze', 'enthalten' => true], ], ], - 'demo-titel' => ['type' => 'text', 'value' => 'Demo-Eintrag'], - 'demo-status' => ['type' => 'select', 'value' => 'published'], - 'demo-leistung' => ['type' => 'number', 'value' => 245], - 'demo-aktiv' => ['type' => 'toggle', 'value' => true], - 'kontakt' => [ - 'type' => 'group', + 'wartungshistorie' => [ + 'type' => 'repeater', 'value' => [ - 'name' => 'Max Mustermann', - 'email' => 'max@moox.org', + ['datum' => '2022-06-15', 'beschreibung' => 'Erstzulassung'], + ['datum' => '2023-06-10', 'beschreibung' => 'Inspektion beim Vertragshändler'], + ['datum' => '2024-03-01', 'beschreibung' => 'Ölwechsel und Bremsen geprüft'], ], ], - 'timeline' => [ - 'type' => 'repeater', + 'fahrzeugbeschreibung' => [ + 'type' => 'rich_text', + 'value' => '

VW Golf GTI (245 PS) in sehr gutem Zustand.

' + .'

Scheckheftgepflegt, Nichtraucherfahrzeug, sofort verfügbar.

', + ], + 'inserat-url' => [ + 'type' => 'link', 'value' => [ - ['datum' => '2022-06-15', 'ereignis' => 'Erstzulassung'], - ['datum' => '2024-03-01', 'ereignis' => 'Inspektion'], + 'url' => 'https://moox.org/items/golf-gti', + 'label' => 'Inserat online ansehen', + 'target' => '_blank', ], ], - 'seiteninhalt' => [ + 'fahrzeugvideo' => [ + 'type' => 'oembed', + 'value' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ], + 'inserat-inhalt' => [ 'type' => 'flexible_content', 'value' => [ [ 'type' => 'hero', 'data' => [ - 'titel' => 'Willkommen beim Layout-Showcase', - 'text' => 'Flexible Inhalte mit unterschiedlichen Layouts.', + 'titel' => 'Golf GTI — sofort verfügbar', + 'text' => '245 PS, Automatik, unfallfrei. Jetzt Probefahrt vereinbaren.', ], ], [ - 'type' => 'text-block', + 'type' => 'technik-block', 'data' => [ - 'inhalt' => '

Dies ist ein Textblock im Flexible-Content-Feld.

', + 'ueberschrift' => 'Technische Daten', + 'details' => '
  • 2,0 l TSI, 245 PS
  • 7-Gang DSG
  • Verbrauch komb.: 6,9 l/100 km
', ], ], ], ], - 'demo-oembed' => [ - 'type' => 'oembed', - 'value' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', - ], ]; foreach ($demos as $fieldName => $demo) { diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index ee1fce8e66..6c5c76adcb 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -40,6 +40,8 @@ 'option_label' => 'Anzeigetext', 'option_value' => 'Wert', 'subfields' => 'Unterfelder', + 'tab_content' => 'Tab-Inhalt', + 'tab_content_helper' => 'Diese Felder erscheinen im Tab. Die Bezeichnung des Felds wird als Tab-Titel verwendet.', 'layouts' => 'Layouts', 'layout_label' => 'Layout-Bezeichnung', 'layout_key' => 'Layout-Schlüssel', @@ -100,6 +102,9 @@ 'prefix' => 'Präfix', 'suffix' => 'Suffix', 'default_value' => 'Standardwert', + 'default_value_toggle_helper' => 'Schalter-Position beim Anlegen eines neuen Datensatzes.', + 'default_value_option_helper' => 'Aus den oben definierten Auswahloptionen wählen.', + 'default_value_multi_option_helper' => 'Eine oder mehrere der oben definierten Optionen wählen.', 'helper_text' => 'Hilfetext', 'min_value' => 'Mindestwert', 'max_value' => 'Höchstwert', @@ -108,6 +113,13 @@ 'step' => 'Schrittweite', 'rows' => 'Zeilen', 'display_format' => 'Anzeigeformat', + 'display_format_helper' => 'So wird das Datum im Formular angezeigt.', + 'display_format_dmy' => '31.12.2026', + 'display_format_dmy_slash' => '31/12/2026', + 'display_format_ymd' => '2026-12-31', + 'display_format_mdy' => '12/31/2026', + 'display_format_dmy_hi' => '31.12.2026 14:30', + 'display_format_dmy_his' => '31.12.2026 14:30:00', ], 'repeater' => [ diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 286ce3c5d8..4e5ad4819f 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -40,6 +40,8 @@ 'option_label' => 'Display text', 'option_value' => 'Value', 'subfields' => 'Subfields', + 'tab_content' => 'Tab content', + 'tab_content_helper' => 'These fields appear inside the tab. The field label is used as the tab title.', 'layouts' => 'Layouts', 'layout_label' => 'Layout label', 'layout_key' => 'Layout key', @@ -100,6 +102,9 @@ 'prefix' => 'Prefix', 'suffix' => 'Suffix', 'default_value' => 'Default value', + 'default_value_toggle_helper' => 'Switch position when creating a new record.', + 'default_value_option_helper' => 'Choose from the options defined above.', + 'default_value_multi_option_helper' => 'Choose one or more options defined above.', 'helper_text' => 'Helper text', 'min_value' => 'Minimum value', 'max_value' => 'Maximum value', @@ -108,6 +113,13 @@ 'step' => 'Step', 'rows' => 'Rows', 'display_format' => 'Display format', + 'display_format_helper' => 'How the date is shown in the form.', + 'display_format_dmy' => '31 Dec 2026', + 'display_format_dmy_slash' => '31/12/2026', + 'display_format_ymd' => '2026-12-31', + 'display_format_mdy' => '12/31/2026', + 'display_format_dmy_hi' => '31.12.2026 14:30', + 'display_format_dmy_his' => '31.12.2026 14:30:00', ], 'repeater' => [ diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index cb42ae2ff4..8c6de200dc 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -16,12 +16,14 @@ use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Services\CustomFieldsManager; use Moox\Builder\Support\OptionValueRules; +use Moox\Builder\Support\StorableFieldCollector; class SchemaCompiler { public function __construct( protected FieldTypeRegistry $fieldTypeRegistry, protected CustomFieldsManager $customFieldsManager, + protected StorableFieldCollector $storableFieldCollector, ) {} /** @@ -31,12 +33,23 @@ public function __construct( */ public function compile(Collection $fieldGroups, ?string $resourceClass = null): array { + $entity = $resourceClass !== null + ? $this->customFieldsManager->locationContextForResource($resourceClass)->entity + : null; + + $storableFields = $entity !== null + ? $this->storableFieldCollector->definitionsFromList( + $fieldGroups->flatMap(fn (FieldGroupDefinition $group): Collection => $group->fields), + ) + : collect(); + $sections = []; foreach ($fieldGroups as $group) { $components = $this->compileRootFields( $group->fields->sortBy(fn (FieldDefinition $field): int => $field->sort)->values(), - $resourceClass, + $entity, + $storableFields, ); if ($components === []) { @@ -52,57 +65,62 @@ public function compile(Collection $fieldGroups, ?string $resourceClass = null): /** * @param Collection $fields - * @param class-string|null $resourceClass * @return list */ - public function compileSubFields(Collection $fields, ?string $resourceClass): array + public function compileSubFields(Collection $fields, ?string $entity = null, ?Collection $storableFields = null): array { + $storableFields ??= collect(); + return $fields ->sortBy(fn (FieldDefinition $field): int => $field->sort) ->values() - ->map(fn (FieldDefinition $field): Component => $this->compileField($field, $resourceClass)) + ->map(fn (FieldDefinition $field): Component => $this->compileField($field, $entity, $storableFields)) ->all(); } /** * @param Collection $fields - * @param class-string|null $resourceClass + * @param Collection $storableFields * @return list */ - protected function compileRootFields(Collection $fields, ?string $resourceClass): array + protected function compileRootFields(Collection $fields, ?string $entity, Collection $storableFields): array { $components = []; - $beforeTabs = []; - $tabPanels = []; - $tabPanelIndex = null; + $sorted = $fields->sortBy(fn (FieldDefinition $field): int => $field->sort)->values(); + $index = 0; + + while ($index < $sorted->count()) { + $field = $sorted[$index]; - foreach ($fields as $field) { if ($field->type === 'tab') { - $tabPanels[] = ['label' => $field->label, 'fields' => []]; - $tabPanelIndex = count($tabPanels) - 1; + $tabPanels = []; - continue; - } + while ($index < $sorted->count() && $sorted[$index]->type === 'tab') { + $tabField = $sorted[$index]; - if ($tabPanelIndex !== null) { - $tabPanels[$tabPanelIndex]['fields'][] = $field; + if ($tabField->children->isNotEmpty()) { + $tabPanels[] = [ + 'label' => $tabField->label, + 'fields' => $tabField->children, + ]; + } - continue; - } + $index++; + } - $beforeTabs[] = $field; - } + if ($tabPanels !== []) { + $components[] = Tabs::make('custom_fields_tabs') + ->tabs(collect($tabPanels)->map( + fn (array $panel): Tab => Tab::make($panel['label']) + ->schema($this->compileSubFields($panel['fields'], $entity, $storableFields)), + )->all()); + } - foreach ($beforeTabs as $field) { - $components[] = $this->compileField($field, $resourceClass); - } + continue; + } - if ($tabPanels !== []) { - $components[] = Tabs::make('custom_fields_tabs') - ->tabs(collect($tabPanels)->map( - fn (array $panel): Tab => Tab::make($panel['label']) - ->schema($this->compileSubFields(collect($panel['fields']), $resourceClass)), - )->all()); + $components[] = $this->compileField($field, $entity, $storableFields); + $index++; } return $components; @@ -137,6 +155,16 @@ protected function rulesForFieldTree(FieldDefinition $field, string $prefix = '' return []; } + if ($field->type === 'tab') { + $rules = []; + + foreach ($field->children as $child) { + $rules = array_merge($rules, $this->rulesForFieldTree($child, $prefix)); + } + + return $rules; + } + if ($fieldType->hasSubFields()) { if ($field->type === 'flexible_content') { return $rules; @@ -204,21 +232,20 @@ protected function rulesForField(FieldDefinition $field): array } /** - * @param class-string|null $resourceClass + * @param Collection $storableFields */ - protected function compileField(FieldDefinition $field, ?string $resourceClass = null): Component + protected function compileField(FieldDefinition $field, ?string $entity = null, ?Collection $storableFields = null): Component { + $storableFields ??= collect(); + $fieldType = $this->fieldTypeRegistry->get($field->type); $component = $fieldType->formComponent($field); - $entity = $resourceClass !== null - ? $this->customFieldsManager->locationContextForResource($resourceClass)->entity - : null; if (! $fieldType->storesValue() || $entity === null) { return $component; } - return $component->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $entity, $fieldType): void { + return $component->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $entity, $fieldType, $storableFields): void { if ($record === null) { return; } @@ -227,10 +254,10 @@ protected function compileField(FieldDefinition $field, ?string $resourceClass = return; } - $values = $this->customFieldsManager->loadValues( + $values = $this->customFieldsManager->loadCachedValues( $entity, $record, - collect([$field]), + $storableFields, ); if (! array_key_exists($field->name, $values)) { diff --git a/packages/builder/src/FieldTypes/Capabilities/Capability.php b/packages/builder/src/FieldTypes/Capabilities/Capability.php index 772410b039..1592944bfd 100644 --- a/packages/builder/src/FieldTypes/Capabilities/Capability.php +++ b/packages/builder/src/FieldTypes/Capabilities/Capability.php @@ -14,6 +14,14 @@ abstract class Capability */ abstract public function builderFields(): array; + /** + * @return list + */ + public function builderFieldsFor(string $fieldType): array + { + return $this->builderFields(); + } + /** * @return list */ diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index 7a90373be9..3cd8ca0049 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -4,9 +4,16 @@ namespace Moox\Builder\FieldTypes\Capabilities; +use Filament\Forms\Components\ColorPicker; +use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\DateTimePicker; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\TimePicker; use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Component; +use Filament\Schemas\Components\Utilities\Get; use Moox\Builder\Data\FieldDefinition; class DefaultValue extends Capability @@ -19,6 +26,69 @@ public function builderFields(): array ]; } + public function builderFieldsFor(string $fieldType): array + { + return match ($fieldType) { + 'toggle' => [ + Toggle::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->helperText(__('builder::builder.capabilities.default_value_toggle_helper')) + ->inline(false) + ->default(false), + ], + 'color' => [ + ColorPicker::make('config.default') + ->label(__('builder::builder.capabilities.default_value')), + ], + 'date' => [ + DatePicker::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->native(false), + ], + 'datetime' => [ + DateTimePicker::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->native(false), + ], + 'time' => [ + TimePicker::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->native(false), + ], + 'select', 'radio', 'button_group' => [ + Select::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->helperText(__('builder::builder.capabilities.default_value_option_helper')) + ->options(fn (Get $get): array => $this->optionChoices($get('options'))) + ->searchable() + ->native(false), + ], + 'multiselect', 'checkbox_list' => [ + Select::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->helperText(__('builder::builder.capabilities.default_value_multi_option_helper')) + ->options(fn (Get $get): array => $this->optionChoices($get('options'))) + ->multiple() + ->searchable() + ->native(false), + ], + 'number', 'range' => [ + TextInput::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->numeric(), + ], + 'textarea', 'rich_text' => [ + Textarea::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->rows(3), + ], + default => [ + TextInput::make('config.default') + ->label(__('builder::builder.capabilities.default_value')), + ], + }; + } + public function apply(Component $component, FieldDefinition $field): Component { if (! array_key_exists('default', $field->config)) { @@ -27,12 +97,92 @@ public function apply(Component $component, FieldDefinition $field): Component $default = $field->config['default']; + if ($default === null || $default === '') { + return $component; + } + if ($component instanceof Toggle) { - $component->default(filter_var($default, FILTER_VALIDATE_BOOLEAN)); - } else { - $component->default($default); + $component->default($this->resolveBooleanDefault($default)); + + return $component; + } + + if (in_array($field->type, ['number', 'range'], true) && is_numeric($default)) { + $component->default($default + 0); + + return $component; + } + + if (in_array($field->type, ['multiselect', 'checkbox_list'], true)) { + $component->default($this->resolveArrayDefault($default)); + + return $component; } + $component->default($default); + return $component; } + + /** + * @param list|null $options + * @return array + */ + protected function optionChoices(?array $options): array + { + if ($options === null) { + return []; + } + + $choices = []; + + foreach ($options as $option) { + $value = $option['value'] ?? null; + + if (blank($value)) { + continue; + } + + $choices[(string) $value] = filled($option['label'] ?? null) + ? (string) $option['label'] + : (string) $value; + } + + return $choices; + } + + protected function resolveBooleanDefault(mixed $default): bool + { + if (is_bool($default)) { + return $default; + } + + return filter_var($default, FILTER_VALIDATE_BOOLEAN); + } + + /** + * @return list + */ + protected function resolveArrayDefault(mixed $default): array + { + if ($default === null || $default === '') { + return []; + } + + if (is_string($default)) { + $decoded = json_decode($default, true); + + if (is_array($decoded)) { + return array_values(array_filter($decoded, fn (mixed $value): bool => $value !== null && $value !== '')); + } + + return [$default]; + } + + if (! is_array($default)) { + return [(string) $default]; + } + + return array_values(array_filter($default, fn (mixed $value): bool => $value !== null && $value !== '')); + } } diff --git a/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php b/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php index 25afbb71e8..1e4ca44e88 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php +++ b/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php @@ -5,7 +5,7 @@ namespace Moox\Builder\FieldTypes\Capabilities; use Filament\Forms\Components\DateTimePicker; -use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Select; use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; @@ -14,9 +14,11 @@ class DisplayFormat extends Capability public function builderFields(): array { return [ - TextInput::make('config.displayFormat') + Select::make('config.displayFormat') ->label(__('builder::builder.capabilities.display_format')) - ->placeholder('d.m.Y'), + ->helperText(__('builder::builder.capabilities.display_format_helper')) + ->options($this->formatOptions()) + ->native(false), ]; } @@ -28,4 +30,19 @@ public function apply(Component $component, FieldDefinition $field): Component return $component; } + + /** + * @return array + */ + protected function formatOptions(): array + { + return [ + 'd.m.Y' => __('builder::builder.capabilities.display_format_dmy'), + 'd/m/Y' => __('builder::builder.capabilities.display_format_dmy_slash'), + 'Y-m-d' => __('builder::builder.capabilities.display_format_ymd'), + 'm/d/Y' => __('builder::builder.capabilities.display_format_mdy'), + 'd.m.Y H:i' => __('builder::builder.capabilities.display_format_dmy_hi'), + 'd.m.Y H:i:s' => __('builder::builder.capabilities.display_format_dmy_his'), + ]; + } } diff --git a/packages/builder/src/FieldTypes/Types/CheckboxListFieldType.php b/packages/builder/src/FieldTypes/Types/CheckboxListFieldType.php index 2034c0f0a6..33dcff7345 100644 --- a/packages/builder/src/FieldTypes/Types/CheckboxListFieldType.php +++ b/packages/builder/src/FieldTypes/Types/CheckboxListFieldType.php @@ -7,6 +7,7 @@ use Filament\Forms\Components\CheckboxList; use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; +use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\FieldTypes\Capabilities\HelperText; use Moox\Builder\FieldTypes\Concerns\BuildsOptionComponents; use Moox\Builder\FieldTypes\FieldType; @@ -28,6 +29,7 @@ public function hasOptions(): bool public function capabilities(): array { return [ + DefaultValue::class, HelperText::class, ]; } diff --git a/packages/builder/src/FieldTypes/Types/MultiselectFieldType.php b/packages/builder/src/FieldTypes/Types/MultiselectFieldType.php index bc0392904a..494da07645 100644 --- a/packages/builder/src/FieldTypes/Types/MultiselectFieldType.php +++ b/packages/builder/src/FieldTypes/Types/MultiselectFieldType.php @@ -7,6 +7,7 @@ use Filament\Forms\Components\Select; use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; +use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\FieldTypes\Capabilities\HelperText; use Moox\Builder\FieldTypes\Concerns\BuildsOptionComponents; use Moox\Builder\FieldTypes\FieldType; @@ -28,6 +29,7 @@ public function hasOptions(): bool public function capabilities(): array { return [ + DefaultValue::class, HelperText::class, ]; } diff --git a/packages/builder/src/FieldTypes/Types/TabFieldType.php b/packages/builder/src/FieldTypes/Types/TabFieldType.php index 5a70ff1838..9f8ab47bdd 100644 --- a/packages/builder/src/FieldTypes/Types/TabFieldType.php +++ b/packages/builder/src/FieldTypes/Types/TabFieldType.php @@ -20,7 +20,7 @@ public function storesValue(): bool return false; } - public function isLayoutMarker(): bool + public function hasSubFields(): bool { return true; } diff --git a/packages/builder/src/Observers/PurgeFieldValuesObserver.php b/packages/builder/src/Observers/PurgeFieldValuesObserver.php index 355359e136..144620f376 100644 --- a/packages/builder/src/Observers/PurgeFieldValuesObserver.php +++ b/packages/builder/src/Observers/PurgeFieldValuesObserver.php @@ -6,6 +6,7 @@ use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; +use Moox\Builder\Services\CompoundFieldValueMigrator; use Moox\Builder\Services\FieldGroupPersistence; use Moox\Builder\Services\FieldValuePurger; @@ -14,6 +15,7 @@ class PurgeFieldValuesObserver public function __construct( protected FieldValuePurger $purger, protected FieldGroupPersistence $fieldGroupPersistence, + protected CompoundFieldValueMigrator $compoundFieldValueMigrator, ) {} public function deleting(FieldGroup|Field $model): void @@ -52,6 +54,12 @@ public function deleted(Field|FieldGroup $model): void $field->fieldGroup->location_rules ?? [], ); + if ($field->parent_field_id !== null) { + $this->compoundFieldValueMigrator->removeNestedSubfield($field, $entities); + + return; + } + $this->purger->purgeForFieldName($field->name, $entities); } } diff --git a/packages/builder/src/Registry/DefinitionRegistry.php b/packages/builder/src/Registry/DefinitionRegistry.php index 0c545c9888..e9472099ed 100644 --- a/packages/builder/src/Registry/DefinitionRegistry.php +++ b/packages/builder/src/Registry/DefinitionRegistry.php @@ -10,6 +10,7 @@ use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\Data\LocationContext; use Moox\Builder\Models\FieldGroup; +use Moox\Builder\Support\FieldRelationTree; class DefinitionRegistry { @@ -60,14 +61,7 @@ protected function loadActiveGroupsAsArrays(): array { return FieldGroup::query() ->active() - ->with([ - 'fields' => fn ($query) => $query->whereNull('parent_field_id')->orderBy('sort'), - 'fields.options', - 'fields.children' => fn ($query) => $query->orderBy('sort'), - 'fields.children.options', - 'fields.children.children' => fn ($query) => $query->orderBy('sort'), - 'fields.children.children.options', - ]) + ->with(FieldRelationTree::eagerLoadForDefinition()) ->orderBy('sort') ->get() ->map(fn (FieldGroup $group): array => FieldGroupDefinition::fromModel($group)->toArray()) diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index f9b0a99680..c6b2508a81 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -194,7 +194,12 @@ public static function form(Schema $schema): Schema ->defaultItems(1), ]) ->visible(fn (callable $get): bool => filled($get('type')) && $registry->get($get('type'))->hasOptions()), - Section::make(__('builder::builder.field.subfields')) + Section::make(fn (callable $get): string => $get('type') === 'tab' + ? __('builder::builder.field.tab_content') + : __('builder::builder.field.subfields')) + ->description(fn (callable $get): ?string => $get('type') === 'tab' + ? __('builder::builder.field.tab_content_helper') + : null) ->collapsed() ->schema([ Repeater::make('children') @@ -427,7 +432,7 @@ protected static function typeSettingsSchema(?string $type): array $components = []; foreach ($fieldType->capabilities() as $capabilityClass) { - $components = array_merge($components, app($capabilityClass)->builderFields()); + $components = array_merge($components, app($capabilityClass)->builderFieldsFor($type)); } return $components; diff --git a/packages/builder/src/Services/CompoundFieldValueMigrator.php b/packages/builder/src/Services/CompoundFieldValueMigrator.php new file mode 100644 index 0000000000..9cfd2ed226 --- /dev/null +++ b/packages/builder/src/Services/CompoundFieldValueMigrator.php @@ -0,0 +1,312 @@ + $entities + */ + public function renameNestedSubfield(Field $field, string $previousName, array $entities): void + { + $context = $this->resolveStorageContext($field); + + if ($context === null) { + $this->fieldValuePurger->purgeForFieldName($previousName, $entities); + + return; + } + + $this->mutateStoredValues( + $entities, + $context->compoundFieldName, + fn (mixed $value) => $this->renameKeyInValue($value, $context, $previousName, $field->name), + ); + } + + /** + * @param list $entities + */ + public function removeNestedSubfield(Field $field, array $entities): void + { + $context = $this->resolveStorageContext($field); + + if ($context === null) { + $this->fieldValuePurger->purgeForFieldName($field->name, $entities); + + return; + } + + $this->mutateStoredValues( + $entities, + $context->compoundFieldName, + fn (mixed $value) => $this->removeKeyInValue($value, $context, $field->name), + ); + } + + /** + * @param list $entities + * @param callable(mixed): mixed $mutator + */ + protected function mutateStoredValues(array $entities, string $compoundFieldName, callable $mutator): void + { + if ($entities === []) { + return; + } + + FieldValue::query() + ->whereIn('entity', $entities) + ->where('field_name', $compoundFieldName) + ->whereNotNull('value_json') + ->each(function (FieldValue $row) use ($mutator): void { + $updated = $mutator($row->value_json); + + if ($updated === $row->value_json) { + return; + } + + if ($this->isEmptyCompoundValue($updated)) { + $row->delete(); + + return; + } + + $row->update(['value_json' => $updated]); + }); + } + + protected function resolveStorageContext(Field $field): ?CompoundStorageContext + { + $field->loadMissing('parentField.parentField'); + + $parent = $field->parentField; + + if ($parent === null) { + return null; + } + + if (in_array($parent->type, ['group', 'repeater'], true)) { + return new CompoundStorageContext($parent->name, $parent->type); + } + + if ($parent->type === 'flexible_layout') { + $flexParent = $parent->parentField; + + if ($flexParent === null || $flexParent->type !== 'flexible_content') { + return null; + } + + return new CompoundStorageContext( + compoundFieldName: $flexParent->name, + compoundType: 'flexible_content', + layoutName: $parent->name, + ); + } + + return null; + } + + protected function renameKeyInValue(mixed $value, CompoundStorageContext $context, string $from, string $to): mixed + { + if (! is_array($value)) { + return $value; + } + + return match ($context->compoundType) { + 'group' => $this->renameKeyInGroupValue($value, $from, $to), + 'repeater' => $this->renameKeyInRepeaterValue($value, $from, $to), + 'flexible_content' => $this->renameKeyInFlexibleContentValue($value, $context->layoutName, $from, $to), + default => $value, + }; + } + + protected function removeKeyInValue(mixed $value, CompoundStorageContext $context, string $key): mixed + { + if (! is_array($value)) { + return $value; + } + + return match ($context->compoundType) { + 'group' => $this->removeKeyInGroupValue($value, $key), + 'repeater' => $this->removeKeyInRepeaterValue($value, $key), + 'flexible_content' => $this->removeKeyInFlexibleContentValue($value, $context->layoutName, $key), + default => $value, + }; + } + + /** + * @param array $value + * @return array + */ + protected function renameKeyInGroupValue(array $value, string $from, string $to): array + { + if (array_is_list($value) && isset($value[0]) && is_array($value[0])) { + $value[0] = $this->renameKeyInAssociativeArray($value[0], $from, $to); + + return $value; + } + + return $this->renameKeyInAssociativeArray($value, $from, $to); + } + + /** + * @param array $value + * @return array + */ + protected function removeKeyInGroupValue(array $value, string $key): array + { + if (array_is_list($value) && isset($value[0]) && is_array($value[0])) { + $value[0] = $this->removeKeyFromAssociativeArray($value[0], $key); + + return $value; + } + + return $this->removeKeyFromAssociativeArray($value, $key); + } + + /** + * @param list> $value + * @return list> + */ + protected function renameKeyInRepeaterValue(array $value, string $from, string $to): array + { + foreach ($value as $index => $item) { + if (is_array($item)) { + $value[$index] = $this->renameKeyInAssociativeArray($item, $from, $to); + } + } + + return $value; + } + + /** + * @param list> $value + * @return list> + */ + protected function removeKeyInRepeaterValue(array $value, string $key): array + { + foreach ($value as $index => $item) { + if (is_array($item)) { + $value[$index] = $this->removeKeyFromAssociativeArray($item, $key); + } + } + + return $value; + } + + /** + * @param list> $value + * @return list> + */ + protected function renameKeyInFlexibleContentValue(array $value, ?string $layoutName, string $from, string $to): array + { + foreach ($value as $index => $item) { + if (! is_array($item)) { + continue; + } + + if ($layoutName !== null && (string) ($item['type'] ?? $item['layout'] ?? '') !== $layoutName) { + continue; + } + + if (isset($item['data']) && is_array($item['data'])) { + $item['data'] = $this->renameKeyInAssociativeArray($item['data'], $from, $to); + } + + $value[$index] = $item; + } + + return $value; + } + + /** + * @param list> $value + * @return list> + */ + protected function removeKeyInFlexibleContentValue(array $value, ?string $layoutName, string $key): array + { + foreach ($value as $index => $item) { + if (! is_array($item)) { + continue; + } + + if ($layoutName !== null && (string) ($item['type'] ?? $item['layout'] ?? '') !== $layoutName) { + continue; + } + + if (isset($item['data']) && is_array($item['data'])) { + $item['data'] = $this->removeKeyFromAssociativeArray($item['data'], $key); + } + + $value[$index] = $item; + } + + return $value; + } + + /** + * @param array $data + * @return array + */ + protected function renameKeyInAssociativeArray(array $data, string $from, string $to): array + { + if (! array_key_exists($from, $data)) { + return $data; + } + + $data[$to] = $data[$from]; + unset($data[$from]); + + return $data; + } + + /** + * @param array $data + * @return array + */ + protected function removeKeyFromAssociativeArray(array $data, string $key): array + { + unset($data[$key]); + + return $data; + } + + protected function isEmptyCompoundValue(mixed $value): bool + { + if ($value === null) { + return true; + } + + if (! is_array($value)) { + return false; + } + + if ($value === []) { + return true; + } + + if (array_is_list($value)) { + return collect($value)->every(fn (mixed $item): bool => $item === null || $item === [] || $item === ''); + } + + return collect($value)->every(fn (mixed $item): bool => $item === null || $item === '' || $item === []); + } +} + +readonly class CompoundStorageContext +{ + public function __construct( + public string $compoundFieldName, + public string $compoundType, + public ?string $layoutName = null, + ) {} +} diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index ca975f436c..e68cd67b23 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -13,14 +13,21 @@ use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\OptionValueRules; +use Moox\Builder\Support\StorableFieldCollector; use Moox\Builder\Support\TypedValueColumns; class CustomFieldsManager { + /** + * @var array> + */ + protected array $valuesCache = []; + public function __construct( protected DefinitionRegistry $definitionRegistry, protected FieldTypeRegistry $fieldTypeRegistry, protected FieldValueValidator $fieldValueValidator, + protected StorableFieldCollector $storableFieldCollector, ) {} /** @@ -41,34 +48,7 @@ public function fieldsForResource(string $resourceClass): Collection $this->locationContextForResource($resourceClass), ); - return $groups->flatMap(fn ($group) => $this->storableFieldsFromList($group->fields))->values(); - } - - /** - * @param Collection $fields - * @return Collection - */ - protected function storableFieldsFromList(Collection $fields): Collection - { - return $fields->flatMap(fn (FieldDefinition $field): Collection => $this->storableFieldsFor($field)); - } - - /** - * @return Collection - */ - protected function storableFieldsFor(FieldDefinition $field): Collection - { - $fieldType = $this->fieldTypeRegistry->get($field->type); - - if ($fieldType->isLayoutMarker()) { - return collect(); - } - - if ($fieldType->hasSubFields()) { - return collect([$field]); - } - - return collect([$field]); + return $groups->flatMap(fn ($group) => $this->storableFieldCollector->definitionsFromList($group->fields))->values(); } /** @@ -128,6 +108,25 @@ public function loadValues(string $entity, Model $record, Collection $fields): a return $values; } + /** + * @param Collection $fields + * @return array + */ + public function loadCachedValues(string $entity, Model $record, Collection $fields): array + { + if ($fields->isEmpty()) { + return []; + } + + $cacheKey = "{$entity}:{$record->getKey()}"; + + if (! array_key_exists($cacheKey, $this->valuesCache)) { + $this->valuesCache[$cacheKey] = $this->loadValues($entity, $record, $fields); + } + + return $this->valuesCache[$cacheKey]; + } + /** * @param class-string $resourceClass * @param array $data @@ -207,6 +206,8 @@ public function saveValues(string $entity, Model $record, array $values, Collect $columns, ); } + + unset($this->valuesCache["{$entity}:{$record->getKey()}"]); } /** diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index fbcbec8850..509111cf03 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -11,6 +11,7 @@ use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldOption; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Support\FieldRelationTree; class FieldGroupPersistence { @@ -150,14 +151,7 @@ public function flattenLocationRulesForForm(array $rules): array */ public function fieldRowsForForm(FieldGroup $group): array { - $group->load([ - 'fields' => fn ($query) => $query->whereNull('parent_field_id')->orderBy('sort'), - 'fields.options', - 'fields.children' => fn ($query) => $query->orderBy('sort'), - 'fields.children.options', - 'fields.children.children' => fn ($query) => $query->orderBy('sort'), - 'fields.children.children.options', - ]); + $group->load(FieldRelationTree::eagerLoadForDefinition()); return $this->mapFieldRows($group->fields); } @@ -262,7 +256,12 @@ protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldI if ($previousName !== null && $previousName !== $field->name) { $entities = $this->entitiesFromLocationRules($group->location_rules ?? []); - $this->fieldValuePurger->purgeForFieldName($previousName, $entities); + + if ($field->parent_field_id !== null) { + app(CompoundFieldValueMigrator::class)->renameNestedSubfield($field, $previousName, $entities); + } else { + $this->fieldValuePurger->purgeForFieldName($previousName, $entities); + } } $retainedIds[] = $field->getKey(); diff --git a/packages/builder/src/Services/FieldGroupValidator.php b/packages/builder/src/Services/FieldGroupValidator.php index 29b5a44b01..f621ba68a9 100644 --- a/packages/builder/src/Services/FieldGroupValidator.php +++ b/packages/builder/src/Services/FieldGroupValidator.php @@ -5,12 +5,16 @@ namespace Moox\Builder\Services; use Illuminate\Validation\ValidationException; +use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\Models\FieldGroup; +use Moox\Builder\Support\FieldRelationTree; +use Moox\Builder\Support\StorableFieldCollector; class FieldGroupValidator { public function __construct( protected FieldGroupPersistence $fieldGroupPersistence, + protected StorableFieldCollector $storableFieldCollector, ) {} /** @@ -26,10 +30,12 @@ public function validate(FieldGroup $group, array $data): void return; } - $messages = []; + $entries = $this->storableFieldCollector->pathsFromRows($fieldRows); - $messages = array_merge($messages, $this->internalDuplicateMessages($fieldRows)); - $messages = array_merge($messages, $this->externalConflictMessages($group, $entities, $this->rootFieldRows($fieldRows))); + $messages = array_merge( + $this->internalDuplicateMessages($entries), + $this->externalConflictMessages($group, $entities, $entries), + ); if ($messages !== []) { throw ValidationException::withMessages($messages); @@ -37,53 +43,24 @@ public function validate(FieldGroup $group, array $data): void } /** - * @param list> $fieldRows - * @return list> - */ - protected function rootFieldRows(array $fieldRows): array - { - return $fieldRows; - } - - /** - * @param list> $fieldRows + * @param list $entries * @return array> */ - protected function internalDuplicateMessages(array $fieldRows, string $pathPrefix = 'fields'): array + protected function internalDuplicateMessages(array $entries): array { $seen = []; $messages = []; - foreach ($fieldRows as $index => $row) { - $name = (string) ($row['name'] ?? ''); - - if ($name === '') { - continue; - } - - if (isset($seen[$name])) { - $messages["{$pathPrefix}.{$index}.name"] = [ - __('builder::builder.validation.duplicate_field_name_internal', ['name' => $name]), + foreach ($entries as $entry) { + if (isset($seen[$entry['name']])) { + $messages[$entry['path']] = [ + __('builder::builder.validation.duplicate_field_name_internal', ['name' => $entry['name']]), ]; continue; } - $seen[$name] = $index; - - if (isset($row['layouts']) && is_array($row['layouts'])) { - $messages = array_merge( - $messages, - $this->internalDuplicateMessages($row['layouts'], "{$pathPrefix}.{$index}.layouts"), - ); - } - - if (isset($row['children']) && is_array($row['children'])) { - $messages = array_merge( - $messages, - $this->internalDuplicateMessages($row['children'], "{$pathPrefix}.{$index}.children"), - ); - } + $seen[$entry['name']] = $entry['path']; } return $messages; @@ -91,27 +68,24 @@ protected function internalDuplicateMessages(array $fieldRows, string $pathPrefi /** * @param list $entities - * @param list> $fieldRows + * @param list $entries * @return array> */ - protected function externalConflictMessages(FieldGroup $group, array $entities, array $fieldRows): array + protected function externalConflictMessages(FieldGroup $group, array $entities, array $entries): array { - $namesByIndex = []; - - foreach ($fieldRows as $index => $row) { - if (filled($row['name'] ?? null)) { - $namesByIndex[$index] = (string) $row['name']; - } + if ($entries === []) { + return []; } - if ($namesByIndex === []) { - return []; + $currentNames = []; + + foreach ($entries as $entry) { + $currentNames[$entry['name']] = $entry['path']; } $otherGroups = FieldGroup::query() ->active() ->when($group->exists, fn ($query) => $query->whereKeyNot($group->getKey())) - ->with('fields') ->get(); $messages = []; @@ -123,19 +97,28 @@ protected function externalConflictMessages(FieldGroup $group, array $entities, continue; } - foreach ($otherGroup->fields as $otherField) { - foreach ($namesByIndex as $index => $name) { - if ($name !== $otherField->name) { - continue; - } - - $messages["fields.{$index}.name"] = [ - __('builder::builder.validation.duplicate_field_name', [ - 'name' => $name, - 'group' => $otherGroup->name, - ]), - ]; + $otherGroup->load(FieldRelationTree::eagerLoadForDefinition()); + $otherNames = $this->storableFieldCollector->namesFromList( + FieldGroupDefinition::fromModel($otherGroup)->fields, + ); + + foreach ($otherNames as $otherName) { + if (! isset($currentNames[$otherName])) { + continue; + } + + $path = $currentNames[$otherName]; + + if (isset($messages[$path])) { + continue; } + + $messages[$path] = [ + __('builder::builder.validation.duplicate_field_name', [ + 'name' => $otherName, + 'group' => $otherGroup->name, + ]), + ]; } } diff --git a/packages/builder/src/Services/FieldValueValidator.php b/packages/builder/src/Services/FieldValueValidator.php index e8221bc738..b803a9c952 100644 --- a/packages/builder/src/Services/FieldValueValidator.php +++ b/packages/builder/src/Services/FieldValueValidator.php @@ -24,6 +24,10 @@ public function messagesFor(FieldDefinition $field, mixed $value, ?string $path $fieldType = $this->fieldTypeRegistry->get($field->type); + if ($field->type === 'tab') { + return []; + } + if ($fieldType->isLayoutMarker()) { return []; } diff --git a/packages/builder/src/Services/TabStructureMigrator.php b/packages/builder/src/Services/TabStructureMigrator.php new file mode 100644 index 0000000000..097f4b369d --- /dev/null +++ b/packages/builder/src/Services/TabStructureMigrator.php @@ -0,0 +1,108 @@ +fields() + ->whereNull('parent_field_id') + ->orderBy('sort') + ->get(); + + if ($roots->isEmpty()) { + return false; + } + + $tabFields = $roots->where('type', 'tab'); + + if ($tabFields->isEmpty()) { + return false; + } + + $needsMigration = $tabFields->contains( + fn (Field $tab): bool => $tab->children()->count() === 0 + && $this->hasFollowingRootFields($roots, $tab), + ); + + if (! $needsMigration) { + return false; + } + + $pendingChildren = collect(); + $currentTab = null; + + foreach ($roots as $field) { + if ($field->type === 'tab') { + if ($currentTab instanceof Field && $pendingChildren->isNotEmpty()) { + $this->assignChildren($currentTab, $pendingChildren); + } + + $currentTab = $field; + $pendingChildren = collect(); + + continue; + } + + if ($currentTab instanceof Field) { + $pendingChildren->push($field); + } + } + + if ($currentTab instanceof Field && $pendingChildren->isNotEmpty()) { + $this->assignChildren($currentTab, $pendingChildren); + } + + return true; + } + + /** + * @param Collection $roots + */ + protected function hasFollowingRootFields(Collection $roots, Field $tab): bool + { + $afterTab = false; + + foreach ($roots as $field) { + if ($field->is($tab)) { + $afterTab = true; + + continue; + } + + if ($afterTab && $field->type !== 'tab') { + return true; + } + + if ($afterTab && $field->type === 'tab') { + return false; + } + } + + return false; + } + + /** + * @param Collection $children + */ + protected function assignChildren(Field $tab, Collection $children): void + { + foreach ($children->values() as $sort => $child) { + $child->update([ + 'parent_field_id' => $tab->getKey(), + 'sort' => $sort, + ]); + } + } +} diff --git a/packages/builder/src/Support/FieldRelationTree.php b/packages/builder/src/Support/FieldRelationTree.php new file mode 100644 index 0000000000..1f42eb1292 --- /dev/null +++ b/packages/builder/src/Support/FieldRelationTree.php @@ -0,0 +1,31 @@ + + */ + public static function eagerLoadForDefinition(int $maxDepth = 4): array + { + $relations = [ + 'fields' => fn ($query) => $query->whereNull('parent_field_id')->orderBy('sort'), + 'fields.options', + ]; + + $path = 'fields'; + + for ($depth = 1; $depth <= $maxDepth; $depth++) { + $path .= '.children'; + $relations[$path] = fn ($query) => $query->orderBy('sort'); + $relations["{$path}.options"] = fn ($query) => $query->orderBy('sort'); + } + + return $relations; + } +} diff --git a/packages/builder/src/Support/StorableFieldCollector.php b/packages/builder/src/Support/StorableFieldCollector.php new file mode 100644 index 0000000000..e5f16f30c9 --- /dev/null +++ b/packages/builder/src/Support/StorableFieldCollector.php @@ -0,0 +1,111 @@ + $fields + * @return Collection + */ + public function definitionsFromList(Collection $fields): Collection + { + return $fields->flatMap(fn (FieldDefinition $field): Collection => $this->definitionsFor($field))->values(); + } + + /** + * @return Collection + */ + public function definitionsFor(FieldDefinition $field): Collection + { + if ($field->type === 'tab') { + return $this->definitionsFromList($field->children); + } + + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if ($fieldType->isLayoutMarker() || ! $fieldType->storesValue()) { + return collect(); + } + + if ($fieldType->hasSubFields()) { + return collect([$field]); + } + + return collect([$field]); + } + + /** + * @param Collection $fields + * @return list + */ + public function namesFromList(Collection $fields): array + { + return $this->definitionsFromList($fields) + ->pluck('name') + ->values() + ->all(); + } + + /** + * @param list> $rows + * @return list + */ + public function pathsFromRows(array $rows, string $prefix = 'fields'): array + { + $entries = []; + + foreach ($rows as $index => $row) { + if (! is_array($row)) { + continue; + } + + $type = (string) ($row['type'] ?? ''); + $name = (string) ($row['name'] ?? ''); + + if ($name === '') { + continue; + } + + $path = "{$prefix}.{$index}.name"; + + if ($type === 'tab') { + $entries = array_merge( + $entries, + $this->pathsFromRows($row['children'] ?? [], "{$prefix}.{$index}.children"), + ); + + continue; + } + + if ($this->isCompoundStorageType($type)) { + $entries[] = ['name' => $name, 'path' => $path]; + + continue; + } + + if (in_array($type, ['message', 'flexible_layout'], true)) { + continue; + } + + $entries[] = ['name' => $name, 'path' => $path]; + } + + return $entries; + } + + protected function isCompoundStorageType(string $type): bool + { + return in_array($type, ['group', 'repeater', 'flexible_content'], true); + } +} From 4a6e0b542b5f050dc977f7ca2f995d142274b417 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:22:37 +0200 Subject: [PATCH 017/105] fix fields --- packages/builder/README.md | 4 +- .../database/seeders/BuilderSeeder.php | 35 +++++-- .../builder/resources/lang/de/builder.php | 2 +- .../builder/resources/lang/en/builder.php | 2 +- .../builder/src/Compiler/LocationMatcher.php | 2 +- .../builder/src/Compiler/SchemaCompiler.php | 19 +++- .../FieldTypes/Capabilities/DefaultValue.php | 48 ++++++--- .../src/Registry/FieldTypeRegistry.php | 22 +++++ .../src/Resources/FieldGroupResource.php | 98 ++++++++++++++++++- .../src/Services/FieldGroupPersistence.php | 67 ++++++++++--- 10 files changed, 256 insertions(+), 43 deletions(-) diff --git a/packages/builder/README.md b/packages/builder/README.md index cb2868a7f0..758a3774f9 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -147,7 +147,7 @@ Im Admin wählst du **„Anzeigen bei“** (Multi-Select). Intern wird das zu: ] ``` -Jede innere Liste = AND-Gruppe, mehrere Gruppen = OR. Aktuell unterstützt der Matcher nur `param: entity` mit `==` / `!=`. +Jede innere Liste = AND-Gruppe, mehrere Gruppen = OR. Aktuell unterstützt der Matcher nur `param: entity` mit `==` / `!=`. Ohne Zuordnung (`Anzeigen bei` leer) erscheint die Gruppe in keinem Formular. --- @@ -454,7 +454,7 @@ packages/builder/ cd packages/builder && composer test ``` -73 Tests (Stand: Paket-intern). +75 Tests (Stand: Paket-intern). ### Manuell im Panel diff --git a/packages/builder/database/seeders/BuilderSeeder.php b/packages/builder/database/seeders/BuilderSeeder.php index fe8030e9ce..1986359350 100644 --- a/packages/builder/database/seeders/BuilderSeeder.php +++ b/packages/builder/database/seeders/BuilderSeeder.php @@ -140,13 +140,17 @@ protected function fahrzeugdatenFields(): array $this->field('besichtigungszeit', 'Bevorzugte Besichtigungszeit', 'time', 1, config: [ 'default' => '14:00', ]), - $this->field('zahlungsoptionen', 'Zahlungsoptionen', 'multiselect', 2, options: [ + $this->field('zahlungsoptionen', 'Zahlungsoptionen', 'multiselect', 2, config: [ + 'default' => ['finance', 'trade_in'], + ], options: [ ['label' => 'Barzahlung', 'value' => 'cash'], ['label' => 'Finanzierung', 'value' => 'finance'], ['label' => 'Leasing', 'value' => 'leasing'], ['label' => 'Inzahlungnahme', 'value' => 'trade_in'], ]), - $this->field('serienausstattung', 'Serienausstattung', 'checkbox_list', 3, options: [ + $this->field('serienausstattung', 'Serienausstattung', 'checkbox_list', 3, config: [ + 'default' => ['climate', 'cruise', 'led'], + ], options: [ ['label' => 'Klimaautomatik', 'value' => 'climate'], ['label' => 'Tempomat', 'value' => 'cruise'], ['label' => 'Einparkhilfe', 'value' => 'parking'], @@ -157,9 +161,9 @@ protected function fahrzeugdatenFields(): array 'label' => 'Zusatzausstattung', 'type' => 'repeater', 'sort' => 4, - 'config' => ['min' => 1, 'max' => 20], + 'config' => ['min' => 0, 'max' => 20], 'children' => [ - $this->field('merkmal', 'Merkmal', 'text', 0, required: true), + $this->field('merkmal', 'Merkmal', 'text', 0), $this->field('enthalten', 'Enthalten', 'toggle', 1, config: ['default' => true]), ], ], @@ -187,7 +191,7 @@ protected function fahrzeugdatenFields(): array 'label' => 'Inserat-Inhalt (Flexible Inhalte)', 'type' => 'flexible_content', 'sort' => 3, - 'config' => ['min' => 1, 'max' => 10], + 'config' => ['min' => 0, 'max' => 10], 'layouts' => [ [ 'name' => 'hero', @@ -261,17 +265,19 @@ protected function field( } /** - * Demo-Werte für Item #1 — nur wenn items-Tabelle und Datensatz existieren. + * Demo-Werte für das erste Item — nur wenn items-Tabelle und ein Datensatz existieren. */ protected function seedDemoValuesForFirstItem(): void { - if (! Schema::hasTable('items') || ! DB::table('items')->where('id', 1)->exists()) { + $recordId = $this->resolveDemoItemId(); + + if ($recordId === null) { return; } FieldValue::query() ->where('entity', 'item') - ->where('record_id', 1) + ->where('record_id', $recordId) ->delete(); $demos = [ @@ -364,10 +370,21 @@ protected function seedDemoValuesForFirstItem(): void ]; foreach ($demos as $fieldName => $demo) { - $this->upsertFieldValue('item', 1, $fieldName, $demo['type'], $demo['value']); + $this->upsertFieldValue('item', $recordId, $fieldName, $demo['type'], $demo['value']); } } + protected function resolveDemoItemId(): ?int + { + if (! Schema::hasTable('items')) { + return null; + } + + $recordId = DB::table('items')->orderBy('id')->value('id'); + + return is_numeric($recordId) ? (int) $recordId : null; + } + /** * @param list $entities */ diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 6c5c76adcb..b0707b83b3 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -18,7 +18,7 @@ 'sort' => 'Reihenfolge', 'sort_helper' => 'Niedrigere Werte werden weiter oben angezeigt.', 'target_entities' => 'Anzeigen bei', - 'target_entities_helper' => 'Diese Feldgruppe erscheint in den Formularen der ausgewählten Ressourcen.', + 'target_entities_helper' => 'Diese Feldgruppe erscheint in den Formularen der ausgewählten Ressourcen. Leer lassen, um die Gruppe zu behalten, aber nirgends anzuzeigen.', 'target_entities_placeholder' => 'Ressource auswählen…', 'no_entities_registered' => 'Noch keine Ressource mit HasCustomFields. Trait an einer Filament-Resource hinzufügen.', 'field_item' => 'Feld', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 4e5ad4819f..7cdee7da02 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -18,7 +18,7 @@ 'sort' => 'Sort order', 'sort_helper' => 'Lower values appear higher in the form.', 'target_entities' => 'Show on', - 'target_entities_helper' => 'This field group appears in the forms of the selected resources.', + 'target_entities_helper' => 'This field group appears in the forms of the selected resources. Leave empty to keep the group without showing it anywhere.', 'target_entities_placeholder' => 'Select a resource…', 'no_entities_registered' => 'No resources use HasCustomFields yet. Add the trait to a Filament resource.', 'field_item' => 'Field', diff --git a/packages/builder/src/Compiler/LocationMatcher.php b/packages/builder/src/Compiler/LocationMatcher.php index daedf79a2f..b27b7c57a1 100644 --- a/packages/builder/src/Compiler/LocationMatcher.php +++ b/packages/builder/src/Compiler/LocationMatcher.php @@ -15,7 +15,7 @@ class LocationMatcher public function matches(array $locationRules, LocationContext $context): bool { if ($locationRules === []) { - return true; + return false; } foreach ($locationRules as $andGroup) { diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 8c6de200dc..0ba9de1d0d 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -13,6 +13,7 @@ use Illuminate\Support\Collection; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Data\FieldGroupDefinition; +use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Services\CustomFieldsManager; use Moox\Builder\Support\OptionValueRules; @@ -246,11 +247,19 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, } return $component->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $entity, $fieldType, $storableFields): void { - if ($record === null) { + if ($field->type === 'password') { return; } - if ($field->type === 'password') { + $defaultValue = app(DefaultValue::class); + + if ($record === null || ! $record->exists) { + $default = $defaultValue->resolveForField($field); + + if ($default !== null && $defaultValue->shouldApplyDefault($state, $field->type)) { + $component->state($default); + } + return; } @@ -261,6 +270,12 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, ); if (! array_key_exists($field->name, $values)) { + $default = $defaultValue->resolveForField($field); + + if ($default !== null && $defaultValue->shouldApplyDefault($state, $field->type)) { + $component->state($default); + } + return; } diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index 3cd8ca0049..aaae3dfbb9 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -91,37 +91,59 @@ public function builderFieldsFor(string $fieldType): array public function apply(Component $component, FieldDefinition $field): Component { - if (! array_key_exists('default', $field->config)) { + $default = $this->resolveForField($field); + + if ($default === null) { return $component; } + $component->default($default); + + return $component; + } + + public function resolveForField(FieldDefinition $field): mixed + { + if (! array_key_exists('default', $field->config)) { + return null; + } + $default = $field->config['default']; if ($default === null || $default === '') { - return $component; + return null; } - if ($component instanceof Toggle) { - $component->default($this->resolveBooleanDefault($default)); - - return $component; + if ($field->type === 'toggle') { + return $this->resolveBooleanDefault($default); } if (in_array($field->type, ['number', 'range'], true) && is_numeric($default)) { - $component->default($default + 0); - - return $component; + return $default + 0; } if (in_array($field->type, ['multiselect', 'checkbox_list'], true)) { - $component->default($this->resolveArrayDefault($default)); + return $this->resolveArrayDefault($default); + } - return $component; + if (in_array($field->type, ['select', 'radio', 'button_group'], true)) { + return (string) $default; } - $component->default($default); + return $default; + } - return $component; + public function shouldApplyDefault(mixed $state, string $type): bool + { + if ($type === 'toggle') { + return $state === null; + } + + if (is_array($state)) { + return $state === []; + } + + return $state === null || $state === ''; } /** diff --git a/packages/builder/src/Registry/FieldTypeRegistry.php b/packages/builder/src/Registry/FieldTypeRegistry.php index c87414c06b..b2fb721791 100644 --- a/packages/builder/src/Registry/FieldTypeRegistry.php +++ b/packages/builder/src/Registry/FieldTypeRegistry.php @@ -56,6 +56,28 @@ public function optionsForSelect(): array return $options; } + /** + * Field types allowed as direct children of a tab (includes group, repeater, flexible content). + * + * @return array + */ + public function optionsForTabChildren(): array + { + $options = []; + + foreach ($this->types as $key => $type) { + if ($type->isInternal() || $key === 'tab') { + continue; + } + + $options[$key] = $type->label(); + } + + asort($options); + + return $options; + } + /** * @return array */ diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index c6b2508a81..e8909223dd 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -114,7 +114,6 @@ public static function form(Schema $schema): Schema ->searchable() ->preload() ->placeholder(__('builder::builder.field_group.target_entities_placeholder')) - ->required() ->disabled($entityOptions === []) ->native(false), ]), @@ -209,7 +208,7 @@ public static function form(Schema $schema): Schema ->collapsible() ->collapsed() ->itemLabel(fn (array $state): string => static::fieldRepeaterItemLabel($registry, $state)) - ->schema(static::subFieldSchema($registry)) + ->schema(static::tabChildFieldSchema($registry)) ->defaultItems(0), ]) ->visible(fn (callable $get): bool => filled($get('type')) && $registry->get($get('type'))->hasSubFields() && $get('type') !== 'flexible_content'), @@ -349,6 +348,101 @@ protected static function layoutSchema(FieldTypeRegistry $registry): array ]; } + /** + * @return list + */ + protected static function tabChildFieldSchema(FieldTypeRegistry $registry): array + { + return [ + Hidden::make('id'), + Hidden::make('sort'), + Grid::make(2) + ->schema([ + TextInput::make('label') + ->label(__('builder::builder.field.label')) + ->helperText(__('builder::builder.field.label_helper')) + ->required() + ->maxLength(255) + ->live(onBlur: true) + ->afterStateUpdated(function ($state, callable $set, callable $get): void { + if (blank($get('name'))) { + $set('name', Str::slug((string) $state, '-')); + } + }), + Select::make('type') + ->label(__('builder::builder.field.type')) + ->options($registry->optionsForTabChildren()) + ->required() + ->searchable() + ->live() + ->native(false), + ]), + TextInput::make('name') + ->label(__('builder::builder.field.name')) + ->helperText(__('builder::builder.field.name_helper')) + ->required() + ->maxLength(255) + ->regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/') + ->live(onBlur: true), + Toggle::make('required') + ->label(__('builder::builder.field.required')) + ->inline(false) + ->live(), + Section::make(__('builder::builder.field.settings')) + ->collapsed() + ->schema(fn (callable $get): array => static::typeSettingsSchema($get('type'))) + ->visible(fn (callable $get): bool => static::typeHasSettings($get('type'))), + Section::make(__('builder::builder.field.options')) + ->collapsed() + ->schema([ + Repeater::make('options') + ->label(__('builder::builder.field.options')) + ->orderColumn('sort') + ->reorderable() + ->schema([ + Hidden::make('id'), + TextInput::make('label') + ->label(__('builder::builder.field.option_label')) + ->required(), + TextInput::make('value') + ->label(__('builder::builder.field.option_value')) + ->required(), + ]) + ->columns(2) + ->defaultItems(1), + ]) + ->visible(fn (callable $get): bool => filled($get('type')) && $registry->get($get('type'))->hasOptions()), + Section::make(__('builder::builder.field.subfields')) + ->collapsed() + ->schema([ + Repeater::make('children') + ->hiddenLabel() + ->orderColumn('sort') + ->reorderable() + ->collapsible() + ->collapsed() + ->itemLabel(fn (array $state): string => static::fieldRepeaterItemLabel($registry, $state)) + ->schema(static::subFieldSchema($registry)) + ->defaultItems(0), + ]) + ->visible(fn (callable $get): bool => filled($get('type')) && $registry->get($get('type'))->hasSubFields() && $get('type') !== 'flexible_content'), + Section::make(__('builder::builder.field.layouts')) + ->collapsed() + ->schema([ + Repeater::make('layouts') + ->hiddenLabel() + ->orderColumn('sort') + ->reorderable() + ->collapsible() + ->collapsed() + ->itemLabel(fn (array $state): string => static::layoutRepeaterItemLabel($registry, $state)) + ->schema(static::layoutSchema($registry)) + ->defaultItems(0), + ]) + ->visible(fn (callable $get): bool => filled($get('type')) && $get('type') === 'flexible_content'), + ]; + } + /** * @return list */ diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 509111cf03..8247925a40 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -229,12 +229,7 @@ protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldI continue; } - $fieldId = $row['id'] ?? null; - $field = $fieldId ? $existing->get((int) $fieldId) : new Field; - - if (! $field instanceof Field) { - $field = new Field; - } + $field = $this->resolveFieldForSync($existing, $row); $previousName = $field->exists ? $field->name : null; @@ -254,6 +249,8 @@ protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldI $field->save(); + $existing->put($field->getKey(), $field); + if ($previousName !== null && $previousName !== $field->name) { $entities = $this->entitiesFromLocationRules($group->location_rules ?? []); @@ -316,12 +313,7 @@ protected function syncOptions(Field $field, array $rows): void continue; } - $optionId = $row['id'] ?? null; - $option = $optionId ? $existing->get((int) $optionId) : new FieldOption; - - if (! $option instanceof FieldOption) { - $option = new FieldOption; - } + $option = $this->resolveOptionForSync($existing, $row); $option->fill([ 'field_id' => $field->getKey(), @@ -331,6 +323,7 @@ protected function syncOptions(Field $field, array $rows): void ]); $option->save(); + $existing->put($option->getKey(), $option); $retainedIds[] = $option->getKey(); } @@ -343,4 +336,54 @@ public function slugFromName(string $name): string { return Str::slug($name); } + + /** + * @param Collection $existing + * @param array $row + */ + protected function resolveFieldForSync(Collection $existing, array $row): Field + { + $fieldId = filled($row['id'] ?? null) ? (int) $row['id'] : null; + + if ($fieldId !== null && $existing->has($fieldId)) { + return $existing->get($fieldId); + } + + $name = (string) ($row['name'] ?? ''); + + if ($name !== '') { + $match = $existing->firstWhere('name', $name); + + if ($match instanceof Field) { + return $match; + } + } + + return new Field; + } + + /** + * @param Collection $existing + * @param array $row + */ + protected function resolveOptionForSync(Collection $existing, array $row): FieldOption + { + $optionId = filled($row['id'] ?? null) ? (int) $row['id'] : null; + + if ($optionId !== null && $existing->has($optionId)) { + return $existing->get($optionId); + } + + $value = (string) ($row['value'] ?? ''); + + if ($value !== '') { + $match = $existing->firstWhere('value', $value); + + if ($match instanceof FieldOption) { + return $match; + } + } + + return new FieldOption; + } } From 58b786101e8a564cce73c86535398574dcce1925 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:28:32 +0200 Subject: [PATCH 018/105] fix color field --- .../builder/src/Compiler/SchemaCompiler.php | 43 +++++++++---------- .../FieldTypes/Capabilities/DefaultValue.php | 26 ++++++++++- .../src/FieldTypes/Types/ColorFieldType.php | 5 ++- 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 0ba9de1d0d..a3292e1d06 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -252,43 +252,42 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, } $defaultValue = app(DefaultValue::class); + $storedValue = null; - if ($record === null || ! $record->exists) { - $default = $defaultValue->resolveForField($field); + if ($record?->exists) { + $values = $this->customFieldsManager->loadCachedValues( + $entity, + $record, + $storableFields, + ); - if ($default !== null && $defaultValue->shouldApplyDefault($state, $field->type)) { - $component->state($default); - } + if (array_key_exists($field->name, $values)) { + $storedValue = $values[$field->name]; - return; + if ($fieldType->hasSubFields() && method_exists($fieldType, 'normalizeForForm')) { + $storedValue = $fieldType->normalizeForForm($storedValue); + } + } } - $values = $this->customFieldsManager->loadCachedValues( - $entity, - $record, - $storableFields, - ); + $valueToApply = $storedValue ?? $state; - if (! array_key_exists($field->name, $values)) { + if ($defaultValue->shouldApplyDefault($valueToApply, $field->type)) { $default = $defaultValue->resolveForField($field); - if ($default !== null && $defaultValue->shouldApplyDefault($state, $field->type)) { + if ($default !== null) { $component->state($default); } return; } - $value = $values[$field->name]; - - if ($fieldType->hasSubFields() && method_exists($fieldType, 'normalizeForForm')) { - $value = $fieldType->normalizeForForm($value); - } - - $component->state($value); + if ($storedValue !== null) { + $component->state($storedValue); - if ($component instanceof Builder) { - $component->hydrateItems(); + if ($component instanceof Builder) { + $component->hydrateItems(); + } } }); } diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index aaae3dfbb9..6dee8bbbd8 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -38,7 +38,8 @@ public function builderFieldsFor(string $fieldType): array ], 'color' => [ ColorPicker::make('config.default') - ->label(__('builder::builder.capabilities.default_value')), + ->label(__('builder::builder.capabilities.default_value')) + ->hex(), ], 'date' => [ DatePicker::make('config.default') @@ -130,9 +131,32 @@ public function resolveForField(FieldDefinition $field): mixed return (string) $default; } + if ($field->type === 'color') { + return $this->normalizeColorDefault($default); + } + return $default; } + protected function normalizeColorDefault(mixed $default): ?string + { + if (! is_string($default)) { + return null; + } + + $color = trim($default); + + if ($color === '') { + return null; + } + + if (! str_starts_with($color, '#')) { + $color = '#'.$color; + } + + return $color; + } + public function shouldApplyDefault(mixed $state, string $type): bool { if ($type === 'toggle') { diff --git a/packages/builder/src/FieldTypes/Types/ColorFieldType.php b/packages/builder/src/FieldTypes/Types/ColorFieldType.php index 7a3236673d..e8143b15de 100644 --- a/packages/builder/src/FieldTypes/Types/ColorFieldType.php +++ b/packages/builder/src/FieldTypes/Types/ColorFieldType.php @@ -8,6 +8,7 @@ use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\FieldTypes\Capabilities\DefaultValue; +use Moox\Builder\FieldTypes\Capabilities\HelperText; use Moox\Builder\FieldTypes\FieldType; class ColorFieldType extends FieldType @@ -21,13 +22,15 @@ public function capabilities(): array { return [ DefaultValue::class, + HelperText::class, ]; } public function formComponent(FieldDefinition $field): Component { $component = ColorPicker::make($field->name) - ->label($field->label); + ->label($field->label) + ->hex(); return $this->applyCapabilitiesAndValidation($component, $field); } From 0cc18c7329477c9ff821e3d7dc5e98e56232b50c Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:04:32 +0200 Subject: [PATCH 019/105] fix date picker --- .../builder/resources/lang/de/builder.php | 6 + .../builder/resources/lang/en/builder.php | 6 + .../FieldTypes/Capabilities/DefaultValue.php | 108 ++++++++++++++++-- .../FieldTypes/Capabilities/DisplayFormat.php | 60 +++++++++- .../src/FieldTypes/Types/DateFieldType.php | 5 +- .../src/Resources/FieldGroupResource.php | 24 +++- 6 files changed, 193 insertions(+), 16 deletions(-) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index b0707b83b3..7ae76ecc6d 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -105,6 +105,10 @@ 'default_value_toggle_helper' => 'Schalter-Position beim Anlegen eines neuen Datensatzes.', 'default_value_option_helper' => 'Aus den oben definierten Auswahloptionen wählen.', 'default_value_multi_option_helper' => 'Eine oder mehrere der oben definierten Optionen wählen.', + 'default_value_now' => 'Aktuelles Datum bzw. aktuelle Uhrzeit', + 'default_value_now_date_helper' => 'Beim Anlegen wird automatisch das heutige Datum gesetzt.', + 'default_value_now_datetime_helper' => 'Beim Anlegen wird automatisch der aktuelle Zeitpunkt gesetzt.', + 'default_value_now_time_helper' => 'Beim Anlegen wird automatisch die aktuelle Uhrzeit gesetzt.', 'helper_text' => 'Hilfetext', 'min_value' => 'Mindestwert', 'max_value' => 'Höchstwert', @@ -120,6 +124,8 @@ 'display_format_mdy' => '12/31/2026', 'display_format_dmy_hi' => '31.12.2026 14:30', 'display_format_dmy_his' => '31.12.2026 14:30:00', + 'display_format_ymd_hi' => '2026-12-31 14:30', + 'display_format_ymd_his' => '2026-12-31 14:30:00', ], 'repeater' => [ diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 7cdee7da02..d4eea7369f 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -105,6 +105,10 @@ 'default_value_toggle_helper' => 'Switch position when creating a new record.', 'default_value_option_helper' => 'Choose from the options defined above.', 'default_value_multi_option_helper' => 'Choose one or more options defined above.', + 'default_value_now' => 'Current date or time', + 'default_value_now_date_helper' => 'Automatically uses today’s date when creating a record.', + 'default_value_now_datetime_helper' => 'Automatically uses the current date and time when creating a record.', + 'default_value_now_time_helper' => 'Automatically uses the current time when creating a record.', 'helper_text' => 'Helper text', 'min_value' => 'Minimum value', 'max_value' => 'Maximum value', @@ -120,6 +124,8 @@ 'display_format_mdy' => '12/31/2026', 'display_format_dmy_hi' => '31.12.2026 14:30', 'display_format_dmy_his' => '31.12.2026 14:30:00', + 'display_format_ymd_hi' => '2026-12-31 14:30', + 'display_format_ymd_his' => '2026-12-31 14:30:00', ], 'repeater' => [ diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index 6dee8bbbd8..c7f9446e24 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -4,6 +4,7 @@ namespace Moox\Builder\FieldTypes\Capabilities; +use Carbon\CarbonInterface; use Filament\Forms\Components\ColorPicker; use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\DateTimePicker; @@ -14,6 +15,7 @@ use Filament\Forms\Components\Toggle; use Filament\Schemas\Components\Component; use Filament\Schemas\Components\Utilities\Get; +use Illuminate\Support\Carbon; use Moox\Builder\Data\FieldDefinition; class DefaultValue extends Capability @@ -41,21 +43,25 @@ public function builderFieldsFor(string $fieldType): array ->label(__('builder::builder.capabilities.default_value')) ->hex(), ], - 'date' => [ + 'date' => $this->temporalDefaultFields( DatePicker::make('config.default') ->label(__('builder::builder.capabilities.default_value')) ->native(false), - ], - 'datetime' => [ + 'date', + ), + 'datetime' => $this->temporalDefaultFields( DateTimePicker::make('config.default') ->label(__('builder::builder.capabilities.default_value')) ->native(false), - ], - 'time' => [ + 'datetime', + ), + 'time' => $this->temporalDefaultFields( TimePicker::make('config.default') ->label(__('builder::builder.capabilities.default_value')) - ->native(false), - ], + ->native(false) + ->seconds(false), + 'time', + ), 'select', 'radio', 'button_group' => [ Select::make('config.default') ->label(__('builder::builder.capabilities.default_value')) @@ -105,6 +111,10 @@ public function apply(Component $component, FieldDefinition $field): Component public function resolveForField(FieldDefinition $field): mixed { + if (in_array($field->type, ['date', 'datetime', 'time'], true)) { + return $this->resolveTemporalDefault($field); + } + if (! array_key_exists('default', $field->config)) { return null; } @@ -207,8 +217,90 @@ protected function resolveBooleanDefault(mixed $default): bool } /** - * @return list + * @return list */ + protected function temporalDefaultFields(Component $picker, string $type): array + { + if ($picker instanceof DateTimePicker) { + $picker = $picker + ->displayFormat(fn (Get $get): string => $this->resolvedDisplayFormat($get, $type)) + ->key(fn (Get $get): string => 'default-picker-'.$this->resolvedDisplayFormat($get, $type)); + + if ($type === 'datetime') { + $picker = $picker->seconds( + fn (Get $get): bool => str_contains($this->resolvedDisplayFormat($get, $type), 'H:i:s'), + ); + } + } + + return [ + Toggle::make('config.defaultNow') + ->label(__('builder::builder.capabilities.default_value_now')) + ->helperText(match ($type) { + 'date' => __('builder::builder.capabilities.default_value_now_date_helper'), + 'datetime' => __('builder::builder.capabilities.default_value_now_datetime_helper'), + default => __('builder::builder.capabilities.default_value_now_time_helper'), + }) + ->inline(false) + ->live(), + $picker->hidden(fn (Get $get): bool => (bool) $get('config.defaultNow')), + ]; + } + + protected function resolvedDisplayFormat(Get $get, string $type): string + { + return filled($get('config.displayFormat')) + ? (string) $get('config.displayFormat') + : DisplayFormat::defaultFor($type); + } + + protected function resolveTemporalDefault(FieldDefinition $field): CarbonInterface|string|null + { + if (($field->config['defaultNow'] ?? false) === true) { + return match ($field->type) { + 'date' => now()->startOfDay(), + 'datetime' => now(), + 'time' => now()->format('H:i'), + default => null, + }; + } + + if (! array_key_exists('default', $field->config)) { + return null; + } + + $default = $field->config['default']; + + if ($default === null || $default === '') { + return null; + } + + return $this->parseTemporalDefault($field->type, $default); + } + + protected function parseTemporalDefault(string $type, mixed $default): CarbonInterface|string|null + { + if ($default instanceof CarbonInterface) { + return $type === 'time' + ? $default->format('H:i') + : $default; + } + + if (! is_string($default)) { + return null; + } + + if ($type === 'time') { + return $default; + } + + try { + return Carbon::parse($default); + } catch (\Throwable) { + return null; + } + } + protected function resolveArrayDefault(mixed $default): array { if ($default === null || $default === '') { diff --git a/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php b/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php index 1e4ca44e88..efacebf325 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php +++ b/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php @@ -12,37 +12,89 @@ class DisplayFormat extends Capability { public function builderFields(): array + { + return $this->builderFieldsFor('date'); + } + + public function builderFieldsFor(string $fieldType): array { return [ Select::make('config.displayFormat') ->label(__('builder::builder.capabilities.display_format')) ->helperText(__('builder::builder.capabilities.display_format_helper')) - ->options($this->formatOptions()) + ->options($this->formatOptionsFor($fieldType)) + ->default(self::defaultFor($fieldType)) + ->live() ->native(false), ]; } public function apply(Component $component, FieldDefinition $field): Component { - if ($component instanceof DateTimePicker && filled($field->config['displayFormat'] ?? null)) { - $component->displayFormat($field->config['displayFormat']); + if ($component instanceof DateTimePicker) { + $format = self::resolveForField($field); + + $component->displayFormat($format); + + if ($field->type === 'datetime') { + $component->seconds(str_contains($format, 'H:i:s')); + } } return $component; } + public static function defaultFor(string $fieldType): string + { + return match ($fieldType) { + 'datetime' => 'd.m.Y H:i', + default => 'd.m.Y', + }; + } + + public static function resolveForField(FieldDefinition $field): string + { + if (filled($field->config['displayFormat'] ?? null)) { + return (string) $field->config['displayFormat']; + } + + return self::defaultFor($field->type); + } + /** * @return array */ - protected function formatOptions(): array + protected function formatOptionsFor(string $fieldType): array + { + return match ($fieldType) { + 'datetime' => $this->datetimeFormatOptions(), + default => $this->dateFormatOptions(), + }; + } + + /** + * @return array + */ + protected function dateFormatOptions(): array { return [ 'd.m.Y' => __('builder::builder.capabilities.display_format_dmy'), 'd/m/Y' => __('builder::builder.capabilities.display_format_dmy_slash'), 'Y-m-d' => __('builder::builder.capabilities.display_format_ymd'), 'm/d/Y' => __('builder::builder.capabilities.display_format_mdy'), + ]; + } + + /** + * @return array + */ + protected function datetimeFormatOptions(): array + { + return [ 'd.m.Y H:i' => __('builder::builder.capabilities.display_format_dmy_hi'), 'd.m.Y H:i:s' => __('builder::builder.capabilities.display_format_dmy_his'), + 'Y-m-d H:i' => __('builder::builder.capabilities.display_format_ymd_hi'), + 'Y-m-d H:i:s' => __('builder::builder.capabilities.display_format_ymd_his'), ]; } } diff --git a/packages/builder/src/FieldTypes/Types/DateFieldType.php b/packages/builder/src/FieldTypes/Types/DateFieldType.php index b9a24733ad..484c5b2a95 100644 --- a/packages/builder/src/FieldTypes/Types/DateFieldType.php +++ b/packages/builder/src/FieldTypes/Types/DateFieldType.php @@ -9,6 +9,7 @@ use Moox\Builder\Data\FieldDefinition; use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\FieldTypes\Capabilities\DisplayFormat; +use Moox\Builder\FieldTypes\Capabilities\HelperText; use Moox\Builder\FieldTypes\FieldType; class DateFieldType extends FieldType @@ -23,13 +24,15 @@ public function capabilities(): array return [ DisplayFormat::class, DefaultValue::class, + HelperText::class, ]; } public function formComponent(FieldDefinition $field): Component { $component = DatePicker::make($field->name) - ->label($field->label); + ->label($field->label) + ->native(false); return $this->applyCapabilitiesAndValidation($component, $field); } diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index e8909223dd..73900f0c42 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -171,7 +171,7 @@ public static function form(Schema $schema): Schema ->live(), Section::make(__('builder::builder.field.settings')) ->collapsed() - ->schema(fn (callable $get): array => static::typeSettingsSchema($get('type'))) + ->schema(fn (callable $get): array => static::reactiveTypeSettingsSchema($get)) ->visible(fn (callable $get): bool => static::typeHasSettings($get('type'))), Section::make(__('builder::builder.field.options')) ->collapsed() @@ -390,7 +390,7 @@ protected static function tabChildFieldSchema(FieldTypeRegistry $registry): arra ->live(), Section::make(__('builder::builder.field.settings')) ->collapsed() - ->schema(fn (callable $get): array => static::typeSettingsSchema($get('type'))) + ->schema(fn (callable $get): array => static::reactiveTypeSettingsSchema($get)) ->visible(fn (callable $get): bool => static::typeHasSettings($get('type'))), Section::make(__('builder::builder.field.options')) ->collapsed() @@ -483,7 +483,7 @@ protected static function subFieldSchema(FieldTypeRegistry $registry): array ->live(), Section::make(__('builder::builder.field.settings')) ->collapsed() - ->schema(fn (callable $get): array => static::typeSettingsSchema($get('type'))) + ->schema(fn (callable $get): array => static::reactiveTypeSettingsSchema($get)) ->visible(fn (callable $get): bool => static::typeHasSettings($get('type'))), Section::make(__('builder::builder.field.options')) ->collapsed() @@ -513,6 +513,24 @@ protected static function typeHasSettings(?string $type): bool return static::typeSettingsSchema($type) !== []; } + /** + * @return list + */ + protected static function reactiveTypeSettingsSchema(callable $get): array + { + $type = $get('type'); + + if (in_array($type, ['date', 'datetime'], true)) { + $get('config.displayFormat'); + } + + if (in_array($type, ['date', 'datetime', 'time'], true)) { + $get('config.defaultNow'); + } + + return static::typeSettingsSchema($type); + } + /** * @return list */ From 10f3b11c51d0f68122a8ef523615dc08e2d20318 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:15:40 +0200 Subject: [PATCH 020/105] fix date time picker --- .../FieldTypes/Capabilities/DefaultValue.php | 66 ++++++++++++++++--- .../FieldTypes/Capabilities/DisplayFormat.php | 8 ++- .../FieldTypes/Types/DatetimeFieldType.php | 5 +- 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index c7f9446e24..86cf844063 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -173,6 +173,28 @@ public function shouldApplyDefault(mixed $state, string $type): bool return $state === null; } + if (in_array($type, ['date', 'datetime', 'time'], true)) { + if ($state === null || $state === '') { + return true; + } + + if ($state instanceof CarbonInterface) { + return false; + } + + if (is_string($state)) { + try { + Carbon::parse($state); + + return false; + } catch (\Throwable) { + return true; + } + } + + return true; + } + if (is_array($state)) { return $state === []; } @@ -221,16 +243,8 @@ protected function resolveBooleanDefault(mixed $default): bool */ protected function temporalDefaultFields(Component $picker, string $type): array { - if ($picker instanceof DateTimePicker) { - $picker = $picker - ->displayFormat(fn (Get $get): string => $this->resolvedDisplayFormat($get, $type)) - ->key(fn (Get $get): string => 'default-picker-'.$this->resolvedDisplayFormat($get, $type)); - - if ($type === 'datetime') { - $picker = $picker->seconds( - fn (Get $get): bool => str_contains($this->resolvedDisplayFormat($get, $type), 'H:i:s'), - ); - } + if (in_array($type, ['date', 'datetime'], true) && $picker instanceof DateTimePicker) { + $picker = $this->configureTemporalAdminPicker($picker, $type); } return [ @@ -247,6 +261,27 @@ protected function temporalDefaultFields(Component $picker, string $type): array ]; } + protected function configureTemporalAdminPicker(DateTimePicker $picker, string $type): DateTimePicker + { + $picker = $picker + ->displayFormat(fn (Get $get): string => $this->resolvedDisplayFormat($get, $type)) + ->format(fn (Get $get): string => $this->resolvedStorageFormat($get, $type)) + ->key(fn (Get $get): string => 'default-picker-'.$this->temporalPickerKeySuffix($get, $type)); + + if ($type === 'datetime') { + $picker = $picker->seconds( + fn (Get $get): bool => str_contains($this->resolvedDisplayFormat($get, $type), 'H:i:s'), + ); + } + + return $picker; + } + + protected function temporalPickerKeySuffix(Get $get, string $type): string + { + return md5($type.':'.$this->resolvedDisplayFormat($get, $type)); + } + protected function resolvedDisplayFormat(Get $get, string $type): string { return filled($get('config.displayFormat')) @@ -254,6 +289,17 @@ protected function resolvedDisplayFormat(Get $get, string $type): string : DisplayFormat::defaultFor($type); } + protected function resolvedStorageFormat(Get $get, string $type): string + { + if ($type === 'datetime') { + return str_contains($this->resolvedDisplayFormat($get, $type), 'H:i:s') + ? 'Y-m-d H:i:s' + : 'Y-m-d H:i'; + } + + return 'Y-m-d'; + } + protected function resolveTemporalDefault(FieldDefinition $field): CarbonInterface|string|null { if (($field->config['defaultNow'] ?? false) === true) { diff --git a/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php b/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php index efacebf325..4e54c4fba2 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php +++ b/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php @@ -37,7 +37,13 @@ public function apply(Component $component, FieldDefinition $field): Component $component->displayFormat($format); if ($field->type === 'datetime') { - $component->seconds(str_contains($format, 'H:i:s')); + $withSeconds = str_contains($format, 'H:i:s'); + $component->seconds($withSeconds); + $component->format($withSeconds ? 'Y-m-d H:i:s' : 'Y-m-d H:i'); + } + + if ($field->type === 'date') { + $component->format('Y-m-d'); } } diff --git a/packages/builder/src/FieldTypes/Types/DatetimeFieldType.php b/packages/builder/src/FieldTypes/Types/DatetimeFieldType.php index 0c263e541a..f55360edd5 100644 --- a/packages/builder/src/FieldTypes/Types/DatetimeFieldType.php +++ b/packages/builder/src/FieldTypes/Types/DatetimeFieldType.php @@ -9,6 +9,7 @@ use Moox\Builder\Data\FieldDefinition; use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\FieldTypes\Capabilities\DisplayFormat; +use Moox\Builder\FieldTypes\Capabilities\HelperText; use Moox\Builder\FieldTypes\FieldType; class DatetimeFieldType extends FieldType @@ -23,13 +24,15 @@ public function capabilities(): array return [ DisplayFormat::class, DefaultValue::class, + HelperText::class, ]; } public function formComponent(FieldDefinition $field): Component { $component = DateTimePicker::make($field->name) - ->label($field->label); + ->label($field->label) + ->native(false); return $this->applyCapabilitiesAndValidation($component, $field); } From f82b9b1b799f59b92efac284a2a13f50a730614f Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:39:29 +0200 Subject: [PATCH 021/105] fix email field --- packages/builder/resources/lang/de/builder.php | 1 + packages/builder/resources/lang/en/builder.php | 1 + .../src/FieldTypes/Capabilities/DefaultValue.php | 14 +++++++++++++- .../src/FieldTypes/Types/EmailFieldType.php | 2 ++ .../builder/src/Resources/FieldGroupResource.php | 6 +++--- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 7ae76ecc6d..ccbfab28a6 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -135,6 +135,7 @@ 'validation' => [ 'invalid_option' => 'Der gewählte Wert ist keine gültige Option.', + 'invalid_email_default' => 'Bitte eine gültige E-Mail-Adresse als Standardwert eingeben.', 'duplicate_field_name' => 'Der Feldschlüssel „:name“ wird bereits in der Gruppe „:group“ verwendet.', 'duplicate_field_name_internal' => 'Der Feldschlüssel „:name“ ist in dieser Gruppe mehrfach vergeben.', 'empty_repeater_item' => 'Eintrag :position in „:field“ ist leer.', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index d4eea7369f..a930a0ee72 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -135,6 +135,7 @@ 'validation' => [ 'invalid_option' => 'The selected value is not a valid option.', + 'invalid_email_default' => 'Please enter a valid email address as the default value.', 'duplicate_field_name' => 'The field key ":name" is already used in the group ":group".', 'duplicate_field_name_internal' => 'The field key ":name" is assigned more than once in this group.', 'empty_repeater_item' => 'Row :position in ":field" is empty.', diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index 86cf844063..305c84daf1 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -82,13 +82,25 @@ public function builderFieldsFor(string $fieldType): array 'number', 'range' => [ TextInput::make('config.default') ->label(__('builder::builder.capabilities.default_value')) - ->numeric(), + ->rules(['nullable', 'numeric']) + ->validationAttribute(__('builder::builder.capabilities.default_value')) + ->live(onBlur: true), ], 'textarea', 'rich_text' => [ Textarea::make('config.default') ->label(__('builder::builder.capabilities.default_value')) ->rows(3), ], + 'email' => [ + TextInput::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->rules(['nullable', 'email']) + ->validationAttribute(__('builder::builder.capabilities.default_value')) + ->validationMessages([ + 'email' => __('builder::builder.validation.invalid_email_default'), + ]) + ->live(onBlur: true), + ], default => [ TextInput::make('config.default') ->label(__('builder::builder.capabilities.default_value')), diff --git a/packages/builder/src/FieldTypes/Types/EmailFieldType.php b/packages/builder/src/FieldTypes/Types/EmailFieldType.php index b48a2f53a7..cac0f6bb3a 100644 --- a/packages/builder/src/FieldTypes/Types/EmailFieldType.php +++ b/packages/builder/src/FieldTypes/Types/EmailFieldType.php @@ -8,6 +8,7 @@ use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\FieldTypes\Capabilities\DefaultValue; +use Moox\Builder\FieldTypes\Capabilities\HelperText; use Moox\Builder\FieldTypes\Capabilities\MaxLength; use Moox\Builder\FieldTypes\Capabilities\Placeholder; use Moox\Builder\FieldTypes\FieldType; @@ -25,6 +26,7 @@ public function capabilities(): array MaxLength::class, Placeholder::class, DefaultValue::class, + HelperText::class, ]; } diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 73900f0c42..2f816d2fca 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -170,7 +170,7 @@ public static function form(Schema $schema): Schema ->inline(false) ->live(), Section::make(__('builder::builder.field.settings')) - ->collapsed() + ->collapsible() ->schema(fn (callable $get): array => static::reactiveTypeSettingsSchema($get)) ->visible(fn (callable $get): bool => static::typeHasSettings($get('type'))), Section::make(__('builder::builder.field.options')) @@ -389,7 +389,7 @@ protected static function tabChildFieldSchema(FieldTypeRegistry $registry): arra ->inline(false) ->live(), Section::make(__('builder::builder.field.settings')) - ->collapsed() + ->collapsible() ->schema(fn (callable $get): array => static::reactiveTypeSettingsSchema($get)) ->visible(fn (callable $get): bool => static::typeHasSettings($get('type'))), Section::make(__('builder::builder.field.options')) @@ -482,7 +482,7 @@ protected static function subFieldSchema(FieldTypeRegistry $registry): array ->inline(false) ->live(), Section::make(__('builder::builder.field.settings')) - ->collapsed() + ->collapsible() ->schema(fn (callable $get): array => static::reactiveTypeSettingsSchema($get)) ->visible(fn (callable $get): bool => static::typeHasSettings($get('type'))), Section::make(__('builder::builder.field.options')) From 1144d5d3d0d690001195419e4fa84be01ad81920 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:55:11 +0200 Subject: [PATCH 022/105] fix flexible content field --- .../builder/src/Compiler/SchemaCompiler.php | 113 ++++++++---- .../FieldTypes/Capabilities/DefaultValue.php | 161 ++++++++++++++++++ .../Types/FlexibleContentFieldType.php | 91 +++++++++- 3 files changed, 333 insertions(+), 32 deletions(-) diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index a3292e1d06..39fca1ad22 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -246,49 +246,100 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, return $component; } - return $component->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $entity, $fieldType, $storableFields): void { - if ($field->type === 'password') { - return; - } + $defaultValue = app(DefaultValue::class); - $defaultValue = app(DefaultValue::class); - $storedValue = null; + return $component + ->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $entity, $fieldType, $storableFields, $defaultValue): void { + if ($field->type === 'password') { + return; + } - if ($record?->exists) { - $values = $this->customFieldsManager->loadCachedValues( - $entity, - $record, - $storableFields, - ); + $storedValue = null; - if (array_key_exists($field->name, $values)) { - $storedValue = $values[$field->name]; + if ($record?->exists) { + $values = $this->customFieldsManager->loadCachedValues( + $entity, + $record, + $storableFields, + ); - if ($fieldType->hasSubFields() && method_exists($fieldType, 'normalizeForForm')) { - $storedValue = $fieldType->normalizeForForm($storedValue); + if (array_key_exists($field->name, $values)) { + $storedValue = $values[$field->name]; + + if ($fieldType->hasSubFields() && method_exists($fieldType, 'normalizeForForm')) { + $storedValue = $fieldType->normalizeForForm($storedValue); + } } } - } - - $valueToApply = $storedValue ?? $state; - if ($defaultValue->shouldApplyDefault($valueToApply, $field->type)) { - $default = $defaultValue->resolveForField($field); + if ($fieldType->hasSubFields()) { + $valueToApply = $storedValue ?? $state; + + if (is_array($valueToApply) && $valueToApply !== []) { + $this->applyCompoundState( + $component, + $field, + $defaultValue, + $defaultValue->normalizeCompoundState($valueToApply), + force: true, + ); + } - if ($default !== null) { - $component->state($default); + return; } - return; - } + $valueToApply = $storedValue ?? $state; - if ($storedValue !== null) { - $component->state($storedValue); + if ($defaultValue->shouldApplyDefault($valueToApply, $field->type)) { + $default = $defaultValue->resolveForField($field); - if ($component instanceof Builder) { - $component->hydrateItems(); + if ($default !== null) { + $component->state($default); + } + } elseif ($storedValue !== null) { + $component->state($storedValue); } - } - }); + }) + ->afterStateUpdated(function (Component $component) use ($field, $fieldType, $defaultValue): void { + if (! $fieldType->hasSubFields()) { + return; + } + + $state = $component->getState(); + + if (! is_array($state) || $state === []) { + return; + } + + $this->applyCompoundState( + $component, + $field, + $defaultValue, + $defaultValue->normalizeCompoundState($state), + ); + }); + } + + /** + * @param array> $state + */ + protected function applyCompoundState( + Component $component, + FieldDefinition $field, + DefaultValue $defaultValue, + array $state, + bool $force = false, + ): void { + $merged = $defaultValue->mergeCompoundDefaults($field, $state); + + if (! $force && $merged == $state) { + return; + } + + $component->state($merged); + + if ($component instanceof Builder) { + $component->hydrateItems(); + } } } diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index 305c84daf1..0399580b9b 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -16,7 +16,9 @@ use Filament\Schemas\Components\Component; use Filament\Schemas\Components\Utilities\Get; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Moox\Builder\Data\FieldDefinition; +use Moox\Builder\Registry\FieldTypeRegistry; class DefaultValue extends Capability { @@ -214,6 +216,165 @@ public function shouldApplyDefault(mixed $state, string $type): bool return $state === null || $state === ''; } + public function mergeCompoundDefaults(FieldDefinition $field, mixed $state): mixed + { + if (! is_array($state)) { + return $state; + } + + return match ($field->type) { + 'flexible_content' => $this->mergeFlexibleContentDefaults($field, $state), + 'repeater' => $this->mergeListItemDefaults($field, $state), + 'group' => $this->mergeGroupDefaults($field, $state), + default => $state, + }; + } + + /** + * @param Collection $children + * @return array + */ + public function defaultDataForChildren(Collection $children): array + { + return $this->mergeSubFieldDefaults($children, []); + } + + /** + * @param array> $state + * @return array> + */ + public function normalizeCompoundState(array $state): array + { + return array_is_list($state) ? array_values($state) : array_values($state); + } + + /** + * @param array> $state + * @return array> + */ + protected function mergeFlexibleContentDefaults(FieldDefinition $field, array $state): array + { + return array_values(array_map(function (mixed $item) use ($field): array { + if (! is_array($item)) { + return []; + } + + $type = $item['type'] ?? null; + $data = $item['data'] ?? []; + + if (! is_string($type) || ! is_array($data)) { + return $item; + } + + $layout = $field->layouts()->firstWhere('name', $type); + + if ($layout === null) { + return $item; + } + + $item['data'] = $this->mergeSubFieldDefaults($layout->children, $data); + + return $item; + }, $state)); + } + + /** + * @param array> $state + * @return array> + */ + protected function mergeGroupDefaults(FieldDefinition $field, array $state): array + { + if ($state === []) { + return $state; + } + + if (! array_is_list($state)) { + return [$this->mergeSubFieldDefaults($field->children, $state)]; + } + + return $this->mergeListItemDefaults($field, $state); + } + + /** + * @param array> $state + * @return array> + */ + protected function mergeListItemDefaults(FieldDefinition $field, array $state): array + { + return array_values(array_map( + fn (mixed $item): array => is_array($item) + ? $this->mergeSubFieldDefaults($field->children, $item) + : [], + $state, + )); + } + + /** + * @param Collection $children + * @param array $data + * @return array + */ + protected function mergeSubFieldDefaults(Collection $children, array $data): array + { + foreach ($children as $child) { + if ($child->type === 'tab') { + foreach ($child->children as $tabChild) { + $data = $this->mergeFieldDefaultIntoData($tabChild, $data); + } + + continue; + } + + $data = $this->mergeFieldDefaultIntoData($child, $data); + } + + return $data; + } + + /** + * @param array $data + * @return array + */ + protected function mergeFieldDefaultIntoData(FieldDefinition $child, array $data): array + { + $fieldType = app(FieldTypeRegistry::class)->get($child->type); + + if ($fieldType->hasSubFields() && in_array($child->type, ['group', 'repeater', 'flexible_content'], true)) { + $key = $child->name; + $nested = $data[$key] ?? []; + + if (! is_array($nested)) { + $nested = []; + } + + if ($child->type === 'group' && ! array_is_list($nested)) { + $data[$key] = $this->mergeSubFieldDefaults($child->children, $nested); + } else { + $data[$key] = $this->mergeCompoundDefaults($child, $nested); + } + + return $data; + } + + if (! $fieldType->storesValue()) { + return $data; + } + + $current = $data[$child->name] ?? null; + + if (! $this->shouldApplyDefault($current, $child->type)) { + return $data; + } + + $default = $this->resolveForField($child); + + if ($default !== null) { + $data[$child->name] = $default; + } + + return $data; + } + /** * @param list|null $options * @return array diff --git a/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php b/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php index 5dad192585..b99642dcb6 100644 --- a/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php +++ b/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php @@ -4,11 +4,13 @@ namespace Moox\Builder\FieldTypes\Types; +use Filament\Actions\Action; use Filament\Forms\Components\Builder; use Filament\Forms\Components\Builder\Block; use Filament\Schemas\Components\Component; use Moox\Builder\Compiler\SchemaCompiler; use Moox\Builder\Data\FieldDefinition; +use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\FieldTypes\Capabilities\RepeaterItems; use Moox\Builder\FieldTypes\FieldType; @@ -39,6 +41,13 @@ public function capabilities(): array public function formComponent(FieldDefinition $field): Component { $compiler = app(SchemaCompiler::class); + $defaultValue = app(DefaultValue::class); + + $layoutDefaults = $field->layouts() + ->mapWithKeys(fn (FieldDefinition $layout): array => [ + $layout->name => $defaultValue->defaultDataForChildren($layout->children), + ]) + ->all(); $blocks = $field->layouts() ->map(fn (FieldDefinition $layout): Block => Block::make($layout->name) @@ -51,7 +60,9 @@ public function formComponent(FieldDefinition $field): Component ->blocks($blocks) ->addActionLabel(__('builder::builder.flexible_content.add_layout')) ->collapsible() - ->collapsed(); + ->collapsed() + ->addAction(fn (Action $action): Action => $this->configureAddAction($action, $layoutDefaults)) + ->addBetweenAction(fn (Action $action): Action => $this->configureAddBetweenAction($action, $layoutDefaults)); return $this->applyNestedValueValidation( $this->applyCapabilitiesAndValidation($component, $field), @@ -59,6 +70,84 @@ public function formComponent(FieldDefinition $field): Component ); } + /** + * @param array> $layoutDefaults + */ + protected function configureAddAction(Action $action, array $layoutDefaults): Action + { + return $action->action(function (array $arguments, Builder $component, array $data = []) use ($layoutDefaults): void { + $this->insertBuilderBlock($component, (string) $arguments['block'], $data, $layoutDefaults, afterItem: null); + }); + } + + /** + * @param array> $layoutDefaults + */ + protected function configureAddBetweenAction(Action $action, array $layoutDefaults): Action + { + return $action->action(function (array $arguments, Builder $component, array $data = []) use ($layoutDefaults): void { + $this->insertBuilderBlock( + $component, + (string) $arguments['block'], + $data, + $layoutDefaults, + afterItem: $arguments['afterItem'] ?? null, + ); + }); + } + + /** + * @param array> $layoutDefaults + */ + protected function insertBuilderBlock( + Builder $component, + string $block, + array $data, + array $layoutDefaults, + ?string $afterItem, + ): void { + if ($data === []) { + $data = $layoutDefaults[$block] ?? []; + } + + $newKey = $component->generateUuid(); + $items = $component->getRawState() ?? []; + + if ($afterItem === null) { + if ($newKey) { + $items[$newKey] = ['type' => $block, 'data' => $data]; + } else { + $items[] = ['type' => $block, 'data' => $data]; + $newKey = (string) array_key_last($items); + } + } else { + $ordered = []; + + foreach ($items as $key => $item) { + $ordered[$key] = $item; + + if ($key === $afterItem) { + if ($newKey) { + $ordered[$newKey] = ['type' => $block, 'data' => $data]; + } else { + $ordered[] = ['type' => $block, 'data' => $data]; + $newKey = (string) array_key_last($ordered); + } + } + } + + $items = $ordered; + } + + $component->rawState($items); + + $schemaKey = $newKey ?? (string) array_key_last($items); + $component->getChildSchema($schemaKey)->fill($data); + + $component->collapsed(false, shouldMakeComponentCollapsible: false); + $component->callAfterStateUpdated(); + } + public function castValue(mixed $raw): mixed { if (! is_array($raw)) { From 29e9d8c031479a3cef9ffd560dc57844a8081ddf Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:22:16 +0200 Subject: [PATCH 023/105] fix group field --- .../builder/src/Compiler/SchemaCompiler.php | 73 ++++++++++++++++++- .../FieldTypes/Capabilities/DefaultValue.php | 26 ++++++- .../Types/FlexibleContentFieldType.php | 34 ++++----- .../src/FieldTypes/Types/GroupFieldType.php | 25 ++----- .../FieldTypes/Types/RepeaterFieldType.php | 51 ++++++++++++- 5 files changed, 163 insertions(+), 46 deletions(-) diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 39fca1ad22..dcd7248d84 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -5,6 +5,7 @@ namespace Moox\Builder\Compiler; use Filament\Forms\Components\Builder; +use Filament\Forms\Components\Repeater; use Filament\Schemas\Components\Component; use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Tabs; @@ -14,6 +15,7 @@ use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\FieldTypes\Capabilities\DefaultValue; +use Moox\Builder\FieldTypes\Types\GroupFieldType; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Services\CustomFieldsManager; use Moox\Builder\Support\OptionValueRules; @@ -79,6 +81,65 @@ public function compileSubFields(Collection $fields, ?string $entity = null, ?Co ->all(); } + public function buildGroupComponent(FieldDefinition $field, ?string $entity = null, ?Collection $storableFields = null): Component + { + $storableFields ??= collect(); + + $component = \Filament\Schemas\Components\Fieldset::make($field->label) + ->schema($this->compileSubFields($field->children, $entity, $storableFields)) + ->statePath($field->name) + ->columns(1) + ->columnSpanFull(); + + if ($entity === null) { + return $component; + } + + $defaultValue = app(DefaultValue::class); + + return $component + ->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $entity, $storableFields, $defaultValue): void { + $storedValue = null; + + if ($record?->exists) { + $values = $this->customFieldsManager->loadCachedValues( + $entity, + $record, + $storableFields, + ); + + if (array_key_exists($field->name, $values)) { + $storedValue = (new GroupFieldType)->normalizeForForm($values[$field->name]); + } + } + + $flat = is_array($storedValue ?? $state) ? ($storedValue ?? $state) : []; + + if (array_is_list($flat) && isset($flat[0]) && is_array($flat[0])) { + $flat = $flat[0]; + } + + $component->state($defaultValue->mergeIntoData($field->children, $flat)); + }) + ->afterStateUpdated(function (Component $component) use ($field, $defaultValue): void { + $state = $component->getState(); + + if (! is_array($state)) { + return; + } + + if (array_is_list($state) && isset($state[0]) && is_array($state[0])) { + $state = $state[0]; + } + + $merged = $defaultValue->mergeIntoData($field->children, $state); + + if ($merged != $state) { + $component->state($merged); + } + }); + } + /** * @param Collection $fields * @param Collection $storableFields @@ -239,6 +300,10 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, { $storableFields ??= collect(); + if ($field->type === 'group') { + return $this->buildGroupComponent($field, $entity, $storableFields); + } + $fieldType = $this->fieldTypeRegistry->get($field->type); $component = $fieldType->formComponent($field); @@ -307,7 +372,11 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, $state = $component->getState(); - if (! is_array($state) || $state === []) { + if (! is_array($state)) { + return; + } + + if ($state === []) { return; } @@ -338,7 +407,7 @@ protected function applyCompoundState( $component->state($merged); - if ($component instanceof Builder) { + if ($component instanceof Builder || $component instanceof Repeater) { $component->hydrateItems(); } } diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index 0399580b9b..4cd36f3009 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -162,15 +162,33 @@ public function resolveForField(FieldDefinition $field): mixed return $default; } + /** + * @param Collection $children + * @param array $data + * @return array + */ + public function mergeIntoData(Collection $children, array $data): array + { + return $this->mergeSubFieldDefaults($children, $data); + } + protected function normalizeColorDefault(mixed $default): ?string { - if (! is_string($default)) { + if ($default === null) { return null; } + if (! is_string($default)) { + if (is_scalar($default)) { + $default = (string) $default; + } else { + return null; + } + } + $color = trim($default); - if ($color === '') { + if ($color === '' || $color === '#') { return null; } @@ -187,6 +205,10 @@ public function shouldApplyDefault(mixed $state, string $type): bool return $state === null; } + if ($type === 'color') { + return $state === null || (is_string($state) && trim($state) === ''); + } + if (in_array($type, ['date', 'datetime', 'time'], true)) { if ($state === null || $state === '') { return true; diff --git a/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php b/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php index b99642dcb6..70b1854d96 100644 --- a/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php +++ b/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php @@ -41,13 +41,6 @@ public function capabilities(): array public function formComponent(FieldDefinition $field): Component { $compiler = app(SchemaCompiler::class); - $defaultValue = app(DefaultValue::class); - - $layoutDefaults = $field->layouts() - ->mapWithKeys(fn (FieldDefinition $layout): array => [ - $layout->name => $defaultValue->defaultDataForChildren($layout->children), - ]) - ->all(); $blocks = $field->layouts() ->map(fn (FieldDefinition $layout): Block => Block::make($layout->name) @@ -61,8 +54,8 @@ public function formComponent(FieldDefinition $field): Component ->addActionLabel(__('builder::builder.flexible_content.add_layout')) ->collapsible() ->collapsed() - ->addAction(fn (Action $action): Action => $this->configureAddAction($action, $layoutDefaults)) - ->addBetweenAction(fn (Action $action): Action => $this->configureAddBetweenAction($action, $layoutDefaults)); + ->addAction(fn (Action $action): Action => $this->configureAddAction($action, $field)) + ->addBetweenAction(fn (Action $action): Action => $this->configureAddBetweenAction($action, $field)); return $this->applyNestedValueValidation( $this->applyCapabilitiesAndValidation($component, $field), @@ -73,41 +66,40 @@ public function formComponent(FieldDefinition $field): Component /** * @param array> $layoutDefaults */ - protected function configureAddAction(Action $action, array $layoutDefaults): Action + protected function configureAddAction(Action $action, FieldDefinition $field): Action { - return $action->action(function (array $arguments, Builder $component, array $data = []) use ($layoutDefaults): void { - $this->insertBuilderBlock($component, (string) $arguments['block'], $data, $layoutDefaults, afterItem: null); + return $action->action(function (array $arguments, Builder $component, array $data = []) use ($field): void { + $this->insertBuilderBlock($component, $field, (string) $arguments['block'], $data, afterItem: null); }); } /** * @param array> $layoutDefaults */ - protected function configureAddBetweenAction(Action $action, array $layoutDefaults): Action + protected function configureAddBetweenAction(Action $action, FieldDefinition $field): Action { - return $action->action(function (array $arguments, Builder $component, array $data = []) use ($layoutDefaults): void { + return $action->action(function (array $arguments, Builder $component, array $data = []) use ($field): void { $this->insertBuilderBlock( $component, + $field, (string) $arguments['block'], $data, - $layoutDefaults, afterItem: $arguments['afterItem'] ?? null, ); }); } - /** - * @param array> $layoutDefaults - */ protected function insertBuilderBlock( Builder $component, + FieldDefinition $field, string $block, array $data, - array $layoutDefaults, ?string $afterItem, ): void { - if ($data === []) { - $data = $layoutDefaults[$block] ?? []; + $layout = $field->layouts()->firstWhere('name', $block); + + if ($layout !== null) { + $data = app(DefaultValue::class)->mergeIntoData($layout->children, $data); } $newKey = $component->generateUuid(); diff --git a/packages/builder/src/FieldTypes/Types/GroupFieldType.php b/packages/builder/src/FieldTypes/Types/GroupFieldType.php index afeac8978b..bc4fbdbd0a 100644 --- a/packages/builder/src/FieldTypes/Types/GroupFieldType.php +++ b/packages/builder/src/FieldTypes/Types/GroupFieldType.php @@ -4,7 +4,6 @@ namespace Moox\Builder\FieldTypes\Types; -use Filament\Forms\Components\Repeater; use Filament\Schemas\Components\Component; use Moox\Builder\Compiler\SchemaCompiler; use Moox\Builder\Data\FieldDefinition; @@ -24,21 +23,7 @@ public function hasSubFields(): bool public function formComponent(FieldDefinition $field): Component { - $compiler = app(SchemaCompiler::class); - - $component = Repeater::make($field->name) - ->label($field->label) - ->schema($compiler->compileSubFields($field->children, null)) - ->minItems(1) - ->maxItems(1) - ->defaultItems(1) - ->addable(false) - ->deletable(false) - ->reorderable(false) - ->collapsible(false) - ->hiddenLabel($field->label === ''); - - return $this->applyNestedValueValidation($component, $field); + return app(SchemaCompiler::class)->buildGroupComponent($field); } public function castValue(mixed $raw): mixed @@ -57,13 +42,13 @@ public function castValue(mixed $raw): mixed public function normalizeForForm(mixed $stored): array { if (! is_array($stored) || $stored === []) { - return [[]]; + return []; } - if (array_is_list($stored)) { - return $stored; + if (array_is_list($stored) && isset($stored[0]) && is_array($stored[0])) { + return $stored[0]; } - return [$stored]; + return $stored; } } diff --git a/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php b/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php index 890fa661a7..0f533f4475 100644 --- a/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php +++ b/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php @@ -4,10 +4,12 @@ namespace Moox\Builder\FieldTypes\Types; +use Filament\Actions\Action; use Filament\Forms\Components\Repeater; use Filament\Schemas\Components\Component; use Moox\Builder\Compiler\SchemaCompiler; use Moox\Builder\Data\FieldDefinition; +use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\FieldTypes\Capabilities\RepeaterItems; use Moox\Builder\FieldTypes\FieldType; @@ -33,13 +35,60 @@ public function capabilities(): array public function formComponent(FieldDefinition $field): Component { $compiler = app(SchemaCompiler::class); + $defaultValue = app(DefaultValue::class); + $defaultRow = $defaultValue->defaultDataForChildren($field->children); $component = Repeater::make($field->name) ->label($field->label) ->schema($compiler->compileSubFields($field->children, null)) ->collapsible() ->defaultItems(0) - ->reorderable(); + ->reorderable() + ->addAction(fn (Action $action): Action => $action->action( + function (Repeater $component) use ($defaultRow, $defaultValue, $field): void { + $data = $defaultValue->mergeIntoData($field->children, $defaultRow); + + $newUuid = $component->generateUuid(); + $items = $component->getRawState() ?? []; + + if ($newUuid) { + $items[$newUuid] = $data; + } else { + $items[] = $data; + $newUuid = array_key_last($items); + } + + $component->rawState($items); + $component->getChildSchema($newUuid)->fill($data); + $component->collapsed(false, shouldMakeComponentCollapsible: false); + $component->callAfterStateUpdated(); + }, + )) + ->addBetweenAction(fn (Action $action): Action => $action->action( + function (array $arguments, Repeater $component) use ($defaultRow, $defaultValue, $field): void { + $data = $defaultValue->mergeIntoData($field->children, $defaultRow); + $newKey = $component->generateUuid(); + $items = []; + + foreach ($component->getRawState() ?? [] as $key => $item) { + $items[$key] = $item; + + if ($key === $arguments['afterItem']) { + if ($newKey) { + $items[$newKey] = $data; + } else { + $items[] = $data; + $newKey = array_key_last($items); + } + } + } + + $component->rawState($items); + $component->getChildSchema($newKey)->fill($data); + $component->collapsed(false, shouldMakeComponentCollapsible: false); + $component->callAfterStateUpdated(); + }, + )); return $this->applyNestedValueValidation( $this->applyCapabilitiesAndValidation($component, $field), From 0c260ba8e56f4f350ff2db65f4ee941713cb4446 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:23:27 +0000 Subject: [PATCH 024/105] Fix styling --- packages/builder/src/Compiler/SchemaCompiler.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index dcd7248d84..8bfec2a7d0 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -7,6 +7,7 @@ use Filament\Forms\Components\Builder; use Filament\Forms\Components\Repeater; use Filament\Schemas\Components\Component; +use Filament\Schemas\Components\Fieldset; use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; @@ -85,7 +86,7 @@ public function buildGroupComponent(FieldDefinition $field, ?string $entity = nu { $storableFields ??= collect(); - $component = \Filament\Schemas\Components\Fieldset::make($field->label) + $component = Fieldset::make($field->label) ->schema($this->compileSubFields($field->children, $entity, $storableFields)) ->statePath($field->name) ->columns(1) From 79799cc90ab3d2e7e76ec2b76514d1a87896fc53 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:25:50 +0200 Subject: [PATCH 025/105] fix link field --- .../builder/resources/lang/de/builder.php | 1 + .../builder/resources/lang/en/builder.php | 1 + .../src/FieldTypes/Types/LinkFieldType.php | 14 +-- .../src/Services/FieldValueValidator.php | 60 +++++++++- .../builder/tests/Unit/LinkFieldTypeTest.php | 105 ++++++++++++++++++ 5 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 packages/builder/tests/Unit/LinkFieldTypeTest.php diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index ccbfab28a6..f247a51f0f 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -136,6 +136,7 @@ 'validation' => [ 'invalid_option' => 'Der gewählte Wert ist keine gültige Option.', 'invalid_email_default' => 'Bitte eine gültige E-Mail-Adresse als Standardwert eingeben.', + 'invalid_link_url' => 'Bitte eine gültige URL eingeben.', 'duplicate_field_name' => 'Der Feldschlüssel „:name“ wird bereits in der Gruppe „:group“ verwendet.', 'duplicate_field_name_internal' => 'Der Feldschlüssel „:name“ ist in dieser Gruppe mehrfach vergeben.', 'empty_repeater_item' => 'Eintrag :position in „:field“ ist leer.', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index a930a0ee72..14aeb62cf3 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -136,6 +136,7 @@ 'validation' => [ 'invalid_option' => 'The selected value is not a valid option.', 'invalid_email_default' => 'Please enter a valid email address as the default value.', + 'invalid_link_url' => 'Please enter a valid URL.', 'duplicate_field_name' => 'The field key ":name" is already used in the group ":group".', 'duplicate_field_name_internal' => 'The field key ":name" is assigned more than once in this group.', 'empty_repeater_item' => 'Row :position in ":field" is empty.', diff --git a/packages/builder/src/FieldTypes/Types/LinkFieldType.php b/packages/builder/src/FieldTypes/Types/LinkFieldType.php index 72ffba0175..56d7fc510f 100644 --- a/packages/builder/src/FieldTypes/Types/LinkFieldType.php +++ b/packages/builder/src/FieldTypes/Types/LinkFieldType.php @@ -9,6 +9,7 @@ use Filament\Schemas\Components\Component; use Filament\Schemas\Components\Fieldset; use Moox\Builder\Data\FieldDefinition; +use Moox\Builder\FieldTypes\Capabilities\Capability; use Moox\Builder\FieldTypes\Capabilities\HelperText; use Moox\Builder\FieldTypes\FieldType; @@ -68,16 +69,15 @@ public function capabilities(): array public function formComponent(FieldDefinition $field): Component { + $urlRules = ($field->validation['required'] ?? false) === true + ? ['required', 'url'] + : ['nullable', 'url']; + $url = TextInput::make('url') ->label(__('builder::builder.link.url')) - ->url() - ->nullable(); + ->rules($urlRules); - if (($field->validation['required'] ?? false) === true) { - $url->required(); - } else { - $url->nullable(); - } + $url = Capability::applyAll($this->capabilities(), $url, $field); $fieldset = Fieldset::make($field->label) ->schema([ diff --git a/packages/builder/src/Services/FieldValueValidator.php b/packages/builder/src/Services/FieldValueValidator.php index b803a9c952..af4fa7136b 100644 --- a/packages/builder/src/Services/FieldValueValidator.php +++ b/packages/builder/src/Services/FieldValueValidator.php @@ -4,6 +4,7 @@ namespace Moox\Builder\Services; +use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Registry\FieldTypeRegistry; @@ -177,6 +178,10 @@ protected function messagesForLeaf(FieldDefinition $field, mixed $value, string return []; } + if ($field->type === 'link') { + return $this->messagesForLink($field, $value, $path); + } + $messages = []; if (($field->validation['required'] ?? false) === true && $this->isEmptyValue($field->type, $value)) { @@ -200,6 +205,47 @@ protected function messagesForLeaf(FieldDefinition $field, mixed $value, string return $messages; } + /** + * @return array> + */ + protected function messagesForLink(FieldDefinition $field, mixed $value, string $path): array + { + $messages = []; + $urlPath = "{$path}.url"; + + if (($field->validation['required'] ?? false) === true && $this->isEmptyValue('link', $value, true)) { + $messages[$urlPath] = [ + __('validation.required', ['attribute' => __('builder::builder.link.url')]), + ]; + } + + if ($this->isEmptyValue('link', $value)) { + return $messages; + } + + if (! is_array($value)) { + return $messages; + } + + $url = $value['url'] ?? null; + + if (blank($url)) { + return $messages; + } + + $validator = Validator::make( + ['url' => $url], + ['url' => ['url']], + ['url.url' => __('builder::builder.validation.invalid_link_url')], + ); + + if ($validator->fails()) { + $messages[$urlPath] = $validator->errors()->get('url'); + } + + return $messages; + } + /** * @param array $row */ @@ -220,12 +266,24 @@ protected function isRowEmpty(FieldDefinition $parent, array $row): bool return true; } - protected function isEmptyValue(string $type, mixed $value): bool + protected function isEmptyValue(string $type, mixed $value, bool $required = false): bool { if ($type === 'toggle') { return $value === null || $value === false; } + if ($type === 'link') { + if (! is_array($value)) { + return true; + } + + if ($required) { + return blank($value['url'] ?? null); + } + + return blank($value['url'] ?? null) && blank($value['label'] ?? null); + } + if (is_array($value)) { return $value === []; } diff --git a/packages/builder/tests/Unit/LinkFieldTypeTest.php b/packages/builder/tests/Unit/LinkFieldTypeTest.php new file mode 100644 index 0000000000..d178db04bb --- /dev/null +++ b/packages/builder/tests/Unit/LinkFieldTypeTest.php @@ -0,0 +1,105 @@ +createItemsTable(); +}); + +it('normalizes link values and treats blank rows as null', function (): void { + $type = new LinkFieldType; + + expect($type->castValue(null))->toBeNull() + ->and($type->castValue(['url' => '', 'label' => '', 'opens_in_new_tab' => false]))->toBeNull() + ->and($type->castValue([ + 'url' => 'https://moox.org', + 'label' => 'Moox', + 'target' => '_blank', + ]))->toBe([ + 'url' => 'https://moox.org', + 'label' => 'Moox', + 'opens_in_new_tab' => true, + ]); +}); + +it('persists and loads link values as json', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + $field = new FieldDefinition(name: 'cta', label: 'CTA', type: 'link'); + + app(CustomFieldsManager::class)->saveValues('item', $record, [ + 'cta' => [ + 'url' => 'https://moox.org/items/demo', + 'label' => 'Zum Inserat', + 'opens_in_new_tab' => true, + ], + ], collect([$field])); + + $stored = FieldValue::query()->forRecord('item', $record->getKey())->first(); + + expect($stored?->value_json)->toMatchArray([ + 'url' => 'https://moox.org/items/demo', + 'label' => 'Zum Inserat', + 'opens_in_new_tab' => true, + ]); + + $loaded = app(CustomFieldsManager::class)->loadValues('item', $record, collect([$field])); + + expect($loaded['cta'])->toMatchArray([ + 'url' => 'https://moox.org/items/demo', + 'label' => 'Zum Inserat', + 'opens_in_new_tab' => true, + ]); +}); + +it('rejects required links without a url', function (): void { + $field = new FieldDefinition( + name: 'cta', + label: 'CTA', + type: 'link', + validation: ['required' => true, 'rules' => []], + ); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [ + 'url' => '', + 'label' => 'Nur Label', + 'opens_in_new_tab' => false, + ]))->toThrow(ValidationException::class); +}); + +it('rejects invalid link urls on save', function (): void { + $field = new FieldDefinition(name: 'cta', label: 'CTA', type: 'link'); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [ + 'url' => 'not-a-valid-url', + 'label' => 'Broken', + 'opens_in_new_tab' => false, + ]))->toThrow(ValidationException::class); +}); + +it('builds link fields with helper text configured on the url input', function (): void { + $field = new FieldDefinition( + name: 'cta', + label: 'CTA', + type: 'link', + config: ['helperText' => 'Link zum externen Inserat.'], + ); + + $component = (new LinkFieldType)->formComponent($field); + + expect($component->getDefaultChildComponents())->toHaveCount(3) + ->and($component->getDefaultChildComponents()[0])->toBeInstanceOf(\Filament\Forms\Components\TextInput::class); +}); From 8e92acffb19666031a4ae0fd6b61263f8e6dcc46 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:27:03 +0000 Subject: [PATCH 026/105] Fix styling --- packages/builder/tests/Unit/LinkFieldTypeTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/builder/tests/Unit/LinkFieldTypeTest.php b/packages/builder/tests/Unit/LinkFieldTypeTest.php index d178db04bb..6f4a74dbe3 100644 --- a/packages/builder/tests/Unit/LinkFieldTypeTest.php +++ b/packages/builder/tests/Unit/LinkFieldTypeTest.php @@ -5,6 +5,7 @@ require_once __DIR__.'/../TestCase.php'; require_once __DIR__.'/../Support/TestItem.php'; +use Filament\Forms\Components\TextInput; use Illuminate\Validation\ValidationException; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\FieldTypes\Types\LinkFieldType; @@ -101,5 +102,5 @@ $component = (new LinkFieldType)->formComponent($field); expect($component->getDefaultChildComponents())->toHaveCount(3) - ->and($component->getDefaultChildComponents()[0])->toBeInstanceOf(\Filament\Forms\Components\TextInput::class); + ->and($component->getDefaultChildComponents()[0])->toBeInstanceOf(TextInput::class); }); From d9a8a14650489455b325ea68b059b75c851220f5 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:32:10 +0200 Subject: [PATCH 027/105] fix message field --- .../builder/resources/lang/de/builder.php | 1 + .../builder/resources/lang/en/builder.php | 1 + .../FieldTypes/Capabilities/MessageBody.php | 4 +++- .../src/Resources/FieldGroupResource.php | 22 ++++++++++++++++--- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index f247a51f0f..a9ff133532 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -84,6 +84,7 @@ 'message' => [ 'body' => 'Hinweistext', + 'body_helper' => 'Wird nur im Formular angezeigt — kein speicherbarer Wert pro Datensatz.', ], 'oembed' => [ diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 14aeb62cf3..997f9f3353 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -84,6 +84,7 @@ 'message' => [ 'body' => 'Message', + 'body_helper' => 'Shown in the form only — no per-record stored value.', ], 'oembed' => [ diff --git a/packages/builder/src/FieldTypes/Capabilities/MessageBody.php b/packages/builder/src/FieldTypes/Capabilities/MessageBody.php index 8bf72cd4a8..ad6e00d127 100644 --- a/packages/builder/src/FieldTypes/Capabilities/MessageBody.php +++ b/packages/builder/src/FieldTypes/Capabilities/MessageBody.php @@ -15,7 +15,9 @@ public function builderFields(): array return [ Textarea::make('config.message') ->label(__('builder::builder.message.body')) - ->rows(4), + ->helperText(__('builder::builder.message.body_helper')) + ->rows(4) + ->required(), ]; } diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 2f816d2fca..2bcebe92ee 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -168,7 +168,8 @@ public static function form(Schema $schema): Schema Toggle::make('required') ->label(__('builder::builder.field.required')) ->inline(false) - ->live(), + ->live() + ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), Section::make(__('builder::builder.field.settings')) ->collapsible() ->schema(fn (callable $get): array => static::reactiveTypeSettingsSchema($get)) @@ -387,7 +388,8 @@ protected static function tabChildFieldSchema(FieldTypeRegistry $registry): arra Toggle::make('required') ->label(__('builder::builder.field.required')) ->inline(false) - ->live(), + ->live() + ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), Section::make(__('builder::builder.field.settings')) ->collapsible() ->schema(fn (callable $get): array => static::reactiveTypeSettingsSchema($get)) @@ -480,7 +482,8 @@ protected static function subFieldSchema(FieldTypeRegistry $registry): array Toggle::make('required') ->label(__('builder::builder.field.required')) ->inline(false) - ->live(), + ->live() + ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), Section::make(__('builder::builder.field.settings')) ->collapsible() ->schema(fn (callable $get): array => static::reactiveTypeSettingsSchema($get)) @@ -513,6 +516,19 @@ protected static function typeHasSettings(?string $type): bool return static::typeSettingsSchema($type) !== []; } + protected static function fieldTypeSupportsRequired(?string $type): bool + { + if (blank($type)) { + return false; + } + + try { + return app(FieldTypeRegistry::class)->get($type)->storesValue(); + } catch (UnknownFieldTypeException) { + return false; + } + } + /** * @return list */ From c64878975da3c4b2f28a459ddc4b5325143cd49f Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:42:37 +0200 Subject: [PATCH 028/105] fix number field --- .../builder/resources/lang/de/builder.php | 3 + .../builder/resources/lang/en/builder.php | 3 + .../FieldTypes/Capabilities/DefaultValue.php | 60 +++++++++++++++++-- .../FieldTypes/Capabilities/PrefixSuffix.php | 6 +- 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index a9ff133532..d5adfbdce6 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -101,8 +101,11 @@ 'max_length' => 'Maximale Länge', 'placeholder' => 'Platzhalter', 'prefix' => 'Präfix', + 'prefix_helper' => 'Wird links im Eingabefeld angezeigt, z. B. „€“ oder „ab“.', 'suffix' => 'Suffix', + 'suffix_helper' => 'Wird rechts im Eingabefeld angezeigt, z. B. „km“, „PS“ oder „%“.', 'default_value' => 'Standardwert', + 'default_value_number_helper' => 'Muss zwischen Mindest- und Höchstwert liegen, sofern gesetzt.', 'default_value_toggle_helper' => 'Schalter-Position beim Anlegen eines neuen Datensatzes.', 'default_value_option_helper' => 'Aus den oben definierten Auswahloptionen wählen.', 'default_value_multi_option_helper' => 'Eine oder mehrere der oben definierten Optionen wählen.', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 997f9f3353..ccd21e077e 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -101,8 +101,11 @@ 'max_length' => 'Maximum length', 'placeholder' => 'Placeholder', 'prefix' => 'Prefix', + 'prefix_helper' => 'Shown to the left of the input, e.g. "€" or "from".', 'suffix' => 'Suffix', + 'suffix_helper' => 'Shown to the right of the input, e.g. "km", "hp", or "%".', 'default_value' => 'Default value', + 'default_value_number_helper' => 'Must be between the minimum and maximum when those are set.', 'default_value_toggle_helper' => 'Switch position when creating a new record.', 'default_value_option_helper' => 'Choose from the options defined above.', 'default_value_multi_option_helper' => 'Choose one or more options defined above.', diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index 4cd36f3009..13d071d7fb 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -82,11 +82,7 @@ public function builderFieldsFor(string $fieldType): array ->native(false), ], 'number', 'range' => [ - TextInput::make('config.default') - ->label(__('builder::builder.capabilities.default_value')) - ->rules(['nullable', 'numeric']) - ->validationAttribute(__('builder::builder.capabilities.default_value')) - ->live(onBlur: true), + $this->numericDefaultField(), ], 'textarea', 'rich_text' => [ Textarea::make('config.default') @@ -144,7 +140,9 @@ public function resolveForField(FieldDefinition $field): mixed } if (in_array($field->type, ['number', 'range'], true) && is_numeric($default)) { - return $default + 0; + $numeric = $default + 0; + + return $this->numericDefaultWithinBounds($numeric, $field) ? $numeric : null; } if (in_array($field->type, ['multiselect', 'checkbox_list'], true)) { @@ -564,4 +562,54 @@ protected function resolveArrayDefault(mixed $default): array return array_values(array_filter($default, fn (mixed $value): bool => $value !== null && $value !== '')); } + + protected function numericDefaultField(): TextInput + { + return TextInput::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->helperText(__('builder::builder.capabilities.default_value_number_helper')) + ->numeric() + ->rules(fn (Get $get): array => $this->numericDefaultRules($get)) + ->validationAttribute(__('builder::builder.capabilities.default_value')) + ->live(onBlur: true); + } + + /** + * @return list + */ + protected function numericDefaultRules(Get $get): array + { + $rules = ['nullable', 'numeric']; + + $min = $get('config.min'); + + if ($min !== null && $min !== '' && is_numeric($min)) { + $rules[] = 'min:'.$min; + } + + $max = $get('config.max'); + + if ($max !== null && $max !== '' && is_numeric($max)) { + $rules[] = 'max:'.$max; + } + + return $rules; + } + + protected function numericDefaultWithinBounds(float|int $value, FieldDefinition $field): bool + { + if (isset($field->config['min']) && $field->config['min'] !== '' && is_numeric($field->config['min'])) { + if ($value < $field->config['min'] + 0) { + return false; + } + } + + if (isset($field->config['max']) && $field->config['max'] !== '' && is_numeric($field->config['max'])) { + if ($value > $field->config['max'] + 0) { + return false; + } + } + + return true; + } } diff --git a/packages/builder/src/FieldTypes/Capabilities/PrefixSuffix.php b/packages/builder/src/FieldTypes/Capabilities/PrefixSuffix.php index bfaed2de91..b426a20681 100644 --- a/packages/builder/src/FieldTypes/Capabilities/PrefixSuffix.php +++ b/packages/builder/src/FieldTypes/Capabilities/PrefixSuffix.php @@ -14,9 +14,11 @@ public function builderFields(): array { return [ TextInput::make('config.prefix') - ->label(__('builder::builder.capabilities.prefix')), + ->label(__('builder::builder.capabilities.prefix')) + ->helperText(__('builder::builder.capabilities.prefix_helper')), TextInput::make('config.suffix') - ->label(__('builder::builder.capabilities.suffix')), + ->label(__('builder::builder.capabilities.suffix')) + ->helperText(__('builder::builder.capabilities.suffix_helper')), ]; } From cd260fd8245fdc574f9c05108c2301704260a353 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:19:22 +0200 Subject: [PATCH 029/105] fix password field --- .../builder/resources/lang/de/builder.php | 4 ++++ .../builder/resources/lang/en/builder.php | 4 ++++ .../builder/src/Compiler/SchemaCompiler.php | 4 ---- .../FieldTypes/Types/PasswordFieldType.php | 21 ++++++++++++++----- .../src/Services/CustomFieldsManager.php | 10 ++------- 5 files changed, 26 insertions(+), 17 deletions(-) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index d5adfbdce6..bc0e2697f9 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -97,6 +97,10 @@ 'opens_in_new_tab' => 'In neuem Tab öffnen', ], + 'password' => [ + 'helper' => 'Maskierte Eingabe — nach dem Speichern per Augen-Symbol anzeigen und kopieren (wie bei ACF).', + ], + 'capabilities' => [ 'max_length' => 'Maximale Länge', 'placeholder' => 'Platzhalter', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index ccd21e077e..419ceb07a9 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -97,6 +97,10 @@ 'opens_in_new_tab' => 'Open in new tab', ], + 'password' => [ + 'helper' => 'Masked input — use the eye icon after save to reveal and copy the value (ACF-style).', + ], + 'capabilities' => [ 'max_length' => 'Maximum length', 'placeholder' => 'Placeholder', diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 8bfec2a7d0..b702ad9c13 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -316,10 +316,6 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, return $component ->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $entity, $fieldType, $storableFields, $defaultValue): void { - if ($field->type === 'password') { - return; - } - $storedValue = null; if ($record?->exists) { diff --git a/packages/builder/src/FieldTypes/Types/PasswordFieldType.php b/packages/builder/src/FieldTypes/Types/PasswordFieldType.php index 0687bb7156..b9afef4213 100644 --- a/packages/builder/src/FieldTypes/Types/PasswordFieldType.php +++ b/packages/builder/src/FieldTypes/Types/PasswordFieldType.php @@ -8,7 +8,10 @@ use Filament\Schemas\Components\Component; use Illuminate\Support\Facades\Hash; use Moox\Builder\Data\FieldDefinition; +use Moox\Builder\FieldTypes\Capabilities\HelperText; use Moox\Builder\FieldTypes\Capabilities\MaxLength; +use Moox\Builder\FieldTypes\Capabilities\Placeholder; +use Moox\Builder\FieldTypes\Capabilities\PrefixSuffix; use Moox\Builder\FieldTypes\FieldType; class PasswordFieldType extends FieldType @@ -22,6 +25,9 @@ public function capabilities(): array { return [ MaxLength::class, + Placeholder::class, + PrefixSuffix::class, + HelperText::class, ]; } @@ -30,10 +36,15 @@ public function formComponent(FieldDefinition $field): Component $component = TextInput::make($field->name) ->label($field->label) ->password() - ->revealable(false) - ->dehydrated(fn (?string $state): bool => filled($state)); + ->revealable(); - return $this->applyCapabilitiesAndValidation($component, $field); + $component = $this->applyCapabilitiesAndValidation($component, $field); + + if (! filled($field->config['helperText'] ?? null)) { + $component->helperText(__('builder::builder.password.helper')); + } + + return $component; } public function castValue(mixed $raw): mixed @@ -45,9 +56,9 @@ public function castValue(mixed $raw): mixed $value = (string) $raw; if (Hash::isHashed($value)) { - return $value; + return null; } - return Hash::make($value); + return $value; } } diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index e68cd67b23..5271b5f467 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -97,7 +97,7 @@ public function loadValues(string $entity, Model $record, Collection $fields): a $row = $rows->get($field->name); - if ($row === null || $field->type === 'password') { + if ($row === null) { continue; } @@ -150,10 +150,6 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da continue; } - if ($field->type === 'password' && blank($data[$field->name])) { - continue; - } - $values[$field->name] = $data[$field->name]; } @@ -188,9 +184,7 @@ public function saveValues(string $entity, Model $record, array $values, Collect $value = $values[$field->name]; - if ($field->type !== 'password') { - OptionValueRules::assertValid($field, $value); - } + OptionValueRules::assertValid($field, $value); $this->fieldValueValidator->assertValid($field, $value); From 5b2af05e3d29b5b2dc82282dc6b4d40296705b36 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:35:06 +0200 Subject: [PATCH 030/105] fix radio button field --- .../FieldTypes/Capabilities/DefaultValue.php | 22 ++++++++++-- .../Concerns/BuildsOptionComponents.php | 5 ++- .../src/FieldTypes/Types/RadioFieldType.php | 2 ++ .../src/Resources/FieldGroupResource.php | 13 +++++-- .../src/Services/CustomFieldsManager.php | 34 +++++++++++++++++-- 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index 13d071d7fb..7072a439d1 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -70,7 +70,8 @@ public function builderFieldsFor(string $fieldType): array ->helperText(__('builder::builder.capabilities.default_value_option_helper')) ->options(fn (Get $get): array => $this->optionChoices($get('options'))) ->searchable() - ->native(false), + ->native(false) + ->live(), ], 'multiselect', 'checkbox_list' => [ Select::make('config.default') @@ -150,7 +151,7 @@ public function resolveForField(FieldDefinition $field): mixed } if (in_array($field->type, ['select', 'radio', 'button_group'], true)) { - return (string) $default; + return $this->resolveOptionDefault($field, (string) $default); } if ($field->type === 'color') { @@ -422,6 +423,23 @@ protected function optionChoices(?array $options): array return $choices; } + protected function resolveOptionDefault(FieldDefinition $field, string $default): ?string + { + foreach ($field->options as $option) { + $value = $option['value'] ?? null; + + if (blank($value)) { + continue; + } + + if ((string) $value === $default) { + return (string) $value; + } + } + + return null; + } + protected function resolveBooleanDefault(mixed $default): bool { if (is_bool($default)) { diff --git a/packages/builder/src/FieldTypes/Concerns/BuildsOptionComponents.php b/packages/builder/src/FieldTypes/Concerns/BuildsOptionComponents.php index ca6f15dcfb..fb5559f0b9 100644 --- a/packages/builder/src/FieldTypes/Concerns/BuildsOptionComponents.php +++ b/packages/builder/src/FieldTypes/Concerns/BuildsOptionComponents.php @@ -35,7 +35,10 @@ protected function applyOptions(Component $component, FieldDefinition $field): C $component->options($options); if ($options !== []) { - $component->in(array_keys($options)); + $component->in(array_map( + fn (mixed $value): string => (string) $value, + array_column($field->options, 'value'), + )); } } diff --git a/packages/builder/src/FieldTypes/Types/RadioFieldType.php b/packages/builder/src/FieldTypes/Types/RadioFieldType.php index af59e74b5e..24ac589396 100644 --- a/packages/builder/src/FieldTypes/Types/RadioFieldType.php +++ b/packages/builder/src/FieldTypes/Types/RadioFieldType.php @@ -8,6 +8,7 @@ use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\FieldTypes\Capabilities\DefaultValue; +use Moox\Builder\FieldTypes\Capabilities\HelperText; use Moox\Builder\FieldTypes\Concerns\BuildsOptionComponents; use Moox\Builder\FieldTypes\FieldType; @@ -29,6 +30,7 @@ public function capabilities(): array { return [ DefaultValue::class, + HelperText::class, ]; } diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 2bcebe92ee..91feaa13f1 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -188,7 +188,8 @@ public static function form(Schema $schema): Schema ->required(), TextInput::make('value') ->label(__('builder::builder.field.option_value')) - ->required(), + ->required() + ->live(onBlur: true), ]) ->columns(2) ->defaultItems(1), @@ -408,7 +409,8 @@ protected static function tabChildFieldSchema(FieldTypeRegistry $registry): arra ->required(), TextInput::make('value') ->label(__('builder::builder.field.option_value')) - ->required(), + ->required() + ->live(onBlur: true), ]) ->columns(2) ->defaultItems(1), @@ -502,7 +504,8 @@ protected static function subFieldSchema(FieldTypeRegistry $registry): array ->required(), TextInput::make('value') ->label(__('builder::builder.field.option_value')) - ->required(), + ->required() + ->live(onBlur: true), ]) ->columns(2) ->defaultItems(1), @@ -544,6 +547,10 @@ protected static function reactiveTypeSettingsSchema(callable $get): array $get('config.defaultNow'); } + if (in_array($type, ['select', 'radio', 'button_group', 'multiselect', 'checkbox_list'], true)) { + $get('options'); + } + return static::typeSettingsSchema($type); } diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index 5271b5f467..be1f36244c 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -9,6 +9,7 @@ use Moox\Builder\Concerns\HasCustomFields; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Data\LocationContext; +use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Registry\FieldTypeRegistry; @@ -140,17 +141,44 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da } $values = []; + $defaultValue = app(DefaultValue::class); + $entity = $this->locationContextForResource($resourceClass)->entity; foreach ($fields as $field) { - if (! array_key_exists($field->name, $data)) { + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if (! $fieldType->storesValue()) { continue; } - if (! $this->fieldTypeRegistry->get($field->type)->storesValue()) { + if (! array_key_exists($field->name, $data)) { + $resolved = $defaultValue->resolveForField($field); + + if ($resolved !== null) { + $values[$field->name] = $resolved; + } + continue; } - $values[$field->name] = $data[$field->name]; + $value = $data[$field->name]; + + if ($defaultValue->shouldApplyDefault($value, $field->type) && $record->exists) { + $hasStoredValue = FieldValue::query() + ->forRecord($entity, $record->getKey()) + ->where('field_name', $field->name) + ->exists(); + + if (! $hasStoredValue) { + $resolved = $defaultValue->resolveForField($field); + + if ($resolved !== null) { + $value = $resolved; + } + } + } + + $values[$field->name] = $value; } if ($values === []) { From f9a4dc36c1ce222836bc8fad16726f4a9fc6c6b7 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:04:13 +0200 Subject: [PATCH 031/105] fix range field --- .../builder/resources/lang/de/builder.php | 7 + .../builder/resources/lang/en/builder.php | 7 + .../builder/src/Compiler/SchemaCompiler.php | 20 +-- .../FieldTypes/Capabilities/DefaultValue.php | 127 +++++++++++++++++- .../src/FieldTypes/Capabilities/MaxValue.php | 46 ++++++- .../src/FieldTypes/Capabilities/MinValue.php | 46 ++++++- .../src/FieldTypes/Types/RangeFieldType.php | 16 ++- .../src/Resources/FieldGroupResource.php | 7 + .../src/Services/FieldGroupValidator.php | 93 +++++++++++++ 9 files changed, 351 insertions(+), 18 deletions(-) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index bc0e2697f9..ecd8e39dc7 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -110,6 +110,7 @@ 'suffix_helper' => 'Wird rechts im Eingabefeld angezeigt, z. B. „km“, „PS“ oder „%“.', 'default_value' => 'Standardwert', 'default_value_number_helper' => 'Muss zwischen Mindest- und Höchstwert liegen, sofern gesetzt.', + 'default_value_range_helper' => 'Muss zwischen Mindest- und Höchstwert liegen und zur Schrittweite passen (z. B. Min 10, Schritt 10 → 10, 20, 30 …).', 'default_value_toggle_helper' => 'Schalter-Position beim Anlegen eines neuen Datensatzes.', 'default_value_option_helper' => 'Aus den oben definierten Auswahloptionen wählen.', 'default_value_multi_option_helper' => 'Eine oder mehrere der oben definierten Optionen wählen.', @@ -120,6 +121,8 @@ 'helper_text' => 'Hilfetext', 'min_value' => 'Mindestwert', 'max_value' => 'Höchstwert', + 'range_min_helper' => 'Muss kleiner als der Höchstwert sein.', + 'range_max_helper' => 'Muss größer als der Mindestwert sein.', 'min_items' => 'Mindestanzahl Einträge', 'max_items' => 'Höchstanzahl Einträge', 'step' => 'Schrittweite', @@ -145,6 +148,10 @@ 'invalid_option' => 'Der gewählte Wert ist keine gültige Option.', 'invalid_email_default' => 'Bitte eine gültige E-Mail-Adresse als Standardwert eingeben.', 'invalid_link_url' => 'Bitte eine gültige URL eingeben.', + 'range_min_lt_max' => 'Der Mindestwert muss kleiner als der Höchstwert sein.', + 'range_max_gt_min' => 'Der Höchstwert muss größer als der Mindestwert sein.', + 'range_default_step' => 'Der Standardwert muss zwischen Minimum und Maximum liegen und zur Schrittweite passen.', + 'range_default_bounds' => 'Der Standardwert muss zwischen Minimum und Maximum liegen.', 'duplicate_field_name' => 'Der Feldschlüssel „:name“ wird bereits in der Gruppe „:group“ verwendet.', 'duplicate_field_name_internal' => 'Der Feldschlüssel „:name“ ist in dieser Gruppe mehrfach vergeben.', 'empty_repeater_item' => 'Eintrag :position in „:field“ ist leer.', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 419ceb07a9..bf43578f18 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -110,6 +110,7 @@ 'suffix_helper' => 'Shown to the right of the input, e.g. "km", "hp", or "%".', 'default_value' => 'Default value', 'default_value_number_helper' => 'Must be between the minimum and maximum when those are set.', + 'default_value_range_helper' => 'Must stay within min/max and align with the step (e.g. min 10, step 10 → 10, 20, 30 …).', 'default_value_toggle_helper' => 'Switch position when creating a new record.', 'default_value_option_helper' => 'Choose from the options defined above.', 'default_value_multi_option_helper' => 'Choose one or more options defined above.', @@ -120,6 +121,8 @@ 'helper_text' => 'Helper text', 'min_value' => 'Minimum value', 'max_value' => 'Maximum value', + 'range_min_helper' => 'Must be less than the maximum value.', + 'range_max_helper' => 'Must be greater than the minimum value.', 'min_items' => 'Minimum items', 'max_items' => 'Maximum items', 'step' => 'Step', @@ -145,6 +148,10 @@ 'invalid_option' => 'The selected value is not a valid option.', 'invalid_email_default' => 'Please enter a valid email address as the default value.', 'invalid_link_url' => 'Please enter a valid URL.', + 'range_min_lt_max' => 'The minimum value must be less than the maximum value.', + 'range_max_gt_min' => 'The maximum value must be greater than the minimum value.', + 'range_default_step' => 'The default value must stay within min/max and align with the step size.', + 'range_default_bounds' => 'The default value must be between the minimum and maximum.', 'duplicate_field_name' => 'The field key ":name" is already used in the group ":group".', 'duplicate_field_name_internal' => 'The field key ":name" is assigned more than once in this group.', 'empty_repeater_item' => 'Row :position in ":field" is empty.', diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index b702ad9c13..facf595160 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -316,6 +316,7 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, return $component ->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $entity, $fieldType, $storableFields, $defaultValue): void { + $hasStoredValue = false; $storedValue = null; if ($record?->exists) { @@ -326,6 +327,7 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, ); if (array_key_exists($field->name, $values)) { + $hasStoredValue = true; $storedValue = $values[$field->name]; if ($fieldType->hasSubFields() && method_exists($fieldType, 'normalizeForForm')) { @@ -335,7 +337,7 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, } if ($fieldType->hasSubFields()) { - $valueToApply = $storedValue ?? $state; + $valueToApply = $hasStoredValue ? $storedValue : $state; if (is_array($valueToApply) && $valueToApply !== []) { $this->applyCompoundState( @@ -350,16 +352,16 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, return; } - $valueToApply = $storedValue ?? $state; + if ($hasStoredValue) { + $component->state($storedValue); + + return; + } - if ($defaultValue->shouldApplyDefault($valueToApply, $field->type)) { - $default = $defaultValue->resolveForField($field); + $default = $defaultValue->resolveForField($field); - if ($default !== null) { - $component->state($default); - } - } elseif ($storedValue !== null) { - $component->state($storedValue); + if ($default !== null) { + $component->state($default); } }) ->afterStateUpdated(function (Component $component) use ($field, $fieldType, $defaultValue): void { diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index 7072a439d1..43f58cdc9e 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -82,9 +82,12 @@ public function builderFieldsFor(string $fieldType): array ->searchable() ->native(false), ], - 'number', 'range' => [ + 'number' => [ $this->numericDefaultField(), ], + 'range' => [ + $this->rangeDefaultField(), + ], 'textarea', 'rich_text' => [ Textarea::make('config.default') ->label(__('builder::builder.capabilities.default_value')) @@ -140,12 +143,22 @@ public function resolveForField(FieldDefinition $field): mixed return $this->resolveBooleanDefault($default); } - if (in_array($field->type, ['number', 'range'], true) && is_numeric($default)) { + if ($field->type === 'number' && is_numeric($default)) { $numeric = $default + 0; return $this->numericDefaultWithinBounds($numeric, $field) ? $numeric : null; } + if ($field->type === 'range' && is_numeric($default)) { + $numeric = $default + 0; + + if (! $this->rangeDefaultIsValid($numeric, $field->config)) { + return null; + } + + return $this->normalizeRangeDefault($numeric); + } + if (in_array($field->type, ['multiselect', 'checkbox_list'], true)) { return $this->resolveArrayDefault($default); } @@ -237,6 +250,43 @@ public function shouldApplyDefault(mixed $state, string $type): bool return $state === null || $state === ''; } + public function shouldReplaceSliderFallbackState(FieldDefinition $field, mixed $state): bool + { + if ($field->type !== 'range' || ! is_numeric($state)) { + return false; + } + + $resolved = $this->resolveForField($field); + + if ($resolved === null) { + return false; + } + + $min = $field->config['min'] ?? null; + + if ($min === null || $min === '' || ! is_numeric($min)) { + return false; + } + + return ($state + 0) === ($min + 0) && ($resolved + 0) !== ($min + 0); + } + + /** + * @param array $config + */ + public function rangeDefaultIsValid(float|int $value, array $config): bool + { + $field = new FieldDefinition( + name: 'range', + label: 'Range', + type: 'range', + config: $config, + ); + + return $this->numericDefaultWithinBounds($value, $field) + && $this->numericDefaultAlignsWithStep($value, $field); + } + public function mergeCompoundDefaults(FieldDefinition $field, mixed $state): mixed { if (! is_array($state)) { @@ -592,6 +642,49 @@ protected function numericDefaultField(): TextInput ->live(onBlur: true); } + protected function rangeDefaultField(): TextInput + { + return TextInput::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->helperText(__('builder::builder.capabilities.default_value_range_helper')) + ->numeric() + ->rules(fn (Get $get): array => $this->rangeDefaultRules($get)) + ->validationAttribute(__('builder::builder.capabilities.default_value')) + ->validationMessages([ + 'min' => __('builder::builder.validation.range_default_bounds'), + 'max' => __('builder::builder.validation.range_default_bounds'), + ]) + ->live(onBlur: true); + } + + /** + * @return list + */ + protected function rangeDefaultRules(Get $get): array + { + $rules = $this->numericDefaultRules($get); + + $rules[] = function (string $attribute, mixed $value, \Closure $fail) use ($get): void { + if ($value === null || $value === '') { + return; + } + + if (! is_numeric($value)) { + return; + } + + if (! $this->rangeDefaultIsValid($value + 0, [ + 'min' => $get('config.min'), + 'max' => $get('config.max'), + 'step' => $get('config.step'), + ])) { + $fail(__('builder::builder.validation.range_default_step')); + } + }; + + return $rules; + } + /** * @return list */ @@ -630,4 +723,34 @@ protected function numericDefaultWithinBounds(float|int $value, FieldDefinition return true; } + + protected function numericDefaultAlignsWithStep(float|int $value, FieldDefinition $field): bool + { + $step = $field->config['step'] ?? null; + + if ($step === null || $step === '' || ! is_numeric($step) || $step + 0 <= 0) { + return true; + } + + $min = $field->config['min'] ?? null; + $origin = ($min !== null && $min !== '' && is_numeric($min)) ? $min + 0 : 0; + $diff = $value - $origin; + + if ($diff < -1e-9) { + return false; + } + + $steps = $diff / ($step + 0); + + return abs($steps - round($steps)) < 1e-6; + } + + protected function normalizeRangeDefault(float|int $value): float|int + { + if (abs($value - round($value)) < 1e-9) { + return (int) round($value); + } + + return round($value, 6); + } } diff --git a/packages/builder/src/FieldTypes/Capabilities/MaxValue.php b/packages/builder/src/FieldTypes/Capabilities/MaxValue.php index 0b0975507b..b04df8aeed 100644 --- a/packages/builder/src/FieldTypes/Capabilities/MaxValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/MaxValue.php @@ -7,6 +7,7 @@ use Filament\Forms\Components\Slider; use Filament\Forms\Components\TextInput; use Filament\Schemas\Components\Component; +use Filament\Schemas\Components\Utilities\Get; use Moox\Builder\Data\FieldDefinition; class MaxValue extends Capability @@ -14,12 +15,51 @@ class MaxValue extends Capability public function builderFields(): array { return [ - TextInput::make('config.max') - ->label(__('builder::builder.capabilities.max_value')) - ->numeric(), + $this->maxValueField(), ]; } + public function builderFieldsFor(string $fieldType): array + { + if ($fieldType === 'range') { + return [ + $this->maxValueField() + ->helperText(__('builder::builder.capabilities.range_max_helper')) + ->rules(fn (Get $get): array => $this->rangeMaxRules($get)) + ->validationAttribute(__('builder::builder.capabilities.max_value')) + ->validationMessages([ + 'gt' => __('builder::builder.validation.range_max_gt_min'), + ]) + ->live(onBlur: true), + ]; + } + + return $this->builderFields(); + } + + protected function maxValueField(): TextInput + { + return TextInput::make('config.max') + ->label(__('builder::builder.capabilities.max_value')) + ->numeric(); + } + + /** + * @return list + */ + protected function rangeMaxRules(Get $get): array + { + $rules = ['nullable', 'numeric']; + + $min = $get('config.min'); + + if ($min !== null && $min !== '' && is_numeric($min)) { + $rules[] = 'gt:'.$min; + } + + return $rules; + } + public function rules(FieldDefinition $field): array { if (! isset($field->config['max'])) { diff --git a/packages/builder/src/FieldTypes/Capabilities/MinValue.php b/packages/builder/src/FieldTypes/Capabilities/MinValue.php index 6c2cfd0a2e..79faf7c9e3 100644 --- a/packages/builder/src/FieldTypes/Capabilities/MinValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/MinValue.php @@ -7,6 +7,7 @@ use Filament\Forms\Components\Slider; use Filament\Forms\Components\TextInput; use Filament\Schemas\Components\Component; +use Filament\Schemas\Components\Utilities\Get; use Moox\Builder\Data\FieldDefinition; class MinValue extends Capability @@ -14,12 +15,51 @@ class MinValue extends Capability public function builderFields(): array { return [ - TextInput::make('config.min') - ->label(__('builder::builder.capabilities.min_value')) - ->numeric(), + $this->minValueField(), ]; } + public function builderFieldsFor(string $fieldType): array + { + if ($fieldType === 'range') { + return [ + $this->minValueField() + ->helperText(__('builder::builder.capabilities.range_min_helper')) + ->rules(fn (Get $get): array => $this->rangeMinRules($get)) + ->validationAttribute(__('builder::builder.capabilities.min_value')) + ->validationMessages([ + 'lt' => __('builder::builder.validation.range_min_lt_max'), + ]) + ->live(onBlur: true), + ]; + } + + return $this->builderFields(); + } + + protected function minValueField(): TextInput + { + return TextInput::make('config.min') + ->label(__('builder::builder.capabilities.min_value')) + ->numeric(); + } + + /** + * @return list + */ + protected function rangeMinRules(Get $get): array + { + $rules = ['nullable', 'numeric']; + + $max = $get('config.max'); + + if ($max !== null && $max !== '' && is_numeric($max)) { + $rules[] = 'lt:'.$max; + } + + return $rules; + } + public function rules(FieldDefinition $field): array { if (! isset($field->config['min'])) { diff --git a/packages/builder/src/FieldTypes/Types/RangeFieldType.php b/packages/builder/src/FieldTypes/Types/RangeFieldType.php index aac86ca892..bce211f88e 100644 --- a/packages/builder/src/FieldTypes/Types/RangeFieldType.php +++ b/packages/builder/src/FieldTypes/Types/RangeFieldType.php @@ -53,13 +53,27 @@ public function castValue(mixed $raw): mixed public function formComponent(FieldDefinition $field): Component { + $defaultValue = app(DefaultValue::class); + $component = Slider::make($field->name) ->label($field->label) ->tooltips(true) ->required(false) ->decimalPlaces($this->decimalPlacesForStep($field)); - return $this->applyCapabilitiesAndValidation($component, $field); + $component = $this->applyCapabilitiesAndValidation($component, $field); + + $component->default(static function (Slider $slider) use ($field, $defaultValue): float|int { + $resolved = $defaultValue->resolveForField($field); + + if ($resolved !== null) { + return $resolved; + } + + return $slider->getMinValue(); + }); + + return $component; } protected function decimalPlacesForStep(FieldDefinition $field): int diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 91feaa13f1..e51fec4ee3 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -551,6 +551,13 @@ protected static function reactiveTypeSettingsSchema(callable $get): array $get('options'); } + if ($type === 'range') { + $get('config.min'); + $get('config.max'); + $get('config.step'); + $get('config.default'); + } + return static::typeSettingsSchema($type); } diff --git a/packages/builder/src/Services/FieldGroupValidator.php b/packages/builder/src/Services/FieldGroupValidator.php index f621ba68a9..3bce1f8e74 100644 --- a/packages/builder/src/Services/FieldGroupValidator.php +++ b/packages/builder/src/Services/FieldGroupValidator.php @@ -6,6 +6,7 @@ use Illuminate\Validation\ValidationException; use Moox\Builder\Data\FieldGroupDefinition; +use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Support\FieldRelationTree; use Moox\Builder\Support\StorableFieldCollector; @@ -25,14 +26,20 @@ public function validate(FieldGroup $group, array $data): void $locationRules = $this->fieldGroupPersistence->resolveLocationRules($data); $entities = $this->fieldGroupPersistence->entitiesFromLocationRules($locationRules); $fieldRows = $data['fields'] ?? []; + $messages = $this->rangeBoundsMessages($fieldRows); if ($entities === [] || $fieldRows === []) { + if ($messages !== []) { + throw ValidationException::withMessages($messages); + } + return; } $entries = $this->storableFieldCollector->pathsFromRows($fieldRows); $messages = array_merge( + $messages, $this->internalDuplicateMessages($entries), $this->externalConflictMessages($group, $entities, $entries), ); @@ -124,4 +131,90 @@ protected function externalConflictMessages(FieldGroup $group, array $entities, return $messages; } + + /** + * @param list> $rows + * @return array> + */ + protected function rangeBoundsMessages(array $rows, string $prefix = 'fields'): array + { + $messages = []; + + foreach ($rows as $index => $row) { + if (! is_array($row)) { + continue; + } + + $basePath = "{$prefix}.{$index}"; + $type = (string) ($row['type'] ?? ''); + + if ($type === 'range' && $this->rangeMaxIsNotGreaterThanMin( + $row['config']['min'] ?? null, + $row['config']['max'] ?? null, + )) { + $messages["{$basePath}.config.max"] = [ + __('builder::builder.validation.range_max_gt_min'), + ]; + } + + if ($type === 'range') { + $default = $row['config']['default'] ?? null; + + if ($default !== null && $default !== '' && is_numeric($default) && ! app(DefaultValue::class)->rangeDefaultIsValid($default + 0, $row['config'] ?? [])) { + $messages["{$basePath}.config.default"] = [ + __('builder::builder.validation.range_default_step'), + ]; + } + } + + if ($type === 'tab') { + $messages = array_merge( + $messages, + $this->rangeBoundsMessages($row['children'] ?? [], "{$basePath}.children"), + ); + + continue; + } + + if ($type === 'flexible_content') { + foreach ($row['layouts'] ?? [] as $layoutIndex => $layout) { + if (! is_array($layout)) { + continue; + } + + $messages = array_merge( + $messages, + $this->rangeBoundsMessages( + $layout['children'] ?? [], + "{$basePath}.layouts.{$layoutIndex}.children", + ), + ); + } + + continue; + } + + if (in_array($type, ['group', 'repeater'], true)) { + $messages = array_merge( + $messages, + $this->rangeBoundsMessages($row['children'] ?? [], "{$basePath}.children"), + ); + } + } + + return $messages; + } + + protected function rangeMaxIsNotGreaterThanMin(mixed $min, mixed $max): bool + { + if ($min === null || $min === '' || $max === null || $max === '') { + return false; + } + + if (! is_numeric($min) || ! is_numeric($max)) { + return false; + } + + return $max + 0 <= $min + 0; + } } From 7012c6af9dcc496ea23d65911e216e489d2a5dc6 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:23:15 +0200 Subject: [PATCH 032/105] fix repeater field --- .../builder/resources/lang/de/builder.php | 2 + .../builder/resources/lang/en/builder.php | 2 + .../builder/src/Compiler/SchemaCompiler.php | 55 +++++++++++++++---- .../FieldTypes/Capabilities/RepeaterItems.php | 50 +++++++++++++---- .../Types/FlexibleContentFieldType.php | 3 +- .../FieldTypes/Types/RepeaterFieldType.php | 11 ++-- .../src/Services/FieldGroupPersistence.php | 53 +++++++++++++++++- 7 files changed, 146 insertions(+), 30 deletions(-) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index ecd8e39dc7..cfbdea4972 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -124,7 +124,9 @@ 'range_min_helper' => 'Muss kleiner als der Höchstwert sein.', 'range_max_helper' => 'Muss größer als der Mindestwert sein.', 'min_items' => 'Mindestanzahl Einträge', + 'min_items_helper' => 'Leer lassen für keine Untergrenze. Nur bei Pflichtfeldern mindestens 1 Eintrag.', 'max_items' => 'Höchstanzahl Einträge', + 'max_items_helper' => 'Leer lassen für keine Obergrenze.', 'step' => 'Schrittweite', 'rows' => 'Zeilen', 'display_format' => 'Anzeigeformat', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index bf43578f18..30f3362dab 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -124,7 +124,9 @@ 'range_min_helper' => 'Must be less than the maximum value.', 'range_max_helper' => 'Must be greater than the minimum value.', 'min_items' => 'Minimum items', + 'min_items_helper' => 'Leave empty for no minimum. Required fields always need at least one item.', 'max_items' => 'Maximum items', + 'max_items_helper' => 'Leave empty for no maximum.', 'step' => 'Step', 'rows' => 'Rows', 'display_format' => 'Display format', diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index facf595160..b0d103a463 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -100,6 +100,7 @@ public function buildGroupComponent(FieldDefinition $field, ?string $entity = nu return $component ->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $entity, $storableFields, $defaultValue): void { + $hasStoredValue = false; $storedValue = null; if ($record?->exists) { @@ -110,11 +111,14 @@ public function buildGroupComponent(FieldDefinition $field, ?string $entity = nu ); if (array_key_exists($field->name, $values)) { + $hasStoredValue = true; $storedValue = (new GroupFieldType)->normalizeForForm($values[$field->name]); } } - $flat = is_array($storedValue ?? $state) ? ($storedValue ?? $state) : []; + $flat = $hasStoredValue + ? (is_array($storedValue) ? $storedValue : []) + : (is_array($state) ? $state : []); if (array_is_list($flat) && isset($flat[0]) && is_array($flat[0])) { $flat = $flat[0]; @@ -337,17 +341,14 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, } if ($fieldType->hasSubFields()) { - $valueToApply = $hasStoredValue ? $storedValue : $state; - - if (is_array($valueToApply) && $valueToApply !== []) { - $this->applyCompoundState( - $component, - $field, - $defaultValue, - $defaultValue->normalizeCompoundState($valueToApply), - force: true, - ); - } + $this->hydrateCompoundFieldState( + $component, + $field, + $defaultValue, + $hasStoredValue, + $storedValue, + $state, + ); return; } @@ -388,6 +389,36 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, }); } + /** + * @param array>|array|null $storedValue + */ + protected function hydrateCompoundFieldState( + Component $component, + FieldDefinition $field, + DefaultValue $defaultValue, + bool $hasStoredValue, + mixed $storedValue, + mixed $state, + ): void { + if ($component instanceof Builder || $component instanceof Repeater) { + $component->hydrateItems(); + } + + $valueToApply = $hasStoredValue ? $storedValue : $state; + + if (! is_array($valueToApply) || $valueToApply === []) { + return; + } + + $this->applyCompoundState( + $component, + $field, + $defaultValue, + $defaultValue->normalizeCompoundState($valueToApply), + force: true, + ); + } + /** * @param array> $state */ diff --git a/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php b/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php index 7f14573001..5d691c5aa3 100644 --- a/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php +++ b/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php @@ -15,14 +15,16 @@ class RepeaterItems extends Capability public function builderFields(): array { return [ - TextInput::make('config.min') + TextInput::make('config.min_items') ->label(__('builder::builder.capabilities.min_items')) + ->helperText(__('builder::builder.capabilities.min_items_helper')) ->numeric() - ->minValue(0), - TextInput::make('config.max') + ->minValue(1), + TextInput::make('config.max_items') ->label(__('builder::builder.capabilities.max_items')) + ->helperText(__('builder::builder.capabilities.max_items_helper')) ->numeric() - ->minValue(0), + ->minValue(1), ]; } @@ -32,17 +34,45 @@ public function apply(Component $component, FieldDefinition $field): Component return $component; } - $min = $field->config['min'] ?? null; - $max = $field->config['max'] ?? null; + $min = $this->resolveMinItems($field); - if (is_numeric($min)) { - $component->minItems((int) $min); + if ($min !== null) { + $component->minItems($min); + } elseif (($field->validation['required'] ?? false) === true) { + $component->minItems(1); } - if (is_numeric($max)) { - $component->maxItems((int) $max); + $max = $this->resolveMaxItems($field); + + if ($max !== null) { + $component->maxItems($max); } return $component; } + + protected function resolveMinItems(FieldDefinition $field): ?int + { + return $this->normalizeItemLimit($field->config['min_items'] ?? null); + } + + protected function resolveMaxItems(FieldDefinition $field): ?int + { + return $this->normalizeItemLimit($field->config['max_items'] ?? null); + } + + protected function normalizeItemLimit(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + if (! is_numeric($value)) { + return null; + } + + $limit = (int) $value; + + return $limit > 0 ? $limit : null; + } } diff --git a/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php b/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php index 70b1854d96..12c2418081 100644 --- a/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php +++ b/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php @@ -134,7 +134,8 @@ protected function insertBuilderBlock( $component->rawState($items); $schemaKey = $newKey ?? (string) array_key_last($items); - $component->getChildSchema($schemaKey)->fill($data); + $component->getChildSchema($schemaKey)->fill(filled($data) ? $data : null); + $component->hydrateItems(); $component->collapsed(false, shouldMakeComponentCollapsible: false); $component->callAfterStateUpdated(); diff --git a/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php b/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php index 0f533f4475..feb2eecc6a 100644 --- a/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php +++ b/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php @@ -36,7 +36,6 @@ public function formComponent(FieldDefinition $field): Component { $compiler = app(SchemaCompiler::class); $defaultValue = app(DefaultValue::class); - $defaultRow = $defaultValue->defaultDataForChildren($field->children); $component = Repeater::make($field->name) ->label($field->label) @@ -45,8 +44,8 @@ public function formComponent(FieldDefinition $field): Component ->defaultItems(0) ->reorderable() ->addAction(fn (Action $action): Action => $action->action( - function (Repeater $component) use ($defaultRow, $defaultValue, $field): void { - $data = $defaultValue->mergeIntoData($field->children, $defaultRow); + function (Repeater $component) use ($defaultValue, $field): void { + $data = $defaultValue->mergeIntoData($field->children, []); $newUuid = $component->generateUuid(); $items = $component->getRawState() ?? []; @@ -60,13 +59,14 @@ function (Repeater $component) use ($defaultRow, $defaultValue, $field): void { $component->rawState($items); $component->getChildSchema($newUuid)->fill($data); + $component->hydrateItems(); $component->collapsed(false, shouldMakeComponentCollapsible: false); $component->callAfterStateUpdated(); }, )) ->addBetweenAction(fn (Action $action): Action => $action->action( - function (array $arguments, Repeater $component) use ($defaultRow, $defaultValue, $field): void { - $data = $defaultValue->mergeIntoData($field->children, $defaultRow); + function (array $arguments, Repeater $component) use ($defaultValue, $field): void { + $data = $defaultValue->mergeIntoData($field->children, []); $newKey = $component->generateUuid(); $items = []; @@ -85,6 +85,7 @@ function (array $arguments, Repeater $component) use ($defaultRow, $defaultValue $component->rawState($items); $component->getChildSchema($newKey)->fill($data); + $component->hydrateItems(); $component->collapsed(false, shouldMakeComponentCollapsible: false); $component->callAfterStateUpdated(); }, diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 8247925a40..5e750458e9 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -7,10 +7,10 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldOption; -use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\FieldRelationTree; class FieldGroupPersistence @@ -239,7 +239,7 @@ protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldI 'name' => (string) $row['name'], 'label' => (string) ($row['label'] ?? $row['name']), 'type' => (string) $row['type'], - 'config' => $row['config'] ?? [], + 'config' => $this->filterConfigForType((string) $row['type'], $row['config'] ?? []), 'validation' => [ 'required' => (bool) ($row['required'] ?? false), 'rules' => $row['validation']['rules'] ?? [], @@ -386,4 +386,53 @@ protected function resolveOptionForSync(Collection $existing, array $row): Field return new FieldOption; } + + /** + * @param array $config + * @return array + */ + protected function filterConfigForType(string $type, array $config): array + { + if ($config === []) { + return []; + } + + $allowed = $this->allowedConfigKeysForType($type); + + if ($allowed === []) { + return []; + } + + return Arr::only($config, $allowed); + } + + /** + * @return list + */ + protected function allowedConfigKeysForType(string $type): array + { + try { + $fieldType = app(FieldTypeRegistry::class)->get($type); + } catch (\Throwable) { + return []; + } + + $keys = []; + + foreach ($fieldType->capabilities() as $capabilityClass) { + $capability = app($capabilityClass); + + foreach ($capability->builderFieldsFor($type) as $component) { + $name = $component->getName(); + + if (! is_string($name) || ! str_starts_with($name, 'config.')) { + continue; + } + + $keys[] = substr($name, strlen('config.')); + } + } + + return array_values(array_unique($keys)); + } } From ac03fb05444c664ffd956046e1e47a450546db39 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:24:16 +0000 Subject: [PATCH 033/105] Fix styling --- packages/builder/src/Services/FieldGroupPersistence.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 5e750458e9..3a9a90023b 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -7,10 +7,10 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; -use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldOption; +use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\FieldRelationTree; class FieldGroupPersistence From 041cf44f5672cda998544a82dee4f3b5550a3193 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:32:17 +0200 Subject: [PATCH 034/105] fix rich text field --- .../FieldTypes/Capabilities/DefaultValue.php | 5 + .../FieldTypes/Types/RichTextFieldType.php | 32 ++++++ .../src/Services/FieldValueValidator.php | 5 + .../builder/src/Support/RichTextValue.php | 107 ++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 packages/builder/src/Support/RichTextValue.php diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index 43f58cdc9e..4e79b7b50d 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -19,6 +19,7 @@ use Illuminate\Support\Collection; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Support\RichTextValue; class DefaultValue extends Capability { @@ -247,6 +248,10 @@ public function shouldApplyDefault(mixed $state, string $type): bool return $state === []; } + if ($type === 'rich_text') { + return RichTextValue::isEmpty($state); + } + return $state === null || $state === ''; } diff --git a/packages/builder/src/FieldTypes/Types/RichTextFieldType.php b/packages/builder/src/FieldTypes/Types/RichTextFieldType.php index 5662d0399d..c3cdf1f521 100644 --- a/packages/builder/src/FieldTypes/Types/RichTextFieldType.php +++ b/packages/builder/src/FieldTypes/Types/RichTextFieldType.php @@ -4,13 +4,16 @@ namespace Moox\Builder\FieldTypes\Types; +use Closure; use Filament\Forms\Components\RichEditor; use Filament\Schemas\Components\Component; use Moox\Builder\Data\FieldDefinition; +use Moox\Builder\FieldTypes\Capabilities\Capability; use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\FieldTypes\Capabilities\HelperText; use Moox\Builder\FieldTypes\Capabilities\MaxLength; use Moox\Builder\FieldTypes\FieldType; +use Moox\Builder\Support\RichTextValue; class RichTextFieldType extends FieldType { @@ -35,4 +38,33 @@ public function formComponent(FieldDefinition $field): Component return $this->applyCapabilitiesAndValidation($component, $field); } + + protected function applyCapabilitiesAndValidation(Component $component, FieldDefinition $field): Component + { + $component = Capability::applyAll($this->capabilities(), $component, $field); + + $rules = []; + + foreach ($this->capabilities() as $capabilityClass) { + $rules = array_merge($rules, app($capabilityClass)->rules($field)); + } + + $rules = array_merge($rules, $this->additionalRules($field)); + + if ($rules !== []) { + $component->rules($rules); + } + + if (($field->validation['required'] ?? false) === true) { + $component->rules([ + fn (): Closure => function (string $attribute, mixed $value, Closure $fail) use ($field): void { + if (RichTextValue::isEmpty($value)) { + $fail(__('validation.required', ['attribute' => $field->label])); + } + }, + ]); + } + + return $component; + } } diff --git a/packages/builder/src/Services/FieldValueValidator.php b/packages/builder/src/Services/FieldValueValidator.php index af4fa7136b..2bce906c29 100644 --- a/packages/builder/src/Services/FieldValueValidator.php +++ b/packages/builder/src/Services/FieldValueValidator.php @@ -9,6 +9,7 @@ use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\OptionValueRules; +use Moox\Builder\Support\RichTextValue; class FieldValueValidator { @@ -268,6 +269,10 @@ protected function isRowEmpty(FieldDefinition $parent, array $row): bool protected function isEmptyValue(string $type, mixed $value, bool $required = false): bool { + if ($type === 'rich_text') { + return RichTextValue::isEmpty($value); + } + if ($type === 'toggle') { return $value === null || $value === false; } diff --git a/packages/builder/src/Support/RichTextValue.php b/packages/builder/src/Support/RichTextValue.php new file mode 100644 index 0000000000..264fc8225b --- /dev/null +++ b/packages/builder/src/Support/RichTextValue.php @@ -0,0 +1,107 @@ + $document + */ + protected static function isEmptyDocument(array $document): bool + { + if (($document['type'] ?? null) !== 'doc') { + return blank($document); + } + + $content = $document['content'] ?? []; + + if ($content === []) { + return true; + } + + foreach ($content as $node) { + if (! is_array($node) || ! self::isEmptyJsonNode($node)) { + return false; + } + } + + return true; + } + + /** + * @param array|null $node + */ + protected static function isEmptyJsonNode(?array $node): bool + { + if ($node === null) { + return true; + } + + $type = $node['type'] ?? null; + + if ($type === 'paragraph' || $type === 'heading') { + $inline = $node['content'] ?? []; + + if ($inline === []) { + return true; + } + + foreach ($inline as $child) { + if (! is_array($child) || ! self::isEmptyInlineNode($child)) { + return false; + } + } + + return true; + } + + return false; + } + + /** + * @param array $node + */ + protected static function isEmptyInlineNode(array $node): bool + { + $type = $node['type'] ?? null; + + if ($type === 'hardBreak') { + return true; + } + + if ($type === 'text') { + return trim(str_replace("\xc2\xa0", ' ', (string) ($node['text'] ?? ''))) === ''; + } + + return false; + } + + protected static function isEmptyHtml(string $value): bool + { + $normalized = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $normalized = preg_replace('//i', '', $normalized) ?? $normalized; + $text = strip_tags($normalized); + $text = str_replace(["\xc2\xa0", ' '], ' ', $text); + + return trim($text) === ''; + } +} From 194e8e92cf79c86691fb6451de2ad4db763d1987 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:50:00 +0200 Subject: [PATCH 035/105] fix select field --- .../src/FieldTypes/Types/SelectFieldType.php | 16 ++- .../src/Resources/FieldGroupResource.php | 103 ++++++------------ 2 files changed, 48 insertions(+), 71 deletions(-) diff --git a/packages/builder/src/FieldTypes/Types/SelectFieldType.php b/packages/builder/src/FieldTypes/Types/SelectFieldType.php index 07f5a8bfaf..62d3c6854d 100644 --- a/packages/builder/src/FieldTypes/Types/SelectFieldType.php +++ b/packages/builder/src/FieldTypes/Types/SelectFieldType.php @@ -36,11 +36,23 @@ public function capabilities(): array public function formComponent(FieldDefinition $field): Component { + $defaultValue = app(DefaultValue::class); + $component = Select::make($field->name) - ->label($field->label); + ->label($field->label) + ->native(false); $component = $this->applyOptions($component, $field); + $component = $this->applyCapabilitiesAndValidation($component, $field); + + $component->default(static function () use ($field, $defaultValue): ?string { + return $defaultValue->resolveForField($field); + }); + + if ($defaultValue->resolveForField($field) !== null) { + $component->selectablePlaceholder(false); + } - return $this->applyCapabilitiesAndValidation($component, $field); + return $component; } } diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index e51fec4ee3..2499543fe3 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -170,31 +170,7 @@ public static function form(Schema $schema): Schema ->inline(false) ->live() ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), - Section::make(__('builder::builder.field.settings')) - ->collapsible() - ->schema(fn (callable $get): array => static::reactiveTypeSettingsSchema($get)) - ->visible(fn (callable $get): bool => static::typeHasSettings($get('type'))), - Section::make(__('builder::builder.field.options')) - ->collapsed() - ->schema([ - Repeater::make('options') - ->label(__('builder::builder.field.options')) - ->orderColumn('sort') - ->reorderable() - ->schema([ - Hidden::make('id'), - TextInput::make('label') - ->label(__('builder::builder.field.option_label')) - ->required(), - TextInput::make('value') - ->label(__('builder::builder.field.option_value')) - ->required() - ->live(onBlur: true), - ]) - ->columns(2) - ->defaultItems(1), - ]) - ->visible(fn (callable $get): bool => filled($get('type')) && $registry->get($get('type'))->hasOptions()), + ...static::optionFieldSections($registry), Section::make(fn (callable $get): string => $get('type') === 'tab' ? __('builder::builder.field.tab_content') : __('builder::builder.field.subfields')) @@ -391,31 +367,7 @@ protected static function tabChildFieldSchema(FieldTypeRegistry $registry): arra ->inline(false) ->live() ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), - Section::make(__('builder::builder.field.settings')) - ->collapsible() - ->schema(fn (callable $get): array => static::reactiveTypeSettingsSchema($get)) - ->visible(fn (callable $get): bool => static::typeHasSettings($get('type'))), - Section::make(__('builder::builder.field.options')) - ->collapsed() - ->schema([ - Repeater::make('options') - ->label(__('builder::builder.field.options')) - ->orderColumn('sort') - ->reorderable() - ->schema([ - Hidden::make('id'), - TextInput::make('label') - ->label(__('builder::builder.field.option_label')) - ->required(), - TextInput::make('value') - ->label(__('builder::builder.field.option_value')) - ->required() - ->live(onBlur: true), - ]) - ->columns(2) - ->defaultItems(1), - ]) - ->visible(fn (callable $get): bool => filled($get('type')) && $registry->get($get('type'))->hasOptions()), + ...static::optionFieldSections($registry), Section::make(__('builder::builder.field.subfields')) ->collapsed() ->schema([ @@ -486,6 +438,34 @@ protected static function subFieldSchema(FieldTypeRegistry $registry): array ->inline(false) ->live() ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), + ...static::optionFieldSections($registry), + ]; + } + + protected static function typeHasSettings(?string $type): bool + { + return static::typeSettingsSchema($type) !== []; + } + + protected static function fieldTypeSupportsRequired(?string $type): bool + { + if (blank($type)) { + return false; + } + + try { + return app(FieldTypeRegistry::class)->get($type)->storesValue(); + } catch (UnknownFieldTypeException) { + return false; + } + } + + /** + * @return list + */ + protected static function optionFieldSections(FieldTypeRegistry $registry): array + { + return [ Section::make(__('builder::builder.field.settings')) ->collapsible() ->schema(fn (callable $get): array => static::reactiveTypeSettingsSchema($get)) @@ -497,11 +477,13 @@ protected static function subFieldSchema(FieldTypeRegistry $registry): array ->label(__('builder::builder.field.options')) ->orderColumn('sort') ->reorderable() + ->live() ->schema([ Hidden::make('id'), TextInput::make('label') ->label(__('builder::builder.field.option_label')) - ->required(), + ->required() + ->live(onBlur: true), TextInput::make('value') ->label(__('builder::builder.field.option_value')) ->required() @@ -514,24 +496,6 @@ protected static function subFieldSchema(FieldTypeRegistry $registry): array ]; } - protected static function typeHasSettings(?string $type): bool - { - return static::typeSettingsSchema($type) !== []; - } - - protected static function fieldTypeSupportsRequired(?string $type): bool - { - if (blank($type)) { - return false; - } - - try { - return app(FieldTypeRegistry::class)->get($type)->storesValue(); - } catch (UnknownFieldTypeException) { - return false; - } - } - /** * @return list */ @@ -549,6 +513,7 @@ protected static function reactiveTypeSettingsSchema(callable $get): array if (in_array($type, ['select', 'radio', 'button_group', 'multiselect', 'checkbox_list'], true)) { $get('options'); + $get('config.default'); } if ($type === 'range') { From 6c3a6f45aaba42c1750fba3f26bc5b2873e56139 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:15:24 +0200 Subject: [PATCH 036/105] fix tab field --- .../builder/src/Compiler/SchemaCompiler.php | 101 ++++++++++++++++-- .../FieldTypes/Capabilities/DefaultValue.php | 30 +++++- .../FieldTypes/Types/ButtonGroupFieldType.php | 7 +- .../src/FieldTypes/Types/ColorFieldType.php | 24 ++++- .../src/Resources/FieldGroupResource.php | 4 + 5 files changed, 153 insertions(+), 13 deletions(-) diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index b0d103a463..7edc6bf920 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -60,8 +60,20 @@ public function compile(Collection $fieldGroups, ?string $resourceClass = null): continue; } - $sections[] = Section::make($group->name) + $groupScalarFields = $this->storableFieldCollector->definitionsFromList($group->fields); + + $section = Section::make($group->name) ->schema($components); + + if ($entity !== null) { + $section = $section->afterStateHydrated( + function (Component $component, mixed $state, ?Model $record) use ($groupScalarFields, $entity, $storableFields): void { + $this->applyScalarFieldDefaults($component, $groupScalarFields, $entity, $record, $storableFields); + }, + ); + } + + $sections[] = $section; } return $sections; @@ -71,14 +83,14 @@ public function compile(Collection $fieldGroups, ?string $resourceClass = null): * @param Collection $fields * @return list */ - public function compileSubFields(Collection $fields, ?string $entity = null, ?Collection $storableFields = null): array + public function compileSubFields(Collection $fields, ?string $entity = null, ?Collection $storableFields = null, bool $insideTabs = false): array { $storableFields ??= collect(); return $fields ->sortBy(fn (FieldDefinition $field): int => $field->sort) ->values() - ->map(fn (FieldDefinition $field): Component => $this->compileField($field, $entity, $storableFields)) + ->map(fn (FieldDefinition $field): Component => $this->compileField($field, $entity, $storableFields, $insideTabs)) ->all(); } @@ -176,11 +188,25 @@ protected function compileRootFields(Collection $fields, ?string $entity, Collec } if ($tabPanels !== []) { - $components[] = Tabs::make('custom_fields_tabs') + $tabScalarFields = collect($tabPanels) + ->flatMap(fn (array $panel): Collection => $this->storableFieldCollector->definitionsFromList($panel['fields'])) + ->values(); + + $tabs = Tabs::make('custom_fields_tabs') ->tabs(collect($tabPanels)->map( fn (array $panel): Tab => Tab::make($panel['label']) - ->schema($this->compileSubFields($panel['fields'], $entity, $storableFields)), + ->schema($this->compileSubFields($panel['fields'], $entity, $storableFields, insideTabs: true)), )->all()); + + if ($entity !== null) { + $tabs = $tabs->afterStateHydrated( + function (Component $component, mixed $state, ?Model $record) use ($tabScalarFields, $entity, $storableFields): void { + $this->applyScalarFieldDefaults($component, $tabScalarFields, $entity, $record, $storableFields); + }, + ); + } + + $components[] = $tabs; } continue; @@ -301,7 +327,7 @@ protected function rulesForField(FieldDefinition $field): array /** * @param Collection $storableFields */ - protected function compileField(FieldDefinition $field, ?string $entity = null, ?Collection $storableFields = null): Component + protected function compileField(FieldDefinition $field, ?string $entity = null, ?Collection $storableFields = null, bool $insideTabs = false): Component { $storableFields ??= collect(); @@ -312,6 +338,10 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, $fieldType = $this->fieldTypeRegistry->get($field->type); $component = $fieldType->formComponent($field); + if ($insideTabs && $fieldType->storesValue()) { + $component->dehydratedWhenHidden(true); + } + if (! $fieldType->storesValue() || $entity === null) { return $component; } @@ -354,7 +384,11 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, } if ($hasStoredValue) { - $component->state($storedValue); + $component->state( + $field->type === 'color' + ? ($defaultValue->normalizeColorValue($storedValue) ?? $storedValue) + : $storedValue, + ); return; } @@ -441,4 +475,57 @@ protected function applyCompoundState( $component->hydrateItems(); } } + + /** + * @param Collection $fields + * @param Collection $storableFields + */ + protected function applyScalarFieldDefaults( + Component $context, + Collection $fields, + string $entity, + ?Model $record, + Collection $storableFields, + ): void { + $defaultValue = app(DefaultValue::class); + $stored = $record?->exists + ? $this->customFieldsManager->loadCachedValues($entity, $record, $storableFields) + : []; + + $root = $context->getRootContainer(); + + foreach ($fields as $field) { + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if (! $fieldType->storesValue() || $fieldType->hasSubFields()) { + continue; + } + + if (array_key_exists($field->name, $stored) && ! $defaultValue->shouldApplyDefault($stored[$field->name], $field->type)) { + continue; + } + + $resolved = $defaultValue->resolveForField($field); + + if ($resolved === null) { + continue; + } + + $fieldComponent = $root->getComponent($field->name); + + if ($fieldComponent === null) { + continue; + } + + if (! $defaultValue->shouldApplyDefault($fieldComponent->getState(), $field->type)) { + continue; + } + + $fieldComponent->state($resolved); + + if (method_exists($fieldComponent, 'partiallyRender')) { + $fieldComponent->partiallyRender(); + } + } + } } diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index 4e79b7b50d..98b6b530fc 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -42,9 +42,7 @@ public function builderFieldsFor(string $fieldType): array ->default(false), ], 'color' => [ - ColorPicker::make('config.default') - ->label(__('builder::builder.capabilities.default_value')) - ->hex(), + $this->colorDefaultField(), ], 'date' => $this->temporalDefaultFields( DatePicker::make('config.default') @@ -212,6 +210,32 @@ protected function normalizeColorDefault(mixed $default): ?string return $color; } + public function normalizeColorValue(mixed $value): ?string + { + return $this->normalizeColorDefault($value); + } + + protected function colorDefaultField(): ColorPicker + { + return ColorPicker::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->live() + ->afterStateHydrated(function (ColorPicker $component, mixed $state): void { + $normalized = $this->normalizeColorValue($state); + + if ($normalized !== null && $normalized !== $state) { + $component->state($normalized); + } + }) + ->afterStateUpdated(function (ColorPicker $component, mixed $state): void { + $normalized = $this->normalizeColorValue($state); + + if ($normalized !== null && $normalized !== $state) { + $component->state($normalized); + } + }); + } + public function shouldApplyDefault(mixed $state, string $type): bool { if ($type === 'toggle') { diff --git a/packages/builder/src/FieldTypes/Types/ButtonGroupFieldType.php b/packages/builder/src/FieldTypes/Types/ButtonGroupFieldType.php index 7ea7138db8..50aa13c795 100644 --- a/packages/builder/src/FieldTypes/Types/ButtonGroupFieldType.php +++ b/packages/builder/src/FieldTypes/Types/ButtonGroupFieldType.php @@ -43,12 +43,17 @@ public function castValue(mixed $raw): mixed public function formComponent(FieldDefinition $field): Component { + $defaultValue = app(DefaultValue::class); + $component = ToggleButtons::make($field->name) ->label($field->label) ->inline(); $component = $this->applyOptions($component, $field); + $component = $this->applyCapabilitiesAndValidation($component, $field); - return $this->applyCapabilitiesAndValidation($component, $field); + return $component->default(static function () use ($field, $defaultValue): ?string { + return $defaultValue->resolveForField($field); + }); } } diff --git a/packages/builder/src/FieldTypes/Types/ColorFieldType.php b/packages/builder/src/FieldTypes/Types/ColorFieldType.php index e8143b15de..9e723fc20c 100644 --- a/packages/builder/src/FieldTypes/Types/ColorFieldType.php +++ b/packages/builder/src/FieldTypes/Types/ColorFieldType.php @@ -28,10 +28,30 @@ public function capabilities(): array public function formComponent(FieldDefinition $field): Component { + $defaultValue = app(DefaultValue::class); + $component = ColorPicker::make($field->name) ->label($field->label) - ->hex(); + ->live() + ->afterStateHydrated(function (ColorPicker $component, mixed $state) use ($defaultValue): void { + $normalized = $defaultValue->normalizeColorValue($state); + + if ($normalized !== null && $normalized !== $state) { + $component->state($normalized); + } + }) + ->afterStateUpdated(function (ColorPicker $component, mixed $state) use ($defaultValue): void { + $normalized = $defaultValue->normalizeColorValue($state); + + if ($normalized !== null && $normalized !== $state) { + $component->state($normalized); + } + }); + + $component = $this->applyCapabilitiesAndValidation($component, $field); - return $this->applyCapabilitiesAndValidation($component, $field); + return $component->default(static function () use ($field, $defaultValue): ?string { + return $defaultValue->resolveForField($field); + }); } } diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 2499543fe3..84f5450c56 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -523,6 +523,10 @@ protected static function reactiveTypeSettingsSchema(callable $get): array $get('config.default'); } + if ($type === 'color') { + $get('config.default'); + } + return static::typeSettingsSchema($type); } From 6599d32b0dd20fff77a0e84e0d7ef56935f4526d Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:16:56 +0200 Subject: [PATCH 037/105] fix time field --- .../builder/resources/lang/de/builder.php | 5 ++ .../builder/resources/lang/en/builder.php | 5 ++ .../builder/src/Compiler/SchemaCompiler.php | 20 ++++-- .../FieldTypes/Capabilities/DefaultValue.php | 69 +++++++++++++++---- .../FieldTypes/Capabilities/DisplayFormat.php | 45 +++++++++++- packages/builder/src/FieldTypes/FieldType.php | 2 +- .../FieldTypes/Types/ButtonGroupFieldType.php | 2 +- .../Types/CheckboxListFieldType.php | 2 +- .../Types/FlexibleContentFieldType.php | 2 +- .../src/FieldTypes/Types/GroupFieldType.php | 2 +- .../src/FieldTypes/Types/LinkFieldType.php | 2 +- .../FieldTypes/Types/MultiselectFieldType.php | 2 +- .../src/FieldTypes/Types/NumberFieldType.php | 2 +- .../FieldTypes/Types/PasswordFieldType.php | 2 +- .../src/FieldTypes/Types/RangeFieldType.php | 2 +- .../FieldTypes/Types/RepeaterFieldType.php | 2 +- .../src/FieldTypes/Types/TimeFieldType.php | 40 ++++++++++- .../src/FieldTypes/Types/ToggleFieldType.php | 2 +- .../src/Resources/FieldGroupResource.php | 6 +- .../src/Services/CustomFieldsManager.php | 4 +- .../src/Services/FieldGroupPersistence.php | 9 ++- 21 files changed, 186 insertions(+), 41 deletions(-) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index cfbdea4972..49b25cd257 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -118,6 +118,7 @@ 'default_value_now_date_helper' => 'Beim Anlegen wird automatisch das heutige Datum gesetzt.', 'default_value_now_datetime_helper' => 'Beim Anlegen wird automatisch der aktuelle Zeitpunkt gesetzt.', 'default_value_now_time_helper' => 'Beim Anlegen wird automatisch die aktuelle Uhrzeit gesetzt.', + 'default_value_now_active_helper' => 'Deaktiviert, solange die aktuelle Uhrzeit aktiv ist.', 'helper_text' => 'Hilfetext', 'min_value' => 'Mindestwert', 'max_value' => 'Höchstwert', @@ -131,6 +132,7 @@ 'rows' => 'Zeilen', 'display_format' => 'Anzeigeformat', 'display_format_helper' => 'So wird das Datum im Formular angezeigt.', + 'display_format_time_helper' => 'So wird die Uhrzeit im Formular angezeigt.', 'display_format_dmy' => '31.12.2026', 'display_format_dmy_slash' => '31/12/2026', 'display_format_ymd' => '2026-12-31', @@ -139,6 +141,9 @@ 'display_format_dmy_his' => '31.12.2026 14:30:00', 'display_format_ymd_hi' => '2026-12-31 14:30', 'display_format_ymd_his' => '2026-12-31 14:30:00', + 'display_format_hi' => '14:30', + 'display_format_his' => '14:30:00', + 'display_format_gi_a' => '2:30 PM', ], 'repeater' => [ diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 30f3362dab..5e17a7ad62 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -118,6 +118,7 @@ 'default_value_now_date_helper' => 'Automatically uses today’s date when creating a record.', 'default_value_now_datetime_helper' => 'Automatically uses the current date and time when creating a record.', 'default_value_now_time_helper' => 'Automatically uses the current time when creating a record.', + 'default_value_now_active_helper' => 'Disabled while current time is active.', 'helper_text' => 'Helper text', 'min_value' => 'Minimum value', 'max_value' => 'Maximum value', @@ -131,6 +132,7 @@ 'rows' => 'Rows', 'display_format' => 'Display format', 'display_format_helper' => 'How the date is shown in the form.', + 'display_format_time_helper' => 'How the time is shown in the form.', 'display_format_dmy' => '31 Dec 2026', 'display_format_dmy_slash' => '31/12/2026', 'display_format_ymd' => '2026-12-31', @@ -139,6 +141,9 @@ 'display_format_dmy_his' => '31.12.2026 14:30:00', 'display_format_ymd_hi' => '2026-12-31 14:30', 'display_format_ymd_his' => '2026-12-31 14:30:00', + 'display_format_hi' => '14:30', + 'display_format_his' => '14:30:00', + 'display_format_gi_a' => '2:30 PM', ], 'repeater' => [ diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 7edc6bf920..fa809c6e3e 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -384,11 +384,11 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, } if ($hasStoredValue) { - $component->state( - $field->type === 'color' - ? ($defaultValue->normalizeColorValue($storedValue) ?? $storedValue) - : $storedValue, - ); + $component->state(match ($field->type) { + 'color' => $defaultValue->normalizeColorValue($storedValue) ?? $storedValue, + 'time' => $defaultValue->normalizeTimeValue($storedValue) ?? $storedValue, + default => $storedValue, + }); return; } @@ -396,7 +396,11 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, $default = $defaultValue->resolveForField($field); if ($default !== null) { - $component->state($default); + $component->state( + $field->type === 'time' + ? ($defaultValue->normalizeTimeValue($default) ?? $default) + : $default, + ); } }) ->afterStateUpdated(function (Component $component) use ($field, $fieldType, $defaultValue): void { @@ -511,6 +515,10 @@ protected function applyScalarFieldDefaults( continue; } + if ($field->type === 'time') { + $resolved = $defaultValue->normalizeTimeValue($resolved) ?? $resolved; + } + $fieldComponent = $root->getComponent($field->name); if ($fieldComponent === null) { diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index 98b6b530fc..140b8da6f2 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -59,8 +59,7 @@ public function builderFieldsFor(string $fieldType): array 'time' => $this->temporalDefaultFields( TimePicker::make('config.default') ->label(__('builder::builder.capabilities.default_value')) - ->native(false) - ->seconds(false), + ->native(true), 'time', ), 'select', 'radio', 'button_group' => [ @@ -215,6 +214,29 @@ public function normalizeColorValue(mixed $value): ?string return $this->normalizeColorDefault($value); } + public function normalizeTimeValue(mixed $value): ?CarbonInterface + { + if ($value === null || $value === '') { + return null; + } + + if ($value instanceof CarbonInterface) { + return Carbon::today()->setTimeFromTimeString($value->format('H:i:s')); + } + + if (! is_string($value)) { + return null; + } + + try { + $parsed = Carbon::parse($value, config('app.timezone')); + + return Carbon::today()->setTime($parsed->hour, $parsed->minute, $parsed->second); + } catch (\Throwable) { + return null; + } + } + protected function colorDefaultField(): ColorPicker { return ColorPicker::make('config.default') @@ -533,7 +555,7 @@ protected function resolveBooleanDefault(mixed $default): bool */ protected function temporalDefaultFields(Component $picker, string $type): array { - if (in_array($type, ['date', 'datetime'], true) && $picker instanceof DateTimePicker) { + if ($picker instanceof DateTimePicker && in_array($type, ['date', 'datetime', 'time'], true)) { $picker = $this->configureTemporalAdminPicker($picker, $type); } @@ -546,13 +568,34 @@ protected function temporalDefaultFields(Component $picker, string $type): array default => __('builder::builder.capabilities.default_value_now_time_helper'), }) ->inline(false) + ->default(false) ->live(), - $picker->hidden(fn (Get $get): bool => (bool) $get('config.defaultNow')), + $picker->hidden(fn (Get $get): bool => $this->defaultNowEnabled($get)), ]; } + protected function defaultNowEnabled(Get $get): bool + { + return filter_var($get('config.defaultNow') ?? false, FILTER_VALIDATE_BOOLEAN); + } + protected function configureTemporalAdminPicker(DateTimePicker $picker, string $type): DateTimePicker { + if ($type === 'time') { + return $picker + ->native(true) + ->seconds( + fn (Get $get): bool => str_contains($this->resolvedDisplayFormat($get, $type), ':s'), + ) + ->afterStateHydrated(function (DateTimePicker $component, mixed $state): void { + $normalized = $this->normalizeTimeValue($state); + + if ($normalized !== null && $normalized !== $state) { + $component->state($normalized); + } + }); + } + $picker = $picker ->displayFormat(fn (Get $get): string => $this->resolvedDisplayFormat($get, $type)) ->format(fn (Get $get): string => $this->resolvedStorageFormat($get, $type)) @@ -581,6 +624,10 @@ protected function resolvedDisplayFormat(Get $get, string $type): string protected function resolvedStorageFormat(Get $get, string $type): string { + if ($type === 'time') { + return DisplayFormat::storageFormatForTime($this->resolvedDisplayFormat($get, $type)); + } + if ($type === 'datetime') { return str_contains($this->resolvedDisplayFormat($get, $type), 'H:i:s') ? 'Y-m-d H:i:s' @@ -596,7 +643,7 @@ protected function resolveTemporalDefault(FieldDefinition $field): CarbonInterfa return match ($field->type) { 'date' => now()->startOfDay(), 'datetime' => now(), - 'time' => now()->format('H:i'), + 'time' => now(), default => null, }; } @@ -616,20 +663,18 @@ protected function resolveTemporalDefault(FieldDefinition $field): CarbonInterfa protected function parseTemporalDefault(string $type, mixed $default): CarbonInterface|string|null { + if ($type === 'time') { + return $this->normalizeTimeValue($default); + } + if ($default instanceof CarbonInterface) { - return $type === 'time' - ? $default->format('H:i') - : $default; + return $default; } if (! is_string($default)) { return null; } - if ($type === 'time') { - return $default; - } - try { return Carbon::parse($default); } catch (\Throwable) { diff --git a/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php b/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php index 4e54c4fba2..4ca93df697 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php +++ b/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php @@ -18,12 +18,22 @@ public function builderFields(): array public function builderFieldsFor(string $fieldType): array { + $defaultFormat = self::defaultFor($fieldType); + return [ Select::make('config.displayFormat') ->label(__('builder::builder.capabilities.display_format')) - ->helperText(__('builder::builder.capabilities.display_format_helper')) + ->helperText(match ($fieldType) { + 'time' => __('builder::builder.capabilities.display_format_time_helper'), + default => __('builder::builder.capabilities.display_format_helper'), + }) ->options($this->formatOptionsFor($fieldType)) - ->default(self::defaultFor($fieldType)) + ->default($defaultFormat) + ->afterStateHydrated(function (Select $component, mixed $state) use ($defaultFormat): void { + if (blank($state)) { + $component->state($defaultFormat); + } + }) ->live() ->native(false), ]; @@ -34,6 +44,18 @@ public function apply(Component $component, FieldDefinition $field): Component if ($component instanceof DateTimePicker) { $format = self::resolveForField($field); + if ($field->type === 'time') { + $withSeconds = str_contains($format, ':s'); + $component->seconds($withSeconds); + + if (! $component->isNative()) { + $component->displayFormat($format); + $component->format(self::storageFormatForTime($format)); + } + + return $component; + } + $component->displayFormat($format); if ($field->type === 'datetime') { @@ -54,10 +76,16 @@ public static function defaultFor(string $fieldType): string { return match ($fieldType) { 'datetime' => 'd.m.Y H:i', + 'time' => 'H:i', default => 'd.m.Y', }; } + public static function storageFormatForTime(string $displayFormat): string + { + return str_contains($displayFormat, ':s') ? 'H:i:s' : 'H:i'; + } + public static function resolveForField(FieldDefinition $field): string { if (filled($field->config['displayFormat'] ?? null)) { @@ -74,10 +102,23 @@ protected function formatOptionsFor(string $fieldType): array { return match ($fieldType) { 'datetime' => $this->datetimeFormatOptions(), + 'time' => $this->timeFormatOptions(), default => $this->dateFormatOptions(), }; } + /** + * @return array + */ + protected function timeFormatOptions(): array + { + return [ + 'H:i' => __('builder::builder.capabilities.display_format_hi'), + 'H:i:s' => __('builder::builder.capabilities.display_format_his'), + 'g:i A' => __('builder::builder.capabilities.display_format_gi_a'), + ]; + } + /** * @return array */ diff --git a/packages/builder/src/FieldTypes/FieldType.php b/packages/builder/src/FieldTypes/FieldType.php index e04208a051..2d52231e11 100644 --- a/packages/builder/src/FieldTypes/FieldType.php +++ b/packages/builder/src/FieldTypes/FieldType.php @@ -23,7 +23,7 @@ public function capabilities(): array return []; } - public function castValue(mixed $raw): mixed + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed { return $raw; } diff --git a/packages/builder/src/FieldTypes/Types/ButtonGroupFieldType.php b/packages/builder/src/FieldTypes/Types/ButtonGroupFieldType.php index 50aa13c795..3c01904ac9 100644 --- a/packages/builder/src/FieldTypes/Types/ButtonGroupFieldType.php +++ b/packages/builder/src/FieldTypes/Types/ButtonGroupFieldType.php @@ -32,7 +32,7 @@ public function capabilities(): array ]; } - public function castValue(mixed $raw): mixed + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed { if ($raw === null || $raw === '') { return null; diff --git a/packages/builder/src/FieldTypes/Types/CheckboxListFieldType.php b/packages/builder/src/FieldTypes/Types/CheckboxListFieldType.php index 33dcff7345..7264ad2ce6 100644 --- a/packages/builder/src/FieldTypes/Types/CheckboxListFieldType.php +++ b/packages/builder/src/FieldTypes/Types/CheckboxListFieldType.php @@ -34,7 +34,7 @@ public function capabilities(): array ]; } - public function castValue(mixed $raw): mixed + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed { if ($raw === null) { return []; diff --git a/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php b/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php index 12c2418081..f1d7ca3a91 100644 --- a/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php +++ b/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php @@ -141,7 +141,7 @@ protected function insertBuilderBlock( $component->callAfterStateUpdated(); } - public function castValue(mixed $raw): mixed + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed { if (! is_array($raw)) { return []; diff --git a/packages/builder/src/FieldTypes/Types/GroupFieldType.php b/packages/builder/src/FieldTypes/Types/GroupFieldType.php index bc4fbdbd0a..67e0466b10 100644 --- a/packages/builder/src/FieldTypes/Types/GroupFieldType.php +++ b/packages/builder/src/FieldTypes/Types/GroupFieldType.php @@ -26,7 +26,7 @@ public function formComponent(FieldDefinition $field): Component return app(SchemaCompiler::class)->buildGroupComponent($field); } - public function castValue(mixed $raw): mixed + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed { if (! is_array($raw)) { return []; diff --git a/packages/builder/src/FieldTypes/Types/LinkFieldType.php b/packages/builder/src/FieldTypes/Types/LinkFieldType.php index 56d7fc510f..d43dd5aa57 100644 --- a/packages/builder/src/FieldTypes/Types/LinkFieldType.php +++ b/packages/builder/src/FieldTypes/Types/LinkFieldType.php @@ -20,7 +20,7 @@ public static function key(): string return 'link'; } - public function castValue(mixed $raw): mixed + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed { if ($raw === null || $raw === '') { return null; diff --git a/packages/builder/src/FieldTypes/Types/MultiselectFieldType.php b/packages/builder/src/FieldTypes/Types/MultiselectFieldType.php index 494da07645..4d8c8643e6 100644 --- a/packages/builder/src/FieldTypes/Types/MultiselectFieldType.php +++ b/packages/builder/src/FieldTypes/Types/MultiselectFieldType.php @@ -34,7 +34,7 @@ public function capabilities(): array ]; } - public function castValue(mixed $raw): mixed + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed { if ($raw === null) { return []; diff --git a/packages/builder/src/FieldTypes/Types/NumberFieldType.php b/packages/builder/src/FieldTypes/Types/NumberFieldType.php index 51306f2457..26e98a7549 100644 --- a/packages/builder/src/FieldTypes/Types/NumberFieldType.php +++ b/packages/builder/src/FieldTypes/Types/NumberFieldType.php @@ -36,7 +36,7 @@ public function capabilities(): array ]; } - public function castValue(mixed $raw): mixed + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed { if ($raw === null || $raw === '') { return null; diff --git a/packages/builder/src/FieldTypes/Types/PasswordFieldType.php b/packages/builder/src/FieldTypes/Types/PasswordFieldType.php index b9afef4213..ee087683d7 100644 --- a/packages/builder/src/FieldTypes/Types/PasswordFieldType.php +++ b/packages/builder/src/FieldTypes/Types/PasswordFieldType.php @@ -47,7 +47,7 @@ public function formComponent(FieldDefinition $field): Component return $component; } - public function castValue(mixed $raw): mixed + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed { if ($raw === null || $raw === '') { return null; diff --git a/packages/builder/src/FieldTypes/Types/RangeFieldType.php b/packages/builder/src/FieldTypes/Types/RangeFieldType.php index bce211f88e..ff56eb5bf9 100644 --- a/packages/builder/src/FieldTypes/Types/RangeFieldType.php +++ b/packages/builder/src/FieldTypes/Types/RangeFieldType.php @@ -32,7 +32,7 @@ public function capabilities(): array ]; } - public function castValue(mixed $raw): mixed + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed { if ($raw === null || $raw === '') { return null; diff --git a/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php b/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php index feb2eecc6a..c69736e5d0 100644 --- a/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php +++ b/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php @@ -97,7 +97,7 @@ function (array $arguments, Repeater $component) use ($defaultValue, $field): vo ); } - public function castValue(mixed $raw): mixed + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed { if (! is_array($raw)) { return []; diff --git a/packages/builder/src/FieldTypes/Types/TimeFieldType.php b/packages/builder/src/FieldTypes/Types/TimeFieldType.php index c334603958..c48eca5a94 100644 --- a/packages/builder/src/FieldTypes/Types/TimeFieldType.php +++ b/packages/builder/src/FieldTypes/Types/TimeFieldType.php @@ -4,10 +4,13 @@ namespace Moox\Builder\FieldTypes\Types; +use Carbon\CarbonInterface; use Filament\Forms\Components\TimePicker; use Filament\Schemas\Components\Component; +use Illuminate\Support\Carbon; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\FieldTypes\Capabilities\DefaultValue; +use Moox\Builder\FieldTypes\Capabilities\DisplayFormat; use Moox\Builder\FieldTypes\FieldType; class TimeFieldType extends FieldType @@ -20,15 +23,48 @@ public static function key(): string public function capabilities(): array { return [ + DisplayFormat::class, DefaultValue::class, ]; } + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed + { + if ($raw === null || $raw === '') { + return null; + } + + $storageFormat = $field !== null + ? DisplayFormat::storageFormatForTime(DisplayFormat::resolveForField($field)) + : 'H:i'; + + if ($raw instanceof CarbonInterface) { + return $raw->format($storageFormat); + } + + if (is_string($raw)) { + try { + return Carbon::parse($raw)->format($storageFormat); + } catch (\Throwable) { + return $raw; + } + } + + return $raw; + } + public function formComponent(FieldDefinition $field): Component { + $defaultValue = app(DefaultValue::class); + $component = TimePicker::make($field->name) - ->label($field->label); + ->label($field->label) + ->native(true); + + $component = $this->applyCapabilitiesAndValidation($component, $field); - return $this->applyCapabilitiesAndValidation($component, $field); + return $component->default(static function () use ($field, $defaultValue): ?CarbonInterface { + return $defaultValue->resolveForField($field); + }); } } diff --git a/packages/builder/src/FieldTypes/Types/ToggleFieldType.php b/packages/builder/src/FieldTypes/Types/ToggleFieldType.php index 924e655af0..48e684dd9d 100644 --- a/packages/builder/src/FieldTypes/Types/ToggleFieldType.php +++ b/packages/builder/src/FieldTypes/Types/ToggleFieldType.php @@ -26,7 +26,7 @@ public function capabilities(): array ]; } - public function castValue(mixed $raw): mixed + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed { return filter_var($raw, FILTER_VALIDATE_BOOLEAN); } diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 84f5450c56..28e201617b 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -503,11 +503,9 @@ protected static function reactiveTypeSettingsSchema(callable $get): array { $type = $get('type'); - if (in_array($type, ['date', 'datetime'], true)) { - $get('config.displayFormat'); - } - if (in_array($type, ['date', 'datetime', 'time'], true)) { + $get('config.displayFormat'); + $get('config.default'); $get('config.defaultNow'); } diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index be1f36244c..46a0543c15 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -103,7 +103,7 @@ public function loadValues(string $entity, Model $record, Collection $fields): a } $raw = TypedValueColumns::read($row, $field->type); - $values[$field->name] = $fieldType->castValue($raw); + $values[$field->name] = $fieldType->castValue($raw, $field); } return $values; @@ -216,7 +216,7 @@ public function saveValues(string $entity, Model $record, array $values, Collect $this->fieldValueValidator->assertValid($field, $value); - $cast = $fieldType->castValue($value); + $cast = $fieldType->castValue($value, $field); $columns = TypedValueColumns::attributesFor($field->type, $cast); FieldValue::query()->updateOrCreate( diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 3a9a90023b..1d4c8fa8c0 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -10,6 +10,7 @@ use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldOption; +use Moox\Builder\FieldTypes\Capabilities\DisplayFormat; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\FieldRelationTree; @@ -403,7 +404,13 @@ protected function filterConfigForType(string $type, array $config): array return []; } - return Arr::only($config, $allowed); + $filtered = Arr::only($config, $allowed); + + if (in_array($type, ['date', 'datetime', 'time'], true) && ! array_key_exists('displayFormat', $filtered)) { + $filtered['displayFormat'] = DisplayFormat::defaultFor($type); + } + + return $filtered; } /** From f202cd411150783227cc9d113265ebe14b9f9ed0 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:18:06 +0000 Subject: [PATCH 038/105] Fix styling --- packages/builder/src/Services/FieldGroupPersistence.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 1d4c8fa8c0..09314731b5 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -7,10 +7,10 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Moox\Builder\FieldTypes\Capabilities\DisplayFormat; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldOption; -use Moox\Builder\FieldTypes\Capabilities\DisplayFormat; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\FieldRelationTree; From 8a43c3b8f33287d9cd15e977d6b352fbcb0bcc7d Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:25:00 +0200 Subject: [PATCH 039/105] fix toggle field --- .../builder/src/Compiler/SchemaCompiler.php | 18 +-- .../FieldTypes/Capabilities/DefaultValue.php | 10 ++ .../src/FieldTypes/Types/ToggleFieldType.php | 12 +- .../src/Services/CustomFieldsManager.php | 15 +- .../tests/Unit/ToggleFieldTypeTest.php | 129 ++++++++++++++++++ 5 files changed, 163 insertions(+), 21 deletions(-) create mode 100644 packages/builder/tests/Unit/ToggleFieldTypeTest.php diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index fa809c6e3e..03ecaad1fb 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -393,14 +393,8 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, return; } - $default = $defaultValue->resolveForField($field); - - if ($default !== null) { - $component->state( - $field->type === 'time' - ? ($defaultValue->normalizeTimeValue($default) ?? $default) - : $default, - ); + if ($defaultValue->hasConfiguredDefault($field)) { + $component->state($defaultValue->resolveForField($field)); } }) ->afterStateUpdated(function (Component $component) use ($field, $fieldType, $defaultValue): void { @@ -509,12 +503,12 @@ protected function applyScalarFieldDefaults( continue; } - $resolved = $defaultValue->resolveForField($field); - - if ($resolved === null) { + if (! $defaultValue->hasConfiguredDefault($field)) { continue; } + $resolved = $defaultValue->resolveForField($field); + if ($field->type === 'time') { $resolved = $defaultValue->normalizeTimeValue($resolved) ?? $resolved; } @@ -525,7 +519,7 @@ protected function applyScalarFieldDefaults( continue; } - if (! $defaultValue->shouldApplyDefault($fieldComponent->getState(), $field->type)) { + if ($field->type !== 'toggle' && ! $defaultValue->shouldApplyDefault($fieldComponent->getState(), $field->type)) { continue; } diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index 140b8da6f2..e9b581ba50 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -258,6 +258,16 @@ protected function colorDefaultField(): ColorPicker }); } + public function hasConfiguredDefault(FieldDefinition $field): bool + { + if (in_array($field->type, ['date', 'datetime', 'time'], true)) { + return ($field->config['defaultNow'] ?? false) === true + || array_key_exists('default', $field->config); + } + + return array_key_exists('default', $field->config); + } + public function shouldApplyDefault(mixed $state, string $type): bool { if ($type === 'toggle') { diff --git a/packages/builder/src/FieldTypes/Types/ToggleFieldType.php b/packages/builder/src/FieldTypes/Types/ToggleFieldType.php index 48e684dd9d..bc23114c41 100644 --- a/packages/builder/src/FieldTypes/Types/ToggleFieldType.php +++ b/packages/builder/src/FieldTypes/Types/ToggleFieldType.php @@ -33,9 +33,19 @@ public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed public function formComponent(FieldDefinition $field): Component { + $defaultValue = app(DefaultValue::class); + $component = Toggle::make($field->name) ->label($field->label); - return $this->applyCapabilitiesAndValidation($component, $field); + $component = $this->applyCapabilitiesAndValidation($component, $field); + + if ($defaultValue->hasConfiguredDefault($field)) { + $component->default(static function () use ($field, $defaultValue): bool { + return (bool) $defaultValue->resolveForField($field); + }); + } + + return $component; } } diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index 46a0543c15..b84c7dc8e2 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -152,10 +152,8 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da } if (! array_key_exists($field->name, $data)) { - $resolved = $defaultValue->resolveForField($field); - - if ($resolved !== null) { - $values[$field->name] = $resolved; + if ($defaultValue->hasConfiguredDefault($field)) { + $values[$field->name] = $defaultValue->resolveForField($field); } continue; @@ -163,16 +161,17 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da $value = $data[$field->name]; - if ($defaultValue->shouldApplyDefault($value, $field->type) && $record->exists) { - $hasStoredValue = FieldValue::query() + $hasStoredValue = $record->exists + && FieldValue::query() ->forRecord($entity, $record->getKey()) ->where('field_name', $field->name) ->exists(); - if (! $hasStoredValue) { + if (! $hasStoredValue && $defaultValue->hasConfiguredDefault($field)) { + if ($field->type === 'toggle' || $defaultValue->shouldApplyDefault($value, $field->type)) { $resolved = $defaultValue->resolveForField($field); - if ($resolved !== null) { + if ($field->type === 'toggle' || $resolved !== null) { $value = $resolved; } } diff --git a/packages/builder/tests/Unit/ToggleFieldTypeTest.php b/packages/builder/tests/Unit/ToggleFieldTypeTest.php new file mode 100644 index 0000000000..d23afa0eab --- /dev/null +++ b/packages/builder/tests/Unit/ToggleFieldTypeTest.php @@ -0,0 +1,129 @@ +createItemsTable(); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Vehicle options', + 'slug' => 'vehicle-options', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'unfallfrei', + 'label' => 'Unfallfrei', + 'type' => 'toggle', + 'sort' => 0, + 'config' => ['default' => true], + 'validation' => ['required' => false, 'rules' => []], + ]); +}); + +it('applies configured toggle defaults at runtime', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'unfallfrei', + label: 'Unfallfrei', + type: 'toggle', + config: ['default' => true], + ); + + $component = (new ToggleFieldType)->formComponent($field); + + expect($component->getDefaultState())->toBeTrue() + ->and($capability->hasConfiguredDefault($field))->toBeTrue() + ->and($capability->resolveForField($field))->toBeTrue(); +}); + +it('treats false as a configured toggle default', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'unfallfrei', + label: 'Unfallfrei', + type: 'toggle', + config: ['default' => false], + ); + + expect($capability->hasConfiguredDefault($field))->toBeTrue() + ->and($capability->resolveForField($field))->toBeFalse() + ->and((new ToggleFieldType)->formComponent($field)->getDefaultState())->toBeFalse(); +}); + +it('persists configured toggle defaults when the form value is false on create', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + ['unfallfrei' => false], + ); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'unfallfrei') + ->first(); + + expect($stored?->value_boolean)->toBeTrue(); +}); + +it('does not reapply toggle defaults when a stored value exists on update', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + 'field_name' => 'unfallfrei', + 'value_boolean' => false, + ]); + + app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + ['unfallfrei' => false], + ); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'unfallfrei') + ->first(); + + expect($stored?->value_boolean)->toBeFalse(); +}); + +it('resolves toggle defaults from cached field group definitions', function (): void { + $field = app(DefinitionRegistry::class) + ->fieldGroupsFor(TestItemResource::customFieldsLocationContext()) + ->first() + ?->fields + ->firstWhere('name', 'unfallfrei'); + + expect($field)->not->toBeNull() + ->and(app(DefaultValue::class)->resolveForField($field))->toBeTrue(); +}); From a5745048294c8477668129aa3d5cd1630cd4c522 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:11:22 +0200 Subject: [PATCH 040/105] fix embed field --- .../builder/resources/lang/de/builder.php | 1 + .../builder/resources/lang/en/builder.php | 1 + .../FieldTypes/Capabilities/DefaultValue.php | 43 +++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 49b25cd257..b94f37070e 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -154,6 +154,7 @@ 'validation' => [ 'invalid_option' => 'Der gewählte Wert ist keine gültige Option.', 'invalid_email_default' => 'Bitte eine gültige E-Mail-Adresse als Standardwert eingeben.', + 'invalid_url_default' => 'Bitte eine gültige URL als Standardwert eingeben.', 'invalid_link_url' => 'Bitte eine gültige URL eingeben.', 'range_min_lt_max' => 'Der Mindestwert muss kleiner als der Höchstwert sein.', 'range_max_gt_min' => 'Der Höchstwert muss größer als der Mindestwert sein.', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 5e17a7ad62..6344fc29c9 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -154,6 +154,7 @@ 'validation' => [ 'invalid_option' => 'The selected value is not a valid option.', 'invalid_email_default' => 'Please enter a valid email address as the default value.', + 'invalid_url_default' => 'Please enter a valid URL as the default value.', 'invalid_link_url' => 'Please enter a valid URL.', 'range_min_lt_max' => 'The minimum value must be less than the maximum value.', 'range_max_gt_min' => 'The maximum value must be greater than the minimum value.', diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index e9b581ba50..aa7476735f 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -16,6 +16,7 @@ use Filament\Schemas\Components\Component; use Filament\Schemas\Components\Utilities\Get; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Validator; use Illuminate\Support\Collection; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Registry\FieldTypeRegistry; @@ -101,6 +102,9 @@ public function builderFieldsFor(string $fieldType): array ]) ->live(onBlur: true), ], + 'url', 'oembed' => [ + $this->urlDefaultField(), + ], default => [ TextInput::make('config.default') ->label(__('builder::builder.capabilities.default_value')), @@ -169,6 +173,10 @@ public function resolveForField(FieldDefinition $field): mixed return $this->normalizeColorDefault($default); } + if (in_array($field->type, ['url', 'oembed'], true)) { + return $this->resolveUrlDefault($default); + } + return $default; } @@ -715,6 +723,41 @@ protected function resolveArrayDefault(mixed $default): array return array_values(array_filter($default, fn (mixed $value): bool => $value !== null && $value !== '')); } + protected function urlDefaultField(): TextInput + { + return TextInput::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->rules(['nullable', 'url']) + ->validationAttribute(__('builder::builder.capabilities.default_value')) + ->validationMessages([ + 'url' => __('builder::builder.validation.invalid_url_default'), + ]) + ->live(onBlur: true); + } + + protected function resolveUrlDefault(mixed $default): ?string + { + if (! is_string($default)) { + return null; + } + + $url = trim($default); + + if ($url === '') { + return null; + } + + return $this->isValidUrl($url) ? $url : null; + } + + protected function isValidUrl(string $url): bool + { + return Validator::make( + ['url' => $url], + ['url' => ['url']], + )->passes(); + } + protected function numericDefaultField(): TextInput { return TextInput::make('config.default') From b21b47fb98f83ecc7f68eeb66225cd47f7377e4f Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:12:28 +0000 Subject: [PATCH 041/105] Fix styling --- packages/builder/src/FieldTypes/Capabilities/DefaultValue.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index aa7476735f..87de8cbae8 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -16,8 +16,8 @@ use Filament\Schemas\Components\Component; use Filament\Schemas\Components\Utilities\Get; use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\Validator; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Validator; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\RichTextValue; From fe853eae88c870e621345e51f18ade52e8b6f51e Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:13:31 +0200 Subject: [PATCH 042/105] fix default value --- .../builder/resources/lang/de/builder.php | 1 + .../builder/resources/lang/en/builder.php | 1 + .../FieldTypes/Capabilities/DefaultValue.php | 108 ++++++++++++++++-- .../src/Resources/FieldGroupResource.php | 5 + 4 files changed, 104 insertions(+), 11 deletions(-) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index b94f37070e..01727f7bfe 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -160,6 +160,7 @@ 'range_max_gt_min' => 'Der Höchstwert muss größer als der Mindestwert sein.', 'range_default_step' => 'Der Standardwert muss zwischen Minimum und Maximum liegen und zur Schrittweite passen.', 'range_default_bounds' => 'Der Standardwert muss zwischen Minimum und Maximum liegen.', + 'default_max_length' => 'Der Standardwert darf höchstens :max Zeichen lang sein.', 'duplicate_field_name' => 'Der Feldschlüssel „:name“ wird bereits in der Gruppe „:group“ verwendet.', 'duplicate_field_name_internal' => 'Der Feldschlüssel „:name“ ist in dieser Gruppe mehrfach vergeben.', 'empty_repeater_item' => 'Eintrag :position in „:field“ ist leer.', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 6344fc29c9..5422d0328a 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -160,6 +160,7 @@ 'range_max_gt_min' => 'The maximum value must be greater than the minimum value.', 'range_default_step' => 'The default value must stay within min/max and align with the step size.', 'range_default_bounds' => 'The default value must be between the minimum and maximum.', + 'default_max_length' => 'The default value must not exceed :max characters.', 'duplicate_field_name' => 'The field key ":name" is already used in the group ":group".', 'duplicate_field_name_internal' => 'The field key ":name" is assigned more than once in this group.', 'empty_repeater_item' => 'Row :position in ":field" is empty.', diff --git a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php index 87de8cbae8..ef3e250e6a 100644 --- a/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -87,20 +87,14 @@ public function builderFieldsFor(string $fieldType): array 'range' => [ $this->rangeDefaultField(), ], + 'text', 'password' => [ + $this->textDefaultField(), + ], 'textarea', 'rich_text' => [ - Textarea::make('config.default') - ->label(__('builder::builder.capabilities.default_value')) - ->rows(3), + $this->textareaDefaultField(), ], 'email' => [ - TextInput::make('config.default') - ->label(__('builder::builder.capabilities.default_value')) - ->rules(['nullable', 'email']) - ->validationAttribute(__('builder::builder.capabilities.default_value')) - ->validationMessages([ - 'email' => __('builder::builder.validation.invalid_email_default'), - ]) - ->live(onBlur: true), + $this->emailDefaultField(), ], 'url', 'oembed' => [ $this->urlDefaultField(), @@ -177,6 +171,11 @@ public function resolveForField(FieldDefinition $field): mixed return $this->resolveUrlDefault($default); } + if (in_array($field->type, ['text', 'textarea', 'rich_text', 'email', 'password'], true) + && ! $this->defaultWithinMaxLength($default, $field)) { + return null; + } + return $default; } @@ -723,6 +722,93 @@ protected function resolveArrayDefault(mixed $default): array return array_values(array_filter($default, fn (mixed $value): bool => $value !== null && $value !== '')); } + protected function textDefaultField(): TextInput + { + return TextInput::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->rules(fn (Get $get): array => $this->maxLengthDefaultRules($get)) + ->maxLength(fn (Get $get): ?int => $this->configuredMaxLength($get('config.maxLength'))) + ->validationAttribute(__('builder::builder.capabilities.default_value')) + ->validationMessages([ + 'max' => __('builder::builder.validation.default_max_length'), + ]) + ->live(onBlur: true); + } + + protected function textareaDefaultField(): Textarea + { + return Textarea::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->rows(3) + ->rules(fn (Get $get): array => $this->maxLengthDefaultRules($get)) + ->maxLength(fn (Get $get): ?int => $this->configuredMaxLength($get('config.maxLength'))) + ->validationAttribute(__('builder::builder.capabilities.default_value')) + ->validationMessages([ + 'max' => __('builder::builder.validation.default_max_length'), + ]) + ->live(onBlur: true); + } + + protected function emailDefaultField(): TextInput + { + return TextInput::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->rules(fn (Get $get): array => array_merge( + ['nullable', 'email'], + $this->maxLengthConstraintRules($get), + )) + ->maxLength(fn (Get $get): ?int => $this->configuredMaxLength($get('config.maxLength'))) + ->validationAttribute(__('builder::builder.capabilities.default_value')) + ->validationMessages([ + 'email' => __('builder::builder.validation.invalid_email_default'), + 'max' => __('builder::builder.validation.default_max_length'), + ]) + ->live(onBlur: true); + } + + /** + * @return list + */ + protected function maxLengthDefaultRules(Get $get): array + { + return array_merge( + ['nullable', 'string'], + $this->maxLengthConstraintRules($get), + ); + } + + /** + * @return list + */ + protected function maxLengthConstraintRules(Get $get): array + { + $maxLength = $this->configuredMaxLength($get('config.maxLength')); + + return $maxLength !== null ? ['max:'.$maxLength] : []; + } + + protected function configuredMaxLength(mixed $maxLength): ?int + { + if ($maxLength === null || $maxLength === '' || ! is_numeric($maxLength)) { + return null; + } + + $maxLength = (int) $maxLength; + + return $maxLength > 0 ? $maxLength : null; + } + + protected function defaultWithinMaxLength(mixed $default, FieldDefinition $field): bool + { + $maxLength = $this->configuredMaxLength($field->config['maxLength'] ?? null); + + if ($maxLength === null || ! is_string($default)) { + return true; + } + + return mb_strlen($default) <= $maxLength; + } + protected function urlDefaultField(): TextInput { return TextInput::make('config.default') diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 28e201617b..a867a56b6f 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -525,6 +525,11 @@ protected static function reactiveTypeSettingsSchema(callable $get): array $get('config.default'); } + if (in_array($type, ['text', 'textarea', 'rich_text', 'email', 'password'], true)) { + $get('config.maxLength'); + $get('config.default'); + } + return static::typeSettingsSchema($type); } From 707194768610ed7f5869a958d3c4bd8b1747c62d Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:42:01 +0200 Subject: [PATCH 043/105] Add InteractsWithCustomFields for native model custom field access and API output --- packages/builder/README.md | 78 +++- .../builder/src/Compiler/SchemaCompiler.php | 2 +- .../Concerns/InteractsWithCustomFields.php | 392 ++++++++++++++++++ .../Database/Eloquent/CustomFieldsBuilder.php | 170 ++++++++ packages/builder/src/FieldTypes/FieldType.php | 54 +++ .../src/FieldTypes/Types/DateFieldType.php | 5 + .../FieldTypes/Types/DatetimeFieldType.php | 5 + .../FieldTypes/Types/PasswordFieldType.php | 32 +- .../src/FieldTypes/Types/TimeFieldType.php | 17 + .../src/FieldTypes/Value/StoredPassword.php | 20 + .../Resources/Concerns/MergesCustomFields.php | 36 ++ .../builder/src/Registry/EntityRegistry.php | 20 + .../src/Services/BuilderValuesResolver.php | 293 +++++++++++++ .../src/Services/CustomFieldsManager.php | 67 ++- packages/item/src/Models/Item.php | 3 +- 15 files changed, 1185 insertions(+), 9 deletions(-) create mode 100644 packages/builder/src/Concerns/InteractsWithCustomFields.php create mode 100644 packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php create mode 100644 packages/builder/src/FieldTypes/Value/StoredPassword.php create mode 100644 packages/builder/src/Http/Resources/Concerns/MergesCustomFields.php create mode 100644 packages/builder/src/Services/BuilderValuesResolver.php diff --git a/packages/builder/README.md b/packages/builder/README.md index 758a3774f9..a4a126940e 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -396,6 +396,74 @@ $values = app(CustomFieldsManager::class)->loadFormData( ); ``` +### Werte auf Model-Ebene (InteractsWithCustomFields) + +Trait auf dem Consumer-Model (z. B. `Item`). Entity-Key: `getResourceName()` → `customFieldsEntity()` → Filament-Resource → Model-Basename. + +```php +use Moox\Builder\Concerns\InteractsWithCustomFields; + +// Lesen +$item->farbe; // wie natives Attribut (gültige PHP-Feldnamen) +$item->customFields(); // alle Custom Fields inkl. Defaults +$item->customFields(fresh: true); // neu aus DB, Cache ignorieren +$item->customField('fahrzeugtyp-modell'); // ein Feld (auch mit Bindestrich) +$item->hasCustomField('farbe'); // Wert vorhanden (inkl. Default)? +$item->hasCustomFieldDefinition('farbe'); // Felddefinition existiert? +$item->toArray(); // DB-Spalten + Custom Fields (roh, intern) + +// Schreiben +$item->farbe = 'Blau'; // oder setCustomField() +$item->setCustomField('farbe', 'Blau'); +$item->setCustomFields(['unfallfrei' => true]); +$item->clearCustomField('farbe'); + +// Queries & Collections +Item::query()->where('farbe', 'Blau')->get(); // normales where auf Custom Fields +Item::query()->withCustomFields()->get(); // Eager Load (kein N+1) +Item::eagerLoadCustomFields($models); // Batch für bestehende Collection + +// Meta +Item::customFieldNames(); // alle definierten Feldnamen +Item::resolveCustomFieldsEntity(); // Entity-Key (z. B. item) +Item::flushCustomFieldDefinitionCache(); // Definitionen-Cache leeren +$item->flushCustomFieldsCache(); // Werte-Cache auf dem Model leeren + +// Optional: abweichender Entity-Key (gleicher Hook wie auf der Filament-Resource) +protected static function customFieldsEntity(): ?string +{ + return 'my-entity'; +} +``` + +**Hinweise:** + +- DB-Spalten haben Vorrang vor Custom Fields mit gleichem Namen (`$item->title` → Spalte, nicht Builder-Feld). +- Passwort-Felder werden beim Speichern gehasht (`Hash::make`) und nie im Klartext zurückgeladen +- `dump($item)` / Tinker zeigt Custom Fields in `__debugInfo()` (Passwörter maskiert). + +### API Resources + +`MergesCustomFields` merged Custom Fields API-formatiert in die Resource (ISO-Dates, maskierte Passwörter, verschachtelte Group/Repeater/Flexible Content). + +```php +use Illuminate\Http\Resources\Json\JsonResource; +use Moox\Builder\Http\Resources\Concerns\MergesCustomFields; + +class ItemResource extends JsonResource +{ + use MergesCustomFields; + + public function toArray($request): array + { + return $this->mergeCustomFields([ + 'id' => $this->id, + 'title' => $this->title, + ]); + } +} +``` + --- ## Paketstruktur @@ -409,7 +477,9 @@ packages/builder/ ├── resources/lang/{de,en}/builder.php └── src/ ├── BuilderServiceProvider.php - ├── Concerns/HasCustomFields.php # Consumer-Trait + ├── Concerns/ + │ ├── HasCustomFields.php # Filament-Resource + │ └── InteractsWithCustomFields.php # Consumer-Model ├── Compiler/ │ ├── LocationMatcher.php │ └── SchemaCompiler.php @@ -497,12 +567,14 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc - Layout-Felder: Tab, Group, Repeater, Flexible Content - Entity-Discovery über `HasCustomFields` in Filament-Panels - Verschachtelte Validierung (`FieldValueValidator`) +- `InteractsWithCustomFields` auf Consumer-Models (`customFields()`, Attribute-Zugriff, Queries, Eager Load) +- `MergesCustomFields` für API Resources +- `FieldType::presentValue()` für API-Serialisierung (Datums-ISO, Passwort-Maskierung, verschachtelte Felder) - Repeater min/max (`RepeaterItems` Capability) **Aktuell nicht implementiert:** -- Werte-API / `HasBuilderValues`-Trait fürs Frontend (nur `CustomFieldsManager::loadFormData()`) -- Query-Scopes auf Consumer-Models (`whereCustomField()`) +- `FieldType::presentValue()`-Spezialisierungen für Media-URLs - Relational-Felder (Post Object, Relationship, User, Taxonomy) - Media-Felder (Image, File, Gallery) - Clone-Feldtyp (ACF) diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 03ecaad1fb..89c356a09e 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -364,7 +364,7 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, $hasStoredValue = true; $storedValue = $values[$field->name]; - if ($fieldType->hasSubFields() && method_exists($fieldType, 'normalizeForForm')) { + if (method_exists($fieldType, 'normalizeForForm')) { $storedValue = $fieldType->normalizeForForm($storedValue); } } diff --git a/packages/builder/src/Concerns/InteractsWithCustomFields.php b/packages/builder/src/Concerns/InteractsWithCustomFields.php new file mode 100644 index 0000000000..56a12022d0 --- /dev/null +++ b/packages/builder/src/Concerns/InteractsWithCustomFields.php @@ -0,0 +1,392 @@ +|null + */ + protected ?array $customFieldsCache = null; + + protected bool $mergeCustomFieldsIntoArray = true; + + /** + * @var array> + */ + protected static array $customFieldNamesCache = []; + + /** + * @var array> + */ + protected static array $passwordCustomFieldNamesCache = []; + + /** + * @return array + */ + public function customFields(bool $fresh = false): array + { + if (! $fresh && $this->customFieldsCache !== null) { + return $this->customFieldsCache; + } + + /** @var CustomFieldsManager $manager */ + $manager = app(CustomFieldsManager::class); + + $entity = static::resolveCustomFieldsEntity(); + $fields = $manager->fieldsForEntity($entity); + + if ($fields->isEmpty()) { + $this->customFieldsCache = []; + + return []; + } + + $values = $fresh + ? $manager->loadValuesWithDefaults($entity, $this, $fields) + : $manager->loadCachedValuesWithDefaults($entity, $this, $fields); + + $this->customFieldsCache = $values; + + return $values; + } + + public function customField(string $name, mixed $default = null): mixed + { + return $this->customFields()[$name] ?? $default; + } + + public function hasCustomField(string $name): bool + { + return array_key_exists($name, $this->customFields()); + } + + public function hasCustomFieldDefinition(string $name): bool + { + return in_array($name, static::customFieldNames(), true); + } + + public function isQueryableCustomField(string $name): bool + { + return $this->hasCustomFieldDefinition($name) && ! $this->modelHasDatabaseColumn($name); + } + + /** + * @return array + */ + public function __debugInfo(): array + { + $debug = $this->attributesToArray(); + $passwordFields = static::passwordCustomFieldNames(); + + foreach ($this->customFields() as $name => $value) { + if ($value instanceof StoredPassword || (in_array($name, $passwordFields, true) && filled($value))) { + $debug[$name] = '••••••••'; + + continue; + } + + $debug[$name] = $value; + } + + return $debug; + } + + /** + * @param \Illuminate\Database\Query\Builder $query + */ + public function newEloquentBuilder($query): CustomFieldsBuilder + { + return new CustomFieldsBuilder($query); + } + + /** + * @return list + */ + public static function customFieldNames(): array + { + $entity = static::resolveCustomFieldsEntity(); + + if (array_key_exists($entity, static::$customFieldNamesCache)) { + return static::$customFieldNamesCache[$entity]; + } + + /** @var CustomFieldsManager $manager */ + $manager = app(CustomFieldsManager::class); + + return static::$customFieldNamesCache[$entity] = $manager + ->fieldsForEntity($entity) + ->pluck('name') + ->values() + ->all(); + } + + /** + * @param array $values + */ + public function setCustomFields(array $values): static + { + if ($values === []) { + return $this; + } + + /** @var CustomFieldsManager $manager */ + $manager = app(CustomFieldsManager::class); + + $entity = static::resolveCustomFieldsEntity(); + $fields = $manager->fieldsForEntity($entity)->keyBy('name'); + + $payload = []; + + foreach ($values as $name => $value) { + if (! $fields->has($name)) { + throw new InvalidArgumentException("Unknown custom field [{$name}] for entity [{$entity}]."); + } + + $payload[$name] = $value; + } + + $manager->saveValues( + $entity, + $this, + $payload, + $fields->only(array_keys($payload))->values(), + ); + + $this->customFieldsCache = null; + + return $this; + } + + public function setCustomField(string $name, mixed $value): static + { + return $this->setCustomFields([$name => $value]); + } + + public function clearCustomField(string $name): static + { + if (! $this->hasCustomFieldDefinition($name)) { + throw new InvalidArgumentException("Unknown custom field [{$name}] for entity [".static::resolveCustomFieldsEntity().'].'); + } + + FieldValue::query() + ->forRecord(static::resolveCustomFieldsEntity(), $this->getKey()) + ->where('field_name', $name) + ->delete(); + + unset($this->customFieldsCache[$name]); + $this->customFieldsCache = null; + + return $this; + } + + /** + * @param iterable $models + */ + public static function eagerLoadCustomFields(iterable $models): void + { + /** @var CustomFieldsManager $manager */ + $manager = app(CustomFieldsManager::class); + + $entity = static::resolveCustomFieldsEntity(); + $fields = $manager->fieldsForEntity($entity); + + app(BuilderValuesResolver::class)->eagerLoad($models, $entity, $fields); + } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeWithCustomFields(Builder $query): Builder + { + return $query->afterQuery(function (EloquentCollection $models): void { + static::eagerLoadCustomFields($models); + }); + } + + /** + * @param array $values + */ + public function setCustomFieldsCache(array $values): void + { + $this->customFieldsCache = $values; + } + + public static function flushCustomFieldDefinitionCache(): void + { + static::$customFieldNamesCache = []; + static::$passwordCustomFieldNamesCache = []; + } + + public function flushCustomFieldsCache(): void + { + $this->customFieldsCache = null; + } + + public function toArray(): array + { + $array = parent::toArray(); + + if ($this->mergeCustomFieldsIntoArray) { + $customFields = []; + + foreach ($this->customFields() as $name => $value) { + $customFields[$name] = $value instanceof StoredPassword ? null : $value; + } + + $array = array_merge($array, $customFields); + } + + return $array; + } + + public function getAttribute($key): mixed + { + if ($this->shouldDelegateToCustomField($key)) { + return $this->customField($key); + } + + return parent::getAttribute($key); + } + + public function setAttribute($key, $value): mixed + { + if ($this->shouldDelegateToCustomField($key)) { + $this->setCustomField($key, $value); + + return $this; + } + + return parent::setAttribute($key, $value); + } + + public static function resolveCustomFieldsEntity(): string + { + if (method_exists(static::class, 'customFieldsEntity')) { + $entity = static::customFieldsEntity(); + + if (is_string($entity) && $entity !== '') { + return $entity; + } + } + + if (method_exists(static::class, 'getResourceName')) { + return static::getResourceName(); + } + + $entity = app(EntityRegistry::class)->entityForModel(static::class); + + if ($entity !== null) { + return $entity; + } + + /** @var class-string $modelClass */ + $modelClass = static::class; + + return Str::kebab(class_basename($modelClass)); + } + + /** + * @return list + */ + protected static function passwordCustomFieldNames(): array + { + $entity = static::resolveCustomFieldsEntity(); + + if (array_key_exists($entity, static::$passwordCustomFieldNamesCache)) { + return static::$passwordCustomFieldNamesCache[$entity]; + } + + /** @var CustomFieldsManager $manager */ + $manager = app(CustomFieldsManager::class); + + return static::$passwordCustomFieldNamesCache[$entity] = $manager + ->fieldsForEntity($entity) + ->where('type', 'password') + ->pluck('name') + ->values() + ->all(); + } + + protected function shouldDelegateToCustomField(string $key): bool + { + return $this->isQueryableCustomField($key); + } + + protected function hasNativeAttribute(string $key): bool + { + if (array_key_exists($key, $this->getAttributes())) { + return true; + } + + if ($this->modelHasDatabaseColumn($key)) { + return true; + } + + if ($this->hasGetMutator($key) || $this->hasAttributeMutator($key)) { + return true; + } + + if ($this->isRelation($key) || $this->relationLoaded($key)) { + return true; + } + + return false; + } + + /** + * @return list + */ + protected function databaseColumns(): array + { + /** @var array> $cache */ + static $cache = []; + + $table = $this->getTable(); + + if (! array_key_exists($table, $cache)) { + $cache[$table] = $this->getConnection() + ->getSchemaBuilder() + ->getColumnListing($table); + } + + return $cache[$table]; + } + + protected function modelHasDatabaseColumn(string $key): bool + { + return in_array($key, $this->databaseColumns(), true); + } + + /** + * Override when the storage entity key differs from getResourceName(). + */ + protected static function customFieldsEntity(): ?string + { + return null; + } +} diff --git a/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php b/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php new file mode 100644 index 0000000000..13caf2a13d --- /dev/null +++ b/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php @@ -0,0 +1,170 @@ + + */ +class CustomFieldsBuilder extends Builder +{ + /** + * @param (\Closure(static): mixed)|array|string $column + * @return $this + */ + public function where($column, $operator = null, $value = null, $boolean = 'and'): static + { + if (is_string($column) && $this->isCustomFieldColumn($column)) { + if (func_num_args() === 2) { + $value = $operator; + $operator = '='; + } + + return $this->addCustomFieldConstraint($column, $operator, $value, $boolean); + } + + return parent::where($column, $operator, $value, $boolean); + } + + /** + * @param (\Closure(static): mixed)|array|string $column + * @return $this + */ + public function orWhere($column, $operator = null, $value = null): static + { + return $this->where($column, $operator, $value, 'or'); + } + + /** + * @param array|\Illuminate\Contracts\Support\Arrayable|\Closure(static): void|string $values + * @return $this + */ + public function whereIn($column, $values, $boolean = 'and', $not = false): static + { + if (is_string($column) && $this->isCustomFieldColumn($column)) { + return $this->addCustomFieldInConstraint($column, $values, $boolean, $not); + } + + return parent::whereIn($column, $values, $boolean, $not); + } + + /** + * @param array|\Illuminate\Contracts\Support\Arrayable|\Closure(static): void|string $values + * @return $this + */ + public function whereNotIn($column, $values, $boolean = 'and'): static + { + return $this->whereIn($column, $values, $boolean, not: true); + } + + protected function isCustomFieldColumn(string $column): bool + { + $model = $this->getModel(); + + if (! in_array(InteractsWithCustomFields::class, class_uses_recursive($model), true)) { + return false; + } + + if (str_contains($column, '.')) { + return false; + } + + /** @var Model&InteractsWithCustomFields $model */ + return $model->isQueryableCustomField($column); + } + + /** + * @return $this + */ + protected function addCustomFieldConstraint( + string $field, + mixed $operator, + mixed $value, + string $boolean = 'and', + ): static { + if (! in_array($operator, ['=', '!=', '<', '>', '<=', '>=', 'like'], true)) { + throw new InvalidArgumentException("Unsupported operator [{$operator}] for custom field [{$field}]."); + } + + $model = $this->getModel(); + + /** @var class-string $modelClass */ + $modelClass = $model::class; + + $entity = $modelClass::resolveCustomFieldsEntity(); + $definition = app(CustomFieldsManager::class) + ->fieldsForEntity($entity) + ->firstWhere('name', $field); + + if ($definition === null) { + throw new InvalidArgumentException("Unknown custom field [{$field}] for entity [{$entity}]."); + } + + $valueColumn = TypedValueColumns::columnForType($definition->type); + $valuesTable = (new FieldValue)->getTable(); + $recordKey = $model->getQualifiedKeyName(); + + return $this->whereExists(function ($subQuery) use ($entity, $field, $valueColumn, $operator, $value, $valuesTable, $recordKey): void { + $subQuery->selectRaw('1') + ->from($valuesTable) + ->whereColumn("{$valuesTable}.record_id", $recordKey) + ->where("{$valuesTable}.entity", $entity) + ->where("{$valuesTable}.field_name", $field) + ->where("{$valuesTable}.{$valueColumn}", $operator, $value); + }, $boolean); + } + + /** + * @param array|\Illuminate\Contracts\Support\Arrayable|\Closure(static): void|string $values + * @return $this + */ + protected function addCustomFieldInConstraint( + string $field, + mixed $values, + string $boolean = 'and', + bool $not = false, + ): static { + $model = $this->getModel(); + + /** @var class-string $modelClass */ + $modelClass = $model::class; + + $entity = $modelClass::resolveCustomFieldsEntity(); + $definition = app(CustomFieldsManager::class) + ->fieldsForEntity($entity) + ->firstWhere('name', $field); + + if ($definition === null) { + throw new InvalidArgumentException("Unknown custom field [{$field}] for entity [{$entity}]."); + } + + $valueColumn = TypedValueColumns::columnForType($definition->type); + $valuesTable = (new FieldValue)->getTable(); + $recordKey = $model->getQualifiedKeyName(); + + return $this->whereExists(function ($subQuery) use ($entity, $field, $valueColumn, $values, $valuesTable, $recordKey, $not): void { + $subQuery->selectRaw('1') + ->from($valuesTable) + ->whereColumn("{$valuesTable}.record_id", $recordKey) + ->where("{$valuesTable}.entity", $entity) + ->where("{$valuesTable}.field_name", $field); + + if ($not) { + $subQuery->whereNotIn("{$valuesTable}.{$valueColumn}", $values); + } else { + $subQuery->whereIn("{$valuesTable}.{$valueColumn}", $values); + } + }, $boolean); + } +} diff --git a/packages/builder/src/FieldTypes/FieldType.php b/packages/builder/src/FieldTypes/FieldType.php index 2d52231e11..df8a682364 100644 --- a/packages/builder/src/FieldTypes/FieldType.php +++ b/packages/builder/src/FieldTypes/FieldType.php @@ -4,7 +4,9 @@ namespace Moox\Builder\FieldTypes; +use Carbon\CarbonInterface; use Filament\Schemas\Components\Component; +use Illuminate\Support\Carbon; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\FieldTypes\Capabilities\Capability; use Moox\Builder\Services\FieldValueValidator; @@ -28,6 +30,58 @@ public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed return $raw; } + public function persistValue(mixed $value, ?FieldDefinition $field = null): mixed + { + return $this->castValue($value, $field); + } + + public function presentValue(mixed $value, ?FieldDefinition $field = null): mixed + { + return $value; + } + + protected function presentIsoDate(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + if ($value instanceof CarbonInterface) { + return $value->format('Y-m-d'); + } + + if (is_string($value)) { + try { + return Carbon::parse($value)->format('Y-m-d'); + } catch (\Throwable) { + return $value; + } + } + + return null; + } + + protected function presentIsoDateTime(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + if ($value instanceof CarbonInterface) { + return $value->toIso8601String(); + } + + if (is_string($value)) { + try { + return Carbon::parse($value)->toIso8601String(); + } catch (\Throwable) { + return $value; + } + } + + return null; + } + public function hasOptions(): bool { return false; diff --git a/packages/builder/src/FieldTypes/Types/DateFieldType.php b/packages/builder/src/FieldTypes/Types/DateFieldType.php index 484c5b2a95..1d88683026 100644 --- a/packages/builder/src/FieldTypes/Types/DateFieldType.php +++ b/packages/builder/src/FieldTypes/Types/DateFieldType.php @@ -36,4 +36,9 @@ public function formComponent(FieldDefinition $field): Component return $this->applyCapabilitiesAndValidation($component, $field); } + + public function presentValue(mixed $value, ?FieldDefinition $field = null): mixed + { + return $this->presentIsoDate($value); + } } diff --git a/packages/builder/src/FieldTypes/Types/DatetimeFieldType.php b/packages/builder/src/FieldTypes/Types/DatetimeFieldType.php index f55360edd5..73cfddcf64 100644 --- a/packages/builder/src/FieldTypes/Types/DatetimeFieldType.php +++ b/packages/builder/src/FieldTypes/Types/DatetimeFieldType.php @@ -36,4 +36,9 @@ public function formComponent(FieldDefinition $field): Component return $this->applyCapabilitiesAndValidation($component, $field); } + + public function presentValue(mixed $value, ?FieldDefinition $field = null): mixed + { + return $this->presentIsoDateTime($value); + } } diff --git a/packages/builder/src/FieldTypes/Types/PasswordFieldType.php b/packages/builder/src/FieldTypes/Types/PasswordFieldType.php index ee087683d7..de53176b78 100644 --- a/packages/builder/src/FieldTypes/Types/PasswordFieldType.php +++ b/packages/builder/src/FieldTypes/Types/PasswordFieldType.php @@ -13,6 +13,7 @@ use Moox\Builder\FieldTypes\Capabilities\Placeholder; use Moox\Builder\FieldTypes\Capabilities\PrefixSuffix; use Moox\Builder\FieldTypes\FieldType; +use Moox\Builder\FieldTypes\Value\StoredPassword; class PasswordFieldType extends FieldType { @@ -53,12 +54,39 @@ public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed return null; } - $value = (string) $raw; + return StoredPassword::instance(); + } + + public function persistValue(mixed $value, ?FieldDefinition $field = null): mixed + { + if ($value === null || $value === '') { + return null; + } + + $value = (string) $value; if (Hash::isHashed($value)) { + return $value; + } + + return Hash::make($value); + } + + public function normalizeForForm(mixed $stored): mixed + { + if ($stored === null || $stored === '') { + return null; + } + + return null; + } + + public function presentValue(mixed $value, ?FieldDefinition $field = null): mixed + { + if ($value === null || $value === '') { return null; } - return $value; + return ['has_value' => true]; } } diff --git a/packages/builder/src/FieldTypes/Types/TimeFieldType.php b/packages/builder/src/FieldTypes/Types/TimeFieldType.php index c48eca5a94..6f8538cc87 100644 --- a/packages/builder/src/FieldTypes/Types/TimeFieldType.php +++ b/packages/builder/src/FieldTypes/Types/TimeFieldType.php @@ -67,4 +67,21 @@ public function formComponent(FieldDefinition $field): Component return $defaultValue->resolveForField($field); }); } + + public function presentValue(mixed $value, ?FieldDefinition $field = null): mixed + { + if ($value === null || $value === '') { + return null; + } + + if ($value instanceof CarbonInterface) { + $format = $field !== null + ? DisplayFormat::storageFormatForTime(DisplayFormat::resolveForField($field)) + : 'H:i'; + + return $value->format($format); + } + + return (string) $value; + } } diff --git a/packages/builder/src/FieldTypes/Value/StoredPassword.php b/packages/builder/src/FieldTypes/Value/StoredPassword.php new file mode 100644 index 0000000000..d7f5d4c29a --- /dev/null +++ b/packages/builder/src/FieldTypes/Value/StoredPassword.php @@ -0,0 +1,20 @@ + $payload + * @return array + */ + protected function mergeCustomFields(array $payload): array + { + $model = $this->resource; + + if (! in_array(InteractsWithCustomFields::class, class_uses_recursive($model), true)) { + return $payload; + } + + /** @var InteractsWithCustomFields $model */ + $manager = app(CustomFieldsManager::class); + $entity = $model::resolveCustomFieldsEntity(); + + $presented = app(BuilderValuesResolver::class)->present( + $manager->fieldsForEntity($entity), + $model->customFields(), + ); + + return array_merge($payload, $presented); + } +} diff --git a/packages/builder/src/Registry/EntityRegistry.php b/packages/builder/src/Registry/EntityRegistry.php index a70371a799..009821dc02 100644 --- a/packages/builder/src/Registry/EntityRegistry.php +++ b/packages/builder/src/Registry/EntityRegistry.php @@ -77,6 +77,26 @@ public function modelFor(string $entity): ?string return is_string($model) ? $model : null; } + /** + * @param class-string $modelClass + */ + public function entityForModel(string $modelClass): ?string + { + foreach ($this->all() as $entity => $definition) { + $resource = $definition['resource'] ?? null; + + if (! is_string($resource) || ! method_exists($resource, 'getModel')) { + continue; + } + + if ($resource::getModel() === $modelClass) { + return $entity; + } + } + + return null; + } + public function labelFor(string $entity): string { $definition = $this->all()[$entity] ?? []; diff --git a/packages/builder/src/Services/BuilderValuesResolver.php b/packages/builder/src/Services/BuilderValuesResolver.php new file mode 100644 index 0000000000..6855f6d6c0 --- /dev/null +++ b/packages/builder/src/Services/BuilderValuesResolver.php @@ -0,0 +1,293 @@ + $fields + * @return array + */ + public function resolveFromRows(Collection $fields, Collection $rowsByFieldName, bool $mergeDefaults = true): array + { + $values = []; + + foreach ($fields as $field) { + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if (! $fieldType->storesValue()) { + continue; + } + + $row = $rowsByFieldName->get($field->name); + + if ($row !== null) { + $raw = TypedValueColumns::read($row, $field->type); + $values[$field->name] = $fieldType->castValue($raw, $field); + + continue; + } + + if ($mergeDefaults) { + $default = $this->resolveDefault($field); + + if ($default !== null || ($field->type === 'toggle' && $this->defaultValue->hasConfiguredDefault($field))) { + $values[$field->name] = $default; + } + } + } + + return $values; + } + + /** + * @param Collection $fields + * @param array $values + * @return array + */ + public function mergeDefaults(Collection $fields, array $values): array + { + foreach ($fields as $field) { + if (array_key_exists($field->name, $values) + && ! $this->defaultValue->shouldApplyDefault($values[$field->name], $field->type)) { + continue; + } + + $default = $this->resolveDefault($field); + + if ($default !== null || ($field->type === 'toggle' && $this->defaultValue->hasConfiguredDefault($field))) { + $values[$field->name] = $default; + } + } + + return $values; + } + + /** + * @param Collection $fields + * @param array $values + * @return array + */ + public function present(Collection $fields, array $values): array + { + $presented = []; + + foreach ($fields as $field) { + if (! array_key_exists($field->name, $values)) { + continue; + } + + $presented[$field->name] = $this->presentFieldValue($field, $values[$field->name]); + } + + return $presented; + } + + public function presentFieldValue(FieldDefinition $field, mixed $value): mixed + { + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if ($fieldType->hasSubFields() && is_array($value)) { + return match ($field->type) { + 'repeater' => $this->presentRepeaterRows($field, $value), + 'flexible_content' => $this->presentFlexibleContentItems($field, $value), + default => $this->presentCompoundRow($field->children, $value), + }; + } + + return $fieldType->presentValue($value, $field); + } + + public function persistFieldValue(FieldDefinition $field, mixed $value): mixed + { + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if ($fieldType->hasSubFields() && is_array($value)) { + $value = $fieldType->castValue($value, $field); + + return match ($field->type) { + 'repeater' => $this->persistRepeaterRows($field, $value), + 'flexible_content' => $this->persistFlexibleContentItems($field, $value), + default => $this->persistCompoundRow($field->children, $value), + }; + } + + return $fieldType->persistValue($value, $field); + } + + /** + * @param Collection $children + * @param array $row + * @return array + */ + protected function persistCompoundRow(Collection $children, array $row): array + { + $persisted = []; + + foreach ($children as $child) { + if (! array_key_exists($child->name, $row)) { + continue; + } + + $persisted[$child->name] = $this->persistFieldValue($child, $row[$child->name]); + } + + return $persisted; + } + + /** + * @param array> $rows + * @return list> + */ + protected function persistRepeaterRows(FieldDefinition $field, array $rows): array + { + return array_values(array_map( + fn (array $row): array => $this->persistCompoundRow($field->children, $row), + $rows, + )); + } + + /** + * @param array> $items + * @return list> + */ + protected function persistFlexibleContentItems(FieldDefinition $field, array $items): array + { + $layouts = $field->layouts()->keyBy('name'); + + return array_values(array_map(function (array $item) use ($layouts): array { + $type = (string) ($item['type'] ?? ''); + $data = is_array($item['data'] ?? null) ? $item['data'] : []; + $layout = $layouts->get($type); + + return [ + 'type' => $type, + 'data' => $layout !== null + ? $this->persistCompoundRow($layout->children, $data) + : $data, + ]; + }, $items)); + } + + /** + * @param Collection $children + * @param array $row + * @return array + */ + protected function presentCompoundRow(Collection $children, array $row): array + { + $presented = []; + + foreach ($children as $child) { + if (! array_key_exists($child->name, $row)) { + continue; + } + + $presented[$child->name] = $this->presentFieldValue($child, $row[$child->name]); + } + + return $presented; + } + + /** + * @param array> $rows + * @return list> + */ + protected function presentRepeaterRows(FieldDefinition $field, array $rows): array + { + return array_values(array_map( + fn (array $row): array => $this->presentCompoundRow($field->children, $row), + $rows, + )); + } + + /** + * @param array> $items + * @return list> + */ + protected function presentFlexibleContentItems(FieldDefinition $field, array $items): array + { + $layouts = $field->layouts()->keyBy('name'); + + return array_values(array_map(function (array $item) use ($layouts): array { + $type = (string) ($item['type'] ?? ''); + $data = is_array($item['data'] ?? null) ? $item['data'] : []; + $layout = $layouts->get($type); + + return [ + 'type' => $type, + 'data' => $layout !== null + ? $this->presentCompoundRow($layout->children, $data) + : $data, + ]; + }, $items)); + } + + /** + * @param iterable $models + */ + public function eagerLoad(iterable $models, string $entity, Collection $fields): void + { + $collection = $models instanceof EloquentCollection + ? $models + : new EloquentCollection(is_array($models) ? $models : iterator_to_array($models)); + + if ($collection->isEmpty() || $fields->isEmpty()) { + return; + } + + $model = $collection->first(); + + if (! $model instanceof Model || ! method_exists($model, 'setCustomFieldsCache')) { + return; + } + + $keyName = $model->getKeyName(); + + $rows = FieldValue::query() + ->where('entity', $entity) + ->whereIn('record_id', $collection->pluck($keyName)) + ->whereIn('field_name', $fields->pluck('name')) + ->get() + ->groupBy('record_id'); + + foreach ($collection as $record) { + /** @var Model&\Moox\Builder\Concerns\InteractsWithCustomFields $record */ + $recordRows = $rows->get($record->getKey(), collect())->keyBy('field_name'); + $record->setCustomFieldsCache( + $this->resolveFromRows($fields, $recordRows, mergeDefaults: true), + ); + } + } + + protected function resolveDefault(FieldDefinition $field): mixed + { + if (! $this->defaultValue->hasConfiguredDefault($field)) { + return null; + } + + $resolved = $this->defaultValue->resolveForField($field); + + if ($field->type === 'toggle') { + return (bool) $resolved; + } + + return $resolved; + } +} diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index b84c7dc8e2..84e75a91ad 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -13,6 +13,7 @@ use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Services\BuilderValuesResolver; use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Support\StorableFieldCollector; use Moox\Builder\Support\TypedValueColumns; @@ -52,6 +53,34 @@ public function fieldsForResource(string $resourceClass): Collection return $groups->flatMap(fn ($group) => $this->storableFieldCollector->definitionsFromList($group->fields))->values(); } + /** + * @return Collection + */ + public function fieldsForEntity(string $entity): Collection + { + $groups = $this->definitionRegistry->fieldGroupsFor(new LocationContext($entity)); + + return $groups->flatMap(fn ($group) => $this->storableFieldCollector->definitionsFromList($group->fields))->values(); + } + + /** + * @return array + */ + public function loadForModel(Model $record, string $entity, bool $fresh = false): array + { + $fields = $this->fieldsForEntity($entity); + + if ($fields->isEmpty()) { + return []; + } + + if ($fresh) { + return $this->loadValues($entity, $record, $fields); + } + + return $this->loadCachedValues($entity, $record, $fields); + } + /** * @param class-string $resourceClass * @return array @@ -128,6 +157,40 @@ public function loadCachedValues(string $entity, Model $record, Collection $fiel return $this->valuesCache[$cacheKey]; } + /** + * @param Collection $fields + * @return array + */ + public function loadValuesWithDefaults(string $entity, Model $record, Collection $fields): array + { + if ($fields->isEmpty()) { + return []; + } + + $values = $this->loadValues($entity, $record, $fields); + + return app(BuilderValuesResolver::class)->mergeDefaults($fields, $values); + } + + /** + * @param Collection $fields + * @return array + */ + public function loadCachedValuesWithDefaults(string $entity, Model $record, Collection $fields): array + { + if ($fields->isEmpty()) { + return []; + } + + $cacheKey = "{$entity}:{$record->getKey()}"; + + if (! array_key_exists($cacheKey, $this->valuesCache)) { + $this->valuesCache[$cacheKey] = $this->loadValuesWithDefaults($entity, $record, $fields); + } + + return $this->valuesCache[$cacheKey]; + } + /** * @param class-string $resourceClass * @param array $data @@ -215,8 +278,8 @@ public function saveValues(string $entity, Model $record, array $values, Collect $this->fieldValueValidator->assertValid($field, $value); - $cast = $fieldType->castValue($value, $field); - $columns = TypedValueColumns::attributesFor($field->type, $cast); + $persisted = app(BuilderValuesResolver::class)->persistFieldValue($field, $value); + $columns = TypedValueColumns::attributesFor($field->type, $persisted); FieldValue::query()->updateOrCreate( [ diff --git a/packages/item/src/Models/Item.php b/packages/item/src/Models/Item.php index 4262423ca4..8cac6e5d04 100644 --- a/packages/item/src/Models/Item.php +++ b/packages/item/src/Models/Item.php @@ -5,12 +5,13 @@ namespace Moox\Item\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Moox\Builder\Concerns\InteractsWithCustomFields; use Moox\Core\Entities\Items\Item\BaseItemModel; use Moox\Item\Database\Factories\ItemFactory; class Item extends BaseItemModel { - use HasFactory; + use HasFactory, InteractsWithCustomFields; protected $fillable = [ 'title', From 1222633cbf1c0eaf038e39058b201c3b673541ce Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:43:16 +0000 Subject: [PATCH 044/105] Fix styling --- .../builder/src/Database/Eloquent/CustomFieldsBuilder.php | 7 ++++--- packages/builder/src/Services/BuilderValuesResolver.php | 3 ++- packages/builder/src/Services/CustomFieldsManager.php | 1 - 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php b/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php index 13caf2a13d..029d4f45fe 100644 --- a/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php +++ b/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php @@ -4,6 +4,7 @@ namespace Moox\Builder\Database\Eloquent; +use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use InvalidArgumentException; @@ -47,7 +48,7 @@ public function orWhere($column, $operator = null, $value = null): static } /** - * @param array|\Illuminate\Contracts\Support\Arrayable|\Closure(static): void|string $values + * @param array|Arrayable|\Closure(static): void|string $values * @return $this */ public function whereIn($column, $values, $boolean = 'and', $not = false): static @@ -60,7 +61,7 @@ public function whereIn($column, $values, $boolean = 'and', $not = false): stati } /** - * @param array|\Illuminate\Contracts\Support\Arrayable|\Closure(static): void|string $values + * @param array|Arrayable|\Closure(static): void|string $values * @return $this */ public function whereNotIn($column, $values, $boolean = 'and'): static @@ -126,7 +127,7 @@ protected function addCustomFieldConstraint( } /** - * @param array|\Illuminate\Contracts\Support\Arrayable|\Closure(static): void|string $values + * @param array|Arrayable|\Closure(static): void|string $values * @return $this */ protected function addCustomFieldInConstraint( diff --git a/packages/builder/src/Services/BuilderValuesResolver.php b/packages/builder/src/Services/BuilderValuesResolver.php index 6855f6d6c0..02b345c5ad 100644 --- a/packages/builder/src/Services/BuilderValuesResolver.php +++ b/packages/builder/src/Services/BuilderValuesResolver.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; +use Moox\Builder\Concerns\InteractsWithCustomFields; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\Models\FieldValue; @@ -268,7 +269,7 @@ public function eagerLoad(iterable $models, string $entity, Collection $fields): ->groupBy('record_id'); foreach ($collection as $record) { - /** @var Model&\Moox\Builder\Concerns\InteractsWithCustomFields $record */ + /** @var Model&InteractsWithCustomFields $record */ $recordRows = $rows->get($record->getKey(), collect())->keyBy('field_name'); $record->setCustomFieldsCache( $this->resolveFromRows($fields, $recordRows, mergeDefaults: true), diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index 84e75a91ad..bef7ca4ffb 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -13,7 +13,6 @@ use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Registry\FieldTypeRegistry; -use Moox\Builder\Services\BuilderValuesResolver; use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Support\StorableFieldCollector; use Moox\Builder\Support\TypedValueColumns; From 044fad67f7852bd432ba6a929440c365d68f8317 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:48:10 +0200 Subject: [PATCH 045/105] Add image custom field with media picker and usage tracking --- .../builder/resources/lang/de/builder.php | 3 + .../builder/resources/lang/en/builder.php | 3 + .../components/builder-media-picker.blade.php | 103 ++++++++ .../builder/src/BuilderServiceProvider.php | 41 ++- .../src/FieldTypes/Types/ImageFieldType.php | 61 +++++ .../Forms/Components/BuilderMediaPicker.php | 69 +++++ .../BuilderFieldValueMediaMetadataSync.php | 70 +++++ .../src/Services/BuilderMediaUsageSync.php | 99 +++++++ .../src/Services/CustomFieldsManager.php | 10 + .../builder/src/Services/FieldValuePurger.php | 25 +- .../src/Services/FieldValueValidator.php | 45 ++++ .../Support/EntityModelDeletionRegistrar.php | 2 +- .../src/Support/MediaFieldValueSupport.php | 246 ++++++++++++++++++ .../builder/src/Support/MediaIntegration.php | 13 + .../builder/src/Support/TypedValueColumns.php | 2 +- 15 files changed, 787 insertions(+), 5 deletions(-) create mode 100644 packages/builder/resources/views/forms/components/builder-media-picker.blade.php create mode 100644 packages/builder/src/FieldTypes/Types/ImageFieldType.php create mode 100644 packages/builder/src/Forms/Components/BuilderMediaPicker.php create mode 100644 packages/builder/src/Services/BuilderFieldValueMediaMetadataSync.php create mode 100644 packages/builder/src/Services/BuilderMediaUsageSync.php create mode 100644 packages/builder/src/Support/MediaFieldValueSupport.php create mode 100644 packages/builder/src/Support/MediaIntegration.php diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 01727f7bfe..f1c96ad879 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -69,6 +69,7 @@ 'time' => 'Uhrzeit', 'color' => 'Farbe', 'link' => 'Link', + 'image' => 'Bild', 'rich_text' => 'Rich Text', 'message' => 'Hinweis', 'oembed' => 'oEmbed', @@ -167,5 +168,7 @@ 'empty_flexible_item' => 'Block :position in „:field“ ist leer.', 'empty_flexible_layout' => 'Bitte ein Layout für jeden Block wählen.', 'unknown_flexible_layout' => 'Unbekanntes Layout „:layout“.', + 'invalid_media' => 'Bitte wähle ein gültiges Medium aus.', + 'missing_media' => 'Das ausgewählte Medium existiert nicht mehr.', ], ]; diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 5422d0328a..07a4bcb71c 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -69,6 +69,7 @@ 'time' => 'Time', 'color' => 'Color', 'link' => 'Link', + 'image' => 'Image', 'rich_text' => 'Rich text', 'message' => 'Message', 'oembed' => 'oEmbed', @@ -167,5 +168,7 @@ 'empty_flexible_item' => 'Block :position in ":field" is empty.', 'empty_flexible_layout' => 'Please choose a layout for each block.', 'unknown_flexible_layout' => 'Unknown layout ":layout".', + 'invalid_media' => 'Please select a valid media item.', + 'missing_media' => 'The selected media item no longer exists.', ], ]; diff --git a/packages/builder/resources/views/forms/components/builder-media-picker.blade.php b/packages/builder/resources/views/forms/components/builder-media-picker.blade.php new file mode 100644 index 0000000000..64c153cd8d --- /dev/null +++ b/packages/builder/resources/views/forms/components/builder-media-picker.blade.php @@ -0,0 +1,103 @@ + + @php + $mimeTypeLabels = \Moox\Media\Helpers\MediaIconHelper::getIconMapWithLabels(); + @endphp +
+ + @if ($this instanceof \Filament\Resources\Pages\EditRecord || $this instanceof \Filament\Resources\Pages\CreateRecord) + + {{ __('media::fields.select_media') }} + + @endif + +
+ +
+ +
+ {{ __('media::fields.no_media_selected') }} +
+ + +
+
diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index b855934082..69867d31e5 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -34,6 +34,7 @@ use Moox\Builder\FieldTypes\Types\ToggleFieldType; use Moox\Builder\FieldTypes\Types\UrlFieldType; use Moox\Builder\Listeners\PersistCustomFields; +use Moox\Builder\Services\BuilderFieldValueMediaMetadataSync; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldOption; @@ -42,6 +43,7 @@ use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\EntityModelDeletionRegistrar; +use Moox\Builder\Support\MediaIntegration; use Moox\Core\MooxServiceProvider; use Spatie\LaravelPackageTools\Package; @@ -52,6 +54,7 @@ public function configureMoox(Package $package): void $package ->name('builder') ->hasConfigFile() + ->hasViews() ->hasTranslations() ->hasMigrations([ 'create_builder_field_groups_table', @@ -86,6 +89,8 @@ public function packageBooted(): void Event::listen(RecordSaved::class, PersistCustomFields::class); + $this->registerMediaMetadataSyncListeners(); + $this->app->booted(function (): void { app(EntityModelDeletionRegistrar::class)->register(); }); @@ -96,7 +101,7 @@ public function packageBooted(): void */ protected function defaultFieldTypes(): array { - return [ + $types = [ new TextFieldType, new TextareaFieldType, new NumberFieldType, @@ -115,6 +120,13 @@ protected function defaultFieldTypes(): array new RangeFieldType, new ButtonGroupFieldType, new LinkFieldType, + ]; + + if (MediaIntegration::isAvailable()) { + $types[] = new FieldTypes\Types\ImageFieldType; + } + + return array_merge($types, [ new RichTextFieldType, new MessageFieldType, new OembedFieldType, @@ -123,6 +135,31 @@ protected function defaultFieldTypes(): array new RepeaterFieldType, new FlexibleContentFieldType, new FlexibleLayoutFieldType, - ]; + ]); + } + + protected function registerMediaMetadataSyncListeners(): void + { + if (! MediaIntegration::isAvailable()) { + return; + } + + $translationClass = 'Moox\Media\Models\MediaTranslation'; + $mediaClass = 'Moox\Media\Models\Media'; + + if (! class_exists($translationClass) || ! class_exists($mediaClass)) { + return; + } + + $sync = function (object $translation) use ($mediaClass): void { + $media = $mediaClass::query()->find($translation->media_id ?? null); + + if ($media !== null) { + app(BuilderFieldValueMediaMetadataSync::class)->syncForMedia($media); + } + }; + + $translationClass::saved($sync); + $translationClass::updated($sync); } } diff --git a/packages/builder/src/FieldTypes/Types/ImageFieldType.php b/packages/builder/src/FieldTypes/Types/ImageFieldType.php new file mode 100644 index 0000000000..15a4442a04 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/ImageFieldType.php @@ -0,0 +1,61 @@ +name) + ->label($field->label) + ->acceptedFileTypes(['image/*']) + ->maxFiles(1) + ->multiple(false); + + return Capability::applyAll($this->capabilities(), $component, $field); + } +} diff --git a/packages/builder/src/Forms/Components/BuilderMediaPicker.php b/packages/builder/src/Forms/Components/BuilderMediaPicker.php new file mode 100644 index 0000000000..96d4837f64 --- /dev/null +++ b/packages/builder/src/Forms/Components/BuilderMediaPicker.php @@ -0,0 +1,69 @@ +loadStateFromRelationshipsUsing(static function (): void { + // Hydration is handled by SchemaCompiler via builder field values. + }); + + $this->getUploadedFileUsing(static function (): ?array { + return null; + }); + + $this->saveRelationshipsUsing(static function (): void { + // Values are persisted by CustomFieldsManager, not model columns. + }); + + $this->dehydrated(true); + } + + /** + * @return list + */ + public function getInitialPreviewMedia(): array + { + if (! class_exists(Media::class)) { + return []; + } + + if (! Schema::hasTable('media')) { + return []; + } + + $ids = MediaFieldValueSupport::extractIds($this->getState()); + + if ($ids === []) { + return []; + } + + return Media::query() + ->whereIn('id', $ids) + ->get() + ->map(static function (Media $media): array { + return [ + 'id' => $media->id, + 'url' => $media->getUrl(), + 'file_name' => $media->file_name, + 'name' => $media->name ?? $media->file_name, + 'mime_type' => $media->mime_type, + ]; + }) + ->values() + ->all(); + } +} diff --git a/packages/builder/src/Services/BuilderFieldValueMediaMetadataSync.php b/packages/builder/src/Services/BuilderFieldValueMediaMetadataSync.php new file mode 100644 index 0000000000..e84ed67842 --- /dev/null +++ b/packages/builder/src/Services/BuilderFieldValueMediaMetadataSync.php @@ -0,0 +1,70 @@ +getKey() : $media; + $fresh = MediaFieldValueSupport::snapshotFromMediaId($mediaId); + + if ($fresh === null) { + return; + } + + FieldValue::query() + ->whereNotNull('value_json') + ->lazyById() + ->each(function (FieldValue $row) use ($fresh, $mediaId): void { + $ids = MediaFieldValueSupport::extractIds($row->value_json); + + if (! in_array($mediaId, $ids, true)) { + return; + } + + $snapshot = MediaFieldValueSupport::normalizeSnapshot($row->value_json); + + if ($snapshot === null || (int) ($snapshot['id'] ?? 0) !== $mediaId) { + return; + } + + $row->update(['value_json' => $fresh]); + + $this->customFieldsManager->forgetValuesCache($row->entity, $row->record_id); + $this->flushCustomFieldsCacheForRow($row); + }); + } + + protected function flushCustomFieldsCacheForRow(FieldValue $row): void + { + $modelClass = $this->entityRegistry->modelFor($row->entity); + + if ($modelClass === null) { + return; + } + + $model = $modelClass::query()->find($row->record_id); + + if ($model !== null && method_exists($model, 'flushCustomFieldsCache')) { + $model->flushCustomFieldsCache(); + } + } +} diff --git a/packages/builder/src/Services/BuilderMediaUsageSync.php b/packages/builder/src/Services/BuilderMediaUsageSync.php new file mode 100644 index 0000000000..6c76a18aca --- /dev/null +++ b/packages/builder/src/Services/BuilderMediaUsageSync.php @@ -0,0 +1,99 @@ + + */ + private const MEDIA_FIELD_TYPES = ['image']; + + /** + * @param Collection $fields + */ + public function syncForRecord(string $entity, Model $record, Collection $fields): void + { + if (! $this->canSync()) { + return; + } + + $mediaIds = $this->collectMediaIds($entity, $record, $fields); + + $query = MediaUsable::query() + ->where('media_usable_id', $record->getKey()) + ->where('media_usable_type', $record::class); + + if ($mediaIds === []) { + $query->delete(); + + return; + } + + (clone $query)->whereNotIn('media_id', $mediaIds)->delete(); + + foreach ($mediaIds as $mediaId) { + MediaUsable::query()->firstOrCreate([ + 'media_id' => $mediaId, + 'media_usable_id' => $record->getKey(), + 'media_usable_type' => $record::class, + ]); + } + } + + public function purgeForRecord(Model $record): void + { + if (! $this->canSync()) { + return; + } + + MediaUsable::query() + ->where('media_usable_id', $record->getKey()) + ->where('media_usable_type', $record::class) + ->delete(); + } + + /** + * @param Collection $fields + * @return list + */ + protected function collectMediaIds(string $entity, Model $record, Collection $fields): array + { + $mediaFieldNames = $fields + ->filter(fn (FieldDefinition $field): bool => in_array($field->type, self::MEDIA_FIELD_TYPES, true)) + ->pluck('name') + ->all(); + + if ($mediaFieldNames === []) { + return []; + } + + $ids = []; + + $rows = FieldValue::query() + ->forRecord($entity, $record->getKey()) + ->whereIn('field_name', $mediaFieldNames) + ->get(); + + foreach ($rows as $row) { + $ids = array_merge($ids, MediaFieldValueSupport::extractIds($row->value_json)); + } + + return array_values(array_unique(array_filter($ids))); + } + + protected function canSync(): bool + { + return class_exists(MediaUsable::class) && Schema::hasTable('media_usables'); + } +} diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index bef7ca4ffb..8c5146ff81 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -13,6 +13,7 @@ use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Support\MediaIntegration; use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Support\StorableFieldCollector; use Moox\Builder\Support\TypedValueColumns; @@ -290,9 +291,18 @@ public function saveValues(string $entity, Model $record, array $values, Collect ); } + if (MediaIntegration::isAvailable()) { + app(BuilderMediaUsageSync::class)->syncForRecord($entity, $record, $fields); + } + unset($this->valuesCache["{$entity}:{$record->getKey()}"]); } + public function forgetValuesCache(string $entity, int|string $recordId): void + { + unset($this->valuesCache["{$entity}:{$recordId}"]); + } + /** * @param class-string $resourceClass */ diff --git a/packages/builder/src/Services/FieldValuePurger.php b/packages/builder/src/Services/FieldValuePurger.php index e4a0d92d1a..e3b20a7eb8 100644 --- a/packages/builder/src/Services/FieldValuePurger.php +++ b/packages/builder/src/Services/FieldValuePurger.php @@ -4,16 +4,39 @@ namespace Moox\Builder\Services; +use Illuminate\Database\Eloquent\Model; use Moox\Builder\Models\FieldValue; +use Moox\Builder\Registry\EntityRegistry; +use Moox\Media\Models\MediaUsable; class FieldValuePurger { - public function purgeForRecord(string $entity, int|string $recordId): void + public function __construct( + protected BuilderMediaUsageSync $mediaUsageSync, + protected EntityRegistry $entityRegistry, + ) {} + + public function purgeForRecord(string $entity, int|string $recordId, ?Model $record = null): void { FieldValue::query() ->where('entity', $entity) ->where('record_id', $recordId) ->delete(); + + if ($record !== null) { + $this->mediaUsageSync->purgeForRecord($record); + + return; + } + + $modelClass = $this->entityRegistry->modelFor($entity); + + if ($modelClass !== null && class_exists(MediaUsable::class)) { + MediaUsable::query() + ->where('media_usable_id', $recordId) + ->where('media_usable_type', $modelClass) + ->delete(); + } } /** diff --git a/packages/builder/src/Services/FieldValueValidator.php b/packages/builder/src/Services/FieldValueValidator.php index 2bce906c29..51d3dff98f 100644 --- a/packages/builder/src/Services/FieldValueValidator.php +++ b/packages/builder/src/Services/FieldValueValidator.php @@ -8,6 +8,7 @@ use Illuminate\Validation\ValidationException; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Support\MediaFieldValueSupport; use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Support\RichTextValue; @@ -183,6 +184,10 @@ protected function messagesForLeaf(FieldDefinition $field, mixed $value, string return $this->messagesForLink($field, $value, $path); } + if ($field->type === 'image') { + return $this->messagesForImage($field, $value, $path); + } + $messages = []; if (($field->validation['required'] ?? false) === true && $this->isEmptyValue($field->type, $value)) { @@ -247,6 +252,46 @@ protected function messagesForLink(FieldDefinition $field, mixed $value, string return $messages; } + /** + * @return array> + */ + protected function messagesForImage(FieldDefinition $field, mixed $value, string $path): array + { + $messages = []; + + if (($field->validation['required'] ?? false) === true && $this->isEmptyValue('image', $value)) { + $messages[$path] = [ + __('validation.required', ['attribute' => $field->label]), + ]; + } + + if ($this->isEmptyValue('image', $value)) { + return $messages; + } + + $ids = MediaFieldValueSupport::extractIds($value); + + if ($ids === []) { + $messages[$path] = [ + __('builder::builder.validation.invalid_media'), + ]; + + return $messages; + } + + foreach ($ids as $mediaId) { + if (! MediaFieldValueSupport::mediaExists($mediaId)) { + $messages[$path] = [ + __('builder::builder.validation.missing_media'), + ]; + + break; + } + } + + return $messages; + } + /** * @param array $row */ diff --git a/packages/builder/src/Support/EntityModelDeletionRegistrar.php b/packages/builder/src/Support/EntityModelDeletionRegistrar.php index a5b911ec03..d9871c491d 100644 --- a/packages/builder/src/Support/EntityModelDeletionRegistrar.php +++ b/packages/builder/src/Support/EntityModelDeletionRegistrar.php @@ -28,7 +28,7 @@ public function register(): void } $modelClass::deleted(function (Model $record) use ($entity): void { - app(FieldValuePurger::class)->purgeForRecord($entity, $record->getKey()); + app(FieldValuePurger::class)->purgeForRecord($entity, $record->getKey(), $record); }); self::$registeredModels[] = $modelClass; diff --git a/packages/builder/src/Support/MediaFieldValueSupport.php b/packages/builder/src/Support/MediaFieldValueSupport.php new file mode 100644 index 0000000000..0b17c84a3b --- /dev/null +++ b/packages/builder/src/Support/MediaFieldValueSupport.php @@ -0,0 +1,246 @@ + 0 ? self::snapshotFromMediaId($id) : null; + } + + if (! isset($value['id']) || ! is_numeric($value['id'])) { + return null; + } + + $id = (int) $value['id']; + + if ($id <= 0) { + return null; + } + + return [ + 'id' => $id, + 'file_name' => (string) ($value['file_name'] ?? ''), + 'title' => self::nullableString($value['title'] ?? null), + 'alt' => self::nullableString($value['alt'] ?? null), + 'description' => self::nullableString($value['description'] ?? null), + 'internal_note' => self::nullableString($value['internal_note'] ?? null), + ]; + } + + /** + * @return list + */ + public static function extractIds(mixed $value): array + { + if ($value === null || $value === '') { + return []; + } + + if (is_numeric($value)) { + $id = (int) $value; + + return $id > 0 ? [$id] : []; + } + + if (! is_array($value)) { + return []; + } + + if (isset($value['id']) && is_numeric($value['id'])) { + $id = (int) $value['id']; + + return $id > 0 ? [$id] : []; + } + + if (array_is_list($value)) { + return array_values(array_filter(array_map( + static fn (mixed $id): ?int => is_numeric($id) ? (int) $id : null, + $value, + ), static fn (?int $id): bool => $id !== null && $id > 0)); + } + + return []; + } + + public static function persistSingle(mixed $value): ?array + { + if ($value === null || $value === '' || $value === []) { + return null; + } + + $snapshot = self::normalizeSnapshot($value); + + if ($snapshot === null) { + return null; + } + + return self::snapshotFromMediaId($snapshot['id']); + } + + public static function mediaExists(int $mediaId): bool + { + if ($mediaId <= 0 || ! self::canQueryMedia()) { + return false; + } + + return DB::table('media')->where('id', $mediaId)->exists(); + } + + public static function presentSingle(mixed $value): ?array + { + $snapshot = self::normalizeSnapshot($value); + + if ($snapshot === null) { + return null; + } + + if (! self::canQueryMedia()) { + return $snapshot; + } + + $media = Media::query()->find($snapshot['id']); + + if (! $media instanceof Media) { + return array_merge($snapshot, [ + 'url' => null, + 'thumbnail_url' => null, + 'preview_url' => null, + ]); + } + + return (new MediaItemResource($media))->resolve(); + } + + /** + * @return array{id: int, file_name: string, title: ?string, alt: ?string, description: ?string, internal_note: ?string}|null + */ + public static function snapshotFromMediaId(int $mediaId): ?array + { + if ($mediaId <= 0 || ! self::canQueryMedia()) { + return null; + } + + $row = DB::table('media')->where('id', $mediaId)->first(); + + if ($row === null) { + return null; + } + + $metadata = self::metadataFromTranslations($mediaId); + + return [ + 'id' => $mediaId, + 'file_name' => (string) $row->file_name, + 'title' => $metadata['title'], + 'alt' => $metadata['alt'], + 'description' => $metadata['description'], + 'internal_note' => $metadata['internal_note'], + ]; + } + + /** + * @return array{title: ?string, alt: ?string, description: ?string, internal_note: ?string} + */ + protected static function metadataFromTranslations(int $mediaId): array + { + if (! Schema::hasTable('media_translations')) { + return [ + 'title' => null, + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ]; + } + + $defaultLocale = 'en_US'; + + if (class_exists(Localization::class)) { + $localization = Localization::query() + ->where('is_default', true) + ->where('is_active_admin', true) + ->with('language') + ->first(); + + if ($localization) { + $defaultLocale = $localization->getAttribute('locale_variant') ?: $localization->language->alpha2; + } + } + + $translations = DB::table('media_translations') + ->where('media_id', $mediaId) + ->get() + ->keyBy('locale'); + + $translation = $translations->get($defaultLocale) + ?? $translations->get('en_US') + ?? $translations->first(); + + if (! $translation) { + return [ + 'title' => null, + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ]; + } + + return [ + 'title' => self::nullableString($translation->title ?? null), + 'alt' => self::nullableString($translation->alt ?? null), + 'description' => self::nullableString($translation->description ?? null), + 'internal_note' => self::nullableString($translation->internal_note ?? null), + ]; + } + + protected static function canQueryMedia(): bool + { + return class_exists(Media::class) && Schema::hasTable('media'); + } + + protected static function nullableString(mixed $value): ?string + { + if (! is_string($value)) { + return null; + } + + $trimmed = trim($value); + + return $trimmed === '' ? null : $trimmed; + } +} diff --git a/packages/builder/src/Support/MediaIntegration.php b/packages/builder/src/Support/MediaIntegration.php new file mode 100644 index 0000000000..ddce9b9724 --- /dev/null +++ b/packages/builder/src/Support/MediaIntegration.php @@ -0,0 +1,13 @@ + 'value_date', 'datetime' => 'value_datetime', 'toggle' => 'value_boolean', - 'multiselect', 'checkbox_list', 'link', 'group', 'repeater', 'flexible_content' => 'value_json', + 'multiselect', 'checkbox_list', 'link', 'image', 'group', 'repeater', 'flexible_content' => 'value_json', 'button_group' => 'value_string', default => 'value_string', }; From 4e5f4ff2e4dca81a18d1f3ac3733f1a1a84916ef Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:49:23 +0000 Subject: [PATCH 046/105] Fix styling --- packages/builder/src/BuilderServiceProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index 69867d31e5..27eb62c62a 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -34,7 +34,6 @@ use Moox\Builder\FieldTypes\Types\ToggleFieldType; use Moox\Builder\FieldTypes\Types\UrlFieldType; use Moox\Builder\Listeners\PersistCustomFields; -use Moox\Builder\Services\BuilderFieldValueMediaMetadataSync; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldOption; @@ -42,6 +41,7 @@ use Moox\Builder\Observers\PurgeFieldValuesObserver; use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Services\BuilderFieldValueMediaMetadataSync; use Moox\Builder\Support\EntityModelDeletionRegistrar; use Moox\Builder\Support\MediaIntegration; use Moox\Core\MooxServiceProvider; From e84a14ea9760708db667e159d33c541f350bff2e Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:34:45 +0200 Subject: [PATCH 047/105] Add gallery custom field with scoped media validation and picker isolation --- .../builder/resources/lang/de/builder.php | 9 + .../builder/resources/lang/en/builder.php | 9 + .../components/builder-media-picker.blade.php | 40 ++- .../builder-media-picker-modal.blade.php | 228 +++++++++++++++ .../builder/src/BuilderServiceProvider.php | 13 + .../FieldTypes/Capabilities/GalleryFiles.php | 67 +++++ .../src/FieldTypes/Types/GalleryFieldType.php | 60 ++++ .../Http/Livewire/BuilderMediaPickerModal.php | 54 ++++ .../BuilderFieldValueMediaMetadataSync.php | 10 +- .../src/Services/BuilderMediaUsageSync.php | 2 +- .../src/Services/CustomFieldsManager.php | 2 +- .../src/Services/FieldValueValidator.php | 131 +++++++-- .../src/Support/MediaFieldValueSupport.php | 264 +++++++++++++++++- .../builder/src/Support/TypedValueColumns.php | 2 +- 14 files changed, 849 insertions(+), 42 deletions(-) create mode 100644 packages/builder/resources/views/livewire/builder-media-picker-modal.blade.php create mode 100644 packages/builder/src/FieldTypes/Capabilities/GalleryFiles.php create mode 100644 packages/builder/src/FieldTypes/Types/GalleryFieldType.php create mode 100644 packages/builder/src/Http/Livewire/BuilderMediaPickerModal.php diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index f1c96ad879..44c4a4fd53 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -70,6 +70,7 @@ 'color' => 'Farbe', 'link' => 'Link', 'image' => 'Bild', + 'gallery' => 'Galerie', 'rich_text' => 'Rich Text', 'message' => 'Hinweis', 'oembed' => 'oEmbed', @@ -129,6 +130,10 @@ 'min_items_helper' => 'Leer lassen für keine Untergrenze. Nur bei Pflichtfeldern mindestens 1 Eintrag.', 'max_items' => 'Höchstanzahl Einträge', 'max_items_helper' => 'Leer lassen für keine Obergrenze.', + 'min_files' => 'Mindestanzahl Dateien', + 'min_files_helper' => 'Leer lassen für keine Untergrenze. Pflichtfelder benötigen mindestens eine Datei.', + 'max_files' => 'Höchstanzahl Dateien', + 'max_files_helper' => 'Leer lassen für keine Obergrenze.', 'step' => 'Schrittweite', 'rows' => 'Zeilen', 'display_format' => 'Anzeigeformat', @@ -170,5 +175,9 @@ 'unknown_flexible_layout' => 'Unbekanntes Layout „:layout“.', 'invalid_media' => 'Bitte wähle ein gültiges Medium aus.', 'missing_media' => 'Das ausgewählte Medium existiert nicht mehr.', + 'invalid_media_type' => 'Das ausgewählte Medium für :attribute muss ein Bild sein.', + 'media_scope_mismatch' => 'Das ausgewählte Medium für :attribute ist für diesen Datensatz nicht verfügbar.', + 'gallery_min_files' => 'Wähle mindestens :min Medien für :attribute aus.', + 'gallery_max_files' => 'Wähle höchstens :max Medien für :attribute aus.', ], ]; diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 07a4bcb71c..1f7c42967b 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -70,6 +70,7 @@ 'color' => 'Color', 'link' => 'Link', 'image' => 'Image', + 'gallery' => 'Gallery', 'rich_text' => 'Rich text', 'message' => 'Message', 'oembed' => 'oEmbed', @@ -129,6 +130,10 @@ 'min_items_helper' => 'Leave empty for no minimum. Required fields always need at least one item.', 'max_items' => 'Maximum items', 'max_items_helper' => 'Leave empty for no maximum.', + 'min_files' => 'Minimum files', + 'min_files_helper' => 'Leave empty for no minimum. Required fields always need at least one file.', + 'max_files' => 'Maximum files', + 'max_files_helper' => 'Leave empty for no maximum.', 'step' => 'Step', 'rows' => 'Rows', 'display_format' => 'Display format', @@ -170,5 +175,9 @@ 'unknown_flexible_layout' => 'Unknown layout ":layout".', 'invalid_media' => 'Please select a valid media item.', 'missing_media' => 'The selected media item no longer exists.', + 'invalid_media_type' => 'The selected media for :attribute must be an image.', + 'media_scope_mismatch' => 'The selected media for :attribute is not available for this record.', + 'gallery_min_files' => 'Select at least :min media items for :attribute.', + 'gallery_max_files' => 'Select no more than :max media items for :attribute.', ], ]; diff --git a/packages/builder/resources/views/forms/components/builder-media-picker.blade.php b/packages/builder/resources/views/forms/components/builder-media-picker.blade.php index 64c153cd8d..1579eb7437 100644 --- a/packages/builder/resources/views/forms/components/builder-media-picker.blade.php +++ b/packages/builder/resources/views/forms/components/builder-media-picker.blade.php @@ -1,8 +1,11 @@ @php $mimeTypeLabels = \Moox\Media\Helpers\MediaIconHelper::getIconMapWithLabels(); + $statePath = $getStatePath(); + $modalId = 'builder-media-picker-' . md5($statePath); @endphp
@@ -41,7 +55,7 @@ modelId: {{ $getRecord()?->id ?? 0 }}, modelClass: '{{ $getRecord() ? addslashes($getRecord()::class) : addslashes($this->getResource()::getModel()) }}' }); - $dispatch('open-modal', { id: 'mediaPickerModal' }); + $dispatch('open-modal', { id: '{{ $modalId }}' }); "> {{ __('media::fields.select_media') }} @@ -94,8 +108,12 @@ {{ __('media::fields.no_media_selected') }}
- diff --git a/packages/builder/resources/views/livewire/builder-media-picker-modal.blade.php b/packages/builder/resources/views/livewire/builder-media-picker-modal.blade.php new file mode 100644 index 0000000000..46ca2d652b --- /dev/null +++ b/packages/builder/resources/views/livewire/builder-media-picker-modal.blade.php @@ -0,0 +1,228 @@ + +
+ {{ $this->form }} +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + @foreach($uploaderOptions as $type => $uploaders) + + @foreach($uploaders as $id => $name) + + @endforeach + + @endforeach + + + + + + + @foreach($collectionOptions as $id => $name) + + @endforeach + + +
+ + +
+ @if($mediaItems) + @foreach ($mediaItems as $item) + @php + $mimeType = $item['mime_type'] ?? null; + $isImage = $mimeType && str_starts_with($mimeType, 'image/'); + $fileData = $mimeTypeLabels[$mimeType] ?? null; + $iconData = $fileData ?? ['icon' => '/vendor/file-icons/unknown.svg', 'label' => 'File']; + @endphp + +
+ @if ($isImage) +
+ +
+ @else +
+ +
+ {{ $item['file_name'] }} +
+
+ @endif + + @if(in_array($item['id'], $selectedMediaIds)) +
+ +
+ @endif +
+ @endforeach + @endif +
+ +
+ +
+ +
+
+ +
+ @if(!empty($selectedMediaMeta['id'])) + +
+
+ {{ __('media::fields.file_name') }} +

{{ $selectedMediaMeta['file_name'] }}

+
+ +
+ {{ __('media::fields.file_type') }} +

{{ $selectedMediaMeta['mime_type'] }}

+
+ +
+ {{ __('media::fields.size') }} +

{{ number_format($selectedMediaMeta['size'] / 1024, 2) }} KB

+
+ +
+ {{ __('media::fields.dimensions') }} +

{{ $selectedMediaMeta['dimensions']['width'] ?? '-' }} x {{ $selectedMediaMeta['dimensions']['height'] ?? '-' }}

+
+ +
+ {{ __('media::fields.created_at') }} +

{{ $selectedMediaMeta['created_at'] ? \Carbon\Carbon::parse($selectedMediaMeta['created_at'])->format('d.m.Y H:i') : '-' }}

+
+ +
+ {{ __('media::fields.updated_at') }} +

{{ $selectedMediaMeta['updated_at'] ? \Carbon\Carbon::parse($selectedMediaMeta['updated_at'])->format('d.m.Y H:i') : '-' }}

+
+ +
+ {{ __('media::fields.uploaded_by') }} +

{{ $selectedMediaMeta['uploader_name'] ?? '-' }}

+
+ +
+ {{ __('media::fields.collection') }} +
+ + + @foreach($collectionOptions as $id => $name) + + @endforeach + + +
+
+
+
+ + + +

{{ __('media::fields.metadata') }}

+
+
+ +
+ {{ __('media::fields.name') }} + + + +
+ +
+ {{ __('media::fields.title') }} + + + +
+ +
+ {{ __('media::fields.description') }} + + + +
+ +
+ {{ __('media::fields.alt_text') }} + + + +
+
+
+ + + +

{{ __('media::fields.internal_note') }}

+
+ + + +
+ +
+ + {{ __('media::fields.close') }} + + + {{ __('media::fields.apply_selection') }} + + +
+ +
+ @else + +

{{ __('media::fields.no_media_selected') }}

+
+ @endif +
+ + + +
diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index 27eb62c62a..bf135bd414 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -42,6 +42,8 @@ use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Services\BuilderFieldValueMediaMetadataSync; +use Livewire\Livewire; +use Moox\Builder\Http\Livewire\BuilderMediaPickerModal; use Moox\Builder\Support\EntityModelDeletionRegistrar; use Moox\Builder\Support\MediaIntegration; use Moox\Core\MooxServiceProvider; @@ -93,6 +95,7 @@ public function packageBooted(): void $this->app->booted(function (): void { app(EntityModelDeletionRegistrar::class)->register(); + $this->registerMediaPickerModal(); }); } @@ -124,6 +127,7 @@ protected function defaultFieldTypes(): array if (MediaIntegration::isAvailable()) { $types[] = new FieldTypes\Types\ImageFieldType; + $types[] = new FieldTypes\Types\GalleryFieldType; } return array_merge($types, [ @@ -138,6 +142,15 @@ protected function defaultFieldTypes(): array ]); } + protected function registerMediaPickerModal(): void + { + if (! MediaIntegration::isAvailable() || ! $this->app->bound('livewire.finder')) { + return; + } + + Livewire::component('builder-media-picker-modal', BuilderMediaPickerModal::class); + } + protected function registerMediaMetadataSyncListeners(): void { if (! MediaIntegration::isAvailable()) { diff --git a/packages/builder/src/FieldTypes/Capabilities/GalleryFiles.php b/packages/builder/src/FieldTypes/Capabilities/GalleryFiles.php new file mode 100644 index 0000000000..1d5517d74e --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/GalleryFiles.php @@ -0,0 +1,67 @@ +label(__('builder::builder.capabilities.min_files')) + ->helperText(__('builder::builder.capabilities.min_files_helper')) + ->numeric() + ->minValue(1), + TextInput::make('config.max_files') + ->label(__('builder::builder.capabilities.max_files')) + ->helperText(__('builder::builder.capabilities.max_files_helper')) + ->numeric() + ->minValue(1), + ]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if (! $component instanceof BuilderMediaPicker) { + return $component; + } + + $min = $this->normalizeFileLimit($field->config['min_files'] ?? null); + + if ($min !== null) { + $component->minFiles($min); + } elseif (($field->validation['required'] ?? false) === true) { + $component->minFiles(1); + } + + $max = $this->normalizeFileLimit($field->config['max_files'] ?? null); + + if ($max !== null) { + $component->maxFiles($max); + } + + return $component; + } + + protected function normalizeFileLimit(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + if (! is_numeric($value)) { + return null; + } + + $limit = (int) $value; + + return $limit > 0 ? $limit : null; + } +} diff --git a/packages/builder/src/FieldTypes/Types/GalleryFieldType.php b/packages/builder/src/FieldTypes/Types/GalleryFieldType.php new file mode 100644 index 0000000000..0b5b6e4c57 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/GalleryFieldType.php @@ -0,0 +1,60 @@ +name) + ->label($field->label) + ->acceptedFileTypes(['image/*']) + ->multiple(true); + + return Capability::applyAll($this->capabilities(), $component, $field); + } +} diff --git a/packages/builder/src/Http/Livewire/BuilderMediaPickerModal.php b/packages/builder/src/Http/Livewire/BuilderMediaPickerModal.php new file mode 100644 index 0000000000..6e48c94820 --- /dev/null +++ b/packages/builder/src/Http/Livewire/BuilderMediaPickerModal.php @@ -0,0 +1,54 @@ + $selectedMedia */ + $selectedMedia = $this->applyMediaScope(Media::query()) + ->whereIn('id', $this->selectedMediaIds) + ->get(); + + $payload = []; + + if ($selectedMedia->isNotEmpty()) { + $payload = $selectedMedia + ->map(static fn (Media $media): array => [ + 'id' => $media->getKey(), + 'url' => $media->getUrl(), + 'file_name' => $media->file_name, + 'mime_type' => $media->mime_type, + 'name' => $media->getAttribute('name'), + ]) + ->values() + ->all(); + } + + $this->dispatch('builder-media-selected', statePath: $this->statePath, media: $payload); + $this->dispatch('close-modal', id: $this->modalId); + } + + public function render(): View + { + /** @var View $view */ + $view = parent::render(); + + return view('builder::livewire.builder-media-picker-modal', array_merge( + $view->getData(), + ['modalId' => $this->modalId], + )); + } +} diff --git a/packages/builder/src/Services/BuilderFieldValueMediaMetadataSync.php b/packages/builder/src/Services/BuilderFieldValueMediaMetadataSync.php index e84ed67842..26fd5d778a 100644 --- a/packages/builder/src/Services/BuilderFieldValueMediaMetadataSync.php +++ b/packages/builder/src/Services/BuilderFieldValueMediaMetadataSync.php @@ -40,13 +40,17 @@ public function syncForMedia(Media|int $media): void return; } - $snapshot = MediaFieldValueSupport::normalizeSnapshot($row->value_json); + $updated = MediaFieldValueSupport::replaceSnapshotInStoredValue( + $row->value_json, + $mediaId, + $fresh, + ); - if ($snapshot === null || (int) ($snapshot['id'] ?? 0) !== $mediaId) { + if ($updated === $row->value_json) { return; } - $row->update(['value_json' => $fresh]); + $row->update(['value_json' => $updated]); $this->customFieldsManager->forgetValuesCache($row->entity, $row->record_id); $this->flushCustomFieldsCacheForRow($row); diff --git a/packages/builder/src/Services/BuilderMediaUsageSync.php b/packages/builder/src/Services/BuilderMediaUsageSync.php index 6c76a18aca..b13a80ec2f 100644 --- a/packages/builder/src/Services/BuilderMediaUsageSync.php +++ b/packages/builder/src/Services/BuilderMediaUsageSync.php @@ -17,7 +17,7 @@ final class BuilderMediaUsageSync /** * @var list */ - private const MEDIA_FIELD_TYPES = ['image']; + private const MEDIA_FIELD_TYPES = ['image', 'gallery']; /** * @param Collection $fields diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index 8c5146ff81..857c649835 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -276,7 +276,7 @@ public function saveValues(string $entity, Model $record, array $values, Collect OptionValueRules::assertValid($field, $value); - $this->fieldValueValidator->assertValid($field, $value); + $this->fieldValueValidator->assertValid($field, $value, $record); $persisted = app(BuilderValuesResolver::class)->persistFieldValue($field, $value); $columns = TypedValueColumns::attributesFor($field->type, $persisted); diff --git a/packages/builder/src/Services/FieldValueValidator.php b/packages/builder/src/Services/FieldValueValidator.php index 51d3dff98f..f238562e82 100644 --- a/packages/builder/src/Services/FieldValueValidator.php +++ b/packages/builder/src/Services/FieldValueValidator.php @@ -4,6 +4,7 @@ namespace Moox\Builder\Services; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; use Moox\Builder\Data\FieldDefinition; @@ -21,7 +22,7 @@ public function __construct( /** * @return array> */ - public function messagesFor(FieldDefinition $field, mixed $value, ?string $path = null): array + public function messagesFor(FieldDefinition $field, mixed $value, ?string $path = null, ?Model $record = null): array { $path ??= $field->name; @@ -36,15 +37,15 @@ public function messagesFor(FieldDefinition $field, mixed $value, ?string $path } if ($fieldType->hasSubFields()) { - return $this->messagesForCompound($field, $value, $path); + return $this->messagesForCompound($field, $value, $path, $record); } - return $this->messagesForLeaf($field, $value, $path); + return $this->messagesForLeaf($field, $value, $path, $record); } - public function assertValid(FieldDefinition $field, mixed $value): void + public function assertValid(FieldDefinition $field, mixed $value, ?Model $record = null): void { - $messages = $this->messagesFor($field, $value); + $messages = $this->messagesFor($field, $value, record: $record); if ($messages !== []) { throw ValidationException::withMessages($messages); @@ -54,14 +55,14 @@ public function assertValid(FieldDefinition $field, mixed $value): void /** * @return array> */ - protected function messagesForCompound(FieldDefinition $field, mixed $value, string $path): array + protected function messagesForCompound(FieldDefinition $field, mixed $value, string $path, ?Model $record = null): array { if ($field->type === 'group') { - return $this->messagesForRow($field, $this->normalizeGroupRow($value), $path); + return $this->messagesForRow($field, $this->normalizeGroupRow($value), $path, $record); } if ($field->type === 'flexible_content') { - return $this->messagesForFlexibleContent($field, $value, $path); + return $this->messagesForFlexibleContent($field, $value, $path, $record); } if (! is_array($value)) { @@ -88,7 +89,7 @@ protected function messagesForCompound(FieldDefinition $field, mixed $value, str continue; } - $messages = array_merge($messages, $this->messagesForRow($field, $item, $itemPath)); + $messages = array_merge($messages, $this->messagesForRow($field, $item, $itemPath, $record)); } return $messages; @@ -97,7 +98,7 @@ protected function messagesForCompound(FieldDefinition $field, mixed $value, str /** * @return array> */ - protected function messagesForFlexibleContent(FieldDefinition $field, mixed $value, string $path): array + protected function messagesForFlexibleContent(FieldDefinition $field, mixed $value, string $path, ?Model $record = null): array { if (! is_array($value)) { return []; @@ -145,7 +146,7 @@ protected function messagesForFlexibleContent(FieldDefinition $field, mixed $val continue; } - $messages = array_merge($messages, $this->messagesForRow($layout, $data, "{$itemPath}.data")); + $messages = array_merge($messages, $this->messagesForRow($layout, $data, "{$itemPath}.data", $record)); } return $messages; @@ -154,7 +155,7 @@ protected function messagesForFlexibleContent(FieldDefinition $field, mixed $val /** * @return array> */ - protected function messagesForRow(FieldDefinition $parent, array $row, string $path): array + protected function messagesForRow(FieldDefinition $parent, array $row, string $path, ?Model $record = null): array { $messages = []; @@ -162,7 +163,7 @@ protected function messagesForRow(FieldDefinition $parent, array $row, string $p $childPath = "{$path}.{$child->name}"; $messages = array_merge( $messages, - $this->messagesFor($child, $row[$child->name] ?? null, $childPath), + $this->messagesFor($child, $row[$child->name] ?? null, $childPath, $record), ); } @@ -172,7 +173,7 @@ protected function messagesForRow(FieldDefinition $parent, array $row, string $p /** * @return array> */ - protected function messagesForLeaf(FieldDefinition $field, mixed $value, string $path): array + protected function messagesForLeaf(FieldDefinition $field, mixed $value, string $path, ?Model $record = null): array { $fieldType = $this->fieldTypeRegistry->get($field->type); @@ -185,7 +186,11 @@ protected function messagesForLeaf(FieldDefinition $field, mixed $value, string } if ($field->type === 'image') { - return $this->messagesForImage($field, $value, $path); + return $this->messagesForImage($field, $value, $path, $record); + } + + if ($field->type === 'gallery') { + return $this->messagesForGallery($field, $value, $path, $record); } $messages = []; @@ -255,17 +260,66 @@ protected function messagesForLink(FieldDefinition $field, mixed $value, string /** * @return array> */ - protected function messagesForImage(FieldDefinition $field, mixed $value, string $path): array + protected function messagesForImage(FieldDefinition $field, mixed $value, string $path, ?Model $record = null): array + { + return $this->messagesForMediaField($field, $value, $path, 'image', $record); + } + + /** + * @return array> + */ + protected function messagesForGallery(FieldDefinition $field, mixed $value, string $path, ?Model $record = null): array + { + $messages = $this->messagesForMediaField($field, $value, $path, 'gallery', $record); + + if ($messages !== [] || $this->isEmptyValue('gallery', $value)) { + return $messages; + } + + $ids = MediaFieldValueSupport::extractIds($value); + $count = count($ids); + $min = $this->normalizeFileLimit($field->config['min_files'] ?? null); + $max = $this->normalizeFileLimit($field->config['max_files'] ?? null); + + if ($min === null && ($field->validation['required'] ?? false) === true) { + $min = 1; + } + + if ($min !== null && $count < $min) { + $messages[$path] = [ + __('builder::builder.validation.gallery_min_files', [ + 'min' => $min, + 'attribute' => $field->label, + ]), + ]; + } + + if ($max !== null && $count > $max) { + $messages[$path] = [ + __('builder::builder.validation.gallery_max_files', [ + 'max' => $max, + 'attribute' => $field->label, + ]), + ]; + } + + return $messages; + } + + /** + * @return array> + */ + protected function messagesForMediaField(FieldDefinition $field, mixed $value, string $path, string $type, ?Model $record = null): array { $messages = []; - if (($field->validation['required'] ?? false) === true && $this->isEmptyValue('image', $value)) { + if (($field->validation['required'] ?? false) === true && $this->isEmptyValue($type, $value)) { $messages[$path] = [ __('validation.required', ['attribute' => $field->label]), ]; } - if ($this->isEmptyValue('image', $value)) { + if ($this->isEmptyValue($type, $value)) { return $messages; } @@ -280,18 +334,45 @@ protected function messagesForImage(FieldDefinition $field, mixed $value, string } foreach ($ids as $mediaId) { - if (! MediaFieldValueSupport::mediaExists($mediaId)) { - $messages[$path] = [ - __('builder::builder.validation.missing_media'), - ]; + $result = MediaFieldValueSupport::mediaValidationResult($mediaId, $type, $record); - break; + if ($result['valid']) { + continue; } + + $messages[$path] = match ($result['reason']) { + 'invalid_type' => [ + __('builder::builder.validation.invalid_media_type', ['attribute' => $field->label]), + ], + 'scope_mismatch' => [ + __('builder::builder.validation.media_scope_mismatch', ['attribute' => $field->label]), + ], + default => [ + __('builder::builder.validation.missing_media'), + ], + }; + + break; } return $messages; } + protected function normalizeFileLimit(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + if (! is_numeric($value)) { + return null; + } + + $limit = (int) $value; + + return $limit > 0 ? $limit : null; + } + /** * @param array $row */ @@ -334,6 +415,10 @@ protected function isEmptyValue(string $type, mixed $value, bool $required = fal return blank($value['url'] ?? null) && blank($value['label'] ?? null); } + if (in_array($type, ['image', 'gallery'], true)) { + return MediaFieldValueSupport::extractIds($value) === []; + } + if (is_array($value)) { return $value === []; } diff --git a/packages/builder/src/Support/MediaFieldValueSupport.php b/packages/builder/src/Support/MediaFieldValueSupport.php index 0b17c84a3b..055dc1d5c2 100644 --- a/packages/builder/src/Support/MediaFieldValueSupport.php +++ b/packages/builder/src/Support/MediaFieldValueSupport.php @@ -4,6 +4,7 @@ namespace Moox\Builder\Support; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Moox\Localization\Models\Localization; @@ -95,9 +96,176 @@ public static function extractIds(mixed $value): array ), static fn (?int $id): bool => $id !== null && $id > 0)); } + if (self::isIndexedGallery($value)) { + $ids = []; + + foreach ($value as $item) { + if (is_array($item)) { + $ids = array_merge($ids, self::extractIds($item)); + } + } + + return array_values(array_unique($ids)); + } + return []; } + public static function isIndexedGallery(mixed $value): bool + { + if (! is_array($value) || $value === [] || array_is_list($value)) { + return false; + } + + if (isset($value['id']) && is_numeric($value['id'])) { + return false; + } + + foreach ($value as $item) { + if (! is_array($item) || ! isset($item['id']) || ! is_numeric($item['id'])) { + return false; + } + } + + return true; + } + + /** + * @return array|null + */ + public static function normalizeGallery(mixed $raw): ?array + { + if ($raw === null || $raw === '' || $raw === []) { + return null; + } + + if (is_string($raw)) { + $decoded = json_decode($raw, true); + + if (json_last_error() === JSON_ERROR_NONE) { + $raw = $decoded; + } + } + + if (! is_array($raw)) { + return null; + } + + if (self::isIndexedGallery($raw)) { + $normalized = []; + $index = 1; + + foreach ($raw as $item) { + $snapshot = self::normalizeSnapshot($item); + + if ($snapshot !== null) { + $normalized[(string) $index] = $snapshot; + $index++; + } + } + + return $normalized === [] ? null : $normalized; + } + + if (array_is_list($raw)) { + return self::persistGallery($raw); + } + + return null; + } + + /** + * @return array|null + */ + public static function persistGallery(mixed $value): ?array + { + if ($value === null || $value === '' || $value === []) { + return null; + } + + $ids = self::extractIds($value); + + if ($ids === []) { + return null; + } + + $attachments = []; + $index = 1; + + foreach ($ids as $mediaId) { + $snapshot = self::snapshotFromMediaId($mediaId); + + if ($snapshot !== null) { + $attachments[(string) $index] = $snapshot; + $index++; + } + } + + return $attachments === [] ? null : $attachments; + } + + /** + * @return list + */ + public static function normalizeGalleryForForm(mixed $stored): array + { + return self::extractIds($stored); + } + + /** + * @return array> + */ + public static function presentGallery(mixed $value): array + { + $gallery = self::normalizeGallery($value); + + if ($gallery === null) { + return []; + } + + $presented = []; + + foreach ($gallery as $key => $snapshot) { + $item = self::presentSingle($snapshot); + + if ($item !== null) { + $presented[(string) $key] = $item; + } + } + + return $presented; + } + + /** + * @param array{id: int, file_name: string, title: ?string, alt: ?string, description: ?string, internal_note: ?string} $fresh + */ + public static function replaceSnapshotInStoredValue(mixed $stored, int $mediaId, array $fresh): mixed + { + if (self::isIndexedGallery($stored)) { + $updated = false; + $gallery = []; + + foreach ($stored as $key => $item) { + if (is_array($item) && (int) ($item['id'] ?? 0) === $mediaId) { + $gallery[$key] = $fresh; + $updated = true; + } else { + $gallery[$key] = $item; + } + } + + return $updated ? $gallery : $stored; + } + + $snapshot = self::normalizeSnapshot($stored); + + if ($snapshot !== null && $snapshot['id'] === $mediaId) { + return $fresh; + } + + return $stored; + } + public static function persistSingle(mixed $value): ?array { if ($value === null || $value === '' || $value === []) { @@ -122,6 +290,70 @@ public static function mediaExists(int $mediaId): bool return DB::table('media')->where('id', $mediaId)->exists(); } + public static function resolveExpectedMediaScope(?Model $record): ?string + { + if ($record === null) { + return null; + } + + if (method_exists($record, 'deriveChildScope')) { + $scope = $record->deriveChildScope('media'); + + if (filled($scope)) { + return self::normalizeScope($scope); + } + } + + if (method_exists($record, 'deriveScopeForOrigin')) { + $scope = $record->deriveScopeForOrigin('media'); + + if (filled($scope)) { + return self::normalizeScope($scope); + } + } + + if (! class_exists(\Moox\Core\Support\Scopes\ScopeValue::class)) { + return null; + } + + return \Moox\Core\Support\Scopes\ScopeValue::forOriginString( + $record->getAttribute('scope'), + 'media', + ); + } + + /** + * @return array{valid: bool, reason: ?string} + */ + public static function mediaValidationResult(int $mediaId, string $fieldType, ?Model $record = null): array + { + if (! self::mediaExists($mediaId)) { + return ['valid' => false, 'reason' => 'missing']; + } + + $row = DB::table('media')->where('id', $mediaId)->first(); + + if ($row === null) { + return ['valid' => false, 'reason' => 'missing']; + } + + if (in_array($fieldType, ['image', 'gallery'], true) && ! self::mediaRowIsImage($row)) { + return ['valid' => false, 'reason' => 'invalid_type']; + } + + $expectedScope = self::resolveExpectedMediaScope($record); + + if (filled($expectedScope) && Schema::hasColumn('media', 'scope')) { + $mediaScope = self::normalizeScope($row->scope ?? null); + + if ($mediaScope !== $expectedScope) { + return ['valid' => false, 'reason' => 'scope_mismatch']; + } + } + + return ['valid' => true, 'reason' => null]; + } + public static function presentSingle(mixed $value): ?array { $snapshot = self::normalizeSnapshot($value); @@ -137,11 +369,15 @@ public static function presentSingle(mixed $value): ?array $media = Media::query()->find($snapshot['id']); if (! $media instanceof Media) { - return array_merge($snapshot, [ + return [ + 'id' => $snapshot['id'], + 'file_name' => $snapshot['file_name'], + 'title' => $snapshot['title'] ?? null, + 'alt' => $snapshot['alt'] ?? null, 'url' => null, 'thumbnail_url' => null, 'preview_url' => null, - ]); + ]; } return (new MediaItemResource($media))->resolve(); @@ -233,6 +469,30 @@ protected static function canQueryMedia(): bool return class_exists(Media::class) && Schema::hasTable('media'); } + protected static function mediaRowIsImage(object $row): bool + { + if (! Schema::hasColumn('media', 'mime_type')) { + return true; + } + + $mimeType = strtolower((string) ($row->mime_type ?? '')); + + return $mimeType !== '' && str_starts_with($mimeType, 'image/'); + } + + protected static function normalizeScope(mixed $scope): ?string + { + if (! is_string($scope) || trim($scope) === '') { + return null; + } + + if (! class_exists(\Moox\Core\Support\Scopes\ScopeValue::class)) { + return trim($scope); + } + + return \Moox\Core\Support\Scopes\ScopeValue::toStringOrNull($scope); + } + protected static function nullableString(mixed $value): ?string { if (! is_string($value)) { diff --git a/packages/builder/src/Support/TypedValueColumns.php b/packages/builder/src/Support/TypedValueColumns.php index 81f971d229..616716120d 100644 --- a/packages/builder/src/Support/TypedValueColumns.php +++ b/packages/builder/src/Support/TypedValueColumns.php @@ -32,7 +32,7 @@ public static function columnForType(string $type): string 'date' => 'value_date', 'datetime' => 'value_datetime', 'toggle' => 'value_boolean', - 'multiselect', 'checkbox_list', 'link', 'image', 'group', 'repeater', 'flexible_content' => 'value_json', + 'multiselect', 'checkbox_list', 'link', 'image', 'gallery', 'group', 'repeater', 'flexible_content' => 'value_json', 'button_group' => 'value_string', default => 'value_string', }; From 4e62f904319a6a532f1fd57389f6af6746cd7bd6 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:35:52 +0000 Subject: [PATCH 048/105] Fix styling --- packages/builder/src/BuilderServiceProvider.php | 4 ++-- packages/builder/src/Support/MediaFieldValueSupport.php | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index bf135bd414..a1523030e3 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -6,6 +6,7 @@ use Filament\Resources\Events\RecordSaved; use Illuminate\Support\Facades\Event; +use Livewire\Livewire; use Moox\Builder\FieldTypes\FieldType; use Moox\Builder\FieldTypes\Types\ButtonGroupFieldType; use Moox\Builder\FieldTypes\Types\CheckboxListFieldType; @@ -33,6 +34,7 @@ use Moox\Builder\FieldTypes\Types\TimeFieldType; use Moox\Builder\FieldTypes\Types\ToggleFieldType; use Moox\Builder\FieldTypes\Types\UrlFieldType; +use Moox\Builder\Http\Livewire\BuilderMediaPickerModal; use Moox\Builder\Listeners\PersistCustomFields; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; @@ -42,8 +44,6 @@ use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Services\BuilderFieldValueMediaMetadataSync; -use Livewire\Livewire; -use Moox\Builder\Http\Livewire\BuilderMediaPickerModal; use Moox\Builder\Support\EntityModelDeletionRegistrar; use Moox\Builder\Support\MediaIntegration; use Moox\Core\MooxServiceProvider; diff --git a/packages/builder/src/Support/MediaFieldValueSupport.php b/packages/builder/src/Support/MediaFieldValueSupport.php index 055dc1d5c2..b3648d73fc 100644 --- a/packages/builder/src/Support/MediaFieldValueSupport.php +++ b/packages/builder/src/Support/MediaFieldValueSupport.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use Moox\Core\Support\Scopes\ScopeValue; use Moox\Localization\Models\Localization; use Moox\Media\Http\Resources\MediaItemResource; use Moox\Media\Models\Media; @@ -312,11 +313,11 @@ public static function resolveExpectedMediaScope(?Model $record): ?string } } - if (! class_exists(\Moox\Core\Support\Scopes\ScopeValue::class)) { + if (! class_exists(ScopeValue::class)) { return null; } - return \Moox\Core\Support\Scopes\ScopeValue::forOriginString( + return ScopeValue::forOriginString( $record->getAttribute('scope'), 'media', ); @@ -486,11 +487,11 @@ protected static function normalizeScope(mixed $scope): ?string return null; } - if (! class_exists(\Moox\Core\Support\Scopes\ScopeValue::class)) { + if (! class_exists(ScopeValue::class)) { return trim($scope); } - return \Moox\Core\Support\Scopes\ScopeValue::toStringOrNull($scope); + return ScopeValue::toStringOrNull($scope); } protected static function nullableString(mixed $value): ?string From 7d782458fd903f57babb42d00cbcdf5de1085fcd Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:53:20 +0200 Subject: [PATCH 049/105] Add file custom field and scope media picker lists by mime type --- .../builder/resources/lang/de/builder.php | 2 + .../builder/resources/lang/en/builder.php | 2 + .../builder-media-picker-modal.blade.php | 14 +- .../builder/src/BuilderServiceProvider.php | 1 + .../src/FieldTypes/Types/FileFieldType.php | 68 +++++++++ .../src/FieldTypes/Types/GalleryFieldType.php | 1 + .../src/FieldTypes/Types/ImageFieldType.php | 1 + .../Forms/Components/BuilderMediaPicker.php | 21 +++ .../Http/Livewire/BuilderMediaPickerModal.php | 130 +++++++++++++++++- .../src/Services/BuilderMediaUsageSync.php | 2 +- .../src/Services/FieldValueValidator.php | 17 ++- .../src/Support/MediaFieldValueSupport.php | 4 + .../builder/src/Support/TypedValueColumns.php | 2 +- 13 files changed, 257 insertions(+), 8 deletions(-) create mode 100644 packages/builder/src/FieldTypes/Types/FileFieldType.php diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 44c4a4fd53..2f96152226 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -71,6 +71,7 @@ 'link' => 'Link', 'image' => 'Bild', 'gallery' => 'Galerie', + 'file' => 'Datei', 'rich_text' => 'Rich Text', 'message' => 'Hinweis', 'oembed' => 'oEmbed', @@ -176,6 +177,7 @@ 'invalid_media' => 'Bitte wähle ein gültiges Medium aus.', 'missing_media' => 'Das ausgewählte Medium existiert nicht mehr.', 'invalid_media_type' => 'Das ausgewählte Medium für :attribute muss ein Bild sein.', + 'invalid_file_media_type' => 'Das ausgewählte Medium für :attribute muss eine Datei sein, kein Bild.', 'media_scope_mismatch' => 'Das ausgewählte Medium für :attribute ist für diesen Datensatz nicht verfügbar.', 'gallery_min_files' => 'Wähle mindestens :min Medien für :attribute aus.', 'gallery_max_files' => 'Wähle höchstens :max Medien für :attribute aus.', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 1f7c42967b..3f7161aa08 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -71,6 +71,7 @@ 'link' => 'Link', 'image' => 'Image', 'gallery' => 'Gallery', + 'file' => 'File', 'rich_text' => 'Rich text', 'message' => 'Message', 'oembed' => 'oEmbed', @@ -176,6 +177,7 @@ 'invalid_media' => 'Please select a valid media item.', 'missing_media' => 'The selected media item no longer exists.', 'invalid_media_type' => 'The selected media for :attribute must be an image.', + 'invalid_file_media_type' => 'The selected media for :attribute must be a file, not an image.', 'media_scope_mismatch' => 'The selected media for :attribute is not available for this record.', 'gallery_min_files' => 'Select at least :min media items for :attribute.', 'gallery_max_files' => 'Select no more than :max media items for :attribute.', diff --git a/packages/builder/resources/views/livewire/builder-media-picker-modal.blade.php b/packages/builder/resources/views/livewire/builder-media-picker-modal.blade.php index 46ca2d652b..79e865fb9a 100644 --- a/packages/builder/resources/views/livewire/builder-media-picker-modal.blade.php +++ b/packages/builder/resources/views/livewire/builder-media-picker-modal.blade.php @@ -15,10 +15,16 @@ - - - - + @if (($onlyMimePrefixes ?? []) === ['image/']) + + @else + @unless(in_array('image/', $excludedMimePrefixes ?? [], true)) + + @endunless + + + + @endif diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index a1523030e3..b5809425f3 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -128,6 +128,7 @@ protected function defaultFieldTypes(): array if (MediaIntegration::isAvailable()) { $types[] = new FieldTypes\Types\ImageFieldType; $types[] = new FieldTypes\Types\GalleryFieldType; + $types[] = new FieldTypes\Types\FileFieldType; } return array_merge($types, [ diff --git a/packages/builder/src/FieldTypes/Types/FileFieldType.php b/packages/builder/src/FieldTypes/Types/FileFieldType.php new file mode 100644 index 0000000000..2fec76a7e3 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/FileFieldType.php @@ -0,0 +1,68 @@ +name) + ->label($field->label) + ->acceptedFileTypes([ + 'application/*', + 'video/*', + 'audio/*', + 'text/*', + 'model/*', + ]) + ->excludeMimePrefixes(['image/']) + ->maxFiles(1) + ->multiple(false); + + return Capability::applyAll($this->capabilities(), $component, $field); + } +} diff --git a/packages/builder/src/FieldTypes/Types/GalleryFieldType.php b/packages/builder/src/FieldTypes/Types/GalleryFieldType.php index 0b5b6e4c57..f717875438 100644 --- a/packages/builder/src/FieldTypes/Types/GalleryFieldType.php +++ b/packages/builder/src/FieldTypes/Types/GalleryFieldType.php @@ -53,6 +53,7 @@ public function formComponent(FieldDefinition $field): Component $component = BuilderMediaPicker::make($field->name) ->label($field->label) ->acceptedFileTypes(['image/*']) + ->onlyMimePrefixes(['image/']) ->multiple(true); return Capability::applyAll($this->capabilities(), $component, $field); diff --git a/packages/builder/src/FieldTypes/Types/ImageFieldType.php b/packages/builder/src/FieldTypes/Types/ImageFieldType.php index 15a4442a04..aeae039a30 100644 --- a/packages/builder/src/FieldTypes/Types/ImageFieldType.php +++ b/packages/builder/src/FieldTypes/Types/ImageFieldType.php @@ -53,6 +53,7 @@ public function formComponent(FieldDefinition $field): Component $component = BuilderMediaPicker::make($field->name) ->label($field->label) ->acceptedFileTypes(['image/*']) + ->onlyMimePrefixes(['image/']) ->maxFiles(1) ->multiple(false); diff --git a/packages/builder/src/Forms/Components/BuilderMediaPicker.php b/packages/builder/src/Forms/Components/BuilderMediaPicker.php index 96d4837f64..14e9098096 100644 --- a/packages/builder/src/Forms/Components/BuilderMediaPicker.php +++ b/packages/builder/src/Forms/Components/BuilderMediaPicker.php @@ -4,6 +4,7 @@ namespace Moox\Builder\Forms\Components; +use Closure; use Illuminate\Support\Facades\Schema; use Moox\Builder\Support\MediaFieldValueSupport; use Moox\Media\Forms\Components\MediaPicker; @@ -32,6 +33,26 @@ protected function setUp(): void $this->dehydrated(true); } + /** + * @param Closure|list $prefixes + */ + public function excludeMimePrefixes(Closure|array $prefixes): static + { + $this->uploadConfig['excluded_mime_prefixes'] = $prefixes instanceof Closure ? $prefixes() : $prefixes; + + return $this; + } + + /** + * @param Closure|list $prefixes + */ + public function onlyMimePrefixes(Closure|array $prefixes): static + { + $this->uploadConfig['only_mime_prefixes'] = $prefixes instanceof Closure ? $prefixes() : $prefixes; + + return $this; + } + /** * @return list */ diff --git a/packages/builder/src/Http/Livewire/BuilderMediaPickerModal.php b/packages/builder/src/Http/Livewire/BuilderMediaPickerModal.php index 6e48c94820..988d29ab63 100644 --- a/packages/builder/src/Http/Livewire/BuilderMediaPickerModal.php +++ b/packages/builder/src/Http/Livewire/BuilderMediaPickerModal.php @@ -4,6 +4,9 @@ namespace Moox\Builder\Http\Livewire; +use Filament\Notifications\Notification; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\View\View; use Moox\Media\Http\Livewire\MediaPickerModal; @@ -15,6 +18,24 @@ class BuilderMediaPickerModal extends MediaPickerModal public string $statePath = ''; + public function toggleMediaSelection(int $mediaId): void + { + $media = $this->applyMediaScope(Media::query()) + ->where('id', $mediaId) + ->first(); + + if ($media instanceof Media && ! $this->isMimeAllowed((string) ($media->mime_type ?? ''))) { + Notification::make() + ->danger() + ->title($this->mimeRestrictionNotification()) + ->send(); + + return; + } + + parent::toggleMediaSelection($mediaId); + } + public function applySelection(): void { /** @var Collection $selectedMedia */ @@ -48,7 +69,114 @@ public function render(): View return view('builder::livewire.builder-media-picker-modal', array_merge( $view->getData(), - ['modalId' => $this->modalId], + [ + 'modalId' => $this->modalId, + 'excludedMimePrefixes' => $this->excludedMimePrefixes(), + 'onlyMimePrefixes' => $this->onlyMimePrefixes(), + ], )); } + + /** + * @template TModel of Model + * + * @param Builder $query + * @return Builder + */ + protected function applyMediaScope(Builder $query): Builder + { + $query = parent::applyMediaScope($query); + + $onlyPrefixes = $this->onlyMimePrefixes(); + + if ($onlyPrefixes !== []) { + $query->where(function (Builder $scopedQuery) use ($onlyPrefixes): void { + foreach ($onlyPrefixes as $prefix) { + $scopedQuery->orWhere('mime_type', 'like', $prefix.'%'); + } + }); + } + + foreach ($this->excludedMimePrefixes() as $prefix) { + $query->where('mime_type', 'not like', $prefix.'%'); + } + + return $query; + } + + /** + * @return list + */ + protected function onlyMimePrefixes(): array + { + return $this->normalizeMimePrefixes($this->uploadConfig['only_mime_prefixes'] ?? []); + } + + /** + * @return list + */ + protected function excludedMimePrefixes(): array + { + return $this->normalizeMimePrefixes($this->uploadConfig['excluded_mime_prefixes'] ?? []); + } + + /** + * @return list + */ + protected function normalizeMimePrefixes(mixed $prefixes): array + { + if (! is_array($prefixes)) { + return []; + } + + return array_values(array_filter( + array_map(static fn (mixed $prefix): string => trim((string) $prefix), $prefixes), + static fn (string $prefix): bool => $prefix !== '', + )); + } + + protected function isMimeAllowed(string $mimeType): bool + { + $mimeType = strtolower($mimeType); + $onlyPrefixes = $this->onlyMimePrefixes(); + + if ($onlyPrefixes !== []) { + if ($mimeType === '') { + return false; + } + + foreach ($onlyPrefixes as $prefix) { + if (str_starts_with($mimeType, strtolower($prefix))) { + return true; + } + } + + return false; + } + + if ($mimeType === '') { + return true; + } + + foreach ($this->excludedMimePrefixes() as $prefix) { + if (str_starts_with($mimeType, strtolower($prefix))) { + return false; + } + } + + return true; + } + + protected function mimeRestrictionNotification(): string + { + if ($this->onlyMimePrefixes() === ['image/']) { + return __('builder::builder.validation.invalid_media_type', ['attribute' => __('builder::builder.field_types.image')]); + } + + if (in_array('image/', $this->excludedMimePrefixes(), true)) { + return __('builder::builder.validation.invalid_file_media_type', ['attribute' => __('builder::builder.field_types.file')]); + } + + return __('builder::builder.validation.invalid_media'); + } } diff --git a/packages/builder/src/Services/BuilderMediaUsageSync.php b/packages/builder/src/Services/BuilderMediaUsageSync.php index b13a80ec2f..669844eb42 100644 --- a/packages/builder/src/Services/BuilderMediaUsageSync.php +++ b/packages/builder/src/Services/BuilderMediaUsageSync.php @@ -17,7 +17,7 @@ final class BuilderMediaUsageSync /** * @var list */ - private const MEDIA_FIELD_TYPES = ['image', 'gallery']; + private const MEDIA_FIELD_TYPES = ['image', 'gallery', 'file']; /** * @param Collection $fields diff --git a/packages/builder/src/Services/FieldValueValidator.php b/packages/builder/src/Services/FieldValueValidator.php index f238562e82..38aea2ff94 100644 --- a/packages/builder/src/Services/FieldValueValidator.php +++ b/packages/builder/src/Services/FieldValueValidator.php @@ -193,6 +193,10 @@ protected function messagesForLeaf(FieldDefinition $field, mixed $value, string return $this->messagesForGallery($field, $value, $path, $record); } + if ($field->type === 'file') { + return $this->messagesForFile($field, $value, $path, $record); + } + $messages = []; if (($field->validation['required'] ?? false) === true && $this->isEmptyValue($field->type, $value)) { @@ -265,6 +269,14 @@ protected function messagesForImage(FieldDefinition $field, mixed $value, string return $this->messagesForMediaField($field, $value, $path, 'image', $record); } + /** + * @return array> + */ + protected function messagesForFile(FieldDefinition $field, mixed $value, string $path, ?Model $record = null): array + { + return $this->messagesForMediaField($field, $value, $path, 'file', $record); + } + /** * @return array> */ @@ -344,6 +356,9 @@ protected function messagesForMediaField(FieldDefinition $field, mixed $value, s 'invalid_type' => [ __('builder::builder.validation.invalid_media_type', ['attribute' => $field->label]), ], + 'invalid_file_type' => [ + __('builder::builder.validation.invalid_file_media_type', ['attribute' => $field->label]), + ], 'scope_mismatch' => [ __('builder::builder.validation.media_scope_mismatch', ['attribute' => $field->label]), ], @@ -415,7 +430,7 @@ protected function isEmptyValue(string $type, mixed $value, bool $required = fal return blank($value['url'] ?? null) && blank($value['label'] ?? null); } - if (in_array($type, ['image', 'gallery'], true)) { + if (in_array($type, ['image', 'gallery', 'file'], true)) { return MediaFieldValueSupport::extractIds($value) === []; } diff --git a/packages/builder/src/Support/MediaFieldValueSupport.php b/packages/builder/src/Support/MediaFieldValueSupport.php index b3648d73fc..29eb9e5ce5 100644 --- a/packages/builder/src/Support/MediaFieldValueSupport.php +++ b/packages/builder/src/Support/MediaFieldValueSupport.php @@ -342,6 +342,10 @@ public static function mediaValidationResult(int $mediaId, string $fieldType, ?M return ['valid' => false, 'reason' => 'invalid_type']; } + if ($fieldType === 'file' && self::mediaRowIsImage($row)) { + return ['valid' => false, 'reason' => 'invalid_file_type']; + } + $expectedScope = self::resolveExpectedMediaScope($record); if (filled($expectedScope) && Schema::hasColumn('media', 'scope')) { diff --git a/packages/builder/src/Support/TypedValueColumns.php b/packages/builder/src/Support/TypedValueColumns.php index 616716120d..cf6449ebb1 100644 --- a/packages/builder/src/Support/TypedValueColumns.php +++ b/packages/builder/src/Support/TypedValueColumns.php @@ -32,7 +32,7 @@ public static function columnForType(string $type): string 'date' => 'value_date', 'datetime' => 'value_datetime', 'toggle' => 'value_boolean', - 'multiselect', 'checkbox_list', 'link', 'image', 'gallery', 'group', 'repeater', 'flexible_content' => 'value_json', + 'multiselect', 'checkbox_list', 'link', 'image', 'gallery', 'file', 'group', 'repeater', 'flexible_content' => 'value_json', 'button_group' => 'value_string', default => 'value_string', }; From 4e3351e8fe6baf6d7de26c93b6a1a9b957c7b497 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:56:23 +0200 Subject: [PATCH 050/105] update README --- packages/builder/README.md | 50 ++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/builder/README.md b/packages/builder/README.md index a4a126940e..3a5b9cc60f 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -88,8 +88,10 @@ Verantwortlich für **Werte** pro Datensatz. | `FieldValuePurger` | Löscht Werte bei Feld-/Gruppenänderungen (Root-Felder) | | `CompoundFieldValueMigrator` | Benennt/entfernt verschachtelte Schlüssel in `value_json` (Group, Repeater, Flexible Content) | | `PersistCustomFields` | Listener auf Filament `RecordSaved` | +| `BuilderMediaUsageSync` | Pflegt `media_usables` bei Image/Gallery/File-Feldern | +| `BuilderFieldValueMediaMetadataSync` | Aktualisiert Media-Snapshots in `value_json` bei Metadaten-Änderungen | -Werte hängen **nicht** am Eloquent-Model (keine `custom_fields`-JSON-Spalte nötig). +Werte hängen **nicht** am Eloquent-Model (keine `custom_fields`-JSON-Spalte nötig). Media-Felder speichern **Referenzen** in `value_json`, nicht in Model-Spalten. --- @@ -132,7 +134,7 @@ Eine Zeile pro Wert: | `value_date` | date | | `value_datetime` | datetime | | `value_boolean` | toggle | -| `value_json` | multiselect, checkbox_list, link, group, repeater, flexible_content | +| `value_json` | multiselect, checkbox_list, link, image, gallery, file, group, repeater, flexible_content | Unique: `(entity, record_id, field_name)`. @@ -303,7 +305,7 @@ Repeater-Zeilen sind standardmäßig eingeklappt und zeigen Typ, Schlüssel und ## Feldtypen & Capabilities -### Eingebaute Feldtypen (25 wählbar) +### Eingebaute Feldtypen (28 wählbar mit `moox/media`, sonst 25) | Kategorie | Keys | |-----------|------| @@ -312,10 +314,30 @@ Repeater-Zeilen sind standardmäßig eingeklappt und zeigen Typ, Schlüssel und | **Auswahl** | `select`, `multiselect`, `checkbox_list`, `radio`, `button_group`, `toggle` | | **Datum** | `date`, `datetime`, `time` | | **Sonstiges** | `color`, `link`, `message`, `oembed` | +| **Media** *(nur mit `moox/media`)* | `image`, `gallery`, `file` | | **Layout** | `tab`, `group`, `repeater`, `flexible_content` | Intern (nur in der DB, nicht wählbar): `flexible_layout` — definiert ein Layout innerhalb von Flexible Content. +### Media-Felder (`moox/media` optional) + +Die Feldtypen `image`, `gallery` und `file` werden nur registriert, wenn `moox/media` installiert ist (`MediaIntegration::isAvailable()`). Es gibt **keine** harte Composer-Abhängigkeit — ohne Media-Paket fehlen diese Typen im Admin. + +| Typ | UI | Speicher in `value_json` | Mediathek | +|-----|----|--------------------------|-----------| +| `image` | Einzelner Media-Picker | Ein Snapshot `{id, file_name, title, alt, …}` | nur Bilder | +| `gallery` | Mehrfach-Picker | Indexierte Snapshots `{"1": {…}, "2": {…}}` | nur Bilder | +| `file` | Einzelner Media-Picker | Ein Snapshot (wie Image) | alles außer Bilder | + +**Architektur:** + +- UI nutzt die Moox-Mediathek (`BuilderMediaPicker` + isolierter Modal pro Feld) +- Werte landen in `builder_field_values`, nicht in Model-Spalten +- `media_usables` trackt Verwendung; Snapshots werden bei Translation-Updates synchronisiert +- Validierung: Media existiert, Scope passt zum Record, MIME-Typ passend zum Feldtyp + +API-Ausgabe über `presentValue()` / `MediaItemResource` (URLs, Thumbnails — kein `internal_note`). + ### Layout-Felder | Typ | Filament-Komponente | Speicher | @@ -341,6 +363,7 @@ Flexible Content entspricht ACF **Flexible Content**: pro Zeile ein wählbares L | `DisplayFormat` | Datumsformat | | `MessageBody` | Hinweistext (message) | | `RepeaterItems` | min/max Einträge (Repeater, Flexible Content) | +| `GalleryFiles` | min/max Dateien (Gallery) | Jeder Feldtyp implementiert `FieldType`: `key()`, `formComponent()`, `capabilities()`, optional `castValue()`, `hasSubFields()`, `hasLayouts()`. @@ -348,6 +371,7 @@ Jeder Feldtyp implementiert `FieldType`: `key()`, `formComponent()`, `capabiliti - **Pflichtfelder** und **Capabilities** werden als Filament-Regeln auf die Komponente angewendet. - **Verschachtelte Werte** (Repeater, Group, Flexible Content) werden zusätzlich durch `FieldValueValidator` geprüft — u. a. leere Repeater-Zeilen und unbekannte Layouts. +- **Media-Felder:** Existenz, Scope, MIME-Typ (Bild vs. Datei) und bei Gallery min/max Dateien. --- @@ -490,7 +514,9 @@ packages/builder/ ├── FieldTypes/ │ ├── FieldType.php │ ├── Capabilities/ - │ └── Types/ # 26 Feldtypen + │ └── Types/ # 29 Feldtypen + ├── Forms/Components/BuilderMediaPicker.php + ├── Http/Livewire/BuilderMediaPickerModal.php ├── Listeners/PersistCustomFields.php ├── Models/ # FieldGroup, Field, FieldOption, FieldValue ├── Observers/ @@ -504,12 +530,16 @@ packages/builder/ ├── Resources/FieldGroupResource.php # Admin-UI ├── Services/ │ ├── CustomFieldsManager.php + │ ├── BuilderMediaUsageSync.php + │ ├── BuilderFieldValueMediaMetadataSync.php │ ├── FieldGroupPersistence.php │ ├── FieldGroupValidator.php │ ├── FieldValuePurger.php │ └── FieldValueValidator.php └── Support/ ├── EntityModelDeletionRegistrar.php + ├── MediaFieldValueSupport.php + ├── MediaIntegration.php ├── OptionValueRules.php └── TypedValueColumns.php ``` @@ -524,12 +554,10 @@ packages/builder/ cd packages/builder && composer test ``` -75 Tests (Stand: Paket-intern). - ### Manuell im Panel 1. **Felder → Feldgruppen** — Demo-Gruppe „Fahrzeugdaten“ (nach Seeder) -2. **Items → Bearbeiten** — Custom-Field-Sections inkl. Tabs, Group, Repeater, Flexible Content +2. **Items → Bearbeiten** — Custom-Field-Sections inkl. Tabs, Group, Repeater, Flexible Content, optional Image/Gallery/File 3. Speichern, dann DB prüfen: ```sql @@ -556,6 +584,7 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc | Feldgruppe mit „Anzeigen bei: Items“ | Section auf Item-Form | | Werte in `builder_field_values` | typisierte Spalten befüllt | | Flexible Content sortierbar | ohne Fehler nach Speichern/Laden | +| Image/Gallery/File (mit `moox/media`) | Mediathek gefiltert, Speichern/Laden, `media_usables` | --- @@ -569,14 +598,15 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc - Verschachtelte Validierung (`FieldValueValidator`) - `InteractsWithCustomFields` auf Consumer-Models (`customFields()`, Attribute-Zugriff, Queries, Eager Load) - `MergesCustomFields` für API Resources -- `FieldType::presentValue()` für API-Serialisierung (Datums-ISO, Passwort-Maskierung, verschachtelte Felder) +- `FieldType::presentValue()` für API-Serialisierung (Datums-ISO, Passwort-Maskierung, verschachtelte Felder, Media via `MediaItemResource`) - Repeater min/max (`RepeaterItems` Capability) +- Media-Felder: `image`, `gallery`, `file` (optional mit `moox/media`) +- `BuilderMediaPicker` mit isoliertem Modal pro Feld und MIME-Filter in der Mediathek +- `media_usables`-Sync und Metadaten-Snapshot-Updates für Media-Felder **Aktuell nicht implementiert:** -- `FieldType::presentValue()`-Spezialisierungen für Media-URLs - Relational-Felder (Post Object, Relationship, User, Taxonomy) -- Media-Felder (Image, File, Gallery) - Clone-Feldtyp (ACF) - Location-Params über `entity` hinaus - `placement`-Steuerung (Sidebar, …) From dc81b81dc748d6ea2ae3abc66675ca6905b261dd Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:18:53 +0200 Subject: [PATCH 051/105] update readme --- packages/builder/README.md | 587 ++++++++++++++++++++----------------- 1 file changed, 326 insertions(+), 261 deletions(-) diff --git a/packages/builder/README.md b/packages/builder/README.md index 3a5b9cc60f..f6b51e414f 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -1,35 +1,37 @@ # Moox Builder -Runtime-Feldgruppen für Filament-Resources — ACF-ähnlich, aber rein Laravel/Filament. +Runtime field groups for Filament resources — ACF-like, but pure Laravel/Filament. -Admins definieren Felder im Panel. Werte werden in typisierten `builder_field_values`-Zeilen gespeichert (kein JSON-Blob am Model, kein WordPress/postmeta). +Admins define fields in the panel. Values are stored in typed `builder_field_values` rows (no JSON blob on the model, no WordPress/postmeta). --- -## Inhaltsverzeichnis +## Table of Contents -1. [Architektur-Überblick](#architektur-überblick) -2. [Die zwei Schichten](#die-zwei-schichten) -3. [Datenbank](#datenbank) -4. [Runtime-Ablauf](#runtime-ablauf) +1. [Architecture Overview](#architecture-overview) +2. [The Two Layers](#the-two-layers) +3. [Database](#database) +4. [Runtime Flow](#runtime-flow) 5. [Installation](#installation) -6. [Resource anbinden](#resource-anbinden) -7. [Feldgruppen im Admin](#feldgruppen-im-admin) -8. [Feldtypen & Capabilities](#feldtypen--capabilities) -9. [Konfiguration](#konfiguration) -10. [Erweiterung](#erweiterung) -11. [Paketstruktur](#paketstruktur) -12. [Testen](#testen) -13. [Grenzen & Roadmap](#grenzen--roadmap) +6. [Connect a Resource](#connect-a-resource) +7. [Field Groups in Admin](#field-groups-in-admin) +8. [Field Types & Capabilities](#field-types--capabilities) +9. [Configuration](#configuration) +10. [Extension](#extension) +11. [Model API](#model-api) +12. [API Serialization](#api-serialization) +13. [Package Structure](#package-structure) +14. [Testing](#testing) +15. [Limits & Roadmap](#limits--roadmap) --- -## Architektur-Überblick +## Architecture Overview ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ ADMIN (Definition) │ -│ Filament → Felder → Feldgruppen (FieldGroupResource) │ +│ Filament → Fields → Field Groups (FieldGroupResource) │ │ ↓ │ │ builder_field_groups / builder_fields / builder_field_options │ └─────────────────────────────────────────────────────────────────────────┘ @@ -38,10 +40,10 @@ Admins definieren Felder im Panel. Werte werden in typisierten `builder_field_va │ ▼ ┌─────────────────────────────────────────────────────────────────────────┐ -│ RUNTIME (Consumer-Resources) │ +│ RUNTIME (Consumer Resources) │ │ ItemResource + HasCustomFields │ │ ↓ │ -│ EntityRegistry → LocationMatcher → SchemaCompiler → Filament-Sections │ +│ EntityRegistry → LocationMatcher → SchemaCompiler → Filament Sections │ │ ↓ │ │ PersistCustomFields (RecordSaved) → CustomFieldsManager │ │ ↓ │ @@ -49,85 +51,85 @@ Admins definieren Felder im Panel. Werte werden in typisierten `builder_field_va └─────────────────────────────────────────────────────────────────────────┘ ``` -**Kernprinzip:** Definition und Speicherung sind strikt getrennt. +**Core principle:** Definition and storage are strictly separated. -| Schicht | Frage | Wo | -|---------|-------|-----| -| **Definition** | Welche Felder gibt es? | `builder_field_*` Tabellen + Admin-UI | -| **Speicher** | Wo liegen die Werte? | `builder_field_values` + `TypedValueColumns` | +| Layer | Question | Where | +|-------|----------|-------| +| **Definition** | Which fields exist? | `builder_field_*` tables + admin UI | +| **Storage** | Where are values stored? | `builder_field_values` + `TypedValueColumns` | --- -## Die zwei Schichten +## The Two Layers ### 1. Definition Layer -Verantwortlich für **was** angezeigt wird und **wo** (Location). +Responsible for **what** is shown and **where** (location). -| Komponente | Aufgabe | -|------------|---------| -| `FieldGroupResource` | Filament-CRUD für Feldgruppen | -| `FieldGroupPersistence` | Speichert Gruppen, Felder, Optionen, Location Rules, verschachtelte Felder; migriert JSON-Werte bei Umbenennung/Löschen verschachtelter Subfelder | -| `FieldGroupValidator` | Prüft doppelte **speicherbare** Feldschlüssel (global pro Gruppe, inkl. Tabs) und Konflikte zwischen Gruppen | -| `DefinitionRegistry` | Lädt aktive Gruppen, cached als Arrays | -| `LocationMatcher` | Prüft `location_rules` gegen `LocationContext` | -| `EntityRegistry` | Findet Filament-Resources mit `HasCustomFields` → Entity-Keys | -| `SchemaCompiler` | Baut Filament-Sections, Tabs, Layout-Felder aus Definitionen | +| Component | Role | +|-----------|------| +| `FieldGroupResource` | Filament CRUD for field groups | +| `FieldGroupPersistence` | Saves groups, fields, options, location rules, nested fields; migrates JSON values when nested subfields are renamed or removed | +| `FieldGroupValidator` | Checks duplicate **storable** field keys (globally per group, including tabs) and conflicts between groups | +| `DefinitionRegistry` | Loads active groups, caches as arrays | +| `LocationMatcher` | Matches `location_rules` against `LocationContext` | +| `EntityRegistry` | Finds Filament resources with `HasCustomFields` → entity keys | +| `SchemaCompiler` | Builds Filament sections, tabs, and layout fields from definitions | -Definitionen werden als **DTOs** (`FieldGroupDefinition`, `FieldDefinition`) transportiert — nicht als lose Eloquent-Models im Runtime-Pfad. +Definitions are transported as **DTOs** (`FieldGroupDefinition`, `FieldDefinition`) — not as loose Eloquent models in the runtime path. ### 2. Storage Layer -Verantwortlich für **Werte** pro Datensatz. +Responsible for **values** per record. -| Komponente | Aufgabe | -|------------|---------| -| `TypedValueColumns` | Mapping Feldtyp → DB-Spalte (`value_string`, `value_json`, …) | -| `CustomFieldsManager` | Laden/Speichern für Resources, Option-Validierung, Hydration-Cache | -| `FieldValueValidator` | Validierung verschachtelter Werte (Repeater, Group, Flexible Content) | -| `FieldValuePurger` | Löscht Werte bei Feld-/Gruppenänderungen (Root-Felder) | -| `CompoundFieldValueMigrator` | Benennt/entfernt verschachtelte Schlüssel in `value_json` (Group, Repeater, Flexible Content) | -| `PersistCustomFields` | Listener auf Filament `RecordSaved` | -| `BuilderMediaUsageSync` | Pflegt `media_usables` bei Image/Gallery/File-Feldern | -| `BuilderFieldValueMediaMetadataSync` | Aktualisiert Media-Snapshots in `value_json` bei Metadaten-Änderungen | +| Component | Role | +|-----------|------| +| `TypedValueColumns` | Maps field type → DB column (`value_string`, `value_json`, …) | +| `CustomFieldsManager` | Load/save for resources, option validation, hydration cache | +| `FieldValueValidator` | Validates nested values (repeater, group, flexible content) | +| `FieldValuePurger` | Deletes values when fields/groups change (root fields) | +| `CompoundFieldValueMigrator` | Renames/removes nested keys in `value_json` (group, repeater, flexible content) | +| `PersistCustomFields` | Listener on Filament `RecordSaved` | +| `BuilderMediaUsageSync` | Maintains `media_usables` for image/gallery/file fields | +| `BuilderFieldValueMediaMetadataSync` | Updates media snapshots in `value_json` when metadata changes | -Werte hängen **nicht** am Eloquent-Model (keine `custom_fields`-JSON-Spalte nötig). Media-Felder speichern **Referenzen** in `value_json`, nicht in Model-Spalten. +Values are **not** attached to the Eloquent model (no `custom_fields` JSON column needed). Media fields store **references** in `value_json`, not in model columns. --- -## Datenbank +## Database ### `builder_field_groups` -| Spalte | Bedeutung | -|--------|-----------| -| `name` | Anzeigename (= Section-Titel im Formular) | -| `slug` | Technischer Schlüssel der Gruppe | -| `location_rules` | JSON: wo die Gruppe erscheint (siehe unten) | -| `placement` | Reserviert (`default`) | -| `settings` | Reserviert für Gruppen-Einstellungen | -| `sort` | Reihenfolge mehrerer Gruppen | -| `active` | Nur aktive Gruppen werden gerendert | +| Column | Meaning | +|--------|---------| +| `name` | Display name (= section title in the form) | +| `slug` | Technical group key | +| `location_rules` | JSON: where the group appears (see below) | +| `placement` | Reserved (`default`) | +| `settings` | Reserved for group settings | +| `sort` | Order of multiple groups | +| `active` | Only active groups are rendered | ### `builder_fields` -Felder einer Gruppe: `name`, `label`, `type`, `config`, `validation`, `sort`. +Fields of a group: `name`, `label`, `type`, `config`, `validation`, `sort`. -**`parent_field_id`** verknüpft Unterfelder mit Layout-Feldern (Group, Repeater, Flexible-Content-Layouts). Die Baumstruktur wird rekursiv in `FieldGroupPersistence` synchronisiert. +**`parent_field_id`** links subfields to layout fields (group, repeater, flexible content layouts). The tree is synced recursively in `FieldGroupPersistence`. ### `builder_field_options` -Optionen für `select`, `radio`, `multiselect`, `checkbox_list`, `button_group`. +Options for `select`, `radio`, `multiselect`, `checkbox_list`, `button_group`. ### `builder_field_values` -Eine Zeile pro Wert: +One row per value: -| Spalte | Feldtypen | -|--------|-----------| -| `entity` | z. B. `item` | -| `record_id` | ID des Datensatzes | -| `field_name` | Feldschlüssel | +| Column | Field types | +|--------|-------------| +| `entity` | e.g. `item` | +| `record_id` | Record ID | +| `field_name` | Field key | | `value_string` | text, email, url, select, password, oembed, … | | `value_text` | textarea, rich_text | | `value_decimal` | number, range | @@ -138,9 +140,9 @@ Eine Zeile pro Wert: Unique: `(entity, record_id, field_name)`. -### Location Rules (intern) +### Location Rules (internal) -Im Admin wählst du **„Anzeigen bei“** (Multi-Select). Intern wird das zu: +In admin you choose **"Show on"** (multi-select). Internally this becomes: ```json [ @@ -149,53 +151,53 @@ Im Admin wählst du **„Anzeigen bei“** (Multi-Select). Intern wird das zu: ] ``` -Jede innere Liste = AND-Gruppe, mehrere Gruppen = OR. Aktuell unterstützt der Matcher nur `param: entity` mit `==` / `!=`. Ohne Zuordnung (`Anzeigen bei` leer) erscheint die Gruppe in keinem Formular. +Each inner list = AND group, multiple groups = OR. The matcher currently only supports `param: entity` with `==` / `!=`. Without assignment ("Show on" empty) the group appears in no form. --- -## Runtime-Ablauf +## Runtime Flow -### Formular öffnen (Create/Edit) +### Open form (create/edit) ``` -1. Resource::form() enthält ...static::customFieldComponents() +1. Resource::form() includes ...static::customFieldComponents() 2. HasCustomFields → DefinitionRegistry::fieldGroupsFor(LocationContext) - → lädt gecachte Gruppen - → LocationMatcher filtert nach entity + → loads cached groups + → LocationMatcher filters by entity 3. SchemaCompiler::compile() - → pro Gruppe eine Filament-Section - → Layout-Felder: Tabs, Group, Repeater, Flexible Content (Builder) - → afterStateHydrated lädt Werte via CustomFieldsManager (ein Query pro Datensatz, gecacht) - → Filament Builder: hydrateItems() für UUID-basierte Block-Keys + → one Filament section per group + → layout fields: tabs, group, repeater, flexible content (Builder) + → afterStateHydrated loads values via CustomFieldsManager (one query per record, cached) + → Filament Builder: hydrateItems() for UUID-based block keys ``` -### Speichern +### Save ``` -1. Filament speichert das Model (title, description, …) +1. Filament saves the model (title, description, …) -2. Event RecordSaved wird gefeuert +2. RecordSaved event fires 3. PersistCustomFields::handle($record, $data, $page) - → prüft: Resource nutzt HasCustomFields? + → checks: does the resource use HasCustomFields? 4. CustomFieldsManager::saveFromFormData() - → extrahiert bekannte Feld-Keys aus $data + → extracts known field keys from $data → FieldValueValidator + OptionValueRules → updateOrCreate in builder_field_values ``` -**Wichtig:** Keine Page-Hooks (`afterCreate`, `mutateFormDataBeforeSave`) nötig. +**Important:** No page hooks (`afterCreate`, `mutateFormDataBeforeSave`) needed. ### Cache -`DefinitionRegistry` cached unter `builder.definitions` als **PHP-Arrays**. +`DefinitionRegistry` caches under `builder.definitions` as **PHP arrays**. -Invalidierung automatisch via `InvalidateDefinitionCacheObserver` bei Änderungen an Gruppen/Feldern/Optionen. +Invalidation is automatic via `InvalidateDefinitionCacheObserver` when groups/fields/options change. -Manuell: `php artisan cache:forget builder.definitions` +Manual: `php artisan cache:forget builder.definitions` --- @@ -208,9 +210,9 @@ composer require moox/builder php artisan moox:install ``` -Migrations, Config, Seeder und `BuilderPlugin` auswählen. +Select migrations, config, seeder, and `BuilderPlugin`. -### Manuell +### Manual ```bash composer require moox/builder @@ -220,7 +222,7 @@ php artisan migrate php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" ``` -Plugin im Panel: +Register the plugin in your panel: ```php use Moox\Builder\Plugins\BuilderPlugin; @@ -232,9 +234,9 @@ $panel->plugins([ --- -## Resource anbinden +## Connect a Resource -### Schritt 1: Trait in der Filament-Resource +### Step 1: Trait on the Filament resource ```php use Moox\Builder\Concerns\HasCustomFields; @@ -246,14 +248,14 @@ class ItemResource extends Resource public static function form(Schema $schema): Schema { return $schema->components([ - // eigene Felder … + // your own fields … ...static::customFieldComponents(), ]); } } ``` -Der **Entity-Key** wird automatisch aus dem Model-Basename abgeleitet (`Item` → `item`). Abweichenden Key per `customFieldsEntity()` überschreiben: +The **entity key** is derived automatically from the model basename (`Item` → `item`). Override with `customFieldsEntity()`: ```php protected static function customFieldsEntity(): ?string @@ -262,134 +264,134 @@ protected static function customFieldsEntity(): ?string } ``` -### Schritt 2: Entity-Discovery +### Step 2: Entity discovery -`EntityRegistry` findet Resources **automatisch** über alle registrierten Filament-Panels — jede Resource mit `HasCustomFields` erscheint im Multi-Select „Anzeigen bei“. Keine manuelle Config-Registrierung nötig. +`EntityRegistry` finds resources **automatically** across all registered Filament panels — every resource with `HasCustomFields` appears in the "Show on" multi-select. No manual config registration needed. -### Schritt 3: Feldgruppe im Admin +### Step 3: Field group in admin -**Felder → Feldgruppen → Erstellen** +**Fields → Field Groups → Create** -- **Anzeigen bei:** z. B. `Items`, `Records` -- Felder definieren -- Aktiv lassen +- **Show on:** e.g. `Items`, `Records` +- Define fields +- Keep active -**Nicht nötig:** +**Not required:** -- Trait oder Spalte am Eloquent-Model -- Eigene Listener oder Page-Hooks -- Migration für JSON am Model -- Manuelle Entity-Config +- Trait or column on the Eloquent model +- Custom listeners or page hooks +- Migration for JSON on the model +- Manual entity config --- -## Feldgruppen im Admin +## Field Groups in Admin -Navigation: **Felder → Feldgruppen** +Navigation: **Fields → Field Groups** -| Bereich | Inhalt | -|---------|--------| -| **Allgemein** | Name, technischer Schlüssel, aktiv, Reihenfolge | -| **Zuordnung** | Multi-Select „Anzeigen bei“ (auto-discovered Resources) | -| **Felder** | Repeater: Bezeichnung, Typ, Feldschlüssel, Pflichtfeld | -| **Einstellungen** | Capability-Felder (nur wenn der Typ welche hat) | -| **Optionen** | Für Select/Radio/Multiselect/Checkbox-Liste | -| **Unterfelder** | Für Group und Repeater | -| **Layouts** | Für Flexible Content (Layout-Schlüssel + Unterfelder) | +| Section | Content | +|---------|---------| +| **General** | Name, technical key, active, sort order | +| **Assignment** | "Show on" multi-select (auto-discovered resources) | +| **Fields** | Repeater: label, type, field key, required | +| **Settings** | Capability fields (only when the type has them) | +| **Options** | For select/radio/multiselect/checkbox list | +| **Subfields** | For group and repeater | +| **Layouts** | For flexible content (layout key + subfields) | -Repeater-Zeilen sind standardmäßig eingeklappt und zeigen Typ, Schlüssel und Pflichtfeld im Label. +Repeater rows are collapsed by default and show type, key, and required flag in the label. -Übersetzungen: `resources/lang/de/builder.php`, `en/builder.php`. +Translations: `resources/lang/de/builder.php`, `en/builder.php`. --- -## Feldtypen & Capabilities +## Field Types & Capabilities -### Eingebaute Feldtypen (28 wählbar mit `moox/media`, sonst 25) +### Built-in field types (28 with `moox/media`, otherwise 25) -| Kategorie | Keys | -|-----------|------| +| Category | Keys | +|----------|------| | **Text** | `text`, `textarea`, `email`, `url`, `password`, `rich_text` | -| **Zahl** | `number`, `range` | -| **Auswahl** | `select`, `multiselect`, `checkbox_list`, `radio`, `button_group`, `toggle` | -| **Datum** | `date`, `datetime`, `time` | -| **Sonstiges** | `color`, `link`, `message`, `oembed` | -| **Media** *(nur mit `moox/media`)* | `image`, `gallery`, `file` | +| **Number** | `number`, `range` | +| **Choice** | `select`, `multiselect`, `checkbox_list`, `radio`, `button_group`, `toggle` | +| **Date** | `date`, `datetime`, `time` | +| **Other** | `color`, `link`, `message`, `oembed` | +| **Media** *(requires `moox/media`)* | `image`, `gallery`, `file` | | **Layout** | `tab`, `group`, `repeater`, `flexible_content` | -Intern (nur in der DB, nicht wählbar): `flexible_layout` — definiert ein Layout innerhalb von Flexible Content. +Internal only (DB, not selectable): `flexible_layout` — defines a layout inside flexible content. -### Media-Felder (`moox/media` optional) +### Media fields (`moox/media` optional) -Die Feldtypen `image`, `gallery` und `file` werden nur registriert, wenn `moox/media` installiert ist (`MediaIntegration::isAvailable()`). Es gibt **keine** harte Composer-Abhängigkeit — ohne Media-Paket fehlen diese Typen im Admin. +Types `image`, `gallery`, and `file` are only registered when `moox/media` is installed (`MediaIntegration::isAvailable()`). There is **no** hard Composer dependency — without the media package these types are missing in admin. -| Typ | UI | Speicher in `value_json` | Mediathek | -|-----|----|--------------------------|-----------| -| `image` | Einzelner Media-Picker | Ein Snapshot `{id, file_name, title, alt, …}` | nur Bilder | -| `gallery` | Mehrfach-Picker | Indexierte Snapshots `{"1": {…}, "2": {…}}` | nur Bilder | -| `file` | Einzelner Media-Picker | Ein Snapshot (wie Image) | alles außer Bilder | +| Type | UI | Storage in `value_json` | Library filter | +|------|----|---------------------------|----------------| +| `image` | Single media picker | One snapshot `{id, file_name, title, alt, …}` | images only | +| `gallery` | Multi picker | Indexed snapshots `{"1": {…}, "2": {…}}` | images only | +| `file` | Single media picker | One snapshot (like image) | everything except images | -**Architektur:** +**Architecture:** -- UI nutzt die Moox-Mediathek (`BuilderMediaPicker` + isolierter Modal pro Feld) -- Werte landen in `builder_field_values`, nicht in Model-Spalten -- `media_usables` trackt Verwendung; Snapshots werden bei Translation-Updates synchronisiert -- Validierung: Media existiert, Scope passt zum Record, MIME-Typ passend zum Feldtyp +- UI uses the Moox media library (`BuilderMediaPicker` + isolated modal per field) +- Values go to `builder_field_values`, not model columns +- `media_usables` tracks usage; snapshots sync on translation updates +- Validation: media exists, scope matches the record, MIME type matches the field type -API-Ausgabe über `presentValue()` / `MediaItemResource` (URLs, Thumbnails — kein `internal_note`). +API output uses `presentValue()` / `MediaItemResource` (URLs, thumbnails — no `internal_note`). See [API Serialization](#api-serialization). -### Layout-Felder +### Layout fields -| Typ | Filament-Komponente | Speicher | -|-----|---------------------|----------| -| `tab` | `Tabs` / `Tab` (Marker, kein Wert) | — | -| `group` | `Repeater` (min/max 1) | `value_json` (Objekt) | -| `repeater` | `Repeater` | `value_json` (Array) | -| `flexible_content` | `Builder` mit Layout-Blöcken | `value_json` (Array mit `type` + `data`) | +| Type | Filament component | Storage | +|------|-------------------|---------| +| `tab` | `Tabs` / `Tab` (marker, no value) | — | +| `group` | `Repeater` (min/max 1) | `value_json` (object) | +| `repeater` | `Repeater` | `value_json` (array) | +| `flexible_content` | `Builder` with layout blocks | `value_json` (array with `type` + `data`) | -Flexible Content entspricht ACF **Flexible Content**: pro Zeile ein wählbares Layout mit eigenen Unterfeldern. +Flexible content works like ACF **Flexible Content**: each row is a selectable layout with its own subfields. -### Capabilities (pro Typ konfigurierbar) +### Capabilities (configurable per type) -| Capability | Wirkung | -|------------|---------| -| `MaxLength` | max. Zeichenlänge | -| `Placeholder` | Platzhaltertext | -| `PrefixSuffix` | Präfix/Suffix | -| `DefaultValue` | Standardwert | -| `HelperText` | Hilfetext unter dem Feld | -| `MinValue` / `MaxValue` / `Step` | Zahlenfelder | -| `Rows` | Textarea-Zeilen | -| `DisplayFormat` | Datumsformat | -| `MessageBody` | Hinweistext (message) | -| `RepeaterItems` | min/max Einträge (Repeater, Flexible Content) | -| `GalleryFiles` | min/max Dateien (Gallery) | +| Capability | Effect | +|------------|--------| +| `MaxLength` | Max character length | +| `Placeholder` | Placeholder text | +| `PrefixSuffix` | Prefix/suffix | +| `DefaultValue` | Default value | +| `HelperText` | Help text below the field | +| `MinValue` / `MaxValue` / `Step` | Number fields | +| `Rows` | Textarea rows | +| `DisplayFormat` | Date format | +| `MessageBody` | Info text (message) | +| `RepeaterItems` | Min/max entries (repeater, flexible content) | +| `GalleryFiles` | Min/max files (gallery) | -Jeder Feldtyp implementiert `FieldType`: `key()`, `formComponent()`, `capabilities()`, optional `castValue()`, `hasSubFields()`, `hasLayouts()`. +Each field type implements `FieldType`: `key()`, `formComponent()`, `capabilities()`, optionally `castValue()`, `hasSubFields()`, `hasLayouts()`. -### Validierung +### Validation -- **Pflichtfelder** und **Capabilities** werden als Filament-Regeln auf die Komponente angewendet. -- **Verschachtelte Werte** (Repeater, Group, Flexible Content) werden zusätzlich durch `FieldValueValidator` geprüft — u. a. leere Repeater-Zeilen und unbekannte Layouts. -- **Media-Felder:** Existenz, Scope, MIME-Typ (Bild vs. Datei) und bei Gallery min/max Dateien. +- **Required fields** and **capabilities** are applied as Filament rules on the component. +- **Nested values** (repeater, group, flexible content) are also validated by `FieldValueValidator` — including empty repeater rows and unknown layouts. +- **Media fields:** existence, scope, MIME type (image vs file), and min/max files for gallery. --- -## Konfiguration +## Configuration `config/builder.php`: -| Key | Default | Beschreibung | -|-----|---------|--------------| -| `navigation_group` | `Felder` | Filament-Navigationsgruppe für Feldgruppen | +| Key | Default | Description | +|-----|---------|-------------| +| `navigation_group` | `Fields` | Filament navigation group for field groups | Env: `BUILDER_NAVIGATION_GROUP`. --- -## Erweiterung +## Extension -### Eigenen Feldtyp registrieren +### Register a custom field type ```php use Moox\Builder\FieldTypes\FieldType; @@ -400,16 +402,16 @@ $this->app->afterResolving(FieldTypeRegistry::class, function (FieldTypeRegistry }); ``` -Übersetzung unter `builder::builder.field_types.{key}` in `resources/lang`. +Translation under `builder::builder.field_types.{key}` in `resources/lang`. -### Validierungsregeln abfragen +### Get validation rules ```php ItemResource::customFieldRules(); -// ['feld-name' => ['required', 'max:255'], 'repeater.*.unterfeld' => ['required'], ...] +// ['field-name' => ['required', 'max:255'], 'repeater.*.subfield' => ['required'], ...] ``` -### Werte programmatisch laden +### Load values for Filament forms ```php use Moox\Builder\Services\CustomFieldsManager; @@ -420,61 +422,74 @@ $values = app(CustomFieldsManager::class)->loadFormData( ); ``` -### Werte auf Model-Ebene (InteractsWithCustomFields) +This is for the Filament form context, not for API output. + +--- + +## Model API -Trait auf dem Consumer-Model (z. B. `Item`). Entity-Key: `getResourceName()` → `customFieldsEntity()` → Filament-Resource → Model-Basename. +Add `InteractsWithCustomFields` to the consumer model (e.g. `Item`). Entity key resolution: `getResourceName()` → `customFieldsEntity()` → Filament resource → model basename. ```php use Moox\Builder\Concerns\InteractsWithCustomFields; -// Lesen -$item->farbe; // wie natives Attribut (gültige PHP-Feldnamen) -$item->customFields(); // alle Custom Fields inkl. Defaults -$item->customFields(fresh: true); // neu aus DB, Cache ignorieren -$item->customField('fahrzeugtyp-modell'); // ein Feld (auch mit Bindestrich) -$item->hasCustomField('farbe'); // Wert vorhanden (inkl. Default)? -$item->hasCustomFieldDefinition('farbe'); // Felddefinition existiert? -$item->toArray(); // DB-Spalten + Custom Fields (roh, intern) - -// Schreiben -$item->farbe = 'Blau'; // oder setCustomField() -$item->setCustomField('farbe', 'Blau'); -$item->setCustomFields(['unfallfrei' => true]); -$item->clearCustomField('farbe'); - -// Queries & Collections -Item::query()->where('farbe', 'Blau')->get(); // normales where auf Custom Fields -Item::query()->withCustomFields()->get(); // Eager Load (kein N+1) -Item::eagerLoadCustomFields($models); // Batch für bestehende Collection +// Read +$item->color; // like a native attribute (valid PHP field names) +$item->customFields(); // all custom fields including defaults +$item->customFields(fresh: true); // reload from DB, ignore cache +$item->customField('vehicle-type'); // single field +$item->hasCustomField('color'); // value present (including default)? +$item->hasCustomFieldDefinition('color'); // field definition exists? +$item->toArray(); // DB columns + raw custom fields (internal) + +// Write +$item->color = 'Blue'; // or setCustomField() +$item->setCustomField('color', 'Blue'); +$item->setCustomFields(['accident_free' => true]); +$item->clearCustomField('color'); + +// Queries & collections +Item::query()->where('color', 'Blue')->get(); // normal where on custom fields +Item::query()->withCustomFields()->get(); // eager load (no N+1) +Item::eagerLoadCustomFields($models); // batch for existing collection // Meta -Item::customFieldNames(); // alle definierten Feldnamen -Item::resolveCustomFieldsEntity(); // Entity-Key (z. B. item) -Item::flushCustomFieldDefinitionCache(); // Definitionen-Cache leeren -$item->flushCustomFieldsCache(); // Werte-Cache auf dem Model leeren +Item::customFieldNames(); // all defined field names +Item::resolveCustomFieldsEntity(); // entity key (e.g. item) +Item::flushCustomFieldDefinitionCache(); // clear definition cache +$item->flushCustomFieldsCache(); // clear value cache on the model -// Optional: abweichender Entity-Key (gleicher Hook wie auf der Filament-Resource) +// Optional: custom entity key (same hook as on the Filament resource) protected static function customFieldsEntity(): ?string { return 'my-entity'; } ``` -**Hinweise:** +**Notes:** -- DB-Spalten haben Vorrang vor Custom Fields mit gleichem Namen (`$item->title` → Spalte, nicht Builder-Feld). -- Passwort-Felder werden beim Speichern gehasht (`Hash::make`) und nie im Klartext zurückgeladen -- `dump($item)` / Tinker zeigt Custom Fields in `__debugInfo()` (Passwörter maskiert). +- DB columns take precedence over custom fields with the same name (`$item->title` → column, not builder field). +- Password fields are hashed on save (`Hash::make`) and never returned in plain text. +- `dump($item)` / Tinker shows custom fields in `__debugInfo()` (passwords masked). + +--- -### API Resources +## API Serialization -`MergesCustomFields` merged Custom Fields API-formatiert in die Resource (ISO-Dates, maskierte Passwörter, verschachtelte Group/Repeater/Flexible Content). +Builder does **not** ship HTTP routes. It provides helpers for your own `JsonResource` classes or controllers. + +### Requirements + +1. The Eloquent model must use `InteractsWithCustomFields`. +2. Without that trait, `mergeCustomFields()` returns your payload unchanged (no error). + +### JsonResource (recommended) ```php use Illuminate\Http\Resources\Json\JsonResource; use Moox\Builder\Http\Resources\Concerns\MergesCustomFields; -class ItemResource extends JsonResource +class ItemApiResource extends JsonResource { use MergesCustomFields; @@ -488,22 +503,69 @@ class ItemResource extends JsonResource } ``` +Use a distinct class name (e.g. `ItemApiResource`) — do not confuse it with the Filament `ItemResource`. + +### Lists (avoid N+1) + +```php +$items = Item::query()->withCustomFields()->get(); + +return ItemApiResource::collection($items); +``` + +### Internal vs API output + +| Method | Use case | Example: `date` | Example: `password` | Example: `image` | +|--------|----------|-----------------|---------------------|-------------------| +| `$item->customFields()` | Internal / PHP | `Carbon` instance | hashed string | snapshot array | +| `$item->toArray()` | Internal arrays | `Carbon` instance | `null` | snapshot array | +| `mergeCustomFields()` | API / JSON | `"2026-06-16"` | `{"has_value": true}` | `MediaItemResource` shape | + +### Output shapes (API) + +| Field type | API output | +|------------|------------| +| `date` | `"2026-06-16"` | +| `datetime` | ISO 8601 string | +| `time` | `"14:30"` | +| `password` | `{"has_value": true}` | +| `image` / `file` | `MediaItemResource` (`url`, `thumbnail_url`, `preview_url`, …) | +| `gallery` | `{"1": {...}, "2": {...}}` (indexed) | +| `group` | `{"subfield": ...}` (nested, presented) | +| `repeater` | `[{...}, {...}]` | +| `flexible_content` | `[{"type": "hero", "data": {...}}]` | + +### Without JsonResource + +```php +use Moox\Builder\Services\BuilderValuesResolver; +use Moox\Builder\Services\CustomFieldsManager; + +$manager = app(CustomFieldsManager::class); +$entity = $item::resolveCustomFieldsEntity(); + +$presented = app(BuilderValuesResolver::class)->present( + $manager->fieldsForEntity($entity), + $item->customFields(), +); +``` + --- -## Paketstruktur +## Package Structure ``` packages/builder/ ├── config/builder.php ├── database/ -│ ├── migrations/ # 4 Tabellen +│ ├── migrations/ # 4 tables │ └── seeders/BuilderSeeder.php ├── resources/lang/{de,en}/builder.php └── src/ ├── BuilderServiceProvider.php ├── Concerns/ - │ ├── HasCustomFields.php # Filament-Resource - │ └── InteractsWithCustomFields.php # Consumer-Model + │ ├── HasCustomFields.php # Filament resource + │ └── InteractsWithCustomFields.php # Consumer model ├── Compiler/ │ ├── LocationMatcher.php │ └── SchemaCompiler.php @@ -514,9 +576,11 @@ packages/builder/ ├── FieldTypes/ │ ├── FieldType.php │ ├── Capabilities/ - │ └── Types/ # 29 Feldtypen + │ └── Types/ # 29 field types ├── Forms/Components/BuilderMediaPicker.php - ├── Http/Livewire/BuilderMediaPickerModal.php + ├── Http/ + │ ├── Livewire/BuilderMediaPickerModal.php + │ └── Resources/Concerns/MergesCustomFields.php ├── Listeners/PersistCustomFields.php ├── Models/ # FieldGroup, Field, FieldOption, FieldValue ├── Observers/ @@ -527,9 +591,10 @@ packages/builder/ │ ├── DefinitionRegistry.php │ ├── EntityRegistry.php │ └── FieldTypeRegistry.php - ├── Resources/FieldGroupResource.php # Admin-UI + ├── Resources/FieldGroupResource.php # Admin UI ├── Services/ │ ├── CustomFieldsManager.php + │ ├── BuilderValuesResolver.php │ ├── BuilderMediaUsageSync.php │ ├── BuilderFieldValueMediaMetadataSync.php │ ├── FieldGroupPersistence.php @@ -546,19 +611,19 @@ packages/builder/ --- -## Testen +## Testing -### Package-Tests +### Package tests ```bash cd packages/builder && composer test ``` -### Manuell im Panel +### Manual in the panel -1. **Felder → Feldgruppen** — Demo-Gruppe „Fahrzeugdaten“ (nach Seeder) -2. **Items → Bearbeiten** — Custom-Field-Sections inkl. Tabs, Group, Repeater, Flexible Content, optional Image/Gallery/File -3. Speichern, dann DB prüfen: +1. **Fields → Field Groups** — demo group "Vehicle data" (after seeder) +2. **Items → Edit** — custom field sections including tabs, group, repeater, flexible content, optional image/gallery/file +3. Save, then check the DB: ```sql SELECT entity, record_id, field_name, value_string, value_decimal, value_json @@ -566,7 +631,7 @@ FROM builder_field_values WHERE entity = 'item'; ``` -4. Item erneut öffnen — Werte müssen geladen sein. +4. Re-open the item — values must be loaded. ### Seeder @@ -574,52 +639,52 @@ WHERE entity = 'item'; php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --force ``` -### Checkliste +### Checklist -| Check | Erwartung | -|-------|-----------| -| 4 Builder-Tabellen existieren | nach `migrate` | -| Resource nutzt `HasCustomFields` | erscheint unter „Anzeigen bei“ | -| `BuilderPlugin` im Panel | Nav „Felder“ sichtbar | -| Feldgruppe mit „Anzeigen bei: Items“ | Section auf Item-Form | -| Werte in `builder_field_values` | typisierte Spalten befüllt | -| Flexible Content sortierbar | ohne Fehler nach Speichern/Laden | -| Image/Gallery/File (mit `moox/media`) | Mediathek gefiltert, Speichern/Laden, `media_usables` | +| Check | Expected | +|-------|----------| +| 4 builder tables exist | after `migrate` | +| Resource uses `HasCustomFields` | appears under "Show on" | +| `BuilderPlugin` in panel | "Fields" nav visible | +| Field group with "Show on: Items" | section on item form | +| Values in `builder_field_values` | typed columns filled | +| Flexible content sortable | no errors after save/load | +| Image/gallery/file (with `moox/media`) | library filtered, save/load works, `media_usables` updated | --- -## Grenzen & Roadmap +## Limits & Roadmap -**Implementiert:** +**Implemented:** -- Nested Fields via `parent_field_id` (Group, Repeater, Flexible Content) -- Layout-Felder: Tab, Group, Repeater, Flexible Content -- Entity-Discovery über `HasCustomFields` in Filament-Panels -- Verschachtelte Validierung (`FieldValueValidator`) -- `InteractsWithCustomFields` auf Consumer-Models (`customFields()`, Attribute-Zugriff, Queries, Eager Load) -- `MergesCustomFields` für API Resources -- `FieldType::presentValue()` für API-Serialisierung (Datums-ISO, Passwort-Maskierung, verschachtelte Felder, Media via `MediaItemResource`) -- Repeater min/max (`RepeaterItems` Capability) -- Media-Felder: `image`, `gallery`, `file` (optional mit `moox/media`) -- `BuilderMediaPicker` mit isoliertem Modal pro Feld und MIME-Filter in der Mediathek -- `media_usables`-Sync und Metadaten-Snapshot-Updates für Media-Felder +- Nested fields via `parent_field_id` (group, repeater, flexible content) +- Layout fields: tab, group, repeater, flexible content +- Entity discovery via `HasCustomFields` in Filament panels +- Nested validation (`FieldValueValidator`) +- `InteractsWithCustomFields` on consumer models (`customFields()`, attribute access, queries, eager load) +- `MergesCustomFields` for API resources +- `FieldType::presentValue()` for API serialization (ISO dates, password masking, nested fields, media via `MediaItemResource`) +- Repeater min/max (`RepeaterItems` capability) +- Media fields: `image`, `gallery`, `file` (optional with `moox/media`) +- `BuilderMediaPicker` with isolated modal per field and MIME filter in the library +- `media_usables` sync and metadata snapshot updates for media fields -**Aktuell nicht implementiert:** +**Not implemented yet:** -- Relational-Felder (Post Object, Relationship, User, Taxonomy) -- Clone-Feldtyp (ACF) -- Location-Params über `entity` hinaus -- `placement`-Steuerung (Sidebar, …) -- Conditional Logic im Formular +- Relational fields (post object, relationship, user, taxonomy) +- Clone field type (ACF) +- Location params beyond `entity` +- `placement` control (sidebar, …) +- Conditional logic in forms -**Bewusst nicht Ziel:** +**Intentionally out of scope:** -- WordPress/postmeta-Treiber -- JSON-Spalte am Model -- Accordion als eigener Feldtyp (Tabs + Sections reichen) +- WordPress/postmeta driver +- JSON column on the model +- Accordion as its own field type (tabs + sections are enough) --- -## Lizenz +## License MIT From f1a21115a41f1ea4e2e3dadb1c91056cf886265d Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:11:47 +0200 Subject: [PATCH 052/105] Add locale-aware translations for builder field definitions and values. --- packages/builder/README.md | 102 +++++++-- packages/builder/config/builder.php | 1 + ...ale_to_builder_field_values_table.php.stub | 137 ++++++++++++ ...er_field_group_translations_table.php.stub | 29 +++ ...r_field_option_translations_table.php.stub | 29 +++ ..._builder_field_translations_table.php.stub | 30 +++ ...create_builder_field_values_table.php.stub | 4 +- .../database/seeders/BuilderSeeder.php | 3 + .../builder/src/BuilderServiceProvider.php | 10 + .../Concerns/InteractsWithCustomFields.php | 28 ++- packages/builder/src/Data/FieldDefinition.php | 52 ++++- .../builder/src/Data/FieldGroupDefinition.php | 27 ++- .../Database/Eloquent/CustomFieldsBuilder.php | 11 +- .../HasBuilderTranslatableAttributes.php | 114 ++++++++++ packages/builder/src/Models/Field.php | 9 +- packages/builder/src/Models/FieldGroup.php | 9 +- .../src/Models/FieldGroupTranslation.php | 20 ++ packages/builder/src/Models/FieldOption.php | 9 +- .../src/Models/FieldOptionTranslation.php | 20 ++ .../builder/src/Models/FieldTranslation.php | 25 +++ packages/builder/src/Models/FieldValue.php | 20 ++ .../src/Registry/DefinitionRegistry.php | 10 +- .../src/Resources/FieldGroupResource.php | 16 +- .../InteractsWithFieldGroupLocale.php | 89 ++++++++ .../Pages/CreateFieldGroup.php | 30 ++- .../Pages/EditFieldGroup.php | 36 +++- .../Pages/ListFieldGroups.php | 39 +++- .../src/Services/BuilderValuesResolver.php | 30 ++- .../src/Services/CustomFieldsManager.php | 73 +++++-- .../src/Services/FieldGroupPersistence.php | 114 ++++++++-- .../src/Support/BuilderLocaleResolver.php | 101 +++++++++ .../src/Support/DefinitionTranslator.php | 204 ++++++++++++++++++ .../builder/src/Support/FieldRelationTree.php | 5 + 33 files changed, 1361 insertions(+), 75 deletions(-) create mode 100644 packages/builder/database/migrations/add_locale_to_builder_field_values_table.php.stub create mode 100644 packages/builder/database/migrations/create_builder_field_group_translations_table.php.stub create mode 100644 packages/builder/database/migrations/create_builder_field_option_translations_table.php.stub create mode 100644 packages/builder/database/migrations/create_builder_field_translations_table.php.stub create mode 100644 packages/builder/src/Models/Concerns/HasBuilderTranslatableAttributes.php create mode 100644 packages/builder/src/Models/FieldGroupTranslation.php create mode 100644 packages/builder/src/Models/FieldOptionTranslation.php create mode 100644 packages/builder/src/Models/FieldTranslation.php create mode 100644 packages/builder/src/Resources/FieldGroupResource/Pages/Concerns/InteractsWithFieldGroupLocale.php create mode 100644 packages/builder/src/Support/BuilderLocaleResolver.php create mode 100644 packages/builder/src/Support/DefinitionTranslator.php diff --git a/packages/builder/README.md b/packages/builder/README.md index f6b51e414f..986c343611 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -16,13 +16,14 @@ Admins define fields in the panel. Values are stored in typed `builder_field_val 6. [Connect a Resource](#connect-a-resource) 7. [Field Groups in Admin](#field-groups-in-admin) 8. [Field Types & Capabilities](#field-types--capabilities) -9. [Configuration](#configuration) -10. [Extension](#extension) -11. [Model API](#model-api) -12. [API Serialization](#api-serialization) -13. [Package Structure](#package-structure) -14. [Testing](#testing) -15. [Limits & Roadmap](#limits--roadmap) +9. [Translations](#translations) +10. [Configuration](#configuration) +11. [Extension](#extension) +12. [Model API](#model-api) +13. [API Serialization](#api-serialization) +14. [Package Structure](#package-structure) +15. [Testing](#testing) +16. [Limits & Roadmap](#limits--roadmap) --- @@ -137,8 +138,23 @@ One row per value: | `value_datetime` | datetime | | `value_boolean` | toggle | | `value_json` | multiselect, checkbox_list, link, image, gallery, file, group, repeater, flexible_content | +| `locale` | Locale variant for this value (e.g. `en_US`, `de_CH`) | -Unique: `(entity, record_id, field_name)`. +Unique: `(entity, record_id, field_name, locale)`. + +### Definition translations (Astrotomic) + +Field group names, field labels, option labels, and translatable field config (`helperText`, `placeholder`, …) use [astrotomic/laravel-translatable](https://docs.astrotomic.info/laravel-translatable) — the same stack as `moox/media` and Moox draft entities (via `moox/localization`). + +| Table | Model | Translated attributes | +|-------|-------|----------------------| +| `builder_field_group_translations` | `FieldGroupTranslation` | `name` | +| `builder_field_translations` | `FieldTranslation` | `label`, `config` (JSON subset) | +| `builder_field_option_translations` | `FieldOptionTranslation` | `label` | + +`FieldGroup`, `Field`, and `FieldOption` implement `TranslatableContract` through `HasBuilderTranslatableAttributes`. Main-table columns (`name`, `label`, …) stay populated for the default locale as fallback columns. + +**Not translated:** field keys (`name`), option values (`value`), structural `config`, location rules, validation rules. ### Location Rules (internal) @@ -301,7 +317,9 @@ Navigation: **Fields → Field Groups** Repeater rows are collapsed by default and show type, key, and required flag in the label. -Translations: `resources/lang/de/builder.php`, `en/builder.php`. +Use the language selector (`?lang=`) on create/edit to translate group names, field labels, option labels, and translatable config. See [Translations](#translations). + +Package UI strings: `resources/lang/de/builder.php`, `en/builder.php`. --- @@ -377,6 +395,56 @@ Each field type implements `FieldType`: `key()`, `formComponent()`, `capabilitie --- +## Translations + +Builder separates **definitions** (what fields are called) from **values** (what users enter). + +| Layer | Mechanism | Locale source | +|-------|-----------|---------------| +| **Definitions** | Astrotomic translation tables | `?lang=` on field group admin, `BuilderLocaleResolver` | +| **Values** | `locale` column on `builder_field_values` | `?lang=` / `request('lang')` on consumer resources | + +### Locale resolution + +`BuilderLocaleResolver` resolves in order: + +1. Explicit locale argument +2. `request('lang')` (query or input) +3. Default locale from `moox/localization` (`localizations.is_default`) +4. Admin UI default from `adminDefaultLocale()` (`is_default` + `is_active_admin`, then first active admin locale) +5. `config('builder.default_locale')` → `config('app.locale')` → `en_US` + +Fallback chain for missing translations: **active locale → default locale → main-table columns**. + +### Admin: field groups + +`EditFieldGroup` / `CreateFieldGroup` / `ListFieldGroups` use `InteractsWithFieldGroupLocale` (same patterns as Moox draft list/edit pages): + +- Language selector in the header (`localization::lang-selector` when available) +- `?lang=de_CH` saves labels/names into the matching translation row via `translateOrNew()` + `saveTranslations()` +- `hydrate()` keeps `request('lang')` in sync on Livewire re-renders (search/filter) +- Invalid or admin-hidden locales redirect to the default admin locale (`is_active_admin`) +- Default locale also updates main-table fallback columns +- List index shows localized group names for the active `?lang=` + +### Runtime: consumer resources + +Pass `?lang=` when loading/saving records so `CustomFieldsManager` reads and writes the correct `builder_field_values.locale` row. Definition labels in forms come from `DefinitionRegistry` + `DefinitionTranslator` (cached, localized at read time). + +### Code patterns (same as Media/Draft) + +```php +$group->translateOrNew('de_CH')->name = 'Grundlagen'; +$group->saveTranslations(); + +$field->translateOrNew('de_CH')->label = 'Farbe'; +$field->saveTranslations(); +``` + +Astrotomic is provided transitively via `moox/core` → `moox/localization` — no extra Composer dependencies needed in this package. + +--- + ## Configuration `config/builder.php`: @@ -384,8 +452,9 @@ Each field type implements `FieldType`: `key()`, `formComponent()`, `capabilitie | Key | Default | Description | |-----|---------|-------------| | `navigation_group` | `Fields` | Filament navigation group for field groups | +| `default_locale` | `en_US` | Fallback when `moox/localization` has no default | -Env: `BUILDER_NAVIGATION_GROUP`. +Env: `BUILDER_NAVIGATION_GROUP`, `BUILDER_DEFAULT_LOCALE`. --- @@ -442,7 +511,7 @@ $item->hasCustomField('color'); // value present (including default)? $item->hasCustomFieldDefinition('color'); // field definition exists? $item->toArray(); // DB columns + raw custom fields (internal) -// Write +// Write $item->color = 'Blue'; // or setCustomField() $item->setCustomField('color', 'Blue'); $item->setCustomFields(['accident_free' => true]); @@ -558,7 +627,7 @@ $presented = app(BuilderValuesResolver::class)->present( packages/builder/ ├── config/builder.php ├── database/ -│ ├── migrations/ # 4 tables +│ ├── migrations/ # 7 tables + translation tables │ └── seeders/BuilderSeeder.php ├── resources/lang/{de,en}/builder.php └── src/ @@ -582,7 +651,10 @@ packages/builder/ │ ├── Livewire/BuilderMediaPickerModal.php │ └── Resources/Concerns/MergesCustomFields.php ├── Listeners/PersistCustomFields.php - ├── Models/ # FieldGroup, Field, FieldOption, FieldValue + ├── Models/ + │ ├── Concerns/HasBuilderTranslatableAttributes.php + │ ├── FieldGroup.php, Field.php, FieldOption.php, FieldValue.php + │ └── *Translation.php # Astrotomic translation models ├── Observers/ │ ├── InvalidateDefinitionCacheObserver.php │ └── PurgeFieldValuesObserver.php @@ -602,6 +674,8 @@ packages/builder/ │ ├── FieldValuePurger.php │ └── FieldValueValidator.php └── Support/ + ├── BuilderLocaleResolver.php + ├── DefinitionTranslator.php ├── EntityModelDeletionRegistrar.php ├── MediaFieldValueSupport.php ├── MediaIntegration.php @@ -626,7 +700,7 @@ cd packages/builder && composer test 3. Save, then check the DB: ```sql -SELECT entity, record_id, field_name, value_string, value_decimal, value_json +SELECT entity, record_id, field_name, locale, value_string, value_decimal, value_json FROM builder_field_values WHERE entity = 'item'; ``` diff --git a/packages/builder/config/builder.php b/packages/builder/config/builder.php index 2fe3587cb1..407e52ca95 100644 --- a/packages/builder/config/builder.php +++ b/packages/builder/config/builder.php @@ -4,4 +4,5 @@ return [ 'navigation_group' => env('BUILDER_NAVIGATION_GROUP', 'Felder'), + 'default_locale' => env('BUILDER_DEFAULT_LOCALE', 'en_US'), ]; diff --git a/packages/builder/database/migrations/add_locale_to_builder_field_values_table.php.stub b/packages/builder/database/migrations/add_locale_to_builder_field_values_table.php.stub new file mode 100644 index 0000000000..3e22c021cb --- /dev/null +++ b/packages/builder/database/migrations/add_locale_to_builder_field_values_table.php.stub @@ -0,0 +1,137 @@ +defaultLocale(); + + if (! Schema::hasColumn('builder_field_values', 'locale')) { + Schema::table('builder_field_values', function (Blueprint $table): void { + $table->string('locale')->default('en_US')->after('field_name'); + }); + + DB::table('builder_field_values')->update(['locale' => $defaultLocale]); + + Schema::table('builder_field_values', function (Blueprint $table): void { + $table->dropUnique(['entity', 'record_id', 'field_name']); + $table->unique(['entity', 'record_id', 'field_name', 'locale']); + $table->index(['entity', 'record_id', 'locale']); + }); + } + + $this->seedDefinitionTranslations($defaultLocale); + } + + public function down(): void + { + if (! Schema::hasColumn('builder_field_values', 'locale')) { + return; + } + + $defaultLocale = $this->defaultLocale(); + + DB::table('builder_field_values') + ->where('locale', '!=', $defaultLocale) + ->delete(); + + Schema::table('builder_field_values', function (Blueprint $table): void { + $table->dropUnique(['entity', 'record_id', 'field_name', 'locale']); + $table->dropIndex(['entity', 'record_id', 'locale']); + $table->unique(['entity', 'record_id', 'field_name']); + $table->dropColumn('locale'); + }); + + Schema::dropIfExists('builder_field_option_translations'); + Schema::dropIfExists('builder_field_translations'); + Schema::dropIfExists('builder_field_group_translations'); + } + + protected function defaultLocale(): string + { + if ( + class_exists(\Moox\Localization\Models\Localization::class) + && Schema::hasTable('localizations') + ) { + $localization = \Moox\Localization\Models\Localization::query() + ->where('is_default', true) + ->first(); + + if ($localization !== null && filled($localization->getAttribute('locale_variant'))) { + return (string) $localization->getAttribute('locale_variant'); + } + } + + return (string) config('builder.default_locale', config('app.locale', 'en_US')); + } + + protected function seedDefinitionTranslations(string $defaultLocale): void + { + if (! Schema::hasTable('builder_field_group_translations')) { + return; + } + + $translatableConfigKeys = [ + 'helperText', + 'placeholder', + 'prefix', + 'suffix', + 'message', + ]; + + foreach (DB::table('builder_field_groups')->get() as $group) { + DB::table('builder_field_group_translations')->updateOrInsert( + [ + 'field_group_id' => $group->id, + 'locale' => $defaultLocale, + ], + [ + 'name' => $group->name, + 'created_at' => now(), + 'updated_at' => now(), + ], + ); + } + + foreach (DB::table('builder_fields')->get() as $field) { + $config = json_decode((string) ($field->config ?? ''), true); + $translatableConfig = is_array($config) + ? array_intersect_key($config, array_flip($translatableConfigKeys)) + : []; + + DB::table('builder_field_translations')->updateOrInsert( + [ + 'field_id' => $field->id, + 'locale' => $defaultLocale, + ], + [ + 'label' => $field->label, + 'config' => $translatableConfig === [] ? null : json_encode($translatableConfig), + 'created_at' => now(), + 'updated_at' => now(), + ], + ); + } + + foreach (DB::table('builder_field_options')->get() as $option) { + DB::table('builder_field_option_translations')->updateOrInsert( + [ + 'field_option_id' => $option->id, + 'locale' => $defaultLocale, + ], + [ + 'label' => $option->label, + 'created_at' => now(), + 'updated_at' => now(), + ], + ); + } + } +}; diff --git a/packages/builder/database/migrations/create_builder_field_group_translations_table.php.stub b/packages/builder/database/migrations/create_builder_field_group_translations_table.php.stub new file mode 100644 index 0000000000..d7cca0ac2c --- /dev/null +++ b/packages/builder/database/migrations/create_builder_field_group_translations_table.php.stub @@ -0,0 +1,29 @@ +id(); + $table->foreignId('field_group_id')->constrained('builder_field_groups')->cascadeOnDelete(); + $table->string('locale'); + $table->string('name'); + $table->timestamps(); + + $table->unique(['field_group_id', 'locale']); + $table->index('locale'); + }); + } + + public function down(): void + { + Schema::dropIfExists('builder_field_group_translations'); + } +}; diff --git a/packages/builder/database/migrations/create_builder_field_option_translations_table.php.stub b/packages/builder/database/migrations/create_builder_field_option_translations_table.php.stub new file mode 100644 index 0000000000..7f103bdf64 --- /dev/null +++ b/packages/builder/database/migrations/create_builder_field_option_translations_table.php.stub @@ -0,0 +1,29 @@ +id(); + $table->foreignId('field_option_id')->constrained('builder_field_options')->cascadeOnDelete(); + $table->string('locale'); + $table->string('label'); + $table->timestamps(); + + $table->unique(['field_option_id', 'locale']); + $table->index('locale'); + }); + } + + public function down(): void + { + Schema::dropIfExists('builder_field_option_translations'); + } +}; diff --git a/packages/builder/database/migrations/create_builder_field_translations_table.php.stub b/packages/builder/database/migrations/create_builder_field_translations_table.php.stub new file mode 100644 index 0000000000..0a644f625b --- /dev/null +++ b/packages/builder/database/migrations/create_builder_field_translations_table.php.stub @@ -0,0 +1,30 @@ +id(); + $table->foreignId('field_id')->constrained('builder_fields')->cascadeOnDelete(); + $table->string('locale'); + $table->string('label'); + $table->json('config')->nullable(); + $table->timestamps(); + + $table->unique(['field_id', 'locale']); + $table->index('locale'); + }); + } + + public function down(): void + { + Schema::dropIfExists('builder_field_translations'); + } +}; diff --git a/packages/builder/database/migrations/create_builder_field_values_table.php.stub b/packages/builder/database/migrations/create_builder_field_values_table.php.stub index 63f98725da..17d86cfc07 100644 --- a/packages/builder/database/migrations/create_builder_field_values_table.php.stub +++ b/packages/builder/database/migrations/create_builder_field_values_table.php.stub @@ -15,6 +15,7 @@ return new class extends Migration $table->string('entity'); $table->unsignedBigInteger('record_id'); $table->string('field_name'); + $table->string('locale')->default('en_US'); $table->string('value_string')->nullable(); $table->text('value_text')->nullable(); $table->decimal('value_decimal', 20, 6)->nullable(); @@ -24,7 +25,8 @@ return new class extends Migration $table->json('value_json')->nullable(); $table->timestamps(); - $table->unique(['entity', 'record_id', 'field_name']); + $table->unique(['entity', 'record_id', 'field_name', 'locale']); + $table->index(['entity', 'record_id', 'locale']); $table->index(['entity', 'field_name', 'value_decimal']); $table->index(['entity', 'field_name', 'value_date']); $table->index(['entity', 'field_name', 'value_datetime']); diff --git a/packages/builder/database/seeders/BuilderSeeder.php b/packages/builder/database/seeders/BuilderSeeder.php index 1986359350..81ade445f9 100644 --- a/packages/builder/database/seeders/BuilderSeeder.php +++ b/packages/builder/database/seeders/BuilderSeeder.php @@ -418,11 +418,14 @@ protected function resetGroupFields(FieldGroup $group): void protected function upsertFieldValue(string $entity, int $recordId, string $fieldName, string $type, mixed $value): void { + $locale = (string) config('builder.default_locale', 'en_US'); + FieldValue::query()->updateOrCreate( [ 'entity' => $entity, 'record_id' => $recordId, 'field_name' => $fieldName, + 'locale' => $locale, ], TypedValueColumns::attributesFor($type, $value), ); diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index b5809425f3..aace67d09b 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -38,7 +38,10 @@ use Moox\Builder\Listeners\PersistCustomFields; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; +use Moox\Builder\Models\FieldGroupTranslation; use Moox\Builder\Models\FieldOption; +use Moox\Builder\Models\FieldOptionTranslation; +use Moox\Builder\Models\FieldTranslation; use Moox\Builder\Observers\InvalidateDefinitionCacheObserver; use Moox\Builder\Observers\PurgeFieldValuesObserver; use Moox\Builder\Registry\EntityRegistry; @@ -63,6 +66,10 @@ public function configureMoox(Package $package): void 'create_builder_fields_table', 'create_builder_field_options_table', 'create_builder_field_values_table', + 'create_builder_field_group_translations_table', + 'create_builder_field_translations_table', + 'create_builder_field_option_translations_table', + 'add_locale_to_builder_field_values_table', ]); } @@ -88,6 +95,9 @@ public function packageBooted(): void Field::observe(InvalidateDefinitionCacheObserver::class); Field::observe(PurgeFieldValuesObserver::class); FieldOption::observe(InvalidateDefinitionCacheObserver::class); + FieldGroupTranslation::observe(InvalidateDefinitionCacheObserver::class); + FieldTranslation::observe(InvalidateDefinitionCacheObserver::class); + FieldOptionTranslation::observe(InvalidateDefinitionCacheObserver::class); Event::listen(RecordSaved::class, PersistCustomFields::class); diff --git a/packages/builder/src/Concerns/InteractsWithCustomFields.php b/packages/builder/src/Concerns/InteractsWithCustomFields.php index 56a12022d0..bd9689508c 100644 --- a/packages/builder/src/Concerns/InteractsWithCustomFields.php +++ b/packages/builder/src/Concerns/InteractsWithCustomFields.php @@ -15,6 +15,7 @@ use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Services\BuilderValuesResolver; use Moox\Builder\Services\CustomFieldsManager; +use Moox\Builder\Support\BuilderLocaleResolver; /** * Expose custom field values as if they belong to the Eloquent model. @@ -32,6 +33,8 @@ trait InteractsWithCustomFields */ protected ?array $customFieldsCache = null; + protected ?string $customFieldsCacheLocale = null; + protected bool $mergeCustomFieldsIntoArray = true; /** @@ -47,9 +50,13 @@ trait InteractsWithCustomFields /** * @return array */ - public function customFields(bool $fresh = false): array + public function customFields(bool $fresh = false, ?string $locale = null): array { - if (! $fresh && $this->customFieldsCache !== null) { + $locale = app(BuilderLocaleResolver::class)->current($locale); + + if (! $fresh + && $this->customFieldsCache !== null + && $this->customFieldsCacheLocale === $locale) { return $this->customFieldsCache; } @@ -61,15 +68,17 @@ public function customFields(bool $fresh = false): array if ($fields->isEmpty()) { $this->customFieldsCache = []; + $this->customFieldsCacheLocale = $locale; return []; } $values = $fresh - ? $manager->loadValuesWithDefaults($entity, $this, $fields) - : $manager->loadCachedValuesWithDefaults($entity, $this, $fields); + ? $manager->loadValuesWithDefaults($entity, $this, $fields, $locale) + : $manager->loadCachedValuesWithDefaults($entity, $this, $fields, $locale); $this->customFieldsCache = $values; + $this->customFieldsCacheLocale = $locale; return $values; } @@ -174,9 +183,10 @@ public function setCustomFields(array $values): static $this, $payload, $fields->only(array_keys($payload))->values(), + app(BuilderLocaleResolver::class)->current(), ); - $this->customFieldsCache = null; + $this->flushCustomFieldsCache(); return $this; } @@ -194,11 +204,11 @@ public function clearCustomField(string $name): static FieldValue::query() ->forRecord(static::resolveCustomFieldsEntity(), $this->getKey()) + ->forLocale(app(BuilderLocaleResolver::class)->current()) ->where('field_name', $name) ->delete(); - unset($this->customFieldsCache[$name]); - $this->customFieldsCache = null; + $this->flushCustomFieldsCache(); return $this; } @@ -231,9 +241,10 @@ public function scopeWithCustomFields(Builder $query): Builder /** * @param array $values */ - public function setCustomFieldsCache(array $values): void + public function setCustomFieldsCache(array $values, ?string $locale = null): void { $this->customFieldsCache = $values; + $this->customFieldsCacheLocale = app(BuilderLocaleResolver::class)->current($locale); } public static function flushCustomFieldDefinitionCache(): void @@ -245,6 +256,7 @@ public static function flushCustomFieldDefinitionCache(): void public function flushCustomFieldsCache(): void { $this->customFieldsCache = null; + $this->customFieldsCacheLocale = null; } public function toArray(): array diff --git a/packages/builder/src/Data/FieldDefinition.php b/packages/builder/src/Data/FieldDefinition.php index 628d083607..6d8431e938 100644 --- a/packages/builder/src/Data/FieldDefinition.php +++ b/packages/builder/src/Data/FieldDefinition.php @@ -6,14 +6,16 @@ use Illuminate\Support\Collection; use Moox\Builder\Models\Field; +use Moox\Builder\Models\FieldOption; readonly class FieldDefinition { /** * @param array $config * @param array $validation - * @param list $options + * @param list}> $options * @param Collection $children + * @param array}> $translations */ public function __construct( public string $name, @@ -24,6 +26,7 @@ public function __construct( public array $validation = [], public array $options = [], public Collection $children = new Collection, + public array $translations = [], ) {} public static function fromModel(Field $field): self @@ -32,6 +35,7 @@ public static function fromModel(Field $field): self ? $field->options->map(fn ($option): array => [ 'label' => $option->label, 'value' => $option->value, + 'translations' => self::mapOptionTranslations($option), ])->values()->all() : []; @@ -51,9 +55,51 @@ public static function fromModel(Field $field): self validation: $field->validation ?? [], options: $options, children: $children, + translations: self::mapFieldTranslations($field), ); } + /** + * @return array}> + */ + protected static function mapFieldTranslations(Field $field): array + { + if (! $field->relationLoaded('translations')) { + return []; + } + + $translations = []; + + foreach ($field->translations as $translation) { + $translations[$translation->locale] = [ + 'label' => $translation->label, + 'config' => $translation->config ?? [], + ]; + } + + return $translations; + } + + /** + * @return array + */ + protected static function mapOptionTranslations(FieldOption $option): array + { + if (! $option->relationLoaded('translations')) { + return []; + } + + $translations = []; + + foreach ($option->translations as $translation) { + $translations[$translation->locale] = [ + 'label' => $translation->label, + ]; + } + + return $translations; + } + /** * @return Collection */ @@ -82,11 +128,12 @@ public function toArray(): array ->map(fn (self $child): array => $child->toArray()) ->values() ->all(), + 'translations' => $this->translations, ]; } /** - * @param array{name: string, label: string, type: string, sort?: int, config?: array, validation?: array, options?: list, children?: list>} $data + * @param array{name: string, label: string, type: string, sort?: int, config?: array, validation?: array, options?: list}>, children?: list>, translations?: array}>} $data */ public static function fromArray(array $data): self { @@ -104,6 +151,7 @@ public static function fromArray(array $data): self validation: $data['validation'] ?? [], options: $data['options'] ?? [], children: $children, + translations: $data['translations'] ?? [], ); } } diff --git a/packages/builder/src/Data/FieldGroupDefinition.php b/packages/builder/src/Data/FieldGroupDefinition.php index b9f6c1051f..3d001cbc4b 100644 --- a/packages/builder/src/Data/FieldGroupDefinition.php +++ b/packages/builder/src/Data/FieldGroupDefinition.php @@ -14,6 +14,7 @@ * @param Collection $fields * @param list> $locationRules * @param array $settings + * @param array $translations */ public function __construct( public string $name, @@ -22,6 +23,7 @@ public function __construct( public Collection $fields, public array $locationRules = [], public array $settings = [], + public array $translations = [], ) {} public static function fromModel(FieldGroup $group): self @@ -41,9 +43,30 @@ public static function fromModel(FieldGroup $group): self fields: $fields, locationRules: $group->location_rules ?? [], settings: $group->settings ?? [], + translations: self::mapGroupTranslations($group), ); } + /** + * @return array + */ + protected static function mapGroupTranslations(FieldGroup $group): array + { + if (! $group->relationLoaded('translations')) { + return []; + } + + $translations = []; + + foreach ($group->translations as $translation) { + $translations[$translation->locale] = [ + 'name' => $translation->name, + ]; + } + + return $translations; + } + /** * @return array{name: string, slug: string, placement: string, fields: list>, locationRules: list>, settings: array} */ @@ -59,11 +82,12 @@ public function toArray(): array ->all(), 'locationRules' => $this->locationRules, 'settings' => $this->settings, + 'translations' => $this->translations, ]; } /** - * @param array{name: string, slug: string, placement: string, fields?: list>, locationRules?: list>, settings?: array} $data + * @param array{name: string, slug: string, placement: string, fields?: list>, locationRules?: list>, settings?: array, translations?: array} $data */ public static function fromArray(array $data): self { @@ -79,6 +103,7 @@ public static function fromArray(array $data): self fields: $fields, locationRules: $data['locationRules'] ?? [], settings: $data['settings'] ?? [], + translations: $data['translations'] ?? [], ); } } diff --git a/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php b/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php index 029d4f45fe..49f0dfc877 100644 --- a/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php +++ b/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php @@ -11,6 +11,7 @@ use Moox\Builder\Concerns\InteractsWithCustomFields; use Moox\Builder\Models\FieldValue; use Moox\Builder\Services\CustomFieldsManager; +use Moox\Builder\Support\BuilderLocaleResolver; use Moox\Builder\Support\TypedValueColumns; /** @@ -115,13 +116,15 @@ protected function addCustomFieldConstraint( $valueColumn = TypedValueColumns::columnForType($definition->type); $valuesTable = (new FieldValue)->getTable(); $recordKey = $model->getQualifiedKeyName(); + $locale = app(BuilderLocaleResolver::class)->current(); - return $this->whereExists(function ($subQuery) use ($entity, $field, $valueColumn, $operator, $value, $valuesTable, $recordKey): void { + return $this->whereExists(function ($subQuery) use ($entity, $field, $valueColumn, $operator, $value, $valuesTable, $recordKey, $locale): void { $subQuery->selectRaw('1') ->from($valuesTable) ->whereColumn("{$valuesTable}.record_id", $recordKey) ->where("{$valuesTable}.entity", $entity) ->where("{$valuesTable}.field_name", $field) + ->where("{$valuesTable}.locale", $locale) ->where("{$valuesTable}.{$valueColumn}", $operator, $value); }, $boolean); } @@ -153,13 +156,15 @@ protected function addCustomFieldInConstraint( $valueColumn = TypedValueColumns::columnForType($definition->type); $valuesTable = (new FieldValue)->getTable(); $recordKey = $model->getQualifiedKeyName(); + $locale = app(BuilderLocaleResolver::class)->current(); - return $this->whereExists(function ($subQuery) use ($entity, $field, $valueColumn, $values, $valuesTable, $recordKey, $not): void { + return $this->whereExists(function ($subQuery) use ($entity, $field, $valueColumn, $values, $valuesTable, $recordKey, $not, $locale): void { $subQuery->selectRaw('1') ->from($valuesTable) ->whereColumn("{$valuesTable}.record_id", $recordKey) ->where("{$valuesTable}.entity", $entity) - ->where("{$valuesTable}.field_name", $field); + ->where("{$valuesTable}.field_name", $field) + ->where("{$valuesTable}.locale", $locale); if ($not) { $subQuery->whereNotIn("{$valuesTable}.{$valueColumn}", $values); diff --git a/packages/builder/src/Models/Concerns/HasBuilderTranslatableAttributes.php b/packages/builder/src/Models/Concerns/HasBuilderTranslatableAttributes.php new file mode 100644 index 0000000000..bd1805b85f --- /dev/null +++ b/packages/builder/src/Models/Concerns/HasBuilderTranslatableAttributes.php @@ -0,0 +1,114 @@ +mirrorDefaultLocaleTranslationsToMainRecord(); + }); + } + + public function saveTranslations(): bool + { + return $this->persistTranslations(); + } + + public function usesTranslationFallback(): bool + { + return true; + } + + public function fill(array $attributes) + { + $resolver = app(BuilderLocaleResolver::class); + $mirrored = []; + + foreach ($this->translatedAttributes as $attribute) { + if ( + array_key_exists($attribute, $attributes) + && $resolver->current() === $resolver->defaultLocale() + ) { + $mirrored[$attribute] = $attributes[$attribute]; + } + } + + $result = $this->translatableFill($attributes); + + foreach ($mirrored as $attribute => $value) { + $this->attributes[$attribute] = $value; + } + + return $result; + } + + public function setAttribute($key, $value) + { + if (in_array($key, $this->translatedAttributes, true)) { + $locale = app(BuilderLocaleResolver::class)->current(); + + $this->translateOrNew($locale)->$key = $value; + + if ($locale === app(BuilderLocaleResolver::class)->defaultLocale()) { + return parent::setAttribute($key, $value); + } + + return $this; + } + + return parent::setAttribute($key, $value); + } + + /** + * @param array $attributes + */ + public function syncTranslationFallbackOnMainRecord(string $locale, array $attributes): void + { + if ($locale !== app(BuilderLocaleResolver::class)->defaultLocale()) { + return; + } + + $this->update($attributes); + } + + protected function locale(): string + { + if ($this->getDefaultLocale()) { + return $this->getDefaultLocale(); + } + + return app(BuilderLocaleResolver::class)->current(); + } + + protected function mirrorDefaultLocaleTranslationsToMainRecord(): void + { + $defaultLocale = app(BuilderLocaleResolver::class)->defaultLocale(); + + foreach ($this->translatedAttributes as $attribute) { + if (filled($this->attributes[$attribute] ?? null)) { + continue; + } + + $translation = $this->translations->firstWhere($this->getLocaleKey(), $defaultLocale) + ?? $this->translations->first(); + + $value = $translation?->getAttribute($attribute); + + if (is_string($value) && $value !== '') { + $this->attributes[$attribute] = $value; + } + } + } +} diff --git a/packages/builder/src/Models/Field.php b/packages/builder/src/Models/Field.php index b0e167fc53..f07cbc3680 100644 --- a/packages/builder/src/Models/Field.php +++ b/packages/builder/src/Models/Field.php @@ -4,15 +4,22 @@ namespace Moox\Builder\Models; +use Astrotomic\Translatable\Contracts\Translatable as TranslatableContract; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Str; +use Moox\Builder\Models\Concerns\HasBuilderTranslatableAttributes; -class Field extends Model +class Field extends Model implements TranslatableContract { + use HasBuilderTranslatableAttributes; + protected $table = 'builder_fields'; + /** @var list */ + public array $translatedAttributes = ['label']; + protected $fillable = [ 'ulid', 'field_group_id', diff --git a/packages/builder/src/Models/FieldGroup.php b/packages/builder/src/Models/FieldGroup.php index a7e0032a57..b4086f9148 100644 --- a/packages/builder/src/Models/FieldGroup.php +++ b/packages/builder/src/Models/FieldGroup.php @@ -4,15 +4,22 @@ namespace Moox\Builder\Models; +use Astrotomic\Translatable\Contracts\Translatable as TranslatableContract; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Str; +use Moox\Builder\Models\Concerns\HasBuilderTranslatableAttributes; -class FieldGroup extends Model +class FieldGroup extends Model implements TranslatableContract { + use HasBuilderTranslatableAttributes; + protected $table = 'builder_field_groups'; + /** @var list */ + public array $translatedAttributes = ['name']; + protected $attributes = [ 'placement' => 'default', 'sort' => 0, diff --git a/packages/builder/src/Models/FieldGroupTranslation.php b/packages/builder/src/Models/FieldGroupTranslation.php new file mode 100644 index 0000000000..79d7307be8 --- /dev/null +++ b/packages/builder/src/Models/FieldGroupTranslation.php @@ -0,0 +1,20 @@ + */ + public array $translatedAttributes = ['label']; + protected $fillable = [ 'ulid', 'field_id', diff --git a/packages/builder/src/Models/FieldOptionTranslation.php b/packages/builder/src/Models/FieldOptionTranslation.php new file mode 100644 index 0000000000..0cdce65fd5 --- /dev/null +++ b/packages/builder/src/Models/FieldOptionTranslation.php @@ -0,0 +1,20 @@ + 'array', + ]; +} diff --git a/packages/builder/src/Models/FieldValue.php b/packages/builder/src/Models/FieldValue.php index c681aeb8bc..604c2de9a0 100644 --- a/packages/builder/src/Models/FieldValue.php +++ b/packages/builder/src/Models/FieldValue.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Moox\Builder\Support\BuilderLocaleResolver; class FieldValue extends Model { @@ -15,6 +16,7 @@ class FieldValue extends Model 'entity', 'record_id', 'field_name', + 'locale', 'value_string', 'value_text', 'value_decimal', @@ -33,6 +35,15 @@ class FieldValue extends Model 'value_json' => 'array', ]; + protected static function booted(): void + { + static::creating(function (FieldValue $value): void { + if (blank($value->locale)) { + $value->locale = app(BuilderLocaleResolver::class)->defaultLocale(); + } + }); + } + /** * @param Builder $query * @return Builder @@ -43,4 +54,13 @@ public function scopeForRecord(Builder $query, string $entity, int|string $recor ->where('entity', $entity) ->where('record_id', $recordId); } + + /** + * @param Builder $query + * @return Builder + */ + public function scopeForLocale(Builder $query, string $locale): Builder + { + return $query->where('locale', $locale); + } } diff --git a/packages/builder/src/Registry/DefinitionRegistry.php b/packages/builder/src/Registry/DefinitionRegistry.php index e9472099ed..d454718286 100644 --- a/packages/builder/src/Registry/DefinitionRegistry.php +++ b/packages/builder/src/Registry/DefinitionRegistry.php @@ -10,6 +10,8 @@ use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\Data\LocationContext; use Moox\Builder\Models\FieldGroup; +use Moox\Builder\Support\BuilderLocaleResolver; +use Moox\Builder\Support\DefinitionTranslator; use Moox\Builder\Support\FieldRelationTree; class DefinitionRegistry @@ -18,18 +20,23 @@ class DefinitionRegistry public function __construct( protected LocationMatcher $locationMatcher, + protected DefinitionTranslator $definitionTranslator, + protected BuilderLocaleResolver $localeResolver, ) {} /** * @return Collection */ - public function fieldGroupsFor(LocationContext $context): Collection + public function fieldGroupsFor(LocationContext $context, ?string $locale = null): Collection { + $locale = $this->localeResolver->current($locale); + return $this->allActiveGroups() ->filter(fn (FieldGroupDefinition $group): bool => $this->locationMatcher->matches( $group->locationRules, $context, )) + ->map(fn (FieldGroupDefinition $group): FieldGroupDefinition => $this->definitionTranslator->localizeGroup($group, $locale)) ->values(); } @@ -62,6 +69,7 @@ protected function loadActiveGroupsAsArrays(): array return FieldGroup::query() ->active() ->with(FieldRelationTree::eagerLoadForDefinition()) + ->with('translations') ->orderBy('sort') ->get() ->map(fn (FieldGroup $group): array => FieldGroupDefinition::fromModel($group)->toArray()) diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index a867a56b6f..6d0605c2e1 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -21,6 +21,7 @@ use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Str; use Moox\Builder\Exceptions\UnknownFieldTypeException; use Moox\Builder\Models\FieldGroup; @@ -30,6 +31,7 @@ use Moox\Builder\Resources\FieldGroupResource\Pages\EditFieldGroup; use Moox\Builder\Resources\FieldGroupResource\Pages\ListFieldGroups; use Moox\Builder\Services\FieldGroupPersistence; +use Moox\Builder\Support\BuilderLocaleResolver; class FieldGroupResource extends Resource { @@ -587,12 +589,24 @@ public static function table(Table $table): Table { $persistence = app(FieldGroupPersistence::class); $entityRegistry = app(EntityRegistry::class); + $localeResolver = app(BuilderLocaleResolver::class); return $table ->columns([ TextColumn::make('name') ->label(__('builder::builder.field_group.name')) - ->searchable() + ->getStateUsing(fn (FieldGroup $record): string => $persistence->localizedGroupName( + $record, + $localeResolver->current(), + )) + ->searchable(query: function (Builder $query, string $search): Builder { + return $query->where(function (Builder $query) use ($search): void { + $query->where('name', 'like', "%{$search}%") + ->orWhereHas('translations', function (Builder $query) use ($search): void { + $query->where('name', 'like', "%{$search}%"); + }); + }); + }) ->sortable() ->description(fn (FieldGroup $record): ?string => $record->slug), TextColumn::make('assigned_entities') diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/Concerns/InteractsWithFieldGroupLocale.php b/packages/builder/src/Resources/FieldGroupResource/Pages/Concerns/InteractsWithFieldGroupLocale.php new file mode 100644 index 0000000000..ade45559a6 --- /dev/null +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/Concerns/InteractsWithFieldGroupLocale.php @@ -0,0 +1,89 @@ +syncLangToRequest(); + } + + public function mountInteractsWithFieldGroupLocale(): void + { + $this->lang = request()->query( + 'lang', + request()->input('lang', app(BuilderLocaleResolver::class)->adminDefaultLocale()), + ); + + $this->syncLangToRequest(); + } + + protected function syncLangToRequest(): void + { + if ($this->lang !== '') { + request()->merge(['lang' => $this->lang]); + } + } + + protected function applyFieldGroupDefaultLocale(object $record): void + { + if (method_exists($record, 'setDefaultLocale') && $this->lang !== '') { + $record->setDefaultLocale($this->lang); + } + } + + protected function guardFieldGroupAdminLocale(): void + { + if ($this->lang === '' || ! class_exists(Localization::class) || ! Schema::hasTable('localizations')) { + return; + } + + $isAllowed = Localization::query() + ->where('locale_variant', $this->lang) + ->where('is_active_admin', true) + ->exists(); + + if ($isAllowed) { + return; + } + + if (! method_exists($this, 'getResource')) { + return; + } + + $default = app(BuilderLocaleResolver::class)->adminDefaultLocale(); + + if (method_exists($this, 'getRecord') && $this->getRecord() !== null) { + $this->redirect(static::getResource()::getUrl('edit', [ + 'record' => $this->getRecord(), + 'lang' => $default, + ])); + + return; + } + + $this->redirect(static::getResource()::getUrl('index', ['lang' => $default])); + } + + protected function getFieldGroupLanguageSelectorAction(): Action + { + if (view()->exists('localization::lang-selector')) { + return Action::make('language_selector') + ->view('localization::lang-selector'); + } + + return Action::make('language_selector') + ->label($this->lang !== '' ? $this->lang : app(BuilderLocaleResolver::class)->adminDefaultLocale()) + ->disabled(); + } +} diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/CreateFieldGroup.php b/packages/builder/src/Resources/FieldGroupResource/Pages/CreateFieldGroup.php index 2ea21f7ed2..bbc6ef5362 100644 --- a/packages/builder/src/Resources/FieldGroupResource/Pages/CreateFieldGroup.php +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/CreateFieldGroup.php @@ -7,17 +7,45 @@ use Filament\Resources\Pages\CreateRecord; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Resources\FieldGroupResource; +use Moox\Builder\Resources\FieldGroupResource\Pages\Concerns\InteractsWithFieldGroupLocale; use Moox\Builder\Services\FieldGroupPersistence; class CreateFieldGroup extends CreateRecord { + use InteractsWithFieldGroupLocale; + protected static string $resource = FieldGroupResource::class; + public function mount(): void + { + $this->mountInteractsWithFieldGroupLocale(); + + parent::mount(); + + $this->guardFieldGroupAdminLocale(); + } + + public function hydrate(): void + { + $this->hydrateInteractsWithFieldGroupLocale(); + } + + protected function getHeaderActions(): array + { + return [ + $this->getFieldGroupLanguageSelectorAction(), + ]; + } + protected function handleRecordCreation(array $data): FieldGroup { + $this->syncLangToRequest(); + $group = new FieldGroup; + $this->applyFieldGroupDefaultLocale($group); + app(FieldGroupPersistence::class)->sync($group, $data); - return $group->fresh(['fields.options']); + return $group->fresh(['fields.options', 'translations']); } } diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php index 879a10b1b8..5897e3ccbe 100644 --- a/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php @@ -8,15 +8,33 @@ use Filament\Resources\Pages\EditRecord; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Resources\FieldGroupResource; +use Moox\Builder\Resources\FieldGroupResource\Pages\Concerns\InteractsWithFieldGroupLocale; use Moox\Builder\Services\FieldGroupPersistence; class EditFieldGroup extends EditRecord { + use InteractsWithFieldGroupLocale; + protected static string $resource = FieldGroupResource::class; + public function mount(int|string $record): void + { + $this->mountInteractsWithFieldGroupLocale(); + + parent::mount($record); + + $this->guardFieldGroupAdminLocale(); + } + + public function hydrate(): void + { + $this->hydrateInteractsWithFieldGroupLocale(); + } + protected function getHeaderActions(): array { return [ + $this->getFieldGroupLanguageSelectorAction(), DeleteAction::make(), ]; } @@ -26,27 +44,35 @@ protected function getHeaderActions(): array */ protected function mutateFormDataBeforeFill(array $data): array { + $this->syncLangToRequest(); + /** @var FieldGroup $record */ $record = $this->getRecord(); - $record->load(['fields.options']); + $record->load(['fields.options', 'translations']); + + $persistence = app(FieldGroupPersistence::class); - $data['location_rules'] = app(FieldGroupPersistence::class)->flattenLocationRulesForForm( + $data['name'] = $persistence->localizedGroupName($record, $this->lang); + $data['location_rules'] = $persistence->flattenLocationRulesForForm( $record->location_rules ?? [], ); - $data['target_entities'] = app(FieldGroupPersistence::class)->entitiesFromLocationRules( + $data['target_entities'] = $persistence->entitiesFromLocationRules( $record->location_rules ?? [], ); - $data['fields'] = app(FieldGroupPersistence::class)->fieldRowsForForm($record); + $data['fields'] = $persistence->fieldRowsForForm($record, $this->lang); return $data; } protected function handleRecordUpdate($record, array $data): FieldGroup { + $this->syncLangToRequest(); + $this->applyFieldGroupDefaultLocale($record); + app(FieldGroupPersistence::class)->sync($record, $data); - return $record->fresh(['fields.options']); + return $record->fresh(['fields.options', 'translations']); } } diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/ListFieldGroups.php b/packages/builder/src/Resources/FieldGroupResource/Pages/ListFieldGroups.php index a8ec0f3c62..309e2517a3 100644 --- a/packages/builder/src/Resources/FieldGroupResource/Pages/ListFieldGroups.php +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/ListFieldGroups.php @@ -6,16 +6,53 @@ use Filament\Actions\CreateAction; use Filament\Resources\Pages\ListRecords; +use Illuminate\Database\Eloquent\Builder; +use Moox\Builder\Models\FieldGroup; use Moox\Builder\Resources\FieldGroupResource; +use Moox\Builder\Resources\FieldGroupResource\Pages\Concerns\InteractsWithFieldGroupLocale; +use Moox\Builder\Support\BuilderLocaleResolver; class ListFieldGroups extends ListRecords { + use InteractsWithFieldGroupLocale; + protected static string $resource = FieldGroupResource::class; + /** @var array> */ + protected $queryString = [ + 'lang' => ['except' => ''], + ]; + + public function hydrate(): void + { + $this->hydrateInteractsWithFieldGroupLocale(); + } + + public function mount(): void + { + parent::mount(); + + $this->mountInteractsWithFieldGroupLocale(); + } + + /** + * @return Builder + */ + protected function getTableQuery(): Builder + { + return parent::getTableQuery()->with('translations'); + } + protected function getHeaderActions(): array { return [ - CreateAction::make(), + $this->getFieldGroupLanguageSelectorAction(), + CreateAction::make() + ->url(fn (): string => FieldGroupResource::getUrl('create', [ + 'lang' => $this->lang !== '' + ? $this->lang + : app(BuilderLocaleResolver::class)->adminDefaultLocale(), + ])), ]; } } diff --git a/packages/builder/src/Services/BuilderValuesResolver.php b/packages/builder/src/Services/BuilderValuesResolver.php index 02b345c5ad..f1cebe0885 100644 --- a/packages/builder/src/Services/BuilderValuesResolver.php +++ b/packages/builder/src/Services/BuilderValuesResolver.php @@ -12,6 +12,7 @@ use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Support\BuilderLocaleResolver; use Moox\Builder\Support\TypedValueColumns; class BuilderValuesResolver @@ -19,6 +20,7 @@ class BuilderValuesResolver public function __construct( protected FieldTypeRegistry $fieldTypeRegistry, protected DefaultValue $defaultValue, + protected BuilderLocaleResolver $localeResolver, ) {} /** @@ -243,7 +245,7 @@ protected function presentFlexibleContentItems(FieldDefinition $field, array $it /** * @param iterable $models */ - public function eagerLoad(iterable $models, string $entity, Collection $fields): void + public function eagerLoad(iterable $models, string $entity, Collection $fields, ?string $locale = null): void { $collection = $models instanceof EloquentCollection ? $models @@ -260,19 +262,41 @@ public function eagerLoad(iterable $models, string $entity, Collection $fields): } $keyName = $model->getKeyName(); + $localeChain = $this->localeResolver->fallbackChain($locale); $rows = FieldValue::query() ->where('entity', $entity) ->whereIn('record_id', $collection->pluck($keyName)) ->whereIn('field_name', $fields->pluck('name')) + ->whereIn('locale', $localeChain) ->get() ->groupBy('record_id'); foreach ($collection as $record) { /** @var Model&InteractsWithCustomFields $record */ - $recordRows = $rows->get($record->getKey(), collect())->keyBy('field_name'); + $recordRows = $rows->get($record->getKey(), collect())->groupBy('field_name'); + $resolvedRows = collect(); + + foreach ($fields as $field) { + $fieldRows = $recordRows->get($field->name, collect()); + $row = null; + + foreach ($localeChain as $candidate) { + $row = $fieldRows->firstWhere('locale', $candidate); + + if ($row !== null) { + break; + } + } + + if ($row !== null) { + $resolvedRows->put($field->name, $row); + } + } + $record->setCustomFieldsCache( - $this->resolveFromRows($fields, $recordRows, mergeDefaults: true), + $this->resolveFromRows($fields, $resolvedRows, mergeDefaults: true), + $this->localeResolver->current($locale), ); } } diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index 857c649835..434406d472 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -13,6 +13,7 @@ use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Support\BuilderLocaleResolver; use Moox\Builder\Support\MediaIntegration; use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Support\StorableFieldCollector; @@ -30,6 +31,7 @@ public function __construct( protected FieldTypeRegistry $fieldTypeRegistry, protected FieldValueValidator $fieldValueValidator, protected StorableFieldCollector $storableFieldCollector, + protected BuilderLocaleResolver $localeResolver, ) {} /** @@ -104,17 +106,20 @@ public function loadFormData(string $resourceClass, Model $record): array * @param Collection $fields * @return array */ - public function loadValues(string $entity, Model $record, Collection $fields): array + public function loadValues(string $entity, Model $record, Collection $fields, ?string $locale = null): array { if ($fields->isEmpty()) { return []; } + $localeChain = $this->localeResolver->fallbackChain($locale); + $rows = FieldValue::query() ->forRecord($entity, $record->getKey()) ->whereIn('field_name', $fields->pluck('name')) + ->whereIn('locale', $localeChain) ->get() - ->keyBy('field_name'); + ->groupBy('field_name'); $values = []; @@ -125,7 +130,7 @@ public function loadValues(string $entity, Model $record, Collection $fields): a continue; } - $row = $rows->get($field->name); + $row = $this->resolveRowForLocale($rows->get($field->name, collect()), $localeChain); if ($row === null) { continue; @@ -142,16 +147,16 @@ public function loadValues(string $entity, Model $record, Collection $fields): a * @param Collection $fields * @return array */ - public function loadCachedValues(string $entity, Model $record, Collection $fields): array + public function loadCachedValues(string $entity, Model $record, Collection $fields, ?string $locale = null): array { if ($fields->isEmpty()) { return []; } - $cacheKey = "{$entity}:{$record->getKey()}"; + $cacheKey = $this->valuesCacheKey($entity, $record->getKey(), $locale); if (! array_key_exists($cacheKey, $this->valuesCache)) { - $this->valuesCache[$cacheKey] = $this->loadValues($entity, $record, $fields); + $this->valuesCache[$cacheKey] = $this->loadValues($entity, $record, $fields, $locale); } return $this->valuesCache[$cacheKey]; @@ -161,13 +166,13 @@ public function loadCachedValues(string $entity, Model $record, Collection $fiel * @param Collection $fields * @return array */ - public function loadValuesWithDefaults(string $entity, Model $record, Collection $fields): array + public function loadValuesWithDefaults(string $entity, Model $record, Collection $fields, ?string $locale = null): array { if ($fields->isEmpty()) { return []; } - $values = $this->loadValues($entity, $record, $fields); + $values = $this->loadValues($entity, $record, $fields, $locale); return app(BuilderValuesResolver::class)->mergeDefaults($fields, $values); } @@ -176,16 +181,16 @@ public function loadValuesWithDefaults(string $entity, Model $record, Collection * @param Collection $fields * @return array */ - public function loadCachedValuesWithDefaults(string $entity, Model $record, Collection $fields): array + public function loadCachedValuesWithDefaults(string $entity, Model $record, Collection $fields, ?string $locale = null): array { if ($fields->isEmpty()) { return []; } - $cacheKey = "{$entity}:{$record->getKey()}"; + $cacheKey = $this->valuesCacheKey($entity, $record->getKey(), $locale); if (! array_key_exists($cacheKey, $this->valuesCache)) { - $this->valuesCache[$cacheKey] = $this->loadValuesWithDefaults($entity, $record, $fields); + $this->valuesCache[$cacheKey] = $this->loadValuesWithDefaults($entity, $record, $fields, $locale); } return $this->valuesCache[$cacheKey]; @@ -206,6 +211,7 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da $values = []; $defaultValue = app(DefaultValue::class); $entity = $this->locationContextForResource($resourceClass)->entity; + $locale = $this->localeResolver->current(); foreach ($fields as $field) { $fieldType = $this->fieldTypeRegistry->get($field->type); @@ -227,6 +233,7 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da $hasStoredValue = $record->exists && FieldValue::query() ->forRecord($entity, $record->getKey()) + ->forLocale($locale) ->where('field_name', $field->name) ->exists(); @@ -252,6 +259,7 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da $record, $values, $fields, + $locale, ); } @@ -259,8 +267,10 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da * @param array $values * @param Collection $fields */ - public function saveValues(string $entity, Model $record, array $values, Collection $fields): void + public function saveValues(string $entity, Model $record, array $values, Collection $fields, ?string $locale = null): void { + $locale = $this->localeResolver->current($locale); + foreach ($fields as $field) { if (! array_key_exists($field->name, $values)) { continue; @@ -286,6 +296,7 @@ public function saveValues(string $entity, Model $record, array $values, Collect 'entity' => $entity, 'record_id' => $record->getKey(), 'field_name' => $field->name, + 'locale' => $locale, ], $columns, ); @@ -295,12 +306,44 @@ public function saveValues(string $entity, Model $record, array $values, Collect app(BuilderMediaUsageSync::class)->syncForRecord($entity, $record, $fields); } - unset($this->valuesCache["{$entity}:{$record->getKey()}"]); + $this->forgetValuesCache($entity, $record->getKey(), $locale); + } + + public function forgetValuesCache(string $entity, int|string $recordId, ?string $locale = null): void + { + if ($locale !== null) { + unset($this->valuesCache[$this->valuesCacheKey($entity, $recordId, $locale)]); + + return; + } + + foreach (array_keys($this->valuesCache) as $cacheKey) { + if (str_starts_with($cacheKey, "{$entity}:{$recordId}:")) { + unset($this->valuesCache[$cacheKey]); + } + } + } + + /** + * @param Collection $rows + * @param list $localeChain + */ + protected function resolveRowForLocale(Collection $rows, array $localeChain): ?FieldValue + { + foreach ($localeChain as $locale) { + $row = $rows->firstWhere('locale', $locale); + + if ($row instanceof FieldValue) { + return $row; + } + } + + return null; } - public function forgetValuesCache(string $entity, int|string $recordId): void + protected function valuesCacheKey(string $entity, int|string $recordId, ?string $locale = null): string { - unset($this->valuesCache["{$entity}:{$recordId}"]); + return "{$entity}:{$recordId}:".$this->localeResolver->current($locale); } /** diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 09314731b5..6df6d8a570 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -12,12 +12,16 @@ use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldOption; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Support\BuilderLocaleResolver; +use Moox\Builder\Support\DefinitionTranslator; use Moox\Builder\Support\FieldRelationTree; class FieldGroupPersistence { public function __construct( protected FieldValuePurger $fieldValuePurger, + protected BuilderLocaleResolver $localeResolver, + protected DefinitionTranslator $definitionTranslator, ) {} /** @@ -27,8 +31,11 @@ public function sync(FieldGroup $group, array $data): void { app(FieldGroupValidator::class)->validate($group, $data); + $locale = $this->localeResolver->current(); + $isDefaultLocale = $locale === $this->localeResolver->defaultLocale(); + $name = (string) ($data['name'] ?? $group->name); + $group->fill(Arr::only($data, [ - 'name', 'slug', 'active', 'sort', @@ -36,12 +43,28 @@ public function sync(FieldGroup $group, array $data): void 'settings', ])); + if ($isDefaultLocale || ! $group->exists || blank($group->name)) { + $group->name = $name; + } + $group->location_rules = $this->resolveLocationRules($data); $group->save(); + $this->syncGroupTranslation($group, $name); + $this->syncFields($group, $data['fields'] ?? []); } + protected function syncGroupTranslation(FieldGroup $group, string $name): void + { + $locale = $this->localeResolver->current(); + + $group->translateOrNew($locale)->name = $name; + $group->saveTranslations(); + + $group->syncTranslationFallbackOnMainRecord($locale, ['name' => $name]); + } + /** * @param array $data * @return list> @@ -150,40 +173,48 @@ public function flattenLocationRulesForForm(array $rules): array /** * @return list> */ - public function fieldRowsForForm(FieldGroup $group): array + public function fieldRowsForForm(FieldGroup $group, ?string $locale = null): array { $group->load(FieldRelationTree::eagerLoadForDefinition()); + $group->load('translations'); - return $this->mapFieldRows($group->fields); + return $this->mapFieldRows($group->fields, $locale); + } + + public function localizedGroupName(FieldGroup $group, ?string $locale = null): string + { + return $this->definitionTranslator->translatedGroupName($group, $locale); } /** * @param Collection $fields * @return list> */ - protected function mapFieldRows(Collection $fields): array + protected function mapFieldRows(Collection $fields, ?string $locale = null): array { - return $fields->map(function (Field $field): array { + $locale = $this->localeResolver->current($locale); + + return $fields->map(function (Field $field) use ($locale): array { $row = [ 'id' => $field->getKey(), - 'label' => $field->label, + 'label' => $this->definitionTranslator->translatedFieldLabel($field, $locale), 'name' => $field->name, 'type' => $field->type, 'required' => (bool) ($field->validation['required'] ?? false), - 'config' => $field->config ?? [], + 'config' => $this->definitionTranslator->translatedFieldConfig($field, $locale), 'sort' => $field->sort, 'options' => $field->options->map(fn (FieldOption $option): array => [ 'id' => $option->getKey(), - 'label' => $option->label, + 'label' => $this->definitionTranslator->translatedOptionLabel($option, $locale), 'value' => $option->value, 'sort' => $option->sort, ])->values()->all(), ]; if ($field->type === 'flexible_content') { - $row['layouts'] = $this->mapLayoutRows($field->children); + $row['layouts'] = $this->mapLayoutRows($field->children, $locale); } else { - $row['children'] = $this->mapFieldRows($field->children); + $row['children'] = $this->mapFieldRows($field->children, $locale); } return $row; @@ -194,16 +225,18 @@ protected function mapFieldRows(Collection $fields): array * @param Collection $fields * @return list> */ - protected function mapLayoutRows(Collection $fields): array + protected function mapLayoutRows(Collection $fields, ?string $locale = null): array { + $locale = $this->localeResolver->current($locale); + return $fields ->filter(fn (Field $field): bool => $field->type === 'flexible_layout') ->map(fn (Field $layout): array => [ 'id' => $layout->getKey(), - 'label' => $layout->label, + 'label' => $this->definitionTranslator->translatedFieldLabel($layout, $locale), 'name' => $layout->name, 'sort' => $layout->sort, - 'children' => $this->mapFieldRows($layout->children), + 'children' => $this->mapFieldRows($layout->children, $locale), ]) ->values() ->all(); @@ -233,14 +266,24 @@ protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldI $field = $this->resolveFieldForSync($existing, $row); $previousName = $field->exists ? $field->name : null; + $locale = $this->localeResolver->current(); + $isDefaultLocale = $locale === $this->localeResolver->defaultLocale(); + $label = (string) ($row['label'] ?? $row['name']); + $config = $this->filterConfigForType((string) $row['type'], $row['config'] ?? []); + $structuralConfig = array_diff_key( + $config, + array_flip(BuilderLocaleResolver::TRANSLATABLE_CONFIG_KEYS), + ); $field->fill([ 'field_group_id' => $group->getKey(), 'parent_field_id' => $parentFieldId, 'name' => (string) $row['name'], - 'label' => (string) ($row['label'] ?? $row['name']), + 'label' => $isDefaultLocale ? $label : ($field->label ?: $label), 'type' => (string) $row['type'], - 'config' => $this->filterConfigForType((string) $row['type'], $row['config'] ?? []), + 'config' => $isDefaultLocale + ? $config + : array_merge($structuralConfig, array_diff_key($field->config ?? [], array_flip(BuilderLocaleResolver::TRANSLATABLE_CONFIG_KEYS))), 'validation' => [ 'required' => (bool) ($row['required'] ?? false), 'rules' => $row['validation']['rules'] ?? [], @@ -250,6 +293,8 @@ protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldI $field->save(); + $this->syncFieldTranslation($field, $label, $config); + $existing->put($field->getKey(), $field); if ($previousName !== null && $previousName !== $field->name) { @@ -315,15 +360,21 @@ protected function syncOptions(Field $field, array $rows): void } $option = $this->resolveOptionForSync($existing, $row); + $locale = $this->localeResolver->current(); + $isDefaultLocale = $locale === $this->localeResolver->defaultLocale(); + $label = (string) ($row['label'] ?? $row['value']); $option->fill([ 'field_id' => $field->getKey(), - 'label' => (string) ($row['label'] ?? $row['value']), + 'label' => $isDefaultLocale ? $label : ($option->label ?: $label), 'value' => (string) $row['value'], 'sort' => (int) ($row['sort'] ?? $index), ]); $option->save(); + + $this->syncOptionTranslation($option, $label); + $existing->put($option->getKey(), $option); $retainedIds[] = $option->getKey(); } @@ -333,6 +384,37 @@ protected function syncOptions(Field $field, array $rows): void ->each(fn (FieldOption $option) => $option->delete()); } + /** + * @param array $config + */ + protected function syncFieldTranslation(Field $field, string $label, array $config): void + { + $locale = $this->localeResolver->current(); + $translatableConfig = $this->definitionTranslator->extractTranslatableConfig( + $this->filterConfigForType($field->type, $config), + ); + + $translation = $field->translateOrNew($locale); + $translation->label = $label; + $translation->config = $translatableConfig === [] ? null : $translatableConfig; + $field->saveTranslations(); + + $field->syncTranslationFallbackOnMainRecord($locale, [ + 'label' => $label, + 'config' => $this->filterConfigForType($field->type, $config), + ]); + } + + protected function syncOptionTranslation(FieldOption $option, string $label): void + { + $locale = $this->localeResolver->current(); + + $option->translateOrNew($locale)->label = $label; + $option->saveTranslations(); + + $option->syncTranslationFallbackOnMainRecord($locale, ['label' => $label]); + } + public function slugFromName(string $name): string { return Str::slug($name); diff --git a/packages/builder/src/Support/BuilderLocaleResolver.php b/packages/builder/src/Support/BuilderLocaleResolver.php new file mode 100644 index 0000000000..af62dc2c53 --- /dev/null +++ b/packages/builder/src/Support/BuilderLocaleResolver.php @@ -0,0 +1,101 @@ + + */ + public const TRANSLATABLE_CONFIG_KEYS = [ + 'helperText', + 'placeholder', + 'prefix', + 'suffix', + 'message', + ]; + + public function current(?string $locale = null): string + { + if (is_string($locale) && $locale !== '') { + return $locale; + } + + $requestLocale = request()->query('lang') ?? request()->input('lang'); + + if (is_string($requestLocale) && $requestLocale !== '') { + return $requestLocale; + } + + return $this->defaultLocale(); + } + + public function defaultLocale(): string + { + if (class_exists(Localization::class) && Schema::hasTable('localizations')) { + $localization = Localization::query() + ->where('is_default', true) + ->first(); + + if ($localization !== null && filled($localization->getAttribute('locale_variant'))) { + return (string) $localization->getAttribute('locale_variant'); + } + } + + return (string) config('builder.default_locale', config('app.locale', 'en_US')); + } + + public function adminDefaultLocale(): string + { + if (class_exists(Localization::class) && Schema::hasTable('localizations')) { + $defaultLocale = Localization::query() + ->where('is_default', true) + ->where('is_active_admin', true) + ->first(); + + if ($defaultLocale !== null) { + $variant = $defaultLocale->getAttribute('locale_variant'); + + if (filled($variant)) { + return (string) $variant; + } + } + + $firstActiveLocale = Localization::query() + ->where('is_active_admin', true) + ->first(); + + if ($firstActiveLocale !== null) { + $variant = $firstActiveLocale->getAttribute('locale_variant'); + + if (filled($variant)) { + return (string) $variant; + } + } + } + + return $this->defaultLocale(); + } + + /** + * @return list + */ + public function fallbackChain(?string $locale = null): array + { + $chain = [ + $this->current($locale), + $this->defaultLocale(), + 'en_US', + ]; + + return array_values(array_unique(array_filter( + $chain, + static fn (string $value): bool => $value !== '', + ))); + } +} diff --git a/packages/builder/src/Support/DefinitionTranslator.php b/packages/builder/src/Support/DefinitionTranslator.php new file mode 100644 index 0000000000..92e53e5991 --- /dev/null +++ b/packages/builder/src/Support/DefinitionTranslator.php @@ -0,0 +1,204 @@ +localeResolver->current($locale); + + return new FieldGroupDefinition( + name: $this->resolveGroupName($group->name, $group->translations, $locale), + slug: $group->slug, + placement: $group->placement, + fields: $group->fields + ->map(fn (FieldDefinition $field): FieldDefinition => $this->localizeField($field, $locale)) + ->values(), + locationRules: $group->locationRules, + settings: $group->settings, + translations: $group->translations, + ); + } + + public function localizeField(FieldDefinition $field, ?string $locale = null): FieldDefinition + { + $locale = $this->localeResolver->current($locale); + + return new FieldDefinition( + name: $field->name, + label: $this->resolveLabel($field->label, $field->translations, $locale, 'label'), + type: $field->type, + sort: $field->sort, + config: $this->resolveConfig($field->config, $field->translations, $locale), + validation: $field->validation, + options: collect($field->options) + ->map(function (array $option) use ($locale): array { + /** @var array{label: string, value: string, translations?: array} $option */ + return [ + 'label' => $this->resolveLabel( + $option['label'], + $option['translations'] ?? [], + $locale, + 'label', + ), + 'value' => $option['value'], + ]; + }) + ->values() + ->all(), + children: $field->children + ->map(fn (FieldDefinition $child): FieldDefinition => $this->localizeField($child, $locale)) + ->values(), + translations: $field->translations, + ); + } + + public function translatedGroupName(FieldGroup $group, ?string $locale = null): string + { + return $this->translatedAttribute($group, 'name', $group->name, $locale); + } + + public function translatedFieldLabel(Field $field, ?string $locale = null): string + { + return $this->translatedAttribute($field, 'label', $field->label, $locale); + } + + public function translatedOptionLabel(FieldOption $option, ?string $locale = null): string + { + return $this->translatedAttribute($option, 'label', $option->label, $locale); + } + + /** + * @return array + */ + public function translatedFieldConfig(Field $field, ?string $locale = null): array + { + $locale = $this->localeResolver->current($locale); + $structuralConfig = array_diff_key( + $field->config ?? [], + array_flip(BuilderLocaleResolver::TRANSLATABLE_CONFIG_KEYS), + ); + + foreach ($this->localeResolver->fallbackChain($locale) as $candidate) { + $translation = $field->translate($candidate, false); + $translatedConfig = is_object($translation) ? ($translation->config ?? null) : null; + + if (is_array($translatedConfig) && $translatedConfig !== []) { + return array_merge($structuralConfig, $translatedConfig); + } + } + + return $field->config ?? []; + } + + /** + * @param array $translations + */ + public function resolveGroupName(string $fallback, array $translations, ?string $locale = null): string + { + return $this->resolveLabel($fallback, $translations, $locale, 'name'); + } + + /** + * @param array $config + * @param array}> $translations + * @return array + */ + public function resolveConfig(array $config, array $translations, ?string $locale = null): array + { + $locale = $this->localeResolver->current($locale); + $translatedConfig = $this->translationValue($translations, $locale, 'config'); + + if (! is_array($translatedConfig)) { + foreach ($this->localeResolver->fallbackChain($locale) as $candidate) { + if ($candidate === $locale) { + continue; + } + + $translatedConfig = $this->translationValue($translations, $candidate, 'config'); + + if (is_array($translatedConfig)) { + break; + } + } + } + + if (! is_array($translatedConfig)) { + return $config; + } + + return array_merge($config, $translatedConfig); + } + + /** + * @param array> $translations + */ + public function resolveLabel(string $fallback, array $translations, ?string $locale, string $key): string + { + $locale = $this->localeResolver->current($locale); + + foreach ($this->localeResolver->fallbackChain($locale) as $candidate) { + $value = $this->translationValue($translations, $candidate, $key); + + if (is_string($value) && $value !== '') { + return $value; + } + } + + return $fallback; + } + + /** + * @param array $config + * @return array + */ + public function extractTranslatableConfig(array $config): array + { + return array_intersect_key($config, array_flip(BuilderLocaleResolver::TRANSLATABLE_CONFIG_KEYS)); + } + + /** + * @param Model&TranslatableContract $model + */ + protected function translatedAttribute(Model $model, string $attribute, string $fallback, ?string $locale = null): string + { + $locale = $this->localeResolver->current($locale); + + foreach ($this->localeResolver->fallbackChain($locale) as $candidate) { + $value = $model->translate($candidate, false)?->getAttribute($attribute); + + if (is_string($value) && $value !== '') { + return $value; + } + } + + return $fallback; + } + + /** + * @param array> $translations + */ + protected function translationValue(array $translations, string $locale, string $key): mixed + { + if (! array_key_exists($locale, $translations)) { + return null; + } + + return $translations[$locale][$key] ?? null; + } +} diff --git a/packages/builder/src/Support/FieldRelationTree.php b/packages/builder/src/Support/FieldRelationTree.php index 1f42eb1292..bf5e5f9604 100644 --- a/packages/builder/src/Support/FieldRelationTree.php +++ b/packages/builder/src/Support/FieldRelationTree.php @@ -14,8 +14,11 @@ final class FieldRelationTree public static function eagerLoadForDefinition(int $maxDepth = 4): array { $relations = [ + 'translations', 'fields' => fn ($query) => $query->whereNull('parent_field_id')->orderBy('sort'), + 'fields.translations', 'fields.options', + 'fields.options.translations', ]; $path = 'fields'; @@ -23,7 +26,9 @@ public static function eagerLoadForDefinition(int $maxDepth = 4): array for ($depth = 1; $depth <= $maxDepth; $depth++) { $path .= '.children'; $relations[$path] = fn ($query) => $query->orderBy('sort'); + $relations["{$path}.translations"] = fn ($query) => $query; $relations["{$path}.options"] = fn ($query) => $query->orderBy('sort'); + $relations["{$path}.options.translations"] = fn ($query) => $query; } return $relations; From 824a865d51256e4d1fcdbd6f612def9153236635 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:27:36 +0200 Subject: [PATCH 053/105] Wire admin locale automatically for HasCustomFields resources. --- packages/builder/README.md | 12 +- .../resources/views/lang-selector.blade.php | 44 ++++++ .../builder/src/BuilderServiceProvider.php | 6 + .../builder/src/Concerns/HasCustomFields.php | 3 +- .../Concerns/InteractsWithBuilderLocale.php | 82 +++++++++++ .../Middleware/ResolveBuilderAdminLocale.php | 88 ++++++++++++ .../builder/src/Plugins/BuilderPlugin.php | 5 + .../InteractsWithFieldGroupLocale.php | 61 +------- .../src/Support/BuilderLocaleResolver.php | 8 ++ .../src/Support/CustomFieldsFilamentHooks.php | 131 ++++++++++++++++++ 10 files changed, 383 insertions(+), 57 deletions(-) create mode 100644 packages/builder/resources/views/lang-selector.blade.php create mode 100644 packages/builder/src/Filament/Resources/Pages/Concerns/InteractsWithBuilderLocale.php create mode 100644 packages/builder/src/Http/Middleware/ResolveBuilderAdminLocale.php create mode 100644 packages/builder/src/Support/CustomFieldsFilamentHooks.php diff --git a/packages/builder/README.md b/packages/builder/README.md index 986c343611..a4e92967a5 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -429,7 +429,13 @@ Fallback chain for missing translations: **active locale → default locale → ### Runtime: consumer resources -Pass `?lang=` when loading/saving records so `CustomFieldsManager` reads and writes the correct `builder_field_values.locale` row. Definition labels in forms come from `DefinitionRegistry` + `DefinitionTranslator` (cached, localized at read time). +No page changes are required. When a resource uses `HasCustomFields`, the builder package automatically: + +- Registers panel middleware that keeps `?lang=` in session (also for Livewire subrequests) +- Shows the language selector on list, create, edit, and view pages +- Loads and saves `builder_field_values` for the active locale via `BuilderLocaleResolver` + +Definition labels in forms come from `DefinitionRegistry` + `DefinitionTranslator` (cached, localized at read time). ### Code patterns (same as Media/Draft) @@ -635,6 +641,8 @@ packages/builder/ ├── Concerns/ │ ├── HasCustomFields.php # Filament resource │ └── InteractsWithCustomFields.php # Consumer model + ├── Filament/Resources/Pages/Concerns/ + │ └── InteractsWithBuilderLocale.php ├── Compiler/ │ ├── LocationMatcher.php │ └── SchemaCompiler.php @@ -648,6 +656,7 @@ packages/builder/ │ └── Types/ # 29 field types ├── Forms/Components/BuilderMediaPicker.php ├── Http/ + │ ├── Middleware/ResolveBuilderAdminLocale.php │ ├── Livewire/BuilderMediaPickerModal.php │ └── Resources/Concerns/MergesCustomFields.php ├── Listeners/PersistCustomFields.php @@ -675,6 +684,7 @@ packages/builder/ │ └── FieldValueValidator.php └── Support/ ├── BuilderLocaleResolver.php + ├── CustomFieldsFilamentHooks.php ├── DefinitionTranslator.php ├── EntityModelDeletionRegistrar.php ├── MediaFieldValueSupport.php diff --git a/packages/builder/resources/views/lang-selector.blade.php b/packages/builder/resources/views/lang-selector.blade.php new file mode 100644 index 0000000000..c5910c1b6b --- /dev/null +++ b/packages/builder/resources/views/lang-selector.blade.php @@ -0,0 +1,44 @@ +@if (class_exists(\Moox\Localization\Models\Localization::class)) + @php + $resolver = app(\Moox\Builder\Support\BuilderLocaleResolver::class); + $currentLang = request()->get('lang') + ?? session(\Moox\Builder\Support\BuilderLocaleResolver::ADMIN_SESSION_KEY) + ?? $resolver->adminDefaultLocale(); + + $currentLocalization = \Moox\Localization\Models\Localization::query() + ->with('language') + ->where('locale_variant', $currentLang) + ->where('is_active_admin', true) + ->first(); + + $localizations = \Moox\Localization\Models\Localization::query() + ->with('language') + ->where('is_active_admin', true) + ->orderBy('language_id') + ->orderBy('locale_variant') + ->get(); + @endphp + + + + + {{ $currentLocalization?->display_name ?? $currentLang }} +
+ +
+
+
+ + @foreach ($localizations as $locale) + @if ($locale->locale_variant !== $currentLang) + @php + $targetUrl = request()->url().'?'.http_build_query(array_merge(request()->query(), ['lang' => $locale->locale_variant])); + @endphp + + {{ $locale->display_name }} + + @endif + @endforeach +
+@endif diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index aace67d09b..7cae9b1473 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -35,6 +35,7 @@ use Moox\Builder\FieldTypes\Types\ToggleFieldType; use Moox\Builder\FieldTypes\Types\UrlFieldType; use Moox\Builder\Http\Livewire\BuilderMediaPickerModal; +use Moox\Builder\Http\Middleware\ResolveBuilderAdminLocale; use Moox\Builder\Listeners\PersistCustomFields; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; @@ -47,6 +48,7 @@ use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Services\BuilderFieldValueMediaMetadataSync; +use Moox\Builder\Support\CustomFieldsFilamentHooks; use Moox\Builder\Support\EntityModelDeletionRegistrar; use Moox\Builder\Support\MediaIntegration; use Moox\Core\MooxServiceProvider; @@ -101,6 +103,10 @@ public function packageBooted(): void Event::listen(RecordSaved::class, PersistCustomFields::class); + CustomFieldsFilamentHooks::register(); + + $this->app['router']->pushMiddlewareToGroup('web', ResolveBuilderAdminLocale::class); + $this->registerMediaMetadataSyncListeners(); $this->app->booted(function (): void { diff --git a/packages/builder/src/Concerns/HasCustomFields.php b/packages/builder/src/Concerns/HasCustomFields.php index 196701a51d..3d701d8ece 100644 --- a/packages/builder/src/Concerns/HasCustomFields.php +++ b/packages/builder/src/Concerns/HasCustomFields.php @@ -19,7 +19,8 @@ * * Field groups are matched by entity key (model basename in kebab-case, e.g. * Item → item). Override with customFieldsEntity() when needed. Loading and - * saving is handled automatically via Filament record events. + * saving is handled automatically via Filament record events. Per-locale + * values and the admin language selector are wired by the builder package. */ trait HasCustomFields { diff --git a/packages/builder/src/Filament/Resources/Pages/Concerns/InteractsWithBuilderLocale.php b/packages/builder/src/Filament/Resources/Pages/Concerns/InteractsWithBuilderLocale.php new file mode 100644 index 0000000000..dec8e5d662 --- /dev/null +++ b/packages/builder/src/Filament/Resources/Pages/Concerns/InteractsWithBuilderLocale.php @@ -0,0 +1,82 @@ +syncLangToRequest(); + } + + public function mountInteractsWithBuilderLocale(): void + { + $this->lang = request()->query( + 'lang', + request()->input('lang', app(BuilderLocaleResolver::class)->adminDefaultLocale()), + ); + + $this->syncLangToRequest(); + } + + public function syncLangToRequest(): void + { + if ($this->lang !== '') { + request()->merge(['lang' => $this->lang]); + } + } + + protected function guardBuilderAdminLocale(): void + { + if ($this->lang === '' || ! class_exists(Localization::class) || ! Schema::hasTable('localizations')) { + return; + } + + $isAllowed = Localization::query() + ->where('locale_variant', $this->lang) + ->where('is_active_admin', true) + ->exists(); + + if ($isAllowed) { + return; + } + + if (! method_exists($this, 'getResource')) { + return; + } + + $default = app(BuilderLocaleResolver::class)->adminDefaultLocale(); + + if (method_exists($this, 'getRecord') && $this->getRecord() !== null) { + $this->redirect(static::getResource()::getUrl('edit', [ + 'record' => $this->getRecord(), + 'lang' => $default, + ])); + + return; + } + + $this->redirect(static::getResource()::getUrl('index', ['lang' => $default])); + } + + protected function getBuilderLanguageSelectorAction(): Action + { + if (view()->exists('localization::lang-selector')) { + return Action::make('language_selector') + ->view('localization::lang-selector'); + } + + return Action::make('language_selector') + ->label($this->lang !== '' ? $this->lang : app(BuilderLocaleResolver::class)->adminDefaultLocale()) + ->disabled(); + } +} diff --git a/packages/builder/src/Http/Middleware/ResolveBuilderAdminLocale.php b/packages/builder/src/Http/Middleware/ResolveBuilderAdminLocale.php new file mode 100644 index 0000000000..8a278a0a6c --- /dev/null +++ b/packages/builder/src/Http/Middleware/ResolveBuilderAdminLocale.php @@ -0,0 +1,88 @@ +shouldResolveLocale($request)) { + return $next($request); + } + + $locale = $request->query('lang') ?? $request->input('lang'); + + if (! is_string($locale) || $locale === '') { + $sessionLocale = session(BuilderLocaleResolver::ADMIN_SESSION_KEY); + + if (is_string($sessionLocale) && $sessionLocale !== '') { + $request->merge(['lang' => $sessionLocale]); + } + + return $next($request); + } + + if (! $this->isAllowedAdminLocale($locale)) { + $locale = $this->localeResolver->adminDefaultLocale(); + + session([BuilderLocaleResolver::ADMIN_SESSION_KEY => $locale]); + $request->merge(['lang' => $locale]); + + if ($request->query('lang') !== null) { + return redirect()->to($request->url().'?'.http_build_query([ + ...$request->query(), + 'lang' => $locale, + ])); + } + + return $next($request); + } + + session([BuilderLocaleResolver::ADMIN_SESSION_KEY => $locale]); + $request->merge(['lang' => $locale]); + + return $next($request); + } + + protected function isAllowedAdminLocale(string $locale): bool + { + if (! class_exists(Localization::class) || ! Schema::hasTable('localizations')) { + return true; + } + + return Localization::query() + ->where('locale_variant', $locale) + ->where('is_active_admin', true) + ->exists(); + } + + protected function shouldResolveLocale(Request $request): bool + { + if ($request->is('livewire/*')) { + return true; + } + + if ($request->query('lang') !== null || $request->input('lang') !== null) { + return true; + } + + if (session(BuilderLocaleResolver::ADMIN_SESSION_KEY) !== null) { + return true; + } + + return $request->is('admin/*') || $request->is('filament/*'); + } +} diff --git a/packages/builder/src/Plugins/BuilderPlugin.php b/packages/builder/src/Plugins/BuilderPlugin.php index a626560de6..23760efe3e 100644 --- a/packages/builder/src/Plugins/BuilderPlugin.php +++ b/packages/builder/src/Plugins/BuilderPlugin.php @@ -7,6 +7,7 @@ use Filament\Contracts\Plugin; use Filament\Panel; use Filament\Support\Concerns\EvaluatesClosures; +use Moox\Builder\Http\Middleware\ResolveBuilderAdminLocale; use Moox\Builder\Resources\FieldGroupResource; class BuilderPlugin implements Plugin @@ -23,6 +24,10 @@ public function register(Panel $panel): void $panel->resources([ FieldGroupResource::class, ]); + + $panel->middleware([ + ResolveBuilderAdminLocale::class, + ], isPersistent: true); } public function boot(Panel $panel): void diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/Concerns/InteractsWithFieldGroupLocale.php b/packages/builder/src/Resources/FieldGroupResource/Pages/Concerns/InteractsWithFieldGroupLocale.php index ade45559a6..016a3da76a 100644 --- a/packages/builder/src/Resources/FieldGroupResource/Pages/Concerns/InteractsWithFieldGroupLocale.php +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/Concerns/InteractsWithFieldGroupLocale.php @@ -5,34 +5,20 @@ namespace Moox\Builder\Resources\FieldGroupResource\Pages\Concerns; use Filament\Actions\Action; -use Illuminate\Support\Facades\Schema; -use Moox\Builder\Support\BuilderLocaleResolver; -use Moox\Localization\Models\Localization; +use Moox\Builder\Filament\Resources\Pages\Concerns\InteractsWithBuilderLocale; trait InteractsWithFieldGroupLocale { - public string $lang = ''; + use InteractsWithBuilderLocale; public function hydrateInteractsWithFieldGroupLocale(): void { - $this->syncLangToRequest(); + $this->hydrateInteractsWithBuilderLocale(); } public function mountInteractsWithFieldGroupLocale(): void { - $this->lang = request()->query( - 'lang', - request()->input('lang', app(BuilderLocaleResolver::class)->adminDefaultLocale()), - ); - - $this->syncLangToRequest(); - } - - protected function syncLangToRequest(): void - { - if ($this->lang !== '') { - request()->merge(['lang' => $this->lang]); - } + $this->mountInteractsWithBuilderLocale(); } protected function applyFieldGroupDefaultLocale(object $record): void @@ -44,46 +30,11 @@ protected function applyFieldGroupDefaultLocale(object $record): void protected function guardFieldGroupAdminLocale(): void { - if ($this->lang === '' || ! class_exists(Localization::class) || ! Schema::hasTable('localizations')) { - return; - } - - $isAllowed = Localization::query() - ->where('locale_variant', $this->lang) - ->where('is_active_admin', true) - ->exists(); - - if ($isAllowed) { - return; - } - - if (! method_exists($this, 'getResource')) { - return; - } - - $default = app(BuilderLocaleResolver::class)->adminDefaultLocale(); - - if (method_exists($this, 'getRecord') && $this->getRecord() !== null) { - $this->redirect(static::getResource()::getUrl('edit', [ - 'record' => $this->getRecord(), - 'lang' => $default, - ])); - - return; - } - - $this->redirect(static::getResource()::getUrl('index', ['lang' => $default])); + $this->guardBuilderAdminLocale(); } protected function getFieldGroupLanguageSelectorAction(): Action { - if (view()->exists('localization::lang-selector')) { - return Action::make('language_selector') - ->view('localization::lang-selector'); - } - - return Action::make('language_selector') - ->label($this->lang !== '' ? $this->lang : app(BuilderLocaleResolver::class)->adminDefaultLocale()) - ->disabled(); + return $this->getBuilderLanguageSelectorAction(); } } diff --git a/packages/builder/src/Support/BuilderLocaleResolver.php b/packages/builder/src/Support/BuilderLocaleResolver.php index af62dc2c53..5869e5f6dd 100644 --- a/packages/builder/src/Support/BuilderLocaleResolver.php +++ b/packages/builder/src/Support/BuilderLocaleResolver.php @@ -9,6 +9,8 @@ final class BuilderLocaleResolver { + public const ADMIN_SESSION_KEY = 'builder.admin_locale'; + /** * @return list */ @@ -32,6 +34,12 @@ public function current(?string $locale = null): string return $requestLocale; } + $sessionLocale = session(self::ADMIN_SESSION_KEY); + + if (is_string($sessionLocale) && $sessionLocale !== '') { + return $sessionLocale; + } + return $this->defaultLocale(); } diff --git a/packages/builder/src/Support/CustomFieldsFilamentHooks.php b/packages/builder/src/Support/CustomFieldsFilamentHooks.php new file mode 100644 index 0000000000..e25daeeb79 --- /dev/null +++ b/packages/builder/src/Support/CustomFieldsFilamentHooks.php @@ -0,0 +1,131 @@ +bound('filament')) { + return; + } + + Filament::serving(function (): void { + self::registerLanguageSelectorHooks(); + }); + } + + private static function registerLanguageSelectorHooks(): void + { + if (self::$languageSelectorHooksRegistered) { + return; + } + + $headerPageClasses = self::customFieldHeaderPageClasses(); + $listPageClasses = self::customFieldListPageClasses(); + + if ($headerPageClasses === [] && $listPageClasses === []) { + return; + } + + self::$languageSelectorHooksRegistered = true; + + $headerRenderer = static function (): string { + if (view()->exists('localization::lang-selector')) { + return Blade::render('@include("localization::lang-selector")'); + } + + if (view()->exists('builder::lang-selector')) { + return view('builder::lang-selector')->render(); + } + + return ''; + }; + + foreach ($headerPageClasses as $pageClass) { + FilamentView::registerRenderHook( + PanelsRenderHook::PAGE_HEADER_ACTIONS_BEFORE, + $headerRenderer, + scopes: $pageClass, + ); + } + + foreach ($listPageClasses as $pageClass) { + FilamentView::registerRenderHook( + TablesRenderHook::TOOLBAR_SEARCH_BEFORE, + static function (): string { + if (view()->exists('builder::lang-selector')) { + return view('builder::lang-selector')->render(); + } + + if (view()->exists('localization::lang-selector')) { + return Blade::render('@include("localization::lang-selector")'); + } + + return ''; + }, + scopes: $pageClass, + ); + } + } + + /** + * @return list + */ + private static function customFieldHeaderPageClasses(): array + { + return self::pageClassesForKeys(['edit', 'create', 'view']); + } + + /** + * @return list + */ + private static function customFieldListPageClasses(): array + { + return self::pageClassesForKeys(['index']); + } + + /** + * @param list $pageKeys + * @return list + */ + private static function pageClassesForKeys(array $pageKeys): array + { + $pageClasses = []; + + foreach (app(EntityRegistry::class)->all() as $definition) { + $resourceClass = $definition['resource'] ?? null; + + if (! is_string($resourceClass)) { + continue; + } + + foreach ($pageKeys as $pageKey) { + $registration = $resourceClass::getPages()[$pageKey] ?? null; + + if ($registration === null) { + continue; + } + + $pageClasses[] = $registration->getPage(); + } + } + + return array_values(array_unique($pageClasses)); + } +} From 34aaacb64c3ccc3dee74e8257355a033d6c286dd Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:47:31 +0200 Subject: [PATCH 054/105] Gate custom field value locales by entity translatability --- .../builder/src/Concerns/HasCustomFields.php | 26 +++++- .../Concerns/InteractsWithCustomFields.php | 20 +++- .../Database/Eloquent/CustomFieldsBuilder.php | 4 +- .../Middleware/ResolveBuilderAdminLocale.php | 93 ++++++++++++++++++- .../src/Services/BuilderValuesResolver.php | 4 +- .../src/Services/CustomFieldsManager.php | 8 +- .../src/Support/BuilderLocaleResolver.php | 39 ++++++++ .../src/Support/CustomFieldsFilamentHooks.php | 9 ++ .../Support/CustomFieldsTranslatability.php | 77 +++++++++++++++ packages/draft/src/Models/Draft.php | 3 +- .../draft/src/Resources/DraftResource.php | 3 + 11 files changed, 267 insertions(+), 19 deletions(-) create mode 100644 packages/builder/src/Support/CustomFieldsTranslatability.php diff --git a/packages/builder/src/Concerns/HasCustomFields.php b/packages/builder/src/Concerns/HasCustomFields.php index 3d701d8ece..806695d7c1 100644 --- a/packages/builder/src/Concerns/HasCustomFields.php +++ b/packages/builder/src/Concerns/HasCustomFields.php @@ -20,10 +20,34 @@ * Field groups are matched by entity key (model basename in kebab-case, e.g. * Item → item). Override with customFieldsEntity() when needed. Loading and * saving is handled automatically via Filament record events. Per-locale - * values and the admin language selector are wired by the builder package. + * values and the admin language selector apply only when the entity model + * is translatable (or customFieldsAreTranslatable() returns true). */ trait HasCustomFields { + /** + * Whether custom field values are stored per admin locale. + * Defaults to whether the model implements Astrotomic TranslatableContract. + */ + public static function customFieldsAreTranslatable(): bool + { + $model = static::getModel(); + + return is_subclass_of($model, \Astrotomic\Translatable\Contracts\Translatable::class); + } + + /** + * Whether the builder should render its own admin locale switcher on this + * resource's pages. Off by default: custom fields are used on translatable + * entities that already ship a locale switcher (e.g. Draft), so the builder + * would only add a duplicate. Opt in per resource when a standalone switcher + * is actually needed. + */ + public static function customFieldsRenderLocaleSwitcher(): bool + { + return false; + } + /** * @return list
*/ diff --git a/packages/builder/src/Concerns/InteractsWithCustomFields.php b/packages/builder/src/Concerns/InteractsWithCustomFields.php index bd9689508c..bf2e8639d0 100644 --- a/packages/builder/src/Concerns/InteractsWithCustomFields.php +++ b/packages/builder/src/Concerns/InteractsWithCustomFields.php @@ -52,7 +52,11 @@ trait InteractsWithCustomFields */ public function customFields(bool $fresh = false, ?string $locale = null): array { - $locale = app(BuilderLocaleResolver::class)->current($locale); + $locale = app(BuilderLocaleResolver::class)->valuesLocaleForEntity( + static::resolveCustomFieldsEntity(), + $locale, + static::class, + ); if (! $fresh && $this->customFieldsCache !== null @@ -183,7 +187,7 @@ public function setCustomFields(array $values): static $this, $payload, $fields->only(array_keys($payload))->values(), - app(BuilderLocaleResolver::class)->current(), + app(BuilderLocaleResolver::class)->valuesLocaleForEntity($entity, null, static::class), ); $this->flushCustomFieldsCache(); @@ -204,7 +208,11 @@ public function clearCustomField(string $name): static FieldValue::query() ->forRecord(static::resolveCustomFieldsEntity(), $this->getKey()) - ->forLocale(app(BuilderLocaleResolver::class)->current()) + ->forLocale(app(BuilderLocaleResolver::class)->valuesLocaleForEntity( + static::resolveCustomFieldsEntity(), + null, + static::class, + )) ->where('field_name', $name) ->delete(); @@ -244,7 +252,11 @@ public function scopeWithCustomFields(Builder $query): Builder public function setCustomFieldsCache(array $values, ?string $locale = null): void { $this->customFieldsCache = $values; - $this->customFieldsCacheLocale = app(BuilderLocaleResolver::class)->current($locale); + $this->customFieldsCacheLocale = app(BuilderLocaleResolver::class)->valuesLocaleForEntity( + static::resolveCustomFieldsEntity(), + $locale, + static::class, + ); } public static function flushCustomFieldDefinitionCache(): void diff --git a/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php b/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php index 49f0dfc877..f9d6815b8c 100644 --- a/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php +++ b/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php @@ -116,7 +116,7 @@ protected function addCustomFieldConstraint( $valueColumn = TypedValueColumns::columnForType($definition->type); $valuesTable = (new FieldValue)->getTable(); $recordKey = $model->getQualifiedKeyName(); - $locale = app(BuilderLocaleResolver::class)->current(); + $locale = app(BuilderLocaleResolver::class)->valuesLocaleForEntity($entity, null, $modelClass); return $this->whereExists(function ($subQuery) use ($entity, $field, $valueColumn, $operator, $value, $valuesTable, $recordKey, $locale): void { $subQuery->selectRaw('1') @@ -156,7 +156,7 @@ protected function addCustomFieldInConstraint( $valueColumn = TypedValueColumns::columnForType($definition->type); $valuesTable = (new FieldValue)->getTable(); $recordKey = $model->getQualifiedKeyName(); - $locale = app(BuilderLocaleResolver::class)->current(); + $locale = app(BuilderLocaleResolver::class)->valuesLocaleForEntity($entity, null, $modelClass); return $this->whereExists(function ($subQuery) use ($entity, $field, $valueColumn, $values, $valuesTable, $recordKey, $not, $locale): void { $subQuery->selectRaw('1') diff --git a/packages/builder/src/Http/Middleware/ResolveBuilderAdminLocale.php b/packages/builder/src/Http/Middleware/ResolveBuilderAdminLocale.php index 8a278a0a6c..04e969910b 100644 --- a/packages/builder/src/Http/Middleware/ResolveBuilderAdminLocale.php +++ b/packages/builder/src/Http/Middleware/ResolveBuilderAdminLocale.php @@ -7,14 +7,20 @@ use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Schema; +use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Support\BuilderLocaleResolver; +use Moox\Builder\Support\CustomFieldsTranslatability; use Moox\Localization\Models\Localization; use Symfony\Component\HttpFoundation\Response; final class ResolveBuilderAdminLocale { + private const FIELD_GROUP_ROUTE_SLUG = 'field-groups'; + public function __construct( protected BuilderLocaleResolver $localeResolver, + protected EntityRegistry $entityRegistry, + protected CustomFieldsTranslatability $translatability, ) {} public function handle(Request $request, Closure $next): Response @@ -71,18 +77,95 @@ protected function isAllowedAdminLocale(string $locale): bool protected function shouldResolveLocale(Request $request): bool { - if ($request->is('livewire/*')) { - return true; + if ($request->query('lang') !== null || $request->input('lang') !== null) { + return $this->targetsBuilderLocaleContext($request); } - if ($request->query('lang') !== null || $request->input('lang') !== null) { - return true; + if ($request->is('livewire/*')) { + return session(BuilderLocaleResolver::ADMIN_SESSION_KEY) !== null + && $this->livewireTargetsBuilderLocaleContext($request); } if (session(BuilderLocaleResolver::ADMIN_SESSION_KEY) !== null) { + return $this->targetsBuilderLocaleContext($request); + } + + return $this->targetsBuilderLocaleContext($request); + } + + protected function targetsBuilderLocaleContext(Request $request): bool + { + if ($this->isFieldGroupAdminRequest($request)) { return true; } - return $request->is('admin/*') || $request->is('filament/*'); + return $this->isTranslatableCustomFieldsRequest($request); + } + + protected function livewireTargetsBuilderLocaleContext(Request $request): bool + { + $referer = (string) $request->headers->get('referer', ''); + + if ($referer === '') { + return false; + } + + $refererPath = (string) parse_url($referer, PHP_URL_PATH); + + return $this->pathTargetsBuilderLocaleContext(trim($refererPath, '/')); + } + + protected function isFieldGroupAdminRequest(Request $request): bool + { + return $this->pathTargetsFieldGroupAdmin(trim($request->path(), '/')); + } + + protected function isTranslatableCustomFieldsRequest(Request $request): bool + { + return $this->pathTargetsTranslatableCustomFields(trim($request->path(), '/')); + } + + protected function pathTargetsBuilderLocaleContext(string $path): bool + { + return $this->pathTargetsFieldGroupAdmin($path) + || $this->pathTargetsTranslatableCustomFields($path); + } + + protected function pathTargetsFieldGroupAdmin(string $path): bool + { + if ($path === '') { + return false; + } + + $slug = self::FIELD_GROUP_ROUTE_SLUG; + + return $slug !== '' && str_contains($path, $slug); + } + + protected function pathTargetsTranslatableCustomFields(string $path): bool + { + if ($path === '') { + return false; + } + + foreach ($this->entityRegistry->all() as $definition) { + $resourceClass = $definition['resource'] ?? null; + + if (! is_string($resourceClass) || ! $this->translatability->forResource($resourceClass)) { + continue; + } + + if (! method_exists($resourceClass, 'getSlug')) { + continue; + } + + $slug = $resourceClass::getSlug(); + + if ($slug !== '' && str_contains($path, $slug)) { + return true; + } + } + + return false; } } diff --git a/packages/builder/src/Services/BuilderValuesResolver.php b/packages/builder/src/Services/BuilderValuesResolver.php index f1cebe0885..8310b1a9be 100644 --- a/packages/builder/src/Services/BuilderValuesResolver.php +++ b/packages/builder/src/Services/BuilderValuesResolver.php @@ -262,7 +262,7 @@ public function eagerLoad(iterable $models, string $entity, Collection $fields, } $keyName = $model->getKeyName(); - $localeChain = $this->localeResolver->fallbackChain($locale); + $localeChain = $this->localeResolver->valuesFallbackChainForEntity($entity, $locale, $model::class); $rows = FieldValue::query() ->where('entity', $entity) @@ -296,7 +296,7 @@ public function eagerLoad(iterable $models, string $entity, Collection $fields, $record->setCustomFieldsCache( $this->resolveFromRows($fields, $resolvedRows, mergeDefaults: true), - $this->localeResolver->current($locale), + $this->localeResolver->valuesLocaleForEntity($entity, $locale, $record::class), ); } } diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index 434406d472..dd00cf415f 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -112,7 +112,7 @@ public function loadValues(string $entity, Model $record, Collection $fields, ?s return []; } - $localeChain = $this->localeResolver->fallbackChain($locale); + $localeChain = $this->localeResolver->valuesFallbackChainForEntity($entity, $locale, $record::class); $rows = FieldValue::query() ->forRecord($entity, $record->getKey()) @@ -211,7 +211,7 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da $values = []; $defaultValue = app(DefaultValue::class); $entity = $this->locationContextForResource($resourceClass)->entity; - $locale = $this->localeResolver->current(); + $locale = $this->localeResolver->valuesLocaleForResource($resourceClass); foreach ($fields as $field) { $fieldType = $this->fieldTypeRegistry->get($field->type); @@ -269,7 +269,7 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da */ public function saveValues(string $entity, Model $record, array $values, Collection $fields, ?string $locale = null): void { - $locale = $this->localeResolver->current($locale); + $locale = $this->localeResolver->valuesLocaleForEntity($entity, $locale, $record::class); foreach ($fields as $field) { if (! array_key_exists($field->name, $values)) { @@ -343,7 +343,7 @@ protected function resolveRowForLocale(Collection $rows, array $localeChain): ?F protected function valuesCacheKey(string $entity, int|string $recordId, ?string $locale = null): string { - return "{$entity}:{$recordId}:".$this->localeResolver->current($locale); + return "{$entity}:{$recordId}:".$this->localeResolver->valuesLocaleForEntity($entity, $locale); } /** diff --git a/packages/builder/src/Support/BuilderLocaleResolver.php b/packages/builder/src/Support/BuilderLocaleResolver.php index 5869e5f6dd..58bbcde5c9 100644 --- a/packages/builder/src/Support/BuilderLocaleResolver.php +++ b/packages/builder/src/Support/BuilderLocaleResolver.php @@ -106,4 +106,43 @@ public function fallbackChain(?string $locale = null): array static fn (string $value): bool => $value !== '', ))); } + + public function valuesLocaleForEntity(string $entity, ?string $locale = null, ?string $modelClass = null): string + { + if (! app(CustomFieldsTranslatability::class)->valuesAreTranslatable($entity, $modelClass)) { + return $this->defaultLocale(); + } + + return $this->current($locale); + } + + /** + * @param class-string $resourceClass + */ + public function valuesLocaleForResource(string $resourceClass, ?string $locale = null): string + { + $entity = $resourceClass::resolveCustomFieldsEntityIdentifier(); + + if (! app(CustomFieldsTranslatability::class)->valuesAreTranslatable( + $entity, + $resourceClass::getModel(), + $resourceClass, + )) { + return $this->defaultLocale(); + } + + return $this->current($locale); + } + + /** + * @return list + */ + public function valuesFallbackChainForEntity(string $entity, ?string $locale = null, ?string $modelClass = null): array + { + if (! app(CustomFieldsTranslatability::class)->valuesAreTranslatable($entity, $modelClass)) { + return [$this->defaultLocale()]; + } + + return $this->fallbackChain($locale); + } } diff --git a/packages/builder/src/Support/CustomFieldsFilamentHooks.php b/packages/builder/src/Support/CustomFieldsFilamentHooks.php index e25daeeb79..6bae76569b 100644 --- a/packages/builder/src/Support/CustomFieldsFilamentHooks.php +++ b/packages/builder/src/Support/CustomFieldsFilamentHooks.php @@ -10,6 +10,7 @@ use Filament\View\PanelsRenderHook; use Illuminate\Support\Facades\Blade; use Moox\Builder\Registry\EntityRegistry; +use Moox\Builder\Support\CustomFieldsTranslatability; final class CustomFieldsFilamentHooks { @@ -115,6 +116,14 @@ private static function pageClassesForKeys(array $pageKeys): array continue; } + if (! app(CustomFieldsTranslatability::class)->forResource($resourceClass)) { + continue; + } + + if (! $resourceClass::customFieldsRenderLocaleSwitcher()) { + continue; + } + foreach ($pageKeys as $pageKey) { $registration = $resourceClass::getPages()[$pageKey] ?? null; diff --git a/packages/builder/src/Support/CustomFieldsTranslatability.php b/packages/builder/src/Support/CustomFieldsTranslatability.php new file mode 100644 index 0000000000..3377f8973b --- /dev/null +++ b/packages/builder/src/Support/CustomFieldsTranslatability.php @@ -0,0 +1,77 @@ +forModel($model); + } + + public function forEntity(string $entity): bool + { + $resource = app(EntityRegistry::class)->resourceFor($entity); + + if ($resource !== null) { + return $this->forResource($resource); + } + + return false; + } + + /** + * @param class-string|null $modelClass + * @param class-string|null $resourceClass + */ + public function valuesAreTranslatable( + string $entity, + ?string $modelClass = null, + ?string $resourceClass = null, + ): bool { + if ($resourceClass !== null && $this->forResource($resourceClass)) { + return true; + } + + if ($modelClass !== null && $this->forModel($modelClass)) { + return true; + } + + return $this->forEntity($entity); + } + + /** + * @param class-string $modelClass + */ + public function forModel(string $modelClass): bool + { + if (method_exists($modelClass, 'customFieldsAreTranslatable')) { + return (bool) $modelClass::customFieldsAreTranslatable(); + } + + return is_subclass_of($modelClass, TranslatableContract::class); + } +} diff --git a/packages/draft/src/Models/Draft.php b/packages/draft/src/Models/Draft.php index 3d0b06052d..025227080d 100644 --- a/packages/draft/src/Models/Draft.php +++ b/packages/draft/src/Models/Draft.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Moox\Builder\Concerns\InteractsWithCustomFields; use Moox\Core\Entities\Items\Draft\BaseDraftModel; use Moox\Core\Traits\Taxonomy\HasModelTaxonomy; use Moox\Draft\Database\Factories\DraftFactory; @@ -35,7 +36,7 @@ */ class Draft extends BaseDraftModel implements HasMedia { - use HasFactory, HasMediaUsable, HasModelTaxonomy, InteractsWithMedia; + use HasFactory, HasMediaUsable, HasModelTaxonomy, InteractsWithCustomFields, InteractsWithMedia; /** * Get custom translated attributes for Draft diff --git a/packages/draft/src/Resources/DraftResource.php b/packages/draft/src/Resources/DraftResource.php index cd6ee37543..9e2dbee6db 100644 --- a/packages/draft/src/Resources/DraftResource.php +++ b/packages/draft/src/Resources/DraftResource.php @@ -17,6 +17,7 @@ use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Table; use Illuminate\Validation\Rules\Unique; +use Moox\Builder\Concerns\HasCustomFields; use Moox\Core\Entities\Items\Draft\BaseDraftResource; use Moox\Core\Traits\Tabs\HasResourceTabs; use Moox\Core\Traits\Taxonomy\HasResourceTaxonomy; @@ -31,6 +32,7 @@ class DraftResource extends BaseDraftResource { + use HasCustomFields; use HasResourceTabs; use HasResourceTaxonomy; @@ -106,6 +108,7 @@ public static function form(Schema $form): Schema ->label(__('core::core.description')), MarkdownEditor::make('content') ->label(__('core::core.content')), + ...static::customFieldComponents(), Grid::make(2) ->schema([ static::getFooterActions()->columnSpan(1), From 6dc6beadd05f13e1bec9760c7b8dc8f6ea33cffb Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:48:42 +0000 Subject: [PATCH 055/105] Fix styling --- packages/builder/src/Concerns/HasCustomFields.php | 3 ++- packages/builder/src/Support/CustomFieldsFilamentHooks.php | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/builder/src/Concerns/HasCustomFields.php b/packages/builder/src/Concerns/HasCustomFields.php index 806695d7c1..a2633e0843 100644 --- a/packages/builder/src/Concerns/HasCustomFields.php +++ b/packages/builder/src/Concerns/HasCustomFields.php @@ -4,6 +4,7 @@ namespace Moox\Builder\Concerns; +use Astrotomic\Translatable\Contracts\Translatable; use Filament\Schemas\Components\Section; use Illuminate\Support\Str; use Moox\Builder\Compiler\SchemaCompiler; @@ -33,7 +34,7 @@ public static function customFieldsAreTranslatable(): bool { $model = static::getModel(); - return is_subclass_of($model, \Astrotomic\Translatable\Contracts\Translatable::class); + return is_subclass_of($model, Translatable::class); } /** diff --git a/packages/builder/src/Support/CustomFieldsFilamentHooks.php b/packages/builder/src/Support/CustomFieldsFilamentHooks.php index 6bae76569b..2fd9c175b4 100644 --- a/packages/builder/src/Support/CustomFieldsFilamentHooks.php +++ b/packages/builder/src/Support/CustomFieldsFilamentHooks.php @@ -10,7 +10,6 @@ use Filament\View\PanelsRenderHook; use Illuminate\Support\Facades\Blade; use Moox\Builder\Registry\EntityRegistry; -use Moox\Builder\Support\CustomFieldsTranslatability; final class CustomFieldsFilamentHooks { From c3edc17ecc69785d337f74642a9475bcad3320be Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:28:21 +0200 Subject: [PATCH 056/105] Make moox/builder integration optional via core bridge traits Consumers (item, record, draft) now use Moox\Core\Traits\HasCustomFields and InteractsWithCustomFields instead of depending on moox/builder directly. The core traits alias the real builder traits when the package is installed and fall back to empty stubs otherwise, mirroring the HasRolesTrait pattern. BaseResource provides a no-op customFieldComponents() so resource forms render without a fatal error when builder is absent. --- packages/core/src/Entities/BaseResource.php | 11 +++++++++++ packages/core/src/Traits/HasCustomFields.php | 16 ++++++++++++++++ .../src/Traits/InteractsWithCustomFields.php | 16 ++++++++++++++++ packages/draft/src/Models/Draft.php | 2 +- packages/draft/src/Resources/DraftResource.php | 2 +- packages/item/src/Models/Item.php | 2 +- packages/item/src/Resources/ItemResource.php | 2 +- packages/record/src/Models/Record.php | 3 ++- packages/record/src/Resources/RecordResource.php | 2 +- 9 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/Traits/HasCustomFields.php create mode 100644 packages/core/src/Traits/InteractsWithCustomFields.php diff --git a/packages/core/src/Entities/BaseResource.php b/packages/core/src/Entities/BaseResource.php index 60ce2523b3..6125524623 100644 --- a/packages/core/src/Entities/BaseResource.php +++ b/packages/core/src/Entities/BaseResource.php @@ -30,6 +30,17 @@ abstract class BaseResource extends Resource { + /** + * Custom field form components. Overridden by the moox/builder HasCustomFields + * trait when the package is installed; returns no components otherwise. + * + * @return array + */ + public static function customFieldComponents(): array + { + return []; + } + public static function make(string $key = 'default'): ResourceConfiguration { return ScopedResourceConfiguration::make(static::class, $key); diff --git a/packages/core/src/Traits/HasCustomFields.php b/packages/core/src/Traits/HasCustomFields.php new file mode 100644 index 0000000000..2e6f8ec2c3 --- /dev/null +++ b/packages/core/src/Traits/HasCustomFields.php @@ -0,0 +1,16 @@ + Date: Thu, 2 Jul 2026 09:52:13 +0200 Subject: [PATCH 057/105] make builder optional --- packages/core/src/Entities/BaseResource.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/core/src/Entities/BaseResource.php b/packages/core/src/Entities/BaseResource.php index 6125524623..b49fee8661 100644 --- a/packages/core/src/Entities/BaseResource.php +++ b/packages/core/src/Entities/BaseResource.php @@ -41,6 +41,17 @@ public static function customFieldComponents(): array return []; } + /** + * Custom field table columns. Overridden by the moox/builder HasCustomFields + * trait when the package is installed; returns no columns otherwise. + * + * @return array + */ + public static function customFieldColumns(): array + { + return []; + } + public static function make(string $key = 'default'): ResourceConfiguration { return ScopedResourceConfiguration::make(static::class, $key); From d668945043f5894033fa57e04754b87fd728044a Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:31:05 +0200 Subject: [PATCH 058/105] Add configurable custom field table columns --- .../builder/resources/lang/de/builder.php | 28 ++ .../builder/resources/lang/en/builder.php | 28 ++ .../src/Compiler/TableColumnCompiler.php | 273 ++++++++++++++++++ .../builder/src/Concerns/HasCustomFields.php | 42 +++ packages/builder/src/Data/FieldDefinition.php | 60 +++- .../src/Resources/FieldGroupResource.php | 173 +++++++++++ .../src/Services/FieldGroupPersistence.php | 22 ++ .../src/Support/DefinitionTranslator.php | 1 + .../builder/src/Support/TypedValueColumns.php | 14 + .../draft/src/Resources/DraftResource.php | 1 + packages/item/src/Resources/ItemResource.php | 1 + .../record/src/Resources/RecordResource.php | 1 + 12 files changed, 643 insertions(+), 1 deletion(-) create mode 100644 packages/builder/src/Compiler/TableColumnCompiler.php diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 2f96152226..aa07a6f721 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -34,6 +34,34 @@ 'type' => 'Feldtyp', 'required' => 'Pflichtfeld', 'required_badge' => 'Pflicht', + 'table_column' => 'Tabellen-Spalte', + 'table_column_helper' => 'Dieses Feld als Spalte auf den Listen-Seiten anzeigen.', + 'show_in_table' => 'In Tabelle anzeigen', + 'show_in_table_helper' => 'Fügt dieses Feld als Spalte auf den Listen-Seiten der Entity hinzu. Standardmäßig ausgeblendet; über den Spalten-Umschalter aktivierbar.', + 'column_sortable' => 'Sortierbar', + 'column_searchable' => 'Durchsuchbar', + 'column_hidden_by_default' => 'Standardmäßig ausgeblendet', + 'column_hidden_by_default_helper' => 'Spalte startet ausgeblendet und kann über den Spalten-Umschalter aktiviert werden. Ausschalten, um sie immer anzuzeigen.', + 'column_badge' => 'Badge', + 'column_badge_helper' => 'Den Wert als farbiges Badge anzeigen.', + 'column_color' => 'Farbe', + 'column_color_default' => 'Standard', + 'column_color_primary' => 'Primär', + 'column_color_gray' => 'Grau', + 'column_color_success' => 'Erfolg', + 'column_color_warning' => 'Warnung', + 'column_color_danger' => 'Gefahr', + 'column_color_info' => 'Info', + 'column_icon' => 'Icon', + 'column_icon_helper' => 'Heroicon-Name, z. B. heroicon-o-star.', + 'column_image_shape' => 'Form', + 'column_image_shape_rectangle' => 'Rechteck', + 'column_image_shape_square' => 'Quadrat', + 'column_image_shape_circular' => 'Rund', + 'column_image_size' => 'Größe', + 'column_image_size_sm' => 'Klein', + 'column_image_size_md' => 'Mittel', + 'column_image_size_lg' => 'Groß', 'subfields_count' => '{1} :count Unterfeld|[2,*] :count Unterfelder', 'settings' => 'Einstellungen', 'options' => 'Auswahloptionen', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 3f7161aa08..e172887aba 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -34,6 +34,34 @@ 'type' => 'Field type', 'required' => 'Required', 'required_badge' => 'Required', + 'table_column' => 'Table column', + 'table_column_helper' => 'Show this field as a column on list pages.', + 'show_in_table' => 'Show in table', + 'show_in_table_helper' => 'Adds this field as a column on entity list pages. Hidden by default; enable via the column toggle.', + 'column_sortable' => 'Sortable', + 'column_searchable' => 'Searchable', + 'column_hidden_by_default' => 'Hidden by default', + 'column_hidden_by_default_helper' => 'Column starts hidden and can be enabled via the column toggle. Turn off to always show it.', + 'column_badge' => 'Badge', + 'column_badge_helper' => 'Display the value as a colored badge.', + 'column_color' => 'Color', + 'column_color_default' => 'Default', + 'column_color_primary' => 'Primary', + 'column_color_gray' => 'Gray', + 'column_color_success' => 'Success', + 'column_color_warning' => 'Warning', + 'column_color_danger' => 'Danger', + 'column_color_info' => 'Info', + 'column_icon' => 'Icon', + 'column_icon_helper' => 'Heroicon name, e.g. heroicon-o-star.', + 'column_image_shape' => 'Shape', + 'column_image_shape_rectangle' => 'Rectangle', + 'column_image_shape_square' => 'Square', + 'column_image_shape_circular' => 'Circular', + 'column_image_size' => 'Size', + 'column_image_size_sm' => 'Small', + 'column_image_size_md' => 'Medium', + 'column_image_size_lg' => 'Large', 'subfields_count' => '{1} :count subfield|[2,*] :count subfields', 'settings' => 'Settings', 'options' => 'Options', diff --git a/packages/builder/src/Compiler/TableColumnCompiler.php b/packages/builder/src/Compiler/TableColumnCompiler.php new file mode 100644 index 0000000000..f32812d5ec --- /dev/null +++ b/packages/builder/src/Compiler/TableColumnCompiler.php @@ -0,0 +1,273 @@ + $fieldGroups + * @param class-string|null $resourceClass + * @return list + */ + public function compile(Collection $fieldGroups, ?string $resourceClass = null): array + { + if ($fieldGroups->isEmpty()) { + return []; + } + + $entity = $resourceClass !== null + ? $this->customFieldsManager->locationContextForResource($resourceClass)->entity + : null; + + if ($entity === null) { + return []; + } + + /** @var class-string|null $modelClass */ + $modelClass = $resourceClass !== null ? $resourceClass::getModel() : null; + + if ($modelClass === null || ! is_subclass_of($modelClass, Model::class)) { + return []; + } + + $locale = $this->localeResolver->valuesLocaleForEntity($entity, null, $modelClass); + $valuesTable = (new FieldValue)->getTable(); + + $fields = $this->storableFieldCollector + ->definitionsFromList($fieldGroups->flatMap(fn (FieldGroupDefinition $group): Collection => $group->fields)) + ->filter(fn (FieldDefinition $field): bool => $field->showInTable() + && ($this->isColumnable($field) || $this->isImageColumn($field))) + ->values(); + + $columns = []; + + foreach ($fields as $field) { + $columns[] = $this->compileColumn($field, $entity, $modelClass, $locale, $valuesTable); + } + + return $columns; + } + + protected function isColumnable(FieldDefinition $field): bool + { + return TypedValueColumns::isColumnableType($field->type); + } + + protected function isImageColumn(FieldDefinition $field): bool + { + return TypedValueColumns::isImageColumnType($field->type); + } + + protected function compileColumn( + FieldDefinition $field, + string $entity, + string $modelClass, + string $locale, + string $valuesTable, + ): Column { + if ($this->isImageColumn($field)) { + return $this->compileImageColumn($field); + } + + $valueColumn = TypedValueColumns::columnForType($field->type); + $name = $field->name; + + if ($field->type === 'toggle') { + $column = IconColumn::make($name) + ->label($field->label) + ->boolean() + ->getStateUsing(fn (Model $record): bool => (bool) $this->resolvePresentedValue($field, $record)); + } else { + $column = TextColumn::make($name) + ->label($field->label) + ->getStateUsing(fn (Model $record): mixed => $this->resolvePresentedValue($field, $record)); + + if (in_array($field->type, ['textarea', 'rich_text'], true)) { + $column->limit(50); + } + + if ($field->columnBadge()) { + $column->badge(); + } + + if (($color = $field->columnColor()) !== null) { + $column->color($color); + } + + if (($icon = $field->columnIcon()) !== null) { + $column->icon($icon); + } + + $this->applyTextColumnPresentation($column, $field); + } + + $column->toggleable(isToggledHiddenByDefault: $field->isColumnHiddenByDefault()); + + if ($field->isColumnSortable()) { + $column->sortable(query: function (Builder $query, string $direction) use ( + $entity, + $field, + $locale, + $valuesTable, + $valueColumn, + ): Builder { + $recordKey = $query->getModel()->getQualifiedKeyName(); + + return $query->orderBy( + FieldValue::query() + ->select($valueColumn) + ->from($valuesTable) + ->whereColumn("{$valuesTable}.record_id", $recordKey) + ->where("{$valuesTable}.entity", $entity) + ->where("{$valuesTable}.field_name", $field->name) + ->where("{$valuesTable}.locale", $locale) + ->limit(1), + $direction, + ); + }); + } + + if ($field->isColumnSearchable()) { + $column->searchable(query: function (Builder $query, string $search) use ($name): Builder { + return $query->where($name, 'like', "%{$search}%"); + }); + } + + return $column; + } + + protected function applyTextColumnPresentation(TextColumn $column, FieldDefinition $field): void + { + $column->placeholder('—'); + + match ($field->type) { + 'date' => $column->date(DisplayFormat::resolveForField($field)), + 'datetime' => $column->dateTime(DisplayFormat::resolveForField($field)), + 'time' => $column->time(DisplayFormat::resolveForField($field)), + 'number', 'range' => $column->numeric(decimalPlaces: $this->numericDecimalPlaces($field)), + default => null, + }; + } + + protected function numericDecimalPlaces(FieldDefinition $field): ?int + { + $step = $field->config['step'] ?? null; + + if (! is_numeric($step)) { + return null; + } + + $stepString = (string) $step; + + if (! str_contains($stepString, '.')) { + return 0; + } + + return strlen(rtrim(substr($stepString, strpos($stepString, '.') + 1), '0')); + } + + protected function compileImageColumn(FieldDefinition $field): Column + { + $column = ImageColumn::make($field->name) + ->label($field->label) + ->checkFileExistence(false) + ->getStateUsing(fn (Model $record): array|string|null => $this->resolveImageState($field, $record)) + ->toggleable(isToggledHiddenByDefault: $field->isColumnHiddenByDefault()); + + $size = match ($field->columnImageSize()) { + 'sm' => 32, + 'lg' => 56, + default => 40, + }; + + match ($field->columnImageShape()) { + 'circular' => $column->circular()->imageSize($size), + 'square' => $column->square()->imageSize($size), + default => $column->imageHeight($size), + }; + + if ($field->type === 'gallery') { + $column->stacked() + ->limit(3) + ->limitedRemainingText(); + } + + return $column; + } + + /** + * @return list|string|null + */ + protected function resolveImageState(FieldDefinition $field, Model $record): array|string|null + { + $value = $this->resolvePresentedValue($field, $record); + + if ($value === null) { + return null; + } + + if ($field->type === 'gallery') { + return collect(is_array($value) ? $value : []) + ->map(fn (mixed $item): ?string => $this->imageUrlFromPresented($item)) + ->filter() + ->values() + ->all(); + } + + return $this->imageUrlFromPresented($value); + } + + protected function imageUrlFromPresented(mixed $item): ?string + { + if (! is_array($item)) { + return null; + } + + $url = $item['thumbnail_url'] ?? $item['preview_url'] ?? $item['url'] ?? null; + + return filled($url) ? (string) $url : null; + } + + protected function resolvePresentedValue(FieldDefinition $field, Model $record): mixed + { + if (! method_exists($record, 'customField')) { + return null; + } + + /** @var Model&InteractsWithCustomFields $record */ + $value = $record->customField($field->name); + + if ($value === null) { + return null; + } + + return $this->valuesResolver->presentFieldValue($field, $value); + } +} diff --git a/packages/builder/src/Concerns/HasCustomFields.php b/packages/builder/src/Concerns/HasCustomFields.php index a2633e0843..a77c55e70b 100644 --- a/packages/builder/src/Concerns/HasCustomFields.php +++ b/packages/builder/src/Concerns/HasCustomFields.php @@ -6,8 +6,12 @@ use Astrotomic\Translatable\Contracts\Translatable; use Filament\Schemas\Components\Section; +use Filament\Tables\Columns\Column; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; use Moox\Builder\Compiler\SchemaCompiler; +use Moox\Builder\Compiler\TableColumnCompiler; use Moox\Builder\Data\LocationContext; use Moox\Builder\Registry\DefinitionRegistry; @@ -65,6 +69,44 @@ public static function customFieldComponents(): array return app(SchemaCompiler::class)->compile($groups, static::class); } + /** + * @return list + */ + public static function customFieldColumns(): array + { + $groups = app(DefinitionRegistry::class)->fieldGroupsFor( + static::customFieldsLocationContext(), + ); + + if ($groups->isEmpty()) { + return []; + } + + return app(TableColumnCompiler::class)->compile($groups, static::class); + } + + /** + * Eager-loads custom field values on list/table queries (via BaseResource *ModifyTableQuery convention). + * + * @param Builder $query + * @return Builder + */ + protected static function customFieldsModifyTableQuery(Builder $query): Builder + { + if (static::customFieldColumns() === []) { + return $query; + } + + /** @var class-string $modelClass */ + $modelClass = static::getModel(); + + if (! in_array(InteractsWithCustomFields::class, class_uses_recursive($modelClass), true)) { + return $query; + } + + return $query->withCustomFields(); + } + public static function customFieldsLocationContext(): LocationContext { return new LocationContext(static::resolveCustomFieldsEntityIdentifier()); diff --git a/packages/builder/src/Data/FieldDefinition.php b/packages/builder/src/Data/FieldDefinition.php index 6d8431e938..5b11a7ea2b 100644 --- a/packages/builder/src/Data/FieldDefinition.php +++ b/packages/builder/src/Data/FieldDefinition.php @@ -13,6 +13,7 @@ /** * @param array $config * @param array $validation + * @param array $settings * @param list}> $options * @param Collection $children * @param array}> $translations @@ -24,11 +25,65 @@ public function __construct( public int $sort = 0, public array $config = [], public array $validation = [], + public array $settings = [], public array $options = [], public Collection $children = new Collection, public array $translations = [], ) {} + public function showInTable(): bool + { + return (bool) ($this->settings['show_in_table'] ?? false); + } + + public function isColumnSortable(): bool + { + return (bool) ($this->settings['sortable'] ?? true); + } + + public function isColumnSearchable(): bool + { + return (bool) ($this->settings['searchable'] ?? true); + } + + public function isColumnHiddenByDefault(): bool + { + return (bool) ($this->settings['hidden_by_default'] ?? true); + } + + public function columnBadge(): bool + { + return (bool) ($this->settings['badge'] ?? false); + } + + public function columnColor(): ?string + { + $color = $this->settings['color'] ?? null; + + return filled($color) ? (string) $color : null; + } + + public function columnIcon(): ?string + { + $icon = $this->settings['icon'] ?? null; + + return filled($icon) ? (string) $icon : null; + } + + public function columnImageShape(): ?string + { + $shape = $this->settings['image_shape'] ?? null; + + return in_array($shape, ['square', 'circular'], true) ? $shape : null; + } + + public function columnImageSize(): string + { + $size = $this->settings['image_size'] ?? null; + + return in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'md'; + } + public static function fromModel(Field $field): self { $options = $field->relationLoaded('options') @@ -53,6 +108,7 @@ public static function fromModel(Field $field): self sort: (int) $field->sort, config: $field->config ?? [], validation: $field->validation ?? [], + settings: $field->settings ?? [], options: $options, children: $children, translations: self::mapFieldTranslations($field), @@ -123,6 +179,7 @@ public function toArray(): array 'sort' => $this->sort, 'config' => $this->config, 'validation' => $this->validation, + 'settings' => $this->settings, 'options' => $this->options, 'children' => $this->children ->map(fn (self $child): array => $child->toArray()) @@ -133,7 +190,7 @@ public function toArray(): array } /** - * @param array{name: string, label: string, type: string, sort?: int, config?: array, validation?: array, options?: list}>, children?: list>, translations?: array}>} $data + * @param array{name: string, label: string, type: string, sort?: int, config?: array, validation?: array, settings?: array, options?: list}>, children?: list>, translations?: array}>} $data */ public static function fromArray(array $data): self { @@ -149,6 +206,7 @@ public static function fromArray(array $data): self sort: (int) ($data['sort'] ?? 0), config: $data['config'] ?? [], validation: $data['validation'] ?? [], + settings: $data['settings'] ?? [], options: $data['options'] ?? [], children: $children, translations: $data['translations'] ?? [], diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 6d0605c2e1..a6c0d859be 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -32,6 +32,7 @@ use Moox\Builder\Resources\FieldGroupResource\Pages\ListFieldGroups; use Moox\Builder\Services\FieldGroupPersistence; use Moox\Builder\Support\BuilderLocaleResolver; +use Moox\Builder\Support\TypedValueColumns; class FieldGroupResource extends Resource { @@ -172,6 +173,7 @@ public static function form(Schema $schema): Schema ->inline(false) ->live() ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), + ...static::columnSettingsSchema(), ...static::optionFieldSections($registry), Section::make(fn (callable $get): string => $get('type') === 'tab' ? __('builder::builder.field.tab_content') @@ -369,6 +371,7 @@ protected static function tabChildFieldSchema(FieldTypeRegistry $registry): arra ->inline(false) ->live() ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), + ...static::columnSettingsSchema(), ...static::optionFieldSections($registry), Section::make(__('builder::builder.field.subfields')) ->collapsed() @@ -462,6 +465,176 @@ protected static function fieldTypeSupportsRequired(?string $type): bool } } + protected static function fieldTypeSupportsColumn(?string $type): bool + { + if (blank($type) || $type === 'password') { + return false; + } + + try { + $fieldType = app(FieldTypeRegistry::class)->get($type); + + if (! $fieldType->storesValue() || $fieldType->hasSubFields()) { + return false; + } + } catch (UnknownFieldTypeException) { + return false; + } + + return TypedValueColumns::isColumnableType($type); + } + + protected static function fieldTypeSupportsImageColumn(?string $type): bool + { + if (blank($type)) { + return false; + } + + try { + $fieldType = app(FieldTypeRegistry::class)->get($type); + + if (! $fieldType->storesValue() || $fieldType->hasSubFields()) { + return false; + } + } catch (UnknownFieldTypeException) { + return false; + } + + return TypedValueColumns::isImageColumnType($type); + } + + protected static function fieldTypeSupportsAnyColumn(?string $type): bool + { + return static::fieldTypeSupportsColumn($type) || static::fieldTypeSupportsImageColumn($type); + } + + /** + * Presentation options (badge, color, icon) only apply to text-based columns, + * not to the boolean icon column used for toggle fields. + */ + protected static function fieldTypeUsesTextColumn(?string $type): bool + { + return static::fieldTypeSupportsColumn($type) && $type !== 'toggle'; + } + + /** + * @return array + */ + protected static function columnColorOptions(): array + { + return [ + 'primary' => __('builder::builder.field.column_color_primary'), + 'gray' => __('builder::builder.field.column_color_gray'), + 'success' => __('builder::builder.field.column_color_success'), + 'warning' => __('builder::builder.field.column_color_warning'), + 'danger' => __('builder::builder.field.column_color_danger'), + 'info' => __('builder::builder.field.column_color_info'), + ]; + } + + /** + * Table-column configuration grouped in its own collapsible section, mirroring + * the existing "Settings"/"Options" sections. Only shown for columnable fields; + * the behaviour options appear once "Show in table" is enabled. + * + * @return list
+ */ + protected static function columnSettingsSchema(): array + { + $enabled = fn (callable $get): bool => $get('settings.show_in_table') === true; + + return [ + Section::make(__('builder::builder.field.table_column')) + ->description(__('builder::builder.field.table_column_helper')) + ->collapsible() + ->collapsed() + ->visible(fn (callable $get): bool => static::fieldTypeSupportsAnyColumn($get('type'))) + ->schema([ + Toggle::make('settings.show_in_table') + ->label(__('builder::builder.field.show_in_table')) + ->inline(false) + ->live(), + Grid::make(2) + ->schema([ + Toggle::make('settings.sortable') + ->label(__('builder::builder.field.column_sortable')) + ->inline(false) + ->default(true), + Toggle::make('settings.searchable') + ->label(__('builder::builder.field.column_searchable')) + ->inline(false) + ->default(true), + ]) + ->visible(fn (callable $get): bool => $enabled($get) + && static::fieldTypeSupportsColumn($get('type'))), + Toggle::make('settings.hidden_by_default') + ->label(__('builder::builder.field.column_hidden_by_default')) + ->hintIcon(Heroicon::OutlinedQuestionMarkCircle, tooltip: __('builder::builder.field.column_hidden_by_default_helper')) + ->inline(false) + ->default(true) + ->visible($enabled), + Grid::make(3) + ->schema([ + Toggle::make('settings.badge') + ->label(__('builder::builder.field.column_badge')) + ->hintIcon(Heroicon::OutlinedQuestionMarkCircle, tooltip: __('builder::builder.field.column_badge_helper')) + ->inline(false) + ->default(false), + Select::make('settings.color') + ->label(__('builder::builder.field.column_color')) + ->options(static::columnColorOptions()) + ->placeholder(__('builder::builder.field.column_color_default')) + ->native(false), + TextInput::make('settings.icon') + ->label(__('builder::builder.field.column_icon')) + ->hintIcon(Heroicon::OutlinedQuestionMarkCircle, tooltip: __('builder::builder.field.column_icon_helper')) + ->placeholder('heroicon-o-star'), + ]) + ->visible(fn (callable $get): bool => $enabled($get) + && static::fieldTypeUsesTextColumn($get('type'))), + Grid::make(2) + ->schema([ + Select::make('settings.image_shape') + ->label(__('builder::builder.field.column_image_shape')) + ->options(static::columnImageShapeOptions()) + ->placeholder(__('builder::builder.field.column_image_shape_rectangle')) + ->native(false), + Select::make('settings.image_size') + ->label(__('builder::builder.field.column_image_size')) + ->options(static::columnImageSizeOptions()) + ->default('md') + ->selectablePlaceholder(false) + ->native(false), + ]) + ->visible(fn (callable $get): bool => $enabled($get) + && static::fieldTypeSupportsImageColumn($get('type'))), + ]), + ]; + } + + /** + * @return array + */ + protected static function columnImageShapeOptions(): array + { + return [ + 'square' => __('builder::builder.field.column_image_shape_square'), + 'circular' => __('builder::builder.field.column_image_shape_circular'), + ]; + } + + /** + * @return array + */ + protected static function columnImageSizeOptions(): array + { + return [ + 'sm' => __('builder::builder.field.column_image_size_sm'), + 'md' => __('builder::builder.field.column_image_size_md'), + 'lg' => __('builder::builder.field.column_image_size_lg'), + ]; + } + /** * @return list */ diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 6df6d8a570..80ceade462 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -202,6 +202,17 @@ protected function mapFieldRows(Collection $fields, ?string $locale = null): arr 'type' => $field->type, 'required' => (bool) ($field->validation['required'] ?? false), 'config' => $this->definitionTranslator->translatedFieldConfig($field, $locale), + 'settings' => [ + 'show_in_table' => (bool) ($field->settings['show_in_table'] ?? false), + 'sortable' => (bool) ($field->settings['sortable'] ?? true), + 'searchable' => (bool) ($field->settings['searchable'] ?? true), + 'hidden_by_default' => (bool) ($field->settings['hidden_by_default'] ?? true), + 'badge' => (bool) ($field->settings['badge'] ?? false), + 'color' => $field->settings['color'] ?? null, + 'icon' => $field->settings['icon'] ?? null, + 'image_shape' => $field->settings['image_shape'] ?? null, + 'image_size' => $field->settings['image_size'] ?? null, + ], 'sort' => $field->sort, 'options' => $field->options->map(fn (FieldOption $option): array => [ 'id' => $option->getKey(), @@ -288,6 +299,17 @@ protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldI 'required' => (bool) ($row['required'] ?? false), 'rules' => $row['validation']['rules'] ?? [], ], + 'settings' => array_merge($field->settings ?? [], [ + 'show_in_table' => (bool) ($row['settings']['show_in_table'] ?? false), + 'sortable' => (bool) ($row['settings']['sortable'] ?? true), + 'searchable' => (bool) ($row['settings']['searchable'] ?? true), + 'hidden_by_default' => (bool) ($row['settings']['hidden_by_default'] ?? true), + 'badge' => (bool) ($row['settings']['badge'] ?? false), + 'color' => filled($row['settings']['color'] ?? null) ? (string) $row['settings']['color'] : null, + 'icon' => filled($row['settings']['icon'] ?? null) ? (string) $row['settings']['icon'] : null, + 'image_shape' => filled($row['settings']['image_shape'] ?? null) ? (string) $row['settings']['image_shape'] : null, + 'image_size' => filled($row['settings']['image_size'] ?? null) ? (string) $row['settings']['image_size'] : null, + ]), 'sort' => $index, ]); diff --git a/packages/builder/src/Support/DefinitionTranslator.php b/packages/builder/src/Support/DefinitionTranslator.php index 92e53e5991..6811593501 100644 --- a/packages/builder/src/Support/DefinitionTranslator.php +++ b/packages/builder/src/Support/DefinitionTranslator.php @@ -46,6 +46,7 @@ public function localizeField(FieldDefinition $field, ?string $locale = null): F sort: $field->sort, config: $this->resolveConfig($field->config, $field->translations, $locale), validation: $field->validation, + settings: $field->settings, options: collect($field->options) ->map(function (array $option) use ($locale): array { /** @var array{label: string, value: string, translations?: array} $option */ diff --git a/packages/builder/src/Support/TypedValueColumns.php b/packages/builder/src/Support/TypedValueColumns.php index cf6449ebb1..a74f1ee392 100644 --- a/packages/builder/src/Support/TypedValueColumns.php +++ b/packages/builder/src/Support/TypedValueColumns.php @@ -38,6 +38,20 @@ public static function columnForType(string $type): string }; } + public static function isColumnableType(string $type): bool + { + if ($type === 'password') { + return false; + } + + return self::columnForType($type) !== 'value_json'; + } + + public static function isImageColumnType(string $type): bool + { + return in_array($type, ['image', 'gallery'], true); + } + /** * @return array */ diff --git a/packages/draft/src/Resources/DraftResource.php b/packages/draft/src/Resources/DraftResource.php index cfd3fa18b7..ea6129b128 100644 --- a/packages/draft/src/Resources/DraftResource.php +++ b/packages/draft/src/Resources/DraftResource.php @@ -169,6 +169,7 @@ public static function table(Table $table): Table ->boolean() ->label('Active') ->sortable(), + ...static::customFieldColumns(), TextColumn::make('description') ->limit(50) ->toggleable(isToggledHiddenByDefault: true), diff --git a/packages/item/src/Resources/ItemResource.php b/packages/item/src/Resources/ItemResource.php index 12c2049f6c..9a78668e66 100644 --- a/packages/item/src/Resources/ItemResource.php +++ b/packages/item/src/Resources/ItemResource.php @@ -101,6 +101,7 @@ public static function table(Table $table): Table ->label(__('core::core.description')) ->searchable() ->sortable(), + ...static::customFieldColumns(), TextColumn::make('custom_properties') ->limit(50), TextColumn::make('created_at') diff --git a/packages/record/src/Resources/RecordResource.php b/packages/record/src/Resources/RecordResource.php index 3faf003345..1ef9b51866 100644 --- a/packages/record/src/Resources/RecordResource.php +++ b/packages/record/src/Resources/RecordResource.php @@ -143,6 +143,7 @@ public static function table(Table $table): Table ->label(__('core::core.content')) ->searchable() ->sortable(), + ...static::customFieldColumns(), TextColumn::make('custom_properties') ->limit(50), TextColumn::make('created_at') From b6b18a1b20537c1fdbd1ab78b2d7ccda7bcdf5d5 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:51:12 +0200 Subject: [PATCH 059/105] Add per-context visibility for builder fields and groups --- .../builder/resources/lang/de/builder.php | 11 +++ .../builder/resources/lang/en/builder.php | 11 +++ .../builder/src/Concerns/HasCustomFields.php | 30 ++++++--- packages/builder/src/Data/FieldDefinition.php | 28 ++++++++ .../builder/src/Data/FieldGroupDefinition.php | 25 +++++++ .../Resources/Concerns/MergesCustomFields.php | 3 +- .../src/Resources/FieldGroupResource.php | 49 ++++++++++++++ .../src/Services/CustomFieldsManager.php | 17 +++++ .../src/Services/FieldGroupPersistence.php | 6 ++ .../builder/src/Support/FieldVisibility.php | 67 +++++++++++++++++++ 10 files changed, 236 insertions(+), 11 deletions(-) create mode 100644 packages/builder/src/Support/FieldVisibility.php diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index aa07a6f721..29662fba56 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -24,6 +24,8 @@ 'field_item' => 'Feld', 'fields_count' => 'Felder', 'assigned_to' => 'Zugeordnet zu', + 'visibility' => 'Sichtbarkeit', + 'visibility_helper' => 'Ganze Gruppe je Kontext ausblenden. Damit werden auch alle enthaltenen Felder dort ausgeblendet.', ], 'field' => [ @@ -62,6 +64,8 @@ 'column_image_size_sm' => 'Klein', 'column_image_size_md' => 'Mittel', 'column_image_size_lg' => 'Groß', + 'visibility' => 'Sichtbarkeit', + 'visibility_helper' => 'Dieses Feld je Kontext ausblenden, ohne es zu löschen.', 'subfields_count' => '{1} :count Unterfeld|[2,*] :count Unterfelder', 'settings' => 'Einstellungen', 'options' => 'Auswahloptionen', @@ -109,6 +113,13 @@ 'flexible_content' => 'Flexible Inhalte', ], + 'visibility' => [ + 'admin' => 'Admin', + 'admin_helper' => 'Wenn aus, wird das Feld im Admin-Formular und in der Tabelle nicht angezeigt oder bearbeitet.', + 'frontend' => 'Frontend', + 'api' => 'API', + ], + 'flexible_content' => [ 'add_layout' => 'Layout hinzufügen', ], diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index e172887aba..ee8fe964f3 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -24,6 +24,8 @@ 'field_item' => 'Field', 'fields_count' => 'Fields', 'assigned_to' => 'Assigned to', + 'visibility' => 'Visibility', + 'visibility_helper' => 'Hide the whole group per context. Hiding it also hides all of its fields there.', ], 'field' => [ @@ -62,6 +64,8 @@ 'column_image_size_sm' => 'Small', 'column_image_size_md' => 'Medium', 'column_image_size_lg' => 'Large', + 'visibility' => 'Visibility', + 'visibility_helper' => 'Hide this field per context without deleting it.', 'subfields_count' => '{1} :count subfield|[2,*] :count subfields', 'settings' => 'Settings', 'options' => 'Options', @@ -109,6 +113,13 @@ 'flexible_content' => 'Flexible content', ], + 'visibility' => [ + 'admin' => 'Admin', + 'admin_helper' => 'When off, the field is not shown or editable in the admin form and table.', + 'frontend' => 'Frontend', + 'api' => 'API', + ], + 'flexible_content' => [ 'add_layout' => 'Add layout', ], diff --git a/packages/builder/src/Concerns/HasCustomFields.php b/packages/builder/src/Concerns/HasCustomFields.php index a77c55e70b..c93ae81fd4 100644 --- a/packages/builder/src/Concerns/HasCustomFields.php +++ b/packages/builder/src/Concerns/HasCustomFields.php @@ -9,11 +9,14 @@ use Filament\Tables\Columns\Column; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Moox\Builder\Compiler\SchemaCompiler; use Moox\Builder\Compiler\TableColumnCompiler; +use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\Data\LocationContext; use Moox\Builder\Registry\DefinitionRegistry; +use Moox\Builder\Support\FieldVisibility; /** * Add custom field group sections to a Filament resource form. @@ -58,9 +61,7 @@ public static function customFieldsRenderLocaleSwitcher(): bool */ public static function customFieldComponents(): array { - $groups = app(DefinitionRegistry::class)->fieldGroupsFor( - static::customFieldsLocationContext(), - ); + $groups = static::visibleCustomFieldGroups(); if ($groups->isEmpty()) { return []; @@ -74,9 +75,7 @@ public static function customFieldComponents(): array */ public static function customFieldColumns(): array { - $groups = app(DefinitionRegistry::class)->fieldGroupsFor( - static::customFieldsLocationContext(), - ); + $groups = static::visibleCustomFieldGroups(); if ($groups->isEmpty()) { return []; @@ -139,10 +138,21 @@ protected static function customFieldsEntity(): ?string */ public static function customFieldRules(): array { - $groups = app(DefinitionRegistry::class)->fieldGroupsFor( - static::customFieldsLocationContext(), - ); + return app(SchemaCompiler::class)->rules(static::visibleCustomFieldGroups()); + } - return app(SchemaCompiler::class)->rules($groups); + /** + * Field groups assigned to this resource, filtered to those visible in the + * admin (Filament) context. Admin-hidden groups/fields are excluded from the + * form, table and validation rules alike. + * + * @return Collection + */ + protected static function visibleCustomFieldGroups(): Collection + { + return FieldVisibility::filterGroups( + app(DefinitionRegistry::class)->fieldGroupsFor(static::customFieldsLocationContext()), + FieldVisibility::ADMIN, + ); } } diff --git a/packages/builder/src/Data/FieldDefinition.php b/packages/builder/src/Data/FieldDefinition.php index 5b11a7ea2b..b008de21f9 100644 --- a/packages/builder/src/Data/FieldDefinition.php +++ b/packages/builder/src/Data/FieldDefinition.php @@ -84,6 +84,34 @@ public function columnImageSize(): string return in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'md'; } + /** + * Whether the field is visible in the given context (admin, frontend, api). + * Defaults to visible when the setting is absent. + */ + public function isVisibleIn(string $context): bool + { + return (bool) ($this->settings["visible_{$context}"] ?? true); + } + + /** + * @param Collection $children + */ + public function withChildren(Collection $children): self + { + return new self( + name: $this->name, + label: $this->label, + type: $this->type, + sort: $this->sort, + config: $this->config, + validation: $this->validation, + settings: $this->settings, + options: $this->options, + children: $children, + translations: $this->translations, + ); + } + public static function fromModel(Field $field): self { $options = $field->relationLoaded('options') diff --git a/packages/builder/src/Data/FieldGroupDefinition.php b/packages/builder/src/Data/FieldGroupDefinition.php index 3d001cbc4b..44ebca6f3d 100644 --- a/packages/builder/src/Data/FieldGroupDefinition.php +++ b/packages/builder/src/Data/FieldGroupDefinition.php @@ -26,6 +26,31 @@ public function __construct( public array $translations = [], ) {} + /** + * Whether the group is visible in the given context (admin, frontend, api). + * Defaults to visible when the setting is absent. + */ + public function isVisibleIn(string $context): bool + { + return (bool) ($this->settings["visible_{$context}"] ?? true); + } + + /** + * @param Collection $fields + */ + public function withFields(Collection $fields): self + { + return new self( + name: $this->name, + slug: $this->slug, + placement: $this->placement, + fields: $fields, + locationRules: $this->locationRules, + settings: $this->settings, + translations: $this->translations, + ); + } + public static function fromModel(FieldGroup $group): self { $fields = $group->relationLoaded('fields') diff --git a/packages/builder/src/Http/Resources/Concerns/MergesCustomFields.php b/packages/builder/src/Http/Resources/Concerns/MergesCustomFields.php index 9b447160b5..0c0d9f3047 100644 --- a/packages/builder/src/Http/Resources/Concerns/MergesCustomFields.php +++ b/packages/builder/src/Http/Resources/Concerns/MergesCustomFields.php @@ -7,6 +7,7 @@ use Moox\Builder\Concerns\InteractsWithCustomFields; use Moox\Builder\Services\BuilderValuesResolver; use Moox\Builder\Services\CustomFieldsManager; +use Moox\Builder\Support\FieldVisibility; trait MergesCustomFields { @@ -27,7 +28,7 @@ protected function mergeCustomFields(array $payload): array $entity = $model::resolveCustomFieldsEntity(); $presented = app(BuilderValuesResolver::class)->present( - $manager->fieldsForEntity($entity), + $manager->visibleFieldsForEntity($entity, FieldVisibility::API), $model->customFields(), ); diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index a6c0d859be..b856df3824 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -120,6 +120,11 @@ public static function form(Schema $schema): Schema ->disabled($entityOptions === []) ->native(false), ]), + Section::make(__('builder::builder.field_group.visibility')) + ->description(__('builder::builder.field_group.visibility_helper')) + ->collapsible() + ->collapsed() + ->schema(static::visibilityToggles()), ]) ->columnSpan(1) ->columns(1), @@ -174,6 +179,7 @@ public static function form(Schema $schema): Schema ->live() ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), ...static::columnSettingsSchema(), + ...static::visibilitySettingsSchema(), ...static::optionFieldSections($registry), Section::make(fn (callable $get): string => $get('type') === 'tab' ? __('builder::builder.field.tab_content') @@ -372,6 +378,7 @@ protected static function tabChildFieldSchema(FieldTypeRegistry $registry): arra ->live() ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), ...static::columnSettingsSchema(), + ...static::visibilitySettingsSchema(), ...static::optionFieldSections($registry), Section::make(__('builder::builder.field.subfields')) ->collapsed() @@ -612,6 +619,48 @@ protected static function columnSettingsSchema(): array ]; } + /** + * Per-context visibility (admin, frontend, API) as its own collapsible + * section. Reused for both fields and – via the toggles – field groups. + * + * @return list
+ */ + protected static function visibilitySettingsSchema(): array + { + return [ + Section::make(__('builder::builder.field.visibility')) + ->description(__('builder::builder.field.visibility_helper')) + ->collapsible() + ->collapsed() + ->schema(static::visibilityToggles()), + ]; + } + + /** + * @return list + */ + protected static function visibilityToggles(): array + { + return [ + Grid::make(3) + ->schema([ + Toggle::make('settings.visible_admin') + ->label(__('builder::builder.visibility.admin')) + ->hintIcon(Heroicon::OutlinedQuestionMarkCircle, tooltip: __('builder::builder.visibility.admin_helper')) + ->inline(false) + ->default(true), + Toggle::make('settings.visible_frontend') + ->label(__('builder::builder.visibility.frontend')) + ->inline(false) + ->default(true), + Toggle::make('settings.visible_api') + ->label(__('builder::builder.visibility.api')) + ->inline(false) + ->default(true), + ]), + ]; + } + /** * @return array */ diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index dd00cf415f..4e65a063d2 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -14,6 +14,7 @@ use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\BuilderLocaleResolver; +use Moox\Builder\Support\FieldVisibility; use Moox\Builder\Support\MediaIntegration; use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Support\StorableFieldCollector; @@ -65,6 +66,22 @@ public function fieldsForEntity(string $entity): Collection return $groups->flatMap(fn ($group) => $this->storableFieldCollector->definitionsFromList($group->fields))->values(); } + /** + * Storable fields of an entity, filtered to those visible in the given + * context (e.g. FieldVisibility::API for JsonResource output). + * + * @return Collection + */ + public function visibleFieldsForEntity(string $entity, string $context): Collection + { + $groups = FieldVisibility::filterGroups( + $this->definitionRegistry->fieldGroupsFor(new LocationContext($entity)), + $context, + ); + + return $groups->flatMap(fn ($group) => $this->storableFieldCollector->definitionsFromList($group->fields))->values(); + } + /** * @return array */ diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 80ceade462..5047c207e3 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -212,6 +212,9 @@ protected function mapFieldRows(Collection $fields, ?string $locale = null): arr 'icon' => $field->settings['icon'] ?? null, 'image_shape' => $field->settings['image_shape'] ?? null, 'image_size' => $field->settings['image_size'] ?? null, + 'visible_admin' => (bool) ($field->settings['visible_admin'] ?? true), + 'visible_frontend' => (bool) ($field->settings['visible_frontend'] ?? true), + 'visible_api' => (bool) ($field->settings['visible_api'] ?? true), ], 'sort' => $field->sort, 'options' => $field->options->map(fn (FieldOption $option): array => [ @@ -309,6 +312,9 @@ protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldI 'icon' => filled($row['settings']['icon'] ?? null) ? (string) $row['settings']['icon'] : null, 'image_shape' => filled($row['settings']['image_shape'] ?? null) ? (string) $row['settings']['image_shape'] : null, 'image_size' => filled($row['settings']['image_size'] ?? null) ? (string) $row['settings']['image_size'] : null, + 'visible_admin' => (bool) ($row['settings']['visible_admin'] ?? true), + 'visible_frontend' => (bool) ($row['settings']['visible_frontend'] ?? true), + 'visible_api' => (bool) ($row['settings']['visible_api'] ?? true), ]), 'sort' => $index, ]); diff --git a/packages/builder/src/Support/FieldVisibility.php b/packages/builder/src/Support/FieldVisibility.php new file mode 100644 index 0000000000..f209a8bfa4 --- /dev/null +++ b/packages/builder/src/Support/FieldVisibility.php @@ -0,0 +1,67 @@ + */ + public const CONTEXTS = [self::ADMIN, self::FRONTEND, self::API]; + + /** + * @param array $settings + */ + public static function isVisible(array $settings, string $context): bool + { + return (bool) ($settings["visible_{$context}"] ?? true); + } + + /** + * Keep only the groups (and, cascading, the fields) visible in the given context. + * + * @param Collection $groups + * @return Collection + */ + public static function filterGroups(Collection $groups, string $context): Collection + { + return $groups + ->filter(fn (FieldGroupDefinition $group): bool => $group->isVisibleIn($context)) + ->map(fn (FieldGroupDefinition $group): FieldGroupDefinition => $group->withFields( + self::filterFields($group->fields, $context), + )) + ->values(); + } + + /** + * @param Collection $fields + * @return Collection + */ + public static function filterFields(Collection $fields, string $context): Collection + { + return $fields + ->filter(fn (FieldDefinition $field): bool => $field->isVisibleIn($context)) + ->map(fn (FieldDefinition $field): FieldDefinition => $field->children->isEmpty() + ? $field + : $field->withChildren(self::filterFields($field->children, $context))) + ->values(); + } +} From 22dc979a1d188ccaa78a4f0fec504cb0ab5deb95 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:04:43 +0200 Subject: [PATCH 060/105] Add field group placement, layout widths and section markers --- packages/builder/README.md | 27 ++++++++-- .../create_builder_fields_table.php.stub | 1 - .../builder/resources/lang/de/builder.php | 13 +++++ .../builder/resources/lang/en/builder.php | 13 +++++ .../builder/src/BuilderServiceProvider.php | 2 + .../builder/src/Compiler/SchemaCompiler.php | 28 ++++++++-- .../builder/src/Concerns/HasCustomFields.php | 14 +++-- packages/builder/src/Data/FieldDefinition.php | 17 ++++++ .../builder/src/Data/FieldGroupDefinition.php | 10 ++++ .../FieldTypes/Types/RepeaterFieldType.php | 2 + .../src/FieldTypes/Types/SectionFieldType.php | 37 +++++++++++++ packages/builder/src/Models/Field.php | 1 - .../src/Resources/FieldGroupResource.php | 54 +++++++++++++++++++ .../Pages/EditFieldGroup.php | 2 + .../src/Services/FieldGroupPersistence.php | 2 + .../src/Support/FieldGroupPlacement.php | 33 ++++++++++++ packages/builder/src/Support/FieldWidth.php | 49 +++++++++++++++++ .../src/Support/StorableFieldCollector.php | 4 +- packages/core/src/Entities/BaseResource.php | 2 +- .../draft/src/Resources/DraftResource.php | 1 + packages/item/src/Resources/ItemResource.php | 1 + .../record/src/Resources/RecordResource.php | 1 + 22 files changed, 298 insertions(+), 16 deletions(-) create mode 100644 packages/builder/src/FieldTypes/Types/SectionFieldType.php create mode 100644 packages/builder/src/Support/FieldGroupPlacement.php create mode 100644 packages/builder/src/Support/FieldWidth.php diff --git a/packages/builder/README.md b/packages/builder/README.md index a4e92967a5..75a0971326 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -107,7 +107,7 @@ Values are **not** attached to the Eloquent model (no `custom_fields` JSON colum | `name` | Display name (= section title in the form) | | `slug` | Technical group key | | `location_rules` | JSON: where the group appears (see below) | -| `placement` | Reserved (`default`) | +| `placement` | Form slot: `main` (default) or `sidebar`. Consumers render each via `customFieldComponents($placement)` | | `settings` | Reserved for group settings | | `sort` | Order of multiple groups | | `active` | Only active groups are rendered | @@ -265,12 +265,15 @@ class ItemResource extends Resource { return $schema->components([ // your own fields … - ...static::customFieldComponents(), + ...static::customFieldComponents(), // groups placed in the main area + ...static::customFieldComponents('sidebar'), // groups placed in the sidebar ]); } } ``` +A group's **placement** (`main` by default, or `sidebar`) decides which slot it renders in. Sidebar groups only appear where the resource form actually has a sidebar column and calls `customFieldComponents('sidebar')` there. + The **entity key** is derived automatically from the model basename (`Item` → `item`). Override with `customFieldsEntity()`: ```php @@ -335,7 +338,7 @@ Package UI strings: `resources/lang/de/builder.php`, `en/builder.php`. | **Date** | `date`, `datetime`, `time` | | **Other** | `color`, `link`, `message`, `oembed` | | **Media** *(requires `moox/media`)* | `image`, `gallery`, `file` | -| **Layout** | `tab`, `group`, `repeater`, `flexible_content` | +| **Layout** | `tab`, `section`, `group`, `repeater`, `flexible_content` | Internal only (DB, not selectable): `flexible_layout` — defines a layout inside flexible content. @@ -362,13 +365,28 @@ API output uses `presentValue()` / `MediaItemResource` (URLs, thumbnails — no | Type | Filament component | Storage | |------|-------------------|---------| -| `tab` | `Tabs` / `Tab` (marker, no value) | — | +| `tab` | `Tabs` / `Tab` (marker, no value) | — (children store flat) | +| `section` | `Section` (marker, no value) | — (children store flat) | | `group` | `Repeater` (min/max 1) | `value_json` (object) | | `repeater` | `Repeater` | `value_json` (array) | | `flexible_content` | `Builder` with layout blocks | `value_json` (array with `type` + `data`) | Flexible content works like ACF **Flexible Content**: each row is a selectable layout with its own subfields. +`tab` and `section` are **visual-only** markers: they wrap the following fields but their children still store flat at the top level (unlike `group`, which nests values). + +### Field width (layout grid) + +Every field renders on a fixed **12-column grid**. Each field's `settings.width` fraction is translated into a Filament `columnSpan`, so the widths themselves define the columns — two `1/2` fields sit side by side, three `1/3` fields form a row. No separate "column count" setting is needed. + +| Width | columnSpan | Width | columnSpan | +|-------|-----------|-------|-----------| +| `full` (default) | 12 | `2/3` | 8 | +| `1/2` | 6 | `1/4` | 3 | +| `1/3` | 4 | `3/4` | 9 | + +Widths apply inside groups, tabs, sections, and repeaters. Defaulting to `full` keeps existing fields rendering exactly as before. Handled by `Moox\Builder\Support\FieldWidth`. + ### Capabilities (configurable per type) | Capability | Effect | @@ -758,7 +776,6 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc - Relational fields (post object, relationship, user, taxonomy) - Clone field type (ACF) - Location params beyond `entity` -- `placement` control (sidebar, …) - Conditional logic in forms **Intentionally out of scope:** diff --git a/packages/builder/database/migrations/create_builder_fields_table.php.stub b/packages/builder/database/migrations/create_builder_fields_table.php.stub index 9edc7d6b73..6ce2401ba0 100644 --- a/packages/builder/database/migrations/create_builder_fields_table.php.stub +++ b/packages/builder/database/migrations/create_builder_fields_table.php.stub @@ -21,7 +21,6 @@ return new class extends Migration $table->json('conditional_logic')->nullable(); $table->json('settings')->nullable(); $table->integer('sort')->default(0); - $table->string('width')->nullable(); $table->timestamps(); $table->unique(['field_group_id', 'name']); diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 29662fba56..514f967d99 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -17,6 +17,10 @@ 'active_helper' => 'Nur aktive Gruppen erscheinen in den Formularen.', 'sort' => 'Reihenfolge', 'sort_helper' => 'Niedrigere Werte werden weiter oben angezeigt.', + 'placement' => 'Platzierung', + 'placement_helper' => 'Wo die Section im Formular erscheint. Sidebar erfordert eine Resource mit Sidebar-Spalte.', + 'placement_main' => 'Hauptbereich', + 'placement_sidebar' => 'Sidebar', 'target_entities' => 'Anzeigen bei', 'target_entities_helper' => 'Diese Feldgruppe erscheint in den Formularen der ausgewählten Ressourcen. Leer lassen, um die Gruppe zu behalten, aber nirgends anzuzeigen.', 'target_entities_placeholder' => 'Ressource auswählen…', @@ -36,6 +40,14 @@ 'type' => 'Feldtyp', 'required' => 'Pflichtfeld', 'required_badge' => 'Pflicht', + 'width' => 'Breite', + 'width_helper' => 'Feldbreite im 12-Spalten-Raster. Felder nebeneinander platzieren.', + 'width_full' => 'Volle Breite', + 'width_half' => 'Hälfte (1/2)', + 'width_third' => 'Drittel (1/3)', + 'width_two_thirds' => 'Zwei Drittel (2/3)', + 'width_quarter' => 'Viertel (1/4)', + 'width_three_quarters' => 'Drei Viertel (3/4)', 'table_column' => 'Tabellen-Spalte', 'table_column_helper' => 'Dieses Feld als Spalte auf den Listen-Seiten anzeigen.', 'show_in_table' => 'In Tabelle anzeigen', @@ -108,6 +120,7 @@ 'message' => 'Hinweis', 'oembed' => 'oEmbed', 'tab' => 'Tab', + 'section' => 'Section', 'group' => 'Gruppe', 'repeater' => 'Repeater', 'flexible_content' => 'Flexible Inhalte', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index ee8fe964f3..edb1d313f8 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -17,6 +17,10 @@ 'active_helper' => 'Only active groups appear in forms.', 'sort' => 'Sort order', 'sort_helper' => 'Lower values appear higher in the form.', + 'placement' => 'Placement', + 'placement_helper' => 'Where the section appears in the form. Sidebar requires a resource with a sidebar column.', + 'placement_main' => 'Main area', + 'placement_sidebar' => 'Sidebar', 'target_entities' => 'Show on', 'target_entities_helper' => 'This field group appears in the forms of the selected resources. Leave empty to keep the group without showing it anywhere.', 'target_entities_placeholder' => 'Select a resource…', @@ -36,6 +40,14 @@ 'type' => 'Field type', 'required' => 'Required', 'required_badge' => 'Required', + 'width' => 'Width', + 'width_helper' => 'Field width on the 12-column grid. Place fields side by side.', + 'width_full' => 'Full width', + 'width_half' => 'Half (1/2)', + 'width_third' => 'Third (1/3)', + 'width_two_thirds' => 'Two thirds (2/3)', + 'width_quarter' => 'Quarter (1/4)', + 'width_three_quarters' => 'Three quarters (3/4)', 'table_column' => 'Table column', 'table_column_helper' => 'Show this field as a column on list pages.', 'show_in_table' => 'Show in table', @@ -108,6 +120,7 @@ 'message' => 'Message', 'oembed' => 'oEmbed', 'tab' => 'Tab', + 'section' => 'Section', 'group' => 'Group', 'repeater' => 'Repeater', 'flexible_content' => 'Flexible content', diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index 7cae9b1473..801b335223 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -27,6 +27,7 @@ use Moox\Builder\FieldTypes\Types\RangeFieldType; use Moox\Builder\FieldTypes\Types\RepeaterFieldType; use Moox\Builder\FieldTypes\Types\RichTextFieldType; +use Moox\Builder\FieldTypes\Types\SectionFieldType; use Moox\Builder\FieldTypes\Types\SelectFieldType; use Moox\Builder\FieldTypes\Types\TabFieldType; use Moox\Builder\FieldTypes\Types\TextareaFieldType; @@ -152,6 +153,7 @@ protected function defaultFieldTypes(): array new MessageFieldType, new OembedFieldType, new TabFieldType, + new SectionFieldType, new GroupFieldType, new RepeaterFieldType, new FlexibleContentFieldType, diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 89c356a09e..2e6d00a738 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -19,6 +19,7 @@ use Moox\Builder\FieldTypes\Types\GroupFieldType; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Services\CustomFieldsManager; +use Moox\Builder\Support\FieldWidth; use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Support\StorableFieldCollector; @@ -63,6 +64,7 @@ public function compile(Collection $fieldGroups, ?string $resourceClass = null): $groupScalarFields = $this->storableFieldCollector->definitionsFromList($group->fields); $section = Section::make($group->name) + ->columns(FieldWidth::GRID_COLUMNS) ->schema($components); if ($entity !== null) { @@ -94,6 +96,20 @@ public function compileSubFields(Collection $fields, ?string $entity = null, ?Co ->all(); } + /** + * Visual section wrapper. Children store flat (like tabs); their defaults are + * hydrated by the enclosing group/tab, so no own hydration is needed here. + */ + public function buildSectionComponent(FieldDefinition $field, ?string $entity = null, ?Collection $storableFields = null, bool $insideTabs = false): Component + { + $storableFields ??= collect(); + + return Section::make($field->label) + ->columns(FieldWidth::GRID_COLUMNS) + ->columnSpan($field->columnSpan()) + ->schema($this->compileSubFields($field->children, $entity, $storableFields, $insideTabs)); + } + public function buildGroupComponent(FieldDefinition $field, ?string $entity = null, ?Collection $storableFields = null): Component { $storableFields ??= collect(); @@ -101,8 +117,8 @@ public function buildGroupComponent(FieldDefinition $field, ?string $entity = nu $component = Fieldset::make($field->label) ->schema($this->compileSubFields($field->children, $entity, $storableFields)) ->statePath($field->name) - ->columns(1) - ->columnSpanFull(); + ->columns(FieldWidth::GRID_COLUMNS) + ->columnSpan($field->columnSpan()); if ($entity === null) { return $component; @@ -195,6 +211,7 @@ protected function compileRootFields(Collection $fields, ?string $entity, Collec $tabs = Tabs::make('custom_fields_tabs') ->tabs(collect($tabPanels)->map( fn (array $panel): Tab => Tab::make($panel['label']) + ->columns(FieldWidth::GRID_COLUMNS) ->schema($this->compileSubFields($panel['fields'], $entity, $storableFields, insideTabs: true)), )->all()); @@ -248,7 +265,7 @@ protected function rulesForFieldTree(FieldDefinition $field, string $prefix = '' return []; } - if ($field->type === 'tab') { + if (in_array($field->type, ['tab', 'section'], true)) { $rules = []; foreach ($field->children as $child) { @@ -331,12 +348,17 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, { $storableFields ??= collect(); + if ($field->type === 'section') { + return $this->buildSectionComponent($field, $entity, $storableFields, $insideTabs); + } + if ($field->type === 'group') { return $this->buildGroupComponent($field, $entity, $storableFields); } $fieldType = $this->fieldTypeRegistry->get($field->type); $component = $fieldType->formComponent($field); + $component->columnSpan($field->columnSpan()); if ($insideTabs && $fieldType->storesValue()) { $component->dehydratedWhenHidden(true); diff --git a/packages/builder/src/Concerns/HasCustomFields.php b/packages/builder/src/Concerns/HasCustomFields.php index c93ae81fd4..3e748232ea 100644 --- a/packages/builder/src/Concerns/HasCustomFields.php +++ b/packages/builder/src/Concerns/HasCustomFields.php @@ -16,6 +16,7 @@ use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\Data\LocationContext; use Moox\Builder\Registry\DefinitionRegistry; +use Moox\Builder\Support\FieldGroupPlacement; use Moox\Builder\Support\FieldVisibility; /** @@ -23,7 +24,8 @@ * * Usage in your resource form schema: * - * ...static::customFieldComponents(), + * ...static::customFieldComponents(), // main area (default) + * ...static::customFieldComponents('sidebar'), // sidebar slot * * Field groups are matched by entity key (model basename in kebab-case, e.g. * Item → item). Override with customFieldsEntity() when needed. Loading and @@ -57,11 +59,17 @@ public static function customFieldsRenderLocaleSwitcher(): bool } /** + * Compiled custom field sections for the given form placement. Defaults to + * the main area; pass FieldGroupPlacement::SIDEBAR (or 'sidebar') to render + * groups assigned to the sidebar in a dedicated slot. + * * @return list
*/ - public static function customFieldComponents(): array + public static function customFieldComponents(string $placement = FieldGroupPlacement::MAIN): array { - $groups = static::visibleCustomFieldGroups(); + $groups = static::visibleCustomFieldGroups() + ->filter(fn (FieldGroupDefinition $group): bool => $group->hasPlacement($placement)) + ->values(); if ($groups->isEmpty()) { return []; diff --git a/packages/builder/src/Data/FieldDefinition.php b/packages/builder/src/Data/FieldDefinition.php index b008de21f9..7b22ad74d9 100644 --- a/packages/builder/src/Data/FieldDefinition.php +++ b/packages/builder/src/Data/FieldDefinition.php @@ -7,6 +7,7 @@ use Illuminate\Support\Collection; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldOption; +use Moox\Builder\Support\FieldWidth; readonly class FieldDefinition { @@ -93,6 +94,22 @@ public function isVisibleIn(string $context): bool return (bool) ($this->settings["visible_{$context}"] ?? true); } + /** + * Layout width fraction (full, 1/2, 1/3, …). Defaults to full width. + */ + public function width(): string + { + return FieldWidth::normalize(is_string($this->settings['width'] ?? null) ? $this->settings['width'] : null); + } + + /** + * Filament columnSpan for this field on the 12-column layout grid. + */ + public function columnSpan(): int + { + return FieldWidth::columnSpan($this->width()); + } + /** * @param Collection $children */ diff --git a/packages/builder/src/Data/FieldGroupDefinition.php b/packages/builder/src/Data/FieldGroupDefinition.php index 44ebca6f3d..dfbfcde782 100644 --- a/packages/builder/src/Data/FieldGroupDefinition.php +++ b/packages/builder/src/Data/FieldGroupDefinition.php @@ -7,6 +7,7 @@ use Illuminate\Support\Collection; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; +use Moox\Builder\Support\FieldGroupPlacement; readonly class FieldGroupDefinition { @@ -35,6 +36,15 @@ public function isVisibleIn(string $context): bool return (bool) ($this->settings["visible_{$context}"] ?? true); } + /** + * Whether the group renders in the given form placement (main, sidebar). + * The legacy "default" placement is treated as main. + */ + public function hasPlacement(string $placement): bool + { + return FieldGroupPlacement::matches($this->placement, $placement); + } + /** * @param Collection $fields */ diff --git a/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php b/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php index c69736e5d0..93259d15ae 100644 --- a/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php +++ b/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php @@ -12,6 +12,7 @@ use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\FieldTypes\Capabilities\RepeaterItems; use Moox\Builder\FieldTypes\FieldType; +use Moox\Builder\Support\FieldWidth; class RepeaterFieldType extends FieldType { @@ -40,6 +41,7 @@ public function formComponent(FieldDefinition $field): Component $component = Repeater::make($field->name) ->label($field->label) ->schema($compiler->compileSubFields($field->children, null)) + ->columns(FieldWidth::GRID_COLUMNS) ->collapsible() ->defaultItems(0) ->reorderable() diff --git a/packages/builder/src/FieldTypes/Types/SectionFieldType.php b/packages/builder/src/FieldTypes/Types/SectionFieldType.php new file mode 100644 index 0000000000..c09bc0b02e --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/SectionFieldType.php @@ -0,0 +1,37 @@ +numeric() ->default(0) ->minValue(0), + Select::make('placement') + ->label(__('builder::builder.field_group.placement')) + ->helperText(__('builder::builder.field_group.placement_helper')) + ->options(static::placementOptions()) + ->default(FieldGroupPlacement::MAIN) + ->selectablePlaceholder(false) + ->native(false), ]), Section::make(__('builder::builder.field_group.assignment')) ->schema([ @@ -178,6 +187,7 @@ public static function form(Schema $schema): Schema ->inline(false) ->live() ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), + static::widthField(), ...static::columnSettingsSchema(), ...static::visibilitySettingsSchema(), ...static::optionFieldSections($registry), @@ -377,6 +387,7 @@ protected static function tabChildFieldSchema(FieldTypeRegistry $registry): arra ->inline(false) ->live() ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), + static::widthField(), ...static::columnSettingsSchema(), ...static::visibilitySettingsSchema(), ...static::optionFieldSections($registry), @@ -450,6 +461,7 @@ protected static function subFieldSchema(FieldTypeRegistry $registry): array ->inline(false) ->live() ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), + static::widthField(), ...static::optionFieldSections($registry), ]; } @@ -661,6 +673,48 @@ protected static function visibilityToggles(): array ]; } + /** + * @return array + */ + protected static function placementOptions(): array + { + return [ + FieldGroupPlacement::MAIN => __('builder::builder.field_group.placement_main'), + FieldGroupPlacement::SIDEBAR => __('builder::builder.field_group.placement_sidebar'), + ]; + } + + /** + * Layout width selector. Fields render on a 12-column grid, so widths define + * the columns (two 1/2 fields sit side by side). Not shown for tab markers. + */ + protected static function widthField(): Select + { + return Select::make('settings.width') + ->label(__('builder::builder.field.width')) + ->helperText(__('builder::builder.field.width_helper')) + ->options(static::widthOptions()) + ->default(FieldWidth::FULL) + ->selectablePlaceholder(false) + ->native(false) + ->visible(fn (callable $get): bool => $get('type') !== 'tab'); + } + + /** + * @return array + */ + protected static function widthOptions(): array + { + return [ + FieldWidth::FULL => __('builder::builder.field.width_full'), + '1/2' => __('builder::builder.field.width_half'), + '1/3' => __('builder::builder.field.width_third'), + '2/3' => __('builder::builder.field.width_two_thirds'), + '1/4' => __('builder::builder.field.width_quarter'), + '3/4' => __('builder::builder.field.width_three_quarters'), + ]; + } + /** * @return array */ diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php index 5897e3ccbe..cb6347440a 100644 --- a/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php @@ -10,6 +10,7 @@ use Moox\Builder\Resources\FieldGroupResource; use Moox\Builder\Resources\FieldGroupResource\Pages\Concerns\InteractsWithFieldGroupLocale; use Moox\Builder\Services\FieldGroupPersistence; +use Moox\Builder\Support\FieldGroupPlacement; class EditFieldGroup extends EditRecord { @@ -53,6 +54,7 @@ protected function mutateFormDataBeforeFill(array $data): array $persistence = app(FieldGroupPersistence::class); $data['name'] = $persistence->localizedGroupName($record, $this->lang); + $data['placement'] = FieldGroupPlacement::normalize($record->placement); $data['location_rules'] = $persistence->flattenLocationRulesForForm( $record->location_rules ?? [], ); diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 5047c207e3..ad696d63ab 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -215,6 +215,7 @@ protected function mapFieldRows(Collection $fields, ?string $locale = null): arr 'visible_admin' => (bool) ($field->settings['visible_admin'] ?? true), 'visible_frontend' => (bool) ($field->settings['visible_frontend'] ?? true), 'visible_api' => (bool) ($field->settings['visible_api'] ?? true), + 'width' => $field->settings['width'] ?? null, ], 'sort' => $field->sort, 'options' => $field->options->map(fn (FieldOption $option): array => [ @@ -315,6 +316,7 @@ protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldI 'visible_admin' => (bool) ($row['settings']['visible_admin'] ?? true), 'visible_frontend' => (bool) ($row['settings']['visible_frontend'] ?? true), 'visible_api' => (bool) ($row['settings']['visible_api'] ?? true), + 'width' => filled($row['settings']['width'] ?? null) ? (string) $row['settings']['width'] : null, ]), 'sort' => $index, ]); diff --git a/packages/builder/src/Support/FieldGroupPlacement.php b/packages/builder/src/Support/FieldGroupPlacement.php new file mode 100644 index 0000000000..bb5a722004 --- /dev/null +++ b/packages/builder/src/Support/FieldGroupPlacement.php @@ -0,0 +1,33 @@ + */ + public const PLACEMENTS = [self::MAIN, self::SIDEBAR]; + + public static function normalize(?string $placement): string + { + return in_array($placement, self::PLACEMENTS, true) ? $placement : self::MAIN; + } + + public static function matches(?string $stored, string $placement): bool + { + return self::normalize($stored) === self::normalize($placement); + } +} diff --git a/packages/builder/src/Support/FieldWidth.php b/packages/builder/src/Support/FieldWidth.php new file mode 100644 index 0000000000..445d847fe9 --- /dev/null +++ b/packages/builder/src/Support/FieldWidth.php @@ -0,0 +1,49 @@ + */ + public const SPANS = [ + self::FULL => 12, + '1/2' => 6, + '1/3' => 4, + '2/3' => 8, + '1/4' => 3, + '3/4' => 9, + ]; + + public static function normalize(?string $width): string + { + return isset(self::SPANS[$width]) ? $width : self::FULL; + } + + public static function columnSpan(?string $width): int + { + return self::SPANS[self::normalize($width)]; + } + + /** + * @return list + */ + public static function values(): array + { + return array_keys(self::SPANS); + } +} diff --git a/packages/builder/src/Support/StorableFieldCollector.php b/packages/builder/src/Support/StorableFieldCollector.php index e5f16f30c9..acaa2ca601 100644 --- a/packages/builder/src/Support/StorableFieldCollector.php +++ b/packages/builder/src/Support/StorableFieldCollector.php @@ -28,7 +28,7 @@ public function definitionsFromList(Collection $fields): Collection */ public function definitionsFor(FieldDefinition $field): Collection { - if ($field->type === 'tab') { + if (in_array($field->type, ['tab', 'section'], true)) { return $this->definitionsFromList($field->children); } @@ -79,7 +79,7 @@ public function pathsFromRows(array $rows, string $prefix = 'fields'): array $path = "{$prefix}.{$index}.name"; - if ($type === 'tab') { + if (in_array($type, ['tab', 'section'], true)) { $entries = array_merge( $entries, $this->pathsFromRows($row['children'] ?? [], "{$prefix}.{$index}.children"), diff --git a/packages/core/src/Entities/BaseResource.php b/packages/core/src/Entities/BaseResource.php index b49fee8661..e1131a6031 100644 --- a/packages/core/src/Entities/BaseResource.php +++ b/packages/core/src/Entities/BaseResource.php @@ -36,7 +36,7 @@ abstract class BaseResource extends Resource * * @return array */ - public static function customFieldComponents(): array + public static function customFieldComponents(string $placement = 'main'): array { return []; } diff --git a/packages/draft/src/Resources/DraftResource.php b/packages/draft/src/Resources/DraftResource.php index ea6129b128..f0cfe32020 100644 --- a/packages/draft/src/Resources/DraftResource.php +++ b/packages/draft/src/Resources/DraftResource.php @@ -137,6 +137,7 @@ public static function form(Schema $form): Schema ColorPicker::make('color') ->label(__('core::core.color')), ]), + ...static::customFieldComponents('sidebar'), Section::make('') ->schema([ ...static::getStandardCopyableFields(), diff --git a/packages/item/src/Resources/ItemResource.php b/packages/item/src/Resources/ItemResource.php index 9a78668e66..273297c4ca 100644 --- a/packages/item/src/Resources/ItemResource.php +++ b/packages/item/src/Resources/ItemResource.php @@ -76,6 +76,7 @@ public static function form(Schema $form): Schema ->schema([ static::getFormActions(), ]), + ...static::customFieldComponents('sidebar'), Section::make('') ->schema([ ...static::getStandardTimestampFields(), diff --git a/packages/record/src/Resources/RecordResource.php b/packages/record/src/Resources/RecordResource.php index 1ef9b51866..6ff935c05c 100644 --- a/packages/record/src/Resources/RecordResource.php +++ b/packages/record/src/Resources/RecordResource.php @@ -106,6 +106,7 @@ public static function form(Schema $form): Schema ->schema([ static::getAuthorSelect(), ]), + ...static::customFieldComponents('sidebar'), Section::make('') ->schema([ ...static::getStandardCopyableFields(), From 59e1d23817cd5ce3374e670debcd7bfd5e551456 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:44:03 +0200 Subject: [PATCH 061/105] Improve field builder UX in FieldGroupResource --- packages/builder/resources/css/builder.css | 71 ++++++++++ .../builder/resources/lang/de/builder.php | 4 + .../builder/resources/lang/en/builder.php | 4 + .../builder/src/BuilderServiceProvider.php | 6 + packages/builder/src/FieldTypes/FieldType.php | 38 ++++++ .../src/Resources/FieldGroupResource.php | 126 +++++++++++++----- 6 files changed, 215 insertions(+), 34 deletions(-) create mode 100644 packages/builder/resources/css/builder.css diff --git a/packages/builder/resources/css/builder.css b/packages/builder/resources/css/builder.css new file mode 100644 index 0000000000..e5409e7e6f --- /dev/null +++ b/packages/builder/resources/css/builder.css @@ -0,0 +1,71 @@ +/* + * Builder field group admin — visual separation of field repeater items. + * Scoped via the `moox-builder-fields` class on the top-level fields repeater, + * so each field reads as a distinct card with a clear starting edge. + */ + +.moox-builder-fields > .fi-fo-repeater-items { + gap: 1.25rem; +} + +.moox-builder-fields > .fi-fo-repeater-items > .fi-fo-repeater-item { + border-inline-start-width: 4px; + border-inline-start-color: var(--primary-500); + box-shadow: + 0 1px 2px 0 rgb(0 0 0 / 0.05), + 0 1px 3px 0 rgb(0 0 0 / 0.05); +} + +.moox-builder-fields > .fi-fo-repeater-items > .fi-fo-repeater-item > .fi-fo-repeater-item-header { + background-color: var(--gray-50); +} + +.dark .moox-builder-fields > .fi-fo-repeater-items > .fi-fo-repeater-item > .fi-fo-repeater-item-header { + background-color: color-mix(in oklab, var(--gray-950) 60%, transparent); +} + +/* + * Item header label — type icon in a tinted circle acts as a visual anchor, + * followed by the field title and muted meta (type · name · required …). + */ +.moox-builder-field-item { + display: inline-flex; + align-items: center; + gap: 0.625rem; + min-width: 0; +} + +.moox-builder-field-item__badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 9999px; + flex-shrink: 0; + background-color: color-mix(in oklab, var(--primary-500) 15%, transparent); + color: var(--primary-600); +} + +.moox-builder-field-item__icon { + width: 1rem; + height: 1rem; +} + +.moox-builder-field-item__title { + font-weight: 600; +} + +.moox-builder-field-item__meta { + color: var(--gray-500); + font-size: 0.875rem; + font-weight: 400; +} + +.dark .moox-builder-field-item__badge { + color: var(--primary-400); +} + +.dark .moox-builder-field-item__meta { + color: var(--gray-400); +} diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 514f967d99..cc7d317a92 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -80,13 +80,17 @@ 'visibility_helper' => 'Dieses Feld je Kontext ausblenden, ohne es zu löschen.', 'subfields_count' => '{1} :count Unterfeld|[2,*] :count Unterfelder', 'settings' => 'Einstellungen', + 'settings_helper' => 'Typspezifische Optionen wie Standardwerte, Grenzen und Formate.', 'options' => 'Auswahloptionen', + 'options_helper' => 'Auswählbare Werte für dieses Feld.', 'option_label' => 'Anzeigetext', 'option_value' => 'Wert', 'subfields' => 'Unterfelder', + 'subfields_helper' => 'Felder, die in diesem Feld verschachtelt sind.', 'tab_content' => 'Tab-Inhalt', 'tab_content_helper' => 'Diese Felder erscheinen im Tab. Die Bezeichnung des Felds wird als Tab-Titel verwendet.', 'layouts' => 'Layouts', + 'layouts_helper' => 'Wiederverwendbare Block-Layouts für flexible Inhalte.', 'layout_label' => 'Layout-Bezeichnung', 'layout_key' => 'Layout-Schlüssel', 'layout_key_helper' => 'Technischer Schlüssel für gespeicherte Blöcke. Nur Kleinbuchstaben, Zahlen und Bindestriche.', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index edb1d313f8..4614fc0748 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -80,13 +80,17 @@ 'visibility_helper' => 'Hide this field per context without deleting it.', 'subfields_count' => '{1} :count subfield|[2,*] :count subfields', 'settings' => 'Settings', + 'settings_helper' => 'Type-specific options like defaults, limits, and formats.', 'options' => 'Options', + 'options_helper' => 'Selectable choices for this field.', 'option_label' => 'Display text', 'option_value' => 'Value', 'subfields' => 'Subfields', + 'subfields_helper' => 'Fields nested inside this field.', 'tab_content' => 'Tab content', 'tab_content_helper' => 'These fields appear inside the tab. The field label is used as the tab title.', 'layouts' => 'Layouts', + 'layouts_helper' => 'Reusable block layouts for flexible content.', 'layout_label' => 'Layout label', 'layout_key' => 'Layout key', 'layout_key_helper' => 'Technical identifier used when storing blocks. Lowercase letters, numbers, and hyphens only.', diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index 801b335223..93d655e6b5 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -5,6 +5,8 @@ namespace Moox\Builder; use Filament\Resources\Events\RecordSaved; +use Filament\Support\Assets\Css; +use Filament\Support\Facades\FilamentAsset; use Illuminate\Support\Facades\Event; use Livewire\Livewire; use Moox\Builder\FieldTypes\FieldType; @@ -104,6 +106,10 @@ public function packageBooted(): void Event::listen(RecordSaved::class, PersistCustomFields::class); + FilamentAsset::register([ + Css::make('moox-builder', __DIR__.'/../resources/css/builder.css'), + ], 'moox/builder'); + CustomFieldsFilamentHooks::register(); $this->app['router']->pushMiddlewareToGroup('web', ResolveBuilderAdminLocale::class); diff --git a/packages/builder/src/FieldTypes/FieldType.php b/packages/builder/src/FieldTypes/FieldType.php index df8a682364..35c6c91251 100644 --- a/packages/builder/src/FieldTypes/FieldType.php +++ b/packages/builder/src/FieldTypes/FieldType.php @@ -119,6 +119,44 @@ public function label(): string return __($key) !== $key ? __($key) : ucfirst(str_replace('_', ' ', static::key())); } + /** + * Heroicon representing this field type in builder UIs (e.g. repeater headers). + */ + public function icon(): string + { + return match (static::key()) { + 'text' => 'heroicon-o-bars-3-bottom-left', + 'textarea' => 'heroicon-o-bars-3', + 'rich_text' => 'heroicon-o-document-text', + 'email' => 'heroicon-o-envelope', + 'password' => 'heroicon-o-key', + 'number' => 'heroicon-o-hashtag', + 'range' => 'heroicon-o-adjustments-horizontal', + 'url', 'link' => 'heroicon-o-link', + 'color' => 'heroicon-o-swatch', + 'date' => 'heroicon-o-calendar', + 'datetime' => 'heroicon-o-calendar-days', + 'time' => 'heroicon-o-clock', + 'toggle' => 'heroicon-o-check-circle', + 'select' => 'heroicon-o-chevron-up-down', + 'multiselect' => 'heroicon-o-queue-list', + 'radio' => 'heroicon-o-list-bullet', + 'checkbox_list' => 'heroicon-o-clipboard-document-check', + 'button_group' => 'heroicon-o-rectangle-group', + 'file' => 'heroicon-o-paper-clip', + 'image' => 'heroicon-o-photo', + 'gallery' => 'heroicon-o-rectangle-stack', + 'message' => 'heroicon-o-chat-bubble-left-right', + 'oembed' => 'heroicon-o-film', + 'repeater' => 'heroicon-o-queue-list', + 'group' => 'heroicon-o-square-2-stack', + 'tab' => 'heroicon-o-folder', + 'section' => 'heroicon-o-view-columns', + 'flexible_content', 'flexible_layout' => 'heroicon-o-squares-plus', + default => 'heroicon-o-cube', + }; + } + protected function applyCapabilitiesAndValidation(Component $component, FieldDefinition $field): Component { $component = Capability::applyAll($this->capabilities(), $component, $field); diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 43f90431f1..2ce270e9e3 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -22,6 +22,7 @@ use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\HtmlString; use Illuminate\Support\Str; use Moox\Builder\Exceptions\UnknownFieldTypeException; use Moox\Builder\Models\FieldGroup; @@ -143,6 +144,7 @@ public static function form(Schema $schema): Schema ->schema([ Repeater::make('fields') ->hiddenLabel() + ->extraAttributes(['class' => 'moox-builder-fields']) ->orderColumn('sort') ->reorderable() ->collapsible() @@ -150,7 +152,7 @@ public static function form(Schema $schema): Schema ->cloneable() ->collapseAllAction(fn (Action $action): Action => $action->hidden()) ->expandAllAction(fn (Action $action): Action => $action->hidden()) - ->itemLabel(fn (array $state): string => static::fieldRepeaterItemLabel($registry, $state)) + ->itemLabel(fn (array $state): HtmlString => static::fieldRepeaterItemLabel($registry, $state)) ->schema([ Hidden::make('id'), Hidden::make('sort'), @@ -182,21 +184,19 @@ public static function form(Schema $schema): Schema ->maxLength(255) ->regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/') ->live(onBlur: true), - Toggle::make('required') - ->label(__('builder::builder.field.required')) - ->inline(false) - ->live() - ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), - static::widthField(), + static::requirementAndWidthRow(), ...static::columnSettingsSchema(), ...static::visibilitySettingsSchema(), ...static::optionFieldSections($registry), Section::make(fn (callable $get): string => $get('type') === 'tab' ? __('builder::builder.field.tab_content') : __('builder::builder.field.subfields')) - ->description(fn (callable $get): ?string => $get('type') === 'tab' + ->description(fn (callable $get): string => $get('type') === 'tab' ? __('builder::builder.field.tab_content_helper') - : null) + : __('builder::builder.field.subfields_helper')) + ->icon(fn (callable $get): Heroicon => $get('type') === 'tab' + ? Heroicon::OutlinedFolder + : Heroicon::OutlinedSquares2x2) ->collapsed() ->schema([ Repeater::make('children') @@ -205,12 +205,14 @@ public static function form(Schema $schema): Schema ->reorderable() ->collapsible() ->collapsed() - ->itemLabel(fn (array $state): string => static::fieldRepeaterItemLabel($registry, $state)) + ->itemLabel(fn (array $state): HtmlString => static::fieldRepeaterItemLabel($registry, $state)) ->schema(static::tabChildFieldSchema($registry)) ->defaultItems(0), ]) ->visible(fn (callable $get): bool => filled($get('type')) && $registry->get($get('type'))->hasSubFields() && $get('type') !== 'flexible_content'), Section::make(__('builder::builder.field.layouts')) + ->description(__('builder::builder.field.layouts_helper')) + ->icon(Heroicon::OutlinedSquaresPlus) ->collapsed() ->schema([ Repeater::make('layouts') @@ -235,41 +237,80 @@ public static function form(Schema $schema): Schema /** * @param array $state */ - protected static function fieldRepeaterItemLabel(FieldTypeRegistry $registry, array $state): string + protected static function fieldRepeaterItemLabel(FieldTypeRegistry $registry, array $state): HtmlString { - $parts = []; + $type = filled($state['type'] ?? null) ? (string) $state['type'] : null; - $parts[] = filled($state['label'] ?? null) + $title = filled($state['label'] ?? null) ? (string) $state['label'] : __('builder::builder.field_group.field_item'); - if (filled($state['type'] ?? null)) { - $parts[] = static::fieldTypeLabel($registry, (string) $state['type']); + $meta = []; + + if ($type !== null) { + $meta[] = static::fieldTypeLabel($registry, $type); } if (filled($state['name'] ?? null)) { - $parts[] = (string) $state['name']; + $meta[] = (string) $state['name']; } if (($state['required'] ?? false) === true) { - $parts[] = __('builder::builder.field.required_badge'); + $meta[] = __('builder::builder.field.required_badge'); } $childrenCount = count($state['children'] ?? []); if ($childrenCount > 0) { - $parts[] = trans_choice('builder::builder.field.subfields_count', $childrenCount, [ + $meta[] = trans_choice('builder::builder.field.subfields_count', $childrenCount, [ 'count' => $childrenCount, ]); } $layoutsCount = count($state['layouts'] ?? []); if ($layoutsCount > 0) { - $parts[] = trans_choice('builder::builder.field.layouts_count', $layoutsCount, [ + $meta[] = trans_choice('builder::builder.field.layouts_count', $layoutsCount, [ 'count' => $layoutsCount, ]); } - return implode(' · ', $parts); + return static::fieldItemLabelHtml($registry, $type, $title, $meta); + } + + /** + * Renders the repeater item header as an icon badge + title + muted meta, + * giving each field a strong visual anchor when several are expanded. + * + * @param list $meta + */ + protected static function fieldItemLabelHtml(FieldTypeRegistry $registry, ?string $type, string $title, array $meta): HtmlString + { + $icon = static::fieldTypeIcon($registry, $type); + $iconSvg = svg($icon, 'moox-builder-field-item__icon')->toHtml(); + + $metaHtml = $meta === [] + ? '' + : ''.e(implode(' · ', $meta)).''; + + return new HtmlString( + '' + .''.$iconSvg.'' + .''.e($title).'' + .$metaHtml + .'' + ); + } + + protected static function fieldTypeIcon(FieldTypeRegistry $registry, ?string $type): string + { + if (blank($type)) { + return 'heroicon-o-cube'; + } + + try { + return $registry->get($type)->icon(); + } catch (UnknownFieldTypeException) { + return 'heroicon-o-cube'; + } } /** @@ -340,7 +381,7 @@ protected static function layoutSchema(FieldTypeRegistry $registry): array ->reorderable() ->collapsible() ->collapsed() - ->itemLabel(fn (array $state): string => static::fieldRepeaterItemLabel($registry, $state)) + ->itemLabel(fn (array $state): HtmlString => static::fieldRepeaterItemLabel($registry, $state)) ->schema(static::subFieldSchema($registry)) ->defaultItems(0), ]; @@ -382,16 +423,13 @@ protected static function tabChildFieldSchema(FieldTypeRegistry $registry): arra ->maxLength(255) ->regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/') ->live(onBlur: true), - Toggle::make('required') - ->label(__('builder::builder.field.required')) - ->inline(false) - ->live() - ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), - static::widthField(), + static::requirementAndWidthRow(), ...static::columnSettingsSchema(), ...static::visibilitySettingsSchema(), ...static::optionFieldSections($registry), Section::make(__('builder::builder.field.subfields')) + ->description(__('builder::builder.field.subfields_helper')) + ->icon(Heroicon::OutlinedSquares2x2) ->collapsed() ->schema([ Repeater::make('children') @@ -400,12 +438,14 @@ protected static function tabChildFieldSchema(FieldTypeRegistry $registry): arra ->reorderable() ->collapsible() ->collapsed() - ->itemLabel(fn (array $state): string => static::fieldRepeaterItemLabel($registry, $state)) + ->itemLabel(fn (array $state): HtmlString => static::fieldRepeaterItemLabel($registry, $state)) ->schema(static::subFieldSchema($registry)) ->defaultItems(0), ]) ->visible(fn (callable $get): bool => filled($get('type')) && $registry->get($get('type'))->hasSubFields() && $get('type') !== 'flexible_content'), Section::make(__('builder::builder.field.layouts')) + ->description(__('builder::builder.field.layouts_helper')) + ->icon(Heroicon::OutlinedSquaresPlus) ->collapsed() ->schema([ Repeater::make('layouts') @@ -456,12 +496,7 @@ protected static function subFieldSchema(FieldTypeRegistry $registry): array ->maxLength(255) ->regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/') ->live(onBlur: true), - Toggle::make('required') - ->label(__('builder::builder.field.required')) - ->inline(false) - ->live() - ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), - static::widthField(), + static::requirementAndWidthRow(), ...static::optionFieldSections($registry), ]; } @@ -565,6 +600,7 @@ protected static function columnSettingsSchema(): array return [ Section::make(__('builder::builder.field.table_column')) ->description(__('builder::builder.field.table_column_helper')) + ->icon(Heroicon::OutlinedTableCells) ->collapsible() ->collapsed() ->visible(fn (callable $get): bool => static::fieldTypeSupportsAnyColumn($get('type'))) @@ -642,6 +678,7 @@ protected static function visibilitySettingsSchema(): array return [ Section::make(__('builder::builder.field.visibility')) ->description(__('builder::builder.field.visibility_helper')) + ->icon(Heroicon::OutlinedEye) ->collapsible() ->collapsed() ->schema(static::visibilityToggles()), @@ -684,6 +721,23 @@ protected static function placementOptions(): array ]; } + /** + * "Required" toggle and width selector share one row to keep the field + * basics compact. Either control hides itself when it does not apply. + */ + protected static function requirementAndWidthRow(): Grid + { + return Grid::make(2) + ->schema([ + Toggle::make('required') + ->label(__('builder::builder.field.required')) + ->inline(false) + ->live() + ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))), + static::widthField(), + ]); + } + /** * Layout width selector. Fields render on a 12-column grid, so widths define * the columns (two 1/2 fields sit side by side). Not shown for tab markers. @@ -745,10 +799,14 @@ protected static function optionFieldSections(FieldTypeRegistry $registry): arra { return [ Section::make(__('builder::builder.field.settings')) + ->description(__('builder::builder.field.settings_helper')) + ->icon(Heroicon::OutlinedCog6Tooth) ->collapsible() ->schema(fn (callable $get): array => static::reactiveTypeSettingsSchema($get)) ->visible(fn (callable $get): bool => static::typeHasSettings($get('type'))), Section::make(__('builder::builder.field.options')) + ->description(__('builder::builder.field.options_helper')) + ->icon(Heroicon::OutlinedListBullet) ->collapsed() ->schema([ Repeater::make('options') From 400888b0bf023438e4f573c4188569854793559f Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:54:27 +0200 Subject: [PATCH 062/105] Add per-group column layout for field groups --- ...ale_to_builder_field_values_table.php.stub | 137 ------------------ .../builder/resources/lang/de/builder.php | 6 +- .../builder/resources/lang/en/builder.php | 6 +- .../builder/src/BuilderServiceProvider.php | 1 - .../builder/src/Compiler/SchemaCompiler.php | 15 +- packages/builder/src/Data/FieldDefinition.php | 19 ++- .../builder/src/Data/FieldGroupDefinition.php | 19 +++ .../src/Resources/FieldGroupResource.php | 31 +++- packages/builder/src/Support/FieldWidth.php | 50 ++++++- 9 files changed, 126 insertions(+), 158 deletions(-) delete mode 100644 packages/builder/database/migrations/add_locale_to_builder_field_values_table.php.stub diff --git a/packages/builder/database/migrations/add_locale_to_builder_field_values_table.php.stub b/packages/builder/database/migrations/add_locale_to_builder_field_values_table.php.stub deleted file mode 100644 index 3e22c021cb..0000000000 --- a/packages/builder/database/migrations/add_locale_to_builder_field_values_table.php.stub +++ /dev/null @@ -1,137 +0,0 @@ -defaultLocale(); - - if (! Schema::hasColumn('builder_field_values', 'locale')) { - Schema::table('builder_field_values', function (Blueprint $table): void { - $table->string('locale')->default('en_US')->after('field_name'); - }); - - DB::table('builder_field_values')->update(['locale' => $defaultLocale]); - - Schema::table('builder_field_values', function (Blueprint $table): void { - $table->dropUnique(['entity', 'record_id', 'field_name']); - $table->unique(['entity', 'record_id', 'field_name', 'locale']); - $table->index(['entity', 'record_id', 'locale']); - }); - } - - $this->seedDefinitionTranslations($defaultLocale); - } - - public function down(): void - { - if (! Schema::hasColumn('builder_field_values', 'locale')) { - return; - } - - $defaultLocale = $this->defaultLocale(); - - DB::table('builder_field_values') - ->where('locale', '!=', $defaultLocale) - ->delete(); - - Schema::table('builder_field_values', function (Blueprint $table): void { - $table->dropUnique(['entity', 'record_id', 'field_name', 'locale']); - $table->dropIndex(['entity', 'record_id', 'locale']); - $table->unique(['entity', 'record_id', 'field_name']); - $table->dropColumn('locale'); - }); - - Schema::dropIfExists('builder_field_option_translations'); - Schema::dropIfExists('builder_field_translations'); - Schema::dropIfExists('builder_field_group_translations'); - } - - protected function defaultLocale(): string - { - if ( - class_exists(\Moox\Localization\Models\Localization::class) - && Schema::hasTable('localizations') - ) { - $localization = \Moox\Localization\Models\Localization::query() - ->where('is_default', true) - ->first(); - - if ($localization !== null && filled($localization->getAttribute('locale_variant'))) { - return (string) $localization->getAttribute('locale_variant'); - } - } - - return (string) config('builder.default_locale', config('app.locale', 'en_US')); - } - - protected function seedDefinitionTranslations(string $defaultLocale): void - { - if (! Schema::hasTable('builder_field_group_translations')) { - return; - } - - $translatableConfigKeys = [ - 'helperText', - 'placeholder', - 'prefix', - 'suffix', - 'message', - ]; - - foreach (DB::table('builder_field_groups')->get() as $group) { - DB::table('builder_field_group_translations')->updateOrInsert( - [ - 'field_group_id' => $group->id, - 'locale' => $defaultLocale, - ], - [ - 'name' => $group->name, - 'created_at' => now(), - 'updated_at' => now(), - ], - ); - } - - foreach (DB::table('builder_fields')->get() as $field) { - $config = json_decode((string) ($field->config ?? ''), true); - $translatableConfig = is_array($config) - ? array_intersect_key($config, array_flip($translatableConfigKeys)) - : []; - - DB::table('builder_field_translations')->updateOrInsert( - [ - 'field_id' => $field->id, - 'locale' => $defaultLocale, - ], - [ - 'label' => $field->label, - 'config' => $translatableConfig === [] ? null : json_encode($translatableConfig), - 'created_at' => now(), - 'updated_at' => now(), - ], - ); - } - - foreach (DB::table('builder_field_options')->get() as $option) { - DB::table('builder_field_option_translations')->updateOrInsert( - [ - 'field_option_id' => $option->id, - 'locale' => $defaultLocale, - ], - [ - 'label' => $option->label, - 'created_at' => now(), - 'updated_at' => now(), - ], - ); - } - } -}; diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index cc7d317a92..11ff0b897f 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -21,6 +21,9 @@ 'placement_helper' => 'Wo die Section im Formular erscheint. Sidebar erfordert eine Resource mit Sidebar-Spalte.', 'placement_main' => 'Hauptbereich', 'placement_sidebar' => 'Sidebar', + 'columns' => 'Spalten', + 'columns_helper' => 'In wie viele Spalten die Felder fließen. Felder mit Breite „Automatisch" verteilen sich gleichmäßig darauf.', + 'columns_option' => '{1} Eine Spalte|[2,*] :count Spalten', 'target_entities' => 'Anzeigen bei', 'target_entities_helper' => 'Diese Feldgruppe erscheint in den Formularen der ausgewählten Ressourcen. Leer lassen, um die Gruppe zu behalten, aber nirgends anzuzeigen.', 'target_entities_placeholder' => 'Ressource auswählen…', @@ -41,7 +44,8 @@ 'required' => 'Pflichtfeld', 'required_badge' => 'Pflicht', 'width' => 'Breite', - 'width_helper' => 'Feldbreite im 12-Spalten-Raster. Felder nebeneinander platzieren.', + 'width_helper' => 'Feldbreite. „Automatisch" folgt dem Spalten-Layout der Gruppe; ein Bruch fixiert eine feste Breite.', + 'width_auto' => 'Automatisch (folgt Gruppe)', 'width_full' => 'Volle Breite', 'width_half' => 'Hälfte (1/2)', 'width_third' => 'Drittel (1/3)', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 4614fc0748..45205d10fc 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -21,6 +21,9 @@ 'placement_helper' => 'Where the section appears in the form. Sidebar requires a resource with a sidebar column.', 'placement_main' => 'Main area', 'placement_sidebar' => 'Sidebar', + 'columns' => 'Columns', + 'columns_helper' => 'How many columns the fields flow into. Fields with width "Automatic" split evenly across them.', + 'columns_option' => '{1} Single column|[2,*] :count columns', 'target_entities' => 'Show on', 'target_entities_helper' => 'This field group appears in the forms of the selected resources. Leave empty to keep the group without showing it anywhere.', 'target_entities_placeholder' => 'Select a resource…', @@ -41,7 +44,8 @@ 'required' => 'Required', 'required_badge' => 'Required', 'width' => 'Width', - 'width_helper' => 'Field width on the 12-column grid. Place fields side by side.', + 'width_helper' => 'Field width. "Automatic" follows the group column layout; a fraction pins a fixed width.', + 'width_auto' => 'Automatic (follows group)', 'width_full' => 'Full width', 'width_half' => 'Half (1/2)', 'width_third' => 'Third (1/3)', diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index 93d655e6b5..0b25c1290f 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -74,7 +74,6 @@ public function configureMoox(Package $package): void 'create_builder_field_group_translations_table', 'create_builder_field_translations_table', 'create_builder_field_option_translations_table', - 'add_locale_to_builder_field_values_table', ]); } diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 2e6d00a738..45e25a4698 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -55,6 +55,7 @@ public function compile(Collection $fieldGroups, ?string $resourceClass = null): $group->fields->sortBy(fn (FieldDefinition $field): int => $field->sort)->values(), $entity, $storableFields, + $group->defaultColumnSpan(), ); if ($components === []) { @@ -85,14 +86,14 @@ function (Component $component, mixed $state, ?Model $record) use ($groupScalarF * @param Collection $fields * @return list */ - public function compileSubFields(Collection $fields, ?string $entity = null, ?Collection $storableFields = null, bool $insideTabs = false): array + public function compileSubFields(Collection $fields, ?string $entity = null, ?Collection $storableFields = null, bool $insideTabs = false, ?int $defaultSpan = null): array { $storableFields ??= collect(); return $fields ->sortBy(fn (FieldDefinition $field): int => $field->sort) ->values() - ->map(fn (FieldDefinition $field): Component => $this->compileField($field, $entity, $storableFields, $insideTabs)) + ->map(fn (FieldDefinition $field): Component => $this->compileField($field, $entity, $storableFields, $insideTabs, $defaultSpan)) ->all(); } @@ -178,7 +179,7 @@ public function buildGroupComponent(FieldDefinition $field, ?string $entity = nu * @param Collection $storableFields * @return list */ - protected function compileRootFields(Collection $fields, ?string $entity, Collection $storableFields): array + protected function compileRootFields(Collection $fields, ?string $entity, Collection $storableFields, int $defaultSpan = FieldWidth::GRID_COLUMNS): array { $components = []; $sorted = $fields->sortBy(fn (FieldDefinition $field): int => $field->sort)->values(); @@ -212,7 +213,7 @@ protected function compileRootFields(Collection $fields, ?string $entity, Collec ->tabs(collect($tabPanels)->map( fn (array $panel): Tab => Tab::make($panel['label']) ->columns(FieldWidth::GRID_COLUMNS) - ->schema($this->compileSubFields($panel['fields'], $entity, $storableFields, insideTabs: true)), + ->schema($this->compileSubFields($panel['fields'], $entity, $storableFields, insideTabs: true, defaultSpan: $defaultSpan)), )->all()); if ($entity !== null) { @@ -229,7 +230,7 @@ function (Component $component, mixed $state, ?Model $record) use ($tabScalarFie continue; } - $components[] = $this->compileField($field, $entity, $storableFields); + $components[] = $this->compileField($field, $entity, $storableFields, defaultSpan: $defaultSpan); $index++; } @@ -344,7 +345,7 @@ protected function rulesForField(FieldDefinition $field): array /** * @param Collection $storableFields */ - protected function compileField(FieldDefinition $field, ?string $entity = null, ?Collection $storableFields = null, bool $insideTabs = false): Component + protected function compileField(FieldDefinition $field, ?string $entity = null, ?Collection $storableFields = null, bool $insideTabs = false, ?int $defaultSpan = null): Component { $storableFields ??= collect(); @@ -358,7 +359,7 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, $fieldType = $this->fieldTypeRegistry->get($field->type); $component = $fieldType->formComponent($field); - $component->columnSpan($field->columnSpan()); + $component->columnSpan($field->columnSpan($defaultSpan)); if ($insideTabs && $fieldType->storesValue()) { $component->dehydratedWhenHidden(true); diff --git a/packages/builder/src/Data/FieldDefinition.php b/packages/builder/src/Data/FieldDefinition.php index 7b22ad74d9..9a8c4ee06e 100644 --- a/packages/builder/src/Data/FieldDefinition.php +++ b/packages/builder/src/Data/FieldDefinition.php @@ -103,10 +103,25 @@ public function width(): string } /** - * Filament columnSpan for this field on the 12-column layout grid. + * Whether the field pins its own width instead of inheriting the group's + * column layout. False for the "auto" default, so the group columns apply. */ - public function columnSpan(): int + public function hasExplicitWidth(): bool { + return FieldWidth::isExplicit($this->settings['width'] ?? null); + } + + /** + * Filament columnSpan on the 12-column grid. Without an explicit width the + * field inherits the group's default span (from its column count) when one + * is provided; otherwise it falls back to full width. + */ + public function columnSpan(?int $groupDefaultSpan = null): int + { + if (! $this->hasExplicitWidth() && $groupDefaultSpan !== null) { + return $groupDefaultSpan; + } + return FieldWidth::columnSpan($this->width()); } diff --git a/packages/builder/src/Data/FieldGroupDefinition.php b/packages/builder/src/Data/FieldGroupDefinition.php index dfbfcde782..16f9a0147c 100644 --- a/packages/builder/src/Data/FieldGroupDefinition.php +++ b/packages/builder/src/Data/FieldGroupDefinition.php @@ -8,6 +8,7 @@ use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Support\FieldGroupPlacement; +use Moox\Builder\Support\FieldWidth; readonly class FieldGroupDefinition { @@ -45,6 +46,24 @@ public function hasPlacement(string $placement): bool return FieldGroupPlacement::matches($this->placement, $placement); } + /** + * Number of columns the group's fields flow into (1–4). Defaults to a single + * column, keeping fields stacked as before. + */ + public function columns(): int + { + return FieldWidth::normalizeColumns($this->settings['columns'] ?? null); + } + + /** + * Default columnSpan inherited by fields without an explicit width, derived + * from the group's column count (2 columns → span 6). + */ + public function defaultColumnSpan(): int + { + return FieldWidth::columnsToSpan($this->columns()); + } + /** * @param Collection $fields */ diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 2ce270e9e3..707b667562 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -112,6 +112,13 @@ public static function form(Schema $schema): Schema ->default(FieldGroupPlacement::MAIN) ->selectablePlaceholder(false) ->native(false), + Select::make('settings.columns') + ->label(__('builder::builder.field_group.columns')) + ->helperText(__('builder::builder.field_group.columns_helper')) + ->options(static::columnsOptions()) + ->default(1) + ->selectablePlaceholder(false) + ->native(false), ]), Section::make(__('builder::builder.field_group.assignment')) ->schema([ @@ -739,8 +746,9 @@ protected static function requirementAndWidthRow(): Grid } /** - * Layout width selector. Fields render on a 12-column grid, so widths define - * the columns (two 1/2 fields sit side by side). Not shown for tab markers. + * Per-field width override. Defaults to "auto", which follows the group's + * column layout; a fixed fraction pins the field width. Hidden for tab + * markers, which span the whole row. */ protected static function widthField(): Select { @@ -748,7 +756,7 @@ protected static function widthField(): Select ->label(__('builder::builder.field.width')) ->helperText(__('builder::builder.field.width_helper')) ->options(static::widthOptions()) - ->default(FieldWidth::FULL) + ->default(FieldWidth::AUTO) ->selectablePlaceholder(false) ->native(false) ->visible(fn (callable $get): bool => $get('type') !== 'tab'); @@ -760,6 +768,7 @@ protected static function widthField(): Select protected static function widthOptions(): array { return [ + FieldWidth::AUTO => __('builder::builder.field.width_auto'), FieldWidth::FULL => __('builder::builder.field.width_full'), '1/2' => __('builder::builder.field.width_half'), '1/3' => __('builder::builder.field.width_third'), @@ -769,6 +778,22 @@ protected static function widthOptions(): array ]; } + /** + * Column-count options for a field group's layout (1–4 columns). + * + * @return array + */ + protected static function columnsOptions(): array + { + $options = []; + + foreach (FieldWidth::GROUP_COLUMNS as $columns) { + $options[$columns] = trans_choice('builder::builder.field_group.columns_option', $columns, ['count' => $columns]); + } + + return $options; + } + /** * @return array */ diff --git a/packages/builder/src/Support/FieldWidth.php b/packages/builder/src/Support/FieldWidth.php index 445d847fe9..25d9b75918 100644 --- a/packages/builder/src/Support/FieldWidth.php +++ b/packages/builder/src/Support/FieldWidth.php @@ -5,13 +5,17 @@ namespace Moox\Builder\Support; /** - * Per-field layout width. Fields render on a fixed 12-column grid; the width - * fraction is translated into a Filament columnSpan. Field widths therefore - * define the column layout (two 1/2 fields sit side by side), so no separate - * "column count" setting is needed. + * Layout widths on a fixed 12-column grid. * - * Stored in the field settings as `width`; defaults to full width, which keeps - * existing fields rendering exactly as before. + * Two levels cooperate: + * - A field group defines how many columns its fields flow into + * (`settings.columns`, 1–4). This sets the default span every field inherits. + * - A single field may override its width (`settings.width`) to span a specific + * fraction; the special `auto` value (the default) keeps it following the + * group's column count. + * + * Both are translated into Filament columnSpans, so no separate grid component + * is needed: a group with 2 columns puts two fields side by side automatically. */ final class FieldWidth { @@ -19,6 +23,12 @@ final class FieldWidth public const FULL = 'full'; + /** + * Sentinel width meaning "inherit the group's column count" instead of a + * fixed fraction. Stored when a field should follow the group layout. + */ + public const AUTO = 'auto'; + /** @var array */ public const SPANS = [ self::FULL => 12, @@ -29,6 +39,9 @@ final class FieldWidth '3/4' => 9, ]; + /** @var list */ + public const GROUP_COLUMNS = [1, 2, 3, 4]; + public static function normalize(?string $width): string { return isset(self::SPANS[$width]) ? $width : self::FULL; @@ -39,6 +52,31 @@ public static function columnSpan(?string $width): int return self::SPANS[self::normalize($width)]; } + /** + * Whether the given raw width value is a fixed fraction (an explicit + * override) rather than the inherit-from-group default. + */ + public static function isExplicit(mixed $width): bool + { + return is_string($width) && isset(self::SPANS[$width]); + } + + public static function normalizeColumns(mixed $columns): int + { + $columns = is_numeric($columns) ? (int) $columns : 1; + + return in_array($columns, self::GROUP_COLUMNS, true) ? $columns : 1; + } + + /** + * Default columnSpan a field inherits when its group uses the given number + * of columns (2 columns → span 6, so two fields share a row). + */ + public static function columnsToSpan(mixed $columns): int + { + return intdiv(self::GRID_COLUMNS, self::normalizeColumns($columns)); + } + /** * @return list */ From ac53f41d8d90225aede65853299c1ef1e040b472 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:08:56 +0200 Subject: [PATCH 063/105] Add relation field type with entity linking, validation, and table columns --- .../builder/resources/lang/de/builder.php | 11 + .../builder/resources/lang/en/builder.php | 11 + .../builder/src/BuilderServiceProvider.php | 2 + .../src/Compiler/TableColumnCompiler.php | 47 ++- .../Capabilities/RelationSettings.php | 81 +++++ packages/builder/src/FieldTypes/FieldType.php | 1 + .../FieldTypes/Types/RelationFieldType.php | 158 ++++++++++ .../builder/src/Registry/EntityRegistry.php | 174 ++++++++++- .../src/Resources/FieldGroupResource.php | 55 +++- .../src/Services/FieldGroupPersistence.php | 10 +- .../src/Services/FieldValueValidator.php | 5 + .../src/Support/RelationTargetResolver.php | 285 ++++++++++++++++++ .../src/Support/RelationValueRules.php | 156 ++++++++++ .../builder/src/Support/TypedValueColumns.php | 7 +- 14 files changed, 996 insertions(+), 7 deletions(-) create mode 100644 packages/builder/src/FieldTypes/Capabilities/RelationSettings.php create mode 100644 packages/builder/src/FieldTypes/Types/RelationFieldType.php create mode 100644 packages/builder/src/Support/RelationTargetResolver.php create mode 100644 packages/builder/src/Support/RelationValueRules.php diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 11ff0b897f..99031d5f9b 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -52,6 +52,12 @@ 'width_two_thirds' => 'Zwei Drittel (2/3)', 'width_quarter' => 'Viertel (1/4)', 'width_three_quarters' => 'Drei Viertel (3/4)', + 'relation_entity' => 'Verknüpfte Ressource', + 'relation_entity_helper' => 'Welche Ressourcen-Datensätze mit diesem Feld verknüpft werden können.', + 'relation_multiple' => 'Mehrfachauswahl', + 'relation_multiple_helper' => 'Wenn aktiv, können mehrere Datensätze verknüpft werden.', + 'relation_min' => 'Mindestanzahl', + 'relation_max' => 'Höchstanzahl', 'table_column' => 'Tabellen-Spalte', 'table_column_helper' => 'Dieses Feld als Spalte auf den Listen-Seiten anzeigen.', 'show_in_table' => 'In Tabelle anzeigen', @@ -121,6 +127,7 @@ 'time' => 'Uhrzeit', 'color' => 'Farbe', 'link' => 'Link', + 'relation' => 'Verknüpfung', 'image' => 'Bild', 'gallery' => 'Galerie', 'file' => 'Datei', @@ -235,6 +242,10 @@ 'empty_flexible_layout' => 'Bitte ein Layout für jeden Block wählen.', 'unknown_flexible_layout' => 'Unbekanntes Layout „:layout“.', 'invalid_media' => 'Bitte wähle ein gültiges Medium aus.', + 'invalid_relation' => 'Bitte einen gültigen verknüpften Datensatz wählen.', + 'invalid_relation_target' => 'Ein oder mehrere ausgewählte Datensätze existieren nicht mehr.', + 'relation_min' => 'Wähle mindestens :min Datensätze für :attribute.', + 'relation_max' => 'Wähle höchstens :max Datensätze für :attribute.', 'missing_media' => 'Das ausgewählte Medium existiert nicht mehr.', 'invalid_media_type' => 'Das ausgewählte Medium für :attribute muss ein Bild sein.', 'invalid_file_media_type' => 'Das ausgewählte Medium für :attribute muss eine Datei sein, kein Bild.', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 45205d10fc..ca676d4957 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -52,6 +52,12 @@ 'width_two_thirds' => 'Two thirds (2/3)', 'width_quarter' => 'Quarter (1/4)', 'width_three_quarters' => 'Three quarters (3/4)', + 'relation_entity' => 'Related resource', + 'relation_entity_helper' => 'Which resource records can be linked to this field.', + 'relation_multiple' => 'Allow multiple', + 'relation_multiple_helper' => 'When enabled, editors can link more than one record.', + 'relation_min' => 'Minimum selections', + 'relation_max' => 'Maximum selections', 'table_column' => 'Table column', 'table_column_helper' => 'Show this field as a column on list pages.', 'show_in_table' => 'Show in table', @@ -121,6 +127,7 @@ 'time' => 'Time', 'color' => 'Color', 'link' => 'Link', + 'relation' => 'Relation', 'image' => 'Image', 'gallery' => 'Gallery', 'file' => 'File', @@ -235,6 +242,10 @@ 'empty_flexible_layout' => 'Please choose a layout for each block.', 'unknown_flexible_layout' => 'Unknown layout ":layout".', 'invalid_media' => 'Please select a valid media item.', + 'invalid_relation' => 'Please select a valid related record.', + 'invalid_relation_target' => 'One or more selected records no longer exist.', + 'relation_min' => 'Select at least :min records for :attribute.', + 'relation_max' => 'Select at most :max records for :attribute.', 'missing_media' => 'The selected media item no longer exists.', 'invalid_media_type' => 'The selected media for :attribute must be an image.', 'invalid_file_media_type' => 'The selected media for :attribute must be a file, not an image.', diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index 0b25c1290f..c40f7d05f3 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -27,6 +27,7 @@ use Moox\Builder\FieldTypes\Types\PasswordFieldType; use Moox\Builder\FieldTypes\Types\RadioFieldType; use Moox\Builder\FieldTypes\Types\RangeFieldType; +use Moox\Builder\FieldTypes\Types\RelationFieldType; use Moox\Builder\FieldTypes\Types\RepeaterFieldType; use Moox\Builder\FieldTypes\Types\RichTextFieldType; use Moox\Builder\FieldTypes\Types\SectionFieldType; @@ -145,6 +146,7 @@ protected function defaultFieldTypes(): array new RangeFieldType, new ButtonGroupFieldType, new LinkFieldType, + new RelationFieldType, ]; if (MediaIntegration::isAvailable()) { diff --git a/packages/builder/src/Compiler/TableColumnCompiler.php b/packages/builder/src/Compiler/TableColumnCompiler.php index f32812d5ec..8779a7520c 100644 --- a/packages/builder/src/Compiler/TableColumnCompiler.php +++ b/packages/builder/src/Compiler/TableColumnCompiler.php @@ -63,7 +63,7 @@ public function compile(Collection $fieldGroups, ?string $resourceClass = null): $fields = $this->storableFieldCollector ->definitionsFromList($fieldGroups->flatMap(fn (FieldGroupDefinition $group): Collection => $group->fields)) ->filter(fn (FieldDefinition $field): bool => $field->showInTable() - && ($this->isColumnable($field) || $this->isImageColumn($field))) + && ($this->isColumnable($field) || $this->isImageColumn($field) || $this->isRelationColumn($field))) ->values(); $columns = []; @@ -85,6 +85,11 @@ protected function isImageColumn(FieldDefinition $field): bool return TypedValueColumns::isImageColumnType($field->type); } + protected function isRelationColumn(FieldDefinition $field): bool + { + return TypedValueColumns::isRelationColumnType($field->type); + } + protected function compileColumn( FieldDefinition $field, string $entity, @@ -96,6 +101,10 @@ protected function compileColumn( return $this->compileImageColumn($field); } + if ($this->isRelationColumn($field)) { + return $this->compileRelationColumn($field); + } + $valueColumn = TypedValueColumns::columnForType($field->type); $name = $field->name; @@ -222,6 +231,42 @@ protected function compileImageColumn(FieldDefinition $field): Column return $column; } + protected function compileRelationColumn(FieldDefinition $field): Column + { + return TextColumn::make($field->name) + ->label($field->label) + ->placeholder('—') + ->getStateUsing(function (Model $record) use ($field): ?string { + $presented = $this->resolvePresentedValue($field, $record); + + return $this->formatRelationColumnState($presented); + }) + ->toggleable(isToggledHiddenByDefault: $field->isColumnHiddenByDefault()); + } + + protected function formatRelationColumnState(mixed $presented): ?string + { + if ($presented === null) { + return null; + } + + if (is_array($presented) && array_key_exists('label', $presented)) { + return (string) $presented['label']; + } + + if (! is_array($presented) || ! array_is_list($presented)) { + return null; + } + + $labels = collect($presented) + ->map(fn (mixed $item): ?string => is_array($item) ? ($item['label'] ?? null) : null) + ->filter() + ->values() + ->all(); + + return $labels === [] ? null : implode(', ', $labels); + } + /** * @return list|string|null */ diff --git a/packages/builder/src/FieldTypes/Capabilities/RelationSettings.php b/packages/builder/src/FieldTypes/Capabilities/RelationSettings.php new file mode 100644 index 0000000000..f4dc66b979 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/RelationSettings.php @@ -0,0 +1,81 @@ + + */ + public function builderFieldsFor(string $fieldType): array + { + if ($fieldType !== 'relation') { + return []; + } + + return $this->builderFields(); + } + + /** + * @return list + */ + public function builderFields(): array + { + return [ + // Searchable requires live(): inside this reactive settings closure + // a searchable select defers its state sync, so without live() the + // selected value is dropped when another live field (e.g. multiple) + // rebuilds the schema. live() commits the value immediately on + // selection so it survives any subsequent rebuild. + Select::make('config.related_entity') + ->label(__('builder::builder.field.relation_entity')) + ->helperText(fn (): string => app(EntityRegistry::class)->relatableOptions() === [] + ? __('builder::builder.field_group.no_entities_registered') + : __('builder::builder.field.relation_entity_helper')) + ->options(fn (): array => app(EntityRegistry::class)->relatableOptions()) + ->required() + ->searchable() + ->live() + ->native(false) + ->disabled(fn (): bool => app(EntityRegistry::class)->relatableOptions() === []), + Toggle::make('config.multiple') + ->label(__('builder::builder.field.relation_multiple')) + ->helperText(__('builder::builder.field.relation_multiple_helper')) + ->inline(false) + ->live(), + TextInput::make('config.min') + ->label(__('builder::builder.field.relation_min')) + ->numeric() + ->minValue(0) + ->visible(fn (callable $get): bool => (bool) $get('config.multiple')), + TextInput::make('config.max') + ->label(__('builder::builder.field.relation_max')) + ->numeric() + ->minValue(1) + ->visible(fn (callable $get): bool => (bool) $get('config.multiple')), + ]; + } + + /** + * @return list + */ + public function rules(FieldDefinition $field): array + { + return RelationValueRules::rules($field); + } + + public function apply(Component $component, FieldDefinition $field): Component + { + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/FieldType.php b/packages/builder/src/FieldTypes/FieldType.php index 35c6c91251..e98077d278 100644 --- a/packages/builder/src/FieldTypes/FieldType.php +++ b/packages/builder/src/FieldTypes/FieldType.php @@ -133,6 +133,7 @@ public function icon(): string 'number' => 'heroicon-o-hashtag', 'range' => 'heroicon-o-adjustments-horizontal', 'url', 'link' => 'heroicon-o-link', + 'relation' => 'heroicon-o-arrows-right-left', 'color' => 'heroicon-o-swatch', 'date' => 'heroicon-o-calendar', 'datetime' => 'heroicon-o-calendar-days', diff --git a/packages/builder/src/FieldTypes/Types/RelationFieldType.php b/packages/builder/src/FieldTypes/Types/RelationFieldType.php new file mode 100644 index 0000000000..9efadd2a1e --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/RelationFieldType.php @@ -0,0 +1,158 @@ +isMultiple($field) ? [] : null; + } + + if ($this->isMultiple($field)) { + return is_array($raw) ? array_values($raw) : [$raw]; + } + + if (is_array($raw)) { + return $raw === [] ? null : reset($raw); + } + + return $raw; + } + + public function persistValue(mixed $value, ?FieldDefinition $field = null): mixed + { + return $this->castValue($value, $field); + } + + public function presentValue(mixed $value, ?FieldDefinition $field = null): mixed + { + if ($field === null) { + return $value; + } + + $entity = $this->relatedEntity($field); + + if ($entity === null) { + return $value; + } + + $ids = RelationValueRules::normalizeIds($value, $this->isMultiple($field)); + + if ($ids === []) { + return $this->isMultiple($field) ? [] : null; + } + + $resolved = app(RelationTargetResolver::class)->resolve($entity, $ids); + + if ($this->isMultiple($field)) { + return $resolved; + } + + return $resolved[0] ?? null; + } + + public function formComponent(FieldDefinition $field): Component + { + $entity = $this->relatedEntity($field); + $multiple = $this->isMultiple($field); + $resolver = app(RelationTargetResolver::class); + + $component = Select::make($field->name) + ->label($field->label) + ->native(false) + ->searchable(); + + if ($multiple) { + $component->multiple(); + } + + if ($entity !== null) { + $component + // Preloaded suggestions so the picker shows records immediately + // (ACF-like) instead of an empty box until the user types. + ->options(fn (): array => $resolver->search($entity, '')) + ->preload() + ->getSearchResultsUsing( + fn (string $search): array => $resolver->search($entity, $search), + ) + ->getOptionLabelUsing( + function (mixed $value) use ($entity, $resolver): ?string { + if (! filled($value)) { + return null; + } + + $labels = $resolver->labelsFor($entity, [$value]); + + return $labels[$value] + ?? $labels[(int) $value] + ?? $labels[(string) $value] + ?? null; + }, + ); + + if ($multiple) { + $component->getOptionLabelsUsing( + function (array $values) use ($entity, $resolver): array { + $labels = $resolver->labelsFor($entity, $values); + $options = []; + + foreach ($values as $value) { + $label = $labels[$value] + ?? $labels[(int) $value] + ?? $labels[(string) $value] + ?? null; + + if ($label !== null) { + $options[$value] = $label; + } + } + + return $options; + }, + ); + } + } + + return $this->applyCapabilitiesAndValidation($component, $field); + } + + protected function isMultiple(FieldDefinition $field): bool + { + return RelationValueRules::isMultiple($field); + } + + protected function relatedEntity(FieldDefinition $field): ?string + { + return RelationValueRules::relatedEntity($field); + } +} diff --git a/packages/builder/src/Registry/EntityRegistry.php b/packages/builder/src/Registry/EntityRegistry.php index 009821dc02..a9f69cee29 100644 --- a/packages/builder/src/Registry/EntityRegistry.php +++ b/packages/builder/src/Registry/EntityRegistry.php @@ -5,6 +5,8 @@ namespace Moox\Builder\Registry; use Filament\Facades\Filament; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Moox\Builder\Concerns\HasCustomFields; @@ -136,6 +138,129 @@ public function optionsForSelect(): array return $options; } + /** + * All Filament resources that can be a relation target, keyed by a stable + * identifier. Unlike all(), this is not limited to resources that host + * custom fields, so relations can point to any Moox entity (e.g. users). + * + * @return array + */ + public function relatableResources(): array + { + $resources = []; + $seenModels = []; + + $panelResources = $this->panelResources(); + sort($panelResources); + + foreach ($panelResources as $resourceClass) { + if (! method_exists($resourceClass, 'getModel')) { + continue; + } + + $model = $resourceClass::getModel(); + + if (! is_string($model) || $model === '' || ! $this->modelIsQueryable($model)) { + continue; + } + + // A relation always points at a model, so multiple resources that + // expose the same model represent the same target: keep only one. + if (isset($seenModels[$model])) { + continue; + } + + $key = $this->relatableKeyFor($resourceClass); + + if ($key === '') { + continue; + } + + if (isset($resources[$key]) && $resources[$key] !== $resourceClass) { + $key .= '_'.Str::of($model)->classBasename()->snake()->toString(); + } + + $seenModels[$model] = true; + $resources[$key] = $resourceClass; + } + + ksort($resources); + + return $resources; + } + + /** + * @return array + */ + public function relatableOptions(): array + { + $resources = $this->relatableResources(); + + $labels = []; + + foreach ($resources as $key => $resourceClass) { + $labels[$key] = $this->labelForResource($resourceClass); + } + + // Distinct targets may share a label (e.g. two "Categories" models from + // different packages). Qualify collisions so every option is unambiguous. + $duplicates = array_keys(array_filter(array_count_values($labels), fn (int $count): bool => $count > 1)); + + foreach ($labels as $key => $label) { + if (in_array($label, $duplicates, true)) { + $labels[$key] = $label.' ('.$this->relatableQualifier($resources[$key]).')'; + } + } + + asort($labels); + + return $labels; + } + + /** + * @return class-string|null + */ + public function relatedModelFor(string $key): ?string + { + $resourceClass = $this->relatableResources()[$key] ?? null; + + if ($resourceClass === null || ! method_exists($resourceClass, 'getModel')) { + return null; + } + + $model = $resourceClass::getModel(); + + return is_string($model) ? $model : null; + } + + /** + * @return class-string|null + */ + public function relatedResourceFor(string $key): ?string + { + return $this->relatableResources()[$key] ?? null; + } + + /** + * Whether the model's database table exists and can be queried safely. + * + * @param class-string $modelClass + */ + public function modelIsQueryable(string $modelClass): bool + { + if (! is_subclass_of($modelClass, Model::class)) { + return false; + } + + try { + $table = (new $modelClass)->getTable(); + + return filled($table) && Schema::hasTable($table); + } catch (\Throwable) { + return false; + } + } + /** * @param class-string $resourceClass */ @@ -160,6 +285,17 @@ protected function resolveEntityKey(string $resourceClass): ?string * @return list */ protected function traitResources(): array + { + return array_values(array_filter( + $this->panelResources(), + fn (string $resourceClass): bool => $this->usesCustomFields($resourceClass), + )); + } + + /** + * @return list + */ + protected function panelResources(): array { if (! class_exists(Filament::class)) { return []; @@ -175,15 +311,47 @@ protected function traitResources(): array foreach ($panels as $panel) { foreach ($panel->getResources() as $resourceClass) { - if ($this->usesCustomFields($resourceClass)) { - $resources[] = $resourceClass; - } + $resources[] = $resourceClass; } } return array_values(array_unique($resources)); } + /** + * @param class-string $resourceClass + */ + protected function relatableKeyFor(string $resourceClass): string + { + $entity = $this->resolveEntityKey($resourceClass); + + if (is_string($entity) && $entity !== '') { + return $entity; + } + + return Str::of(class_basename($resourceClass)) + ->beforeLast('Resource') + ->snake() + ->toString(); + } + + /** + * A human-readable qualifier used to disambiguate resources that share a + * label, derived from the owning package/namespace segment. + * + * @param class-string $resourceClass + */ + protected function relatableQualifier(string $resourceClass): string + { + $segments = explode('\\', $resourceClass); + + // e.g. Moox\Press\Resources\CategoryResource => "Press", + // App\Filament\Resources\CategoryResource => "App". + $segment = $segments[1] ?? $segments[0]; + + return Str::headline($segment); + } + /** * @param class-string $resourceClass */ diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 707b667562..332e0deeca 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -182,6 +182,7 @@ public static function form(Schema $schema): Schema ->required() ->searchable() ->live() + ->afterStateUpdated(fn ($state, callable $set, callable $get): mixed => static::seedConfigForFieldType((string) $state, $set, $get)) ->native(false), ]), TextInput::make('name') @@ -421,6 +422,7 @@ protected static function tabChildFieldSchema(FieldTypeRegistry $registry): arra ->required() ->searchable() ->live() + ->afterStateUpdated(fn ($state, callable $set, callable $get): mixed => static::seedConfigForFieldType((string) $state, $set, $get)) ->native(false), ]), TextInput::make('name') @@ -495,6 +497,7 @@ protected static function subFieldSchema(FieldTypeRegistry $registry): array ->required() ->searchable() ->live() + ->afterStateUpdated(fn ($state, callable $set, callable $get): mixed => static::seedConfigForFieldType((string) $state, $set, $get)) ->native(false), ]), TextInput::make('name') @@ -564,9 +567,30 @@ protected static function fieldTypeSupportsImageColumn(?string $type): bool return TypedValueColumns::isImageColumnType($type); } + protected static function fieldTypeSupportsRelationColumn(?string $type): bool + { + if (blank($type)) { + return false; + } + + try { + $fieldType = app(FieldTypeRegistry::class)->get($type); + + if (! $fieldType->storesValue() || $fieldType->hasSubFields()) { + return false; + } + } catch (UnknownFieldTypeException) { + return false; + } + + return TypedValueColumns::isRelationColumnType($type); + } + protected static function fieldTypeSupportsAnyColumn(?string $type): bool { - return static::fieldTypeSupportsColumn($type) || static::fieldTypeSupportsImageColumn($type); + return static::fieldTypeSupportsColumn($type) + || static::fieldTypeSupportsImageColumn($type) + || static::fieldTypeSupportsRelationColumn($type); } /** @@ -891,9 +915,38 @@ protected static function reactiveTypeSettingsSchema(callable $get): array $get('config.default'); } + if ($type === 'relation') { + $get('config.multiple'); + $get('config.related_entity'); + } + return static::typeSettingsSchema($type); } + /** + * Ensures relation config keys exist in the repeater item state when the + * type settings section mounts inside a reactive schema closure. + * + * @see https://github.com/filamentphp/filament/issues/3575 + */ + protected static function seedConfigForFieldType(string $type, callable $set, callable $get): void + { + if ($type !== 'relation') { + return; + } + + $config = $get('config'); + + if (! is_array($config)) { + $config = []; + } + + $set('config', array_merge([ + 'related_entity' => null, + 'multiple' => false, + ], $config)); + } + /** * @return list */ diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index ad696d63ab..b913b1460a 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -296,9 +296,17 @@ protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldI 'name' => (string) $row['name'], 'label' => $isDefaultLocale ? $label : ($field->label ?: $label), 'type' => (string) $row['type'], + // Structural config keys (e.g. related_entity, multiple) are + // locale-independent and must always take the submitted value. + // On a non-default locale only the translatable keys stored on + // the main record are preserved as the default-locale fallback; + // their localized values live in the translation row. 'config' => $isDefaultLocale ? $config - : array_merge($structuralConfig, array_diff_key($field->config ?? [], array_flip(BuilderLocaleResolver::TRANSLATABLE_CONFIG_KEYS))), + : array_merge( + array_intersect_key($field->config ?? [], array_flip(BuilderLocaleResolver::TRANSLATABLE_CONFIG_KEYS)), + $structuralConfig, + ), 'validation' => [ 'required' => (bool) ($row['required'] ?? false), 'rules' => $row['validation']['rules'] ?? [], diff --git a/packages/builder/src/Services/FieldValueValidator.php b/packages/builder/src/Services/FieldValueValidator.php index 38aea2ff94..778d533b8d 100644 --- a/packages/builder/src/Services/FieldValueValidator.php +++ b/packages/builder/src/Services/FieldValueValidator.php @@ -11,6 +11,7 @@ use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\MediaFieldValueSupport; use Moox\Builder\Support\OptionValueRules; +use Moox\Builder\Support\RelationValueRules; use Moox\Builder\Support\RichTextValue; class FieldValueValidator @@ -197,6 +198,10 @@ protected function messagesForLeaf(FieldDefinition $field, mixed $value, string return $this->messagesForFile($field, $value, $path, $record); } + if ($field->type === 'relation') { + return RelationValueRules::messages($field, $value, $path); + } + $messages = []; if (($field->validation['required'] ?? false) === true && $this->isEmptyValue($field->type, $value)) { diff --git a/packages/builder/src/Support/RelationTargetResolver.php b/packages/builder/src/Support/RelationTargetResolver.php new file mode 100644 index 0000000000..6232271d9c --- /dev/null +++ b/packages/builder/src/Support/RelationTargetResolver.php @@ -0,0 +1,285 @@ +|null + */ + public function modelClass(string $entity): ?string + { + return $this->entityRegistry->relatedModelFor($entity); + } + + /** + * @return class-string|null + */ + public function resourceClass(string $entity): ?string + { + return $this->entityRegistry->relatedResourceFor($entity); + } + + /** + * @return array + */ + public function search(string $entity, string $term, int $limit = 50): array + { + $modelClass = $this->modelClass($entity); + + if ($modelClass === null || ! $this->entityRegistry->modelIsQueryable($modelClass)) { + return []; + } + + $titleAttribute = $this->titleAttributeFor($entity); + + if (! $this->modelHasColumn($modelClass, $titleAttribute)) { + return []; + } + + try { + $query = $modelClass::query(); + + if ($term !== '') { + $query->where($titleAttribute, 'like', "%{$term}%"); + } + + $results = []; + + foreach ($query->limit($limit)->get() as $record) { + $title = $this->titleFor($entity, $record); + + if ($title === '') { + continue; + } + + $results[$record->getKey()] = $title; + } + + return $results; + } catch (QueryException) { + return []; + } + } + + /** + * @param list $ids + * @return array + */ + public function labelsFor(string $entity, array $ids): array + { + $ids = array_values(array_unique(array_filter($ids, fn (mixed $id): bool => filled($id)))); + + if ($ids === []) { + return []; + } + + $modelClass = $this->modelClass($entity); + + if ($modelClass === null || ! $this->entityRegistry->modelIsQueryable($modelClass)) { + return []; + } + + $model = new $modelClass; + $labels = []; + + try { + foreach ($modelClass::query()->whereIn($model->getKeyName(), $ids)->get() as $record) { + $labels[$record->getKey()] = $this->titleFor($entity, $record); + } + } catch (QueryException) { + return []; + } + + return $labels; + } + + /** + * @param list $ids + * @return list + */ + public function resolve(string $entity, array $ids): array + { + $labels = $this->labelsFor($entity, $ids); + $resolved = []; + + foreach ($ids as $id) { + $key = $this->matchingKey($labels, $id); + + if ($key === null) { + continue; + } + + $resolved[] = [ + 'id' => $key, + 'label' => $labels[$key], + ]; + } + + return $resolved; + } + + public function recordExists(string $entity, int|string $id): bool + { + $modelClass = $this->modelClass($entity); + + if ($modelClass === null || ! $this->entityRegistry->modelIsQueryable($modelClass)) { + return false; + } + + try { + return $modelClass::query()->whereKey($id)->exists(); + } catch (QueryException) { + return false; + } + } + + protected function titleFor(string $entity, Model $record): string + { + $resource = $this->resourceClass($entity); + + if ($resource !== null + && method_exists($resource, 'hasRecordTitle') + && $resource::hasRecordTitle() + && method_exists($resource, 'getRecordTitle')) { + $title = $resource::getRecordTitle($record); + + if (filled($title)) { + return (string) $title; + } + } + + foreach ($this->titleAttributeCandidates($entity) as $attribute) { + $value = $record->getAttribute($attribute); + + if (filled($value)) { + return (string) $value; + } + } + + return (string) $record->getKey(); + } + + /** + * @return list + */ + protected function titleAttributeCandidates(string $entity): array + { + $resource = $this->resourceClass($entity); + + if ($resource !== null && method_exists($resource, 'getRecordTitleAttribute')) { + $attribute = $resource::getRecordTitleAttribute(); + + if (filled($attribute)) { + return [(string) $attribute]; + } + } + + $modelClass = $this->modelClass($entity); + $candidates = ['display_name', 'title', 'name', 'label']; + + if ($modelClass === null) { + return $candidates; + } + + return array_values(array_filter( + $candidates, + fn (string $candidate): bool => $this->modelHasColumn($modelClass, $candidate) + || $this->modelHasAccessor($modelClass, $candidate), + )); + } + + protected function titleAttributeFor(string $entity): string + { + $candidates = $this->titleAttributeCandidates($entity); + + foreach ($candidates as $candidate) { + if ($candidate === 'display_name') { + continue; + } + + $modelClass = $this->modelClass($entity); + + if ($modelClass !== null && $this->modelHasColumn($modelClass, $candidate)) { + return $candidate; + } + } + + return 'id'; + } + + /** + * @param class-string $modelClass + */ + protected function modelHasAccessor(string $modelClass, string $attribute): bool + { + if (! is_subclass_of($modelClass, Model::class)) { + return false; + } + + $instance = new $modelClass; + + if (in_array($attribute, $instance->getAppends(), true)) { + return true; + } + + $studly = str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $attribute))); + $method = 'get'.$studly.'Attribute'; + + return method_exists($instance, $method); + } + + /** + * @param class-string $modelClass + */ + protected function modelHasColumn(string $modelClass, string $column): bool + { + try { + $table = (new $modelClass)->getTable(); + + return filled($table) && Schema::hasTable($table) && Schema::hasColumn($table, $column); + } catch (\Throwable) { + return false; + } + } + + /** + * @param array $labels + */ + protected function matchingKey(array $labels, mixed $id): int|string|null + { + if (array_key_exists($id, $labels)) { + return $id; + } + + if (is_numeric($id)) { + $intId = (int) $id; + + if (array_key_exists($intId, $labels)) { + return $intId; + } + } + + $stringId = (string) $id; + + if (array_key_exists($stringId, $labels)) { + return $stringId; + } + + return null; + } +} diff --git a/packages/builder/src/Support/RelationValueRules.php b/packages/builder/src/Support/RelationValueRules.php new file mode 100644 index 0000000000..b14108fbb1 --- /dev/null +++ b/packages/builder/src/Support/RelationValueRules.php @@ -0,0 +1,156 @@ +> + */ + public static function messages(FieldDefinition $field, mixed $value, string $path): array + { + $entity = self::relatedEntity($field); + + if ($entity === null) { + return []; + } + + $multiple = self::isMultiple($field); + $messages = []; + + if (($field->validation['required'] ?? false) === true && self::isEmpty($value, $multiple)) { + $messages[$path] = [ + __('validation.required', ['attribute' => $field->label]), + ]; + } + + if (self::isEmpty($value, $multiple)) { + return $messages; + } + + $ids = self::normalizeIds($value, $multiple); + + if ($ids === []) { + $messages[$path] = [ + __('builder::builder.validation.invalid_relation'), + ]; + + return $messages; + } + + $min = self::normalizeLimit($field->config['min'] ?? null); + $max = self::normalizeLimit($field->config['max'] ?? null); + + if ($multiple && $min !== null && count($ids) < $min) { + $messages[$path] = [ + __('builder::builder.validation.relation_min', [ + 'min' => $min, + 'attribute' => $field->label, + ]), + ]; + } + + if ($multiple && $max !== null && count($ids) > $max) { + $messages[$path] = [ + __('builder::builder.validation.relation_max', [ + 'max' => $max, + 'attribute' => $field->label, + ]), + ]; + } + + $resolver = app(RelationTargetResolver::class); + + foreach ($ids as $id) { + if (! $resolver->recordExists($entity, $id)) { + $messages[$path] = [ + __('builder::builder.validation.invalid_relation_target'), + ]; + + break; + } + } + + return $messages; + } + + public static function assertValid(FieldDefinition $field, mixed $value, ?string $path = null): void + { + $path ??= $field->name; + $messages = self::messages($field, $value, $path); + + if ($messages !== []) { + throw ValidationException::withMessages($messages); + } + } + + /** + * @return list + */ + public static function rules(FieldDefinition $field): array + { + return [ + fn (): Closure => function (string $attribute, mixed $value, Closure $fail) use ($field): void { + foreach (self::messages($field, $value, $attribute) as $messages) { + foreach ($messages as $message) { + $fail($message); + } + } + }, + ]; + } + + public static function relatedEntity(FieldDefinition $field): ?string + { + $entity = $field->config['related_entity'] ?? null; + + return filled($entity) ? (string) $entity : null; + } + + public static function isMultiple(FieldDefinition $field): bool + { + return (bool) ($field->config['multiple'] ?? false); + } + + public static function isEmpty(mixed $value, bool $multiple): bool + { + if ($multiple) { + return ! is_array($value) || $value === []; + } + + return blank($value); + } + + /** + * @return list + */ + public static function normalizeIds(mixed $value, bool $multiple): array + { + if ($multiple) { + if (! is_array($value)) { + return []; + } + + return array_values(array_filter($value, fn (mixed $id): bool => filled($id))); + } + + return filled($value) ? [$value] : []; + } + + protected static function normalizeLimit(mixed $limit): ?int + { + if (! is_numeric($limit)) { + return null; + } + + $limit = (int) $limit; + + return $limit >= 0 ? $limit : null; + } +} diff --git a/packages/builder/src/Support/TypedValueColumns.php b/packages/builder/src/Support/TypedValueColumns.php index a74f1ee392..96dd05ca6c 100644 --- a/packages/builder/src/Support/TypedValueColumns.php +++ b/packages/builder/src/Support/TypedValueColumns.php @@ -32,7 +32,7 @@ public static function columnForType(string $type): string 'date' => 'value_date', 'datetime' => 'value_datetime', 'toggle' => 'value_boolean', - 'multiselect', 'checkbox_list', 'link', 'image', 'gallery', 'file', 'group', 'repeater', 'flexible_content' => 'value_json', + 'multiselect', 'checkbox_list', 'link', 'relation', 'image', 'gallery', 'file', 'group', 'repeater', 'flexible_content' => 'value_json', 'button_group' => 'value_string', default => 'value_string', }; @@ -52,6 +52,11 @@ public static function isImageColumnType(string $type): bool return in_array($type, ['image', 'gallery'], true); } + public static function isRelationColumnType(string $type): bool + { + return $type === 'relation'; + } + /** * @return array */ From 0960cf478f4142dcd82d5adb91454d7fd652f845 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:35:30 +0200 Subject: [PATCH 064/105] Add conditional logic for builder field visibility --- .../builder/resources/lang/de/builder.php | 19 ++ .../builder/resources/lang/en/builder.php | 19 ++ .../builder/src/Compiler/SchemaCompiler.php | 45 +++- packages/builder/src/Data/FieldDefinition.php | 22 ++ .../src/Resources/FieldGroupResource.php | 224 ++++++++++++++++ .../src/Services/CustomFieldsManager.php | 7 +- .../src/Services/FieldGroupPersistence.php | 3 + .../builder/src/Support/ConditionalLogic.php | 250 ++++++++++++++++++ 8 files changed, 580 insertions(+), 9 deletions(-) create mode 100644 packages/builder/src/Support/ConditionalLogic.php diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 99031d5f9b..3cb125967a 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -88,6 +88,25 @@ 'column_image_size_lg' => 'Groß', 'visibility' => 'Sichtbarkeit', 'visibility_helper' => 'Dieses Feld je Kontext ausblenden, ohne es zu löschen.', + 'conditional_logic' => 'Bedingte Logik', + 'conditional_logic_helper' => 'Dieses Feld abhängig vom Wert eines anderen Felds in derselben Gruppe ein- oder ausblenden.', + 'conditional_logic_enabled' => 'Bedingte Logik aktivieren', + 'conditional_logic_action' => 'Aktion', + 'conditional_logic_action_show' => 'Feld anzeigen, wenn Regeln zutreffen', + 'conditional_logic_action_hide' => 'Feld ausblenden, wenn Regeln zutreffen', + 'conditional_logic_logic' => 'Regelverknüpfung', + 'conditional_logic_logic_and' => 'Alle Regeln (UND)', + 'conditional_logic_logic_or' => 'Eine Regel (ODER)', + 'conditional_logic_rules' => 'Regeln', + 'conditional_logic_rules_helper' => 'Vergleicht Geschwisterfelder auf Root-Ebene dieser Feldgruppe.', + 'conditional_logic_field' => 'Feld', + 'conditional_logic_operator' => 'Operator', + 'conditional_logic_value' => 'Wert', + 'conditional_logic_operator_equals' => 'ist gleich', + 'conditional_logic_operator_not_equals' => 'ist nicht gleich', + 'conditional_logic_operator_empty' => 'ist leer', + 'conditional_logic_operator_not_empty' => 'ist nicht leer', + 'conditional_logic_operator_contains' => 'enthält', 'subfields_count' => '{1} :count Unterfeld|[2,*] :count Unterfelder', 'settings' => 'Einstellungen', 'settings_helper' => 'Typspezifische Optionen wie Standardwerte, Grenzen und Formate.', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index ca676d4957..032d0d6998 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -88,6 +88,25 @@ 'column_image_size_lg' => 'Large', 'visibility' => 'Visibility', 'visibility_helper' => 'Hide this field per context without deleting it.', + 'conditional_logic' => 'Conditional logic', + 'conditional_logic_helper' => 'Show or hide this field based on the value of another field in the same group.', + 'conditional_logic_enabled' => 'Enable conditional logic', + 'conditional_logic_action' => 'Action', + 'conditional_logic_action_show' => 'Show field when rules match', + 'conditional_logic_action_hide' => 'Hide field when rules match', + 'conditional_logic_logic' => 'Rule matching', + 'conditional_logic_logic_and' => 'All rules (AND)', + 'conditional_logic_logic_or' => 'Any rule (OR)', + 'conditional_logic_rules' => 'Rules', + 'conditional_logic_rules_helper' => 'Compare sibling fields at the root level of this field group.', + 'conditional_logic_field' => 'Field', + 'conditional_logic_operator' => 'Operator', + 'conditional_logic_value' => 'Value', + 'conditional_logic_operator_equals' => 'equals', + 'conditional_logic_operator_not_equals' => 'does not equal', + 'conditional_logic_operator_empty' => 'is empty', + 'conditional_logic_operator_not_empty' => 'is not empty', + 'conditional_logic_operator_contains' => 'contains', 'subfields_count' => '{1} :count subfield|[2,*] :count subfields', 'settings' => 'Settings', 'settings_helper' => 'Type-specific options like defaults, limits, and formats.', diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 45e25a4698..31df722ce2 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -11,6 +11,7 @@ use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; +use Filament\Schemas\Components\Utilities\Get; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Moox\Builder\Data\FieldDefinition; @@ -19,6 +20,7 @@ use Moox\Builder\FieldTypes\Types\GroupFieldType; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Services\CustomFieldsManager; +use Moox\Builder\Support\ConditionalLogic; use Moox\Builder\Support\FieldWidth; use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Support\StorableFieldCollector; @@ -51,11 +53,15 @@ public function compile(Collection $fieldGroups, ?string $resourceClass = null): $sections = []; foreach ($fieldGroups as $group) { + $sortedFields = $group->fields->sortBy(fn (FieldDefinition $field): int => $field->sort)->values(); + $conditionalTriggers = $this->conditionalTriggerNames($sortedFields); + $components = $this->compileRootFields( - $group->fields->sortBy(fn (FieldDefinition $field): int => $field->sort)->values(), + $sortedFields, $entity, $storableFields, $group->defaultColumnSpan(), + $conditionalTriggers, ); if ($components === []) { @@ -86,14 +92,14 @@ function (Component $component, mixed $state, ?Model $record) use ($groupScalarF * @param Collection $fields * @return list */ - public function compileSubFields(Collection $fields, ?string $entity = null, ?Collection $storableFields = null, bool $insideTabs = false, ?int $defaultSpan = null): array + public function compileSubFields(Collection $fields, ?string $entity = null, ?Collection $storableFields = null, bool $insideTabs = false, ?int $defaultSpan = null, array $conditionalTriggers = []): array { $storableFields ??= collect(); return $fields ->sortBy(fn (FieldDefinition $field): int => $field->sort) ->values() - ->map(fn (FieldDefinition $field): Component => $this->compileField($field, $entity, $storableFields, $insideTabs, $defaultSpan)) + ->map(fn (FieldDefinition $field): Component => $this->compileField($field, $entity, $storableFields, $insideTabs, $defaultSpan, $conditionalTriggers)) ->all(); } @@ -179,7 +185,7 @@ public function buildGroupComponent(FieldDefinition $field, ?string $entity = nu * @param Collection $storableFields * @return list */ - protected function compileRootFields(Collection $fields, ?string $entity, Collection $storableFields, int $defaultSpan = FieldWidth::GRID_COLUMNS): array + protected function compileRootFields(Collection $fields, ?string $entity, Collection $storableFields, int $defaultSpan = FieldWidth::GRID_COLUMNS, array $conditionalTriggers = []): array { $components = []; $sorted = $fields->sortBy(fn (FieldDefinition $field): int => $field->sort)->values(); @@ -213,7 +219,7 @@ protected function compileRootFields(Collection $fields, ?string $entity, Collec ->tabs(collect($tabPanels)->map( fn (array $panel): Tab => Tab::make($panel['label']) ->columns(FieldWidth::GRID_COLUMNS) - ->schema($this->compileSubFields($panel['fields'], $entity, $storableFields, insideTabs: true, defaultSpan: $defaultSpan)), + ->schema($this->compileSubFields($panel['fields'], $entity, $storableFields, insideTabs: true, defaultSpan: $defaultSpan, conditionalTriggers: $conditionalTriggers)), )->all()); if ($entity !== null) { @@ -230,7 +236,7 @@ function (Component $component, mixed $state, ?Model $record) use ($tabScalarFie continue; } - $components[] = $this->compileField($field, $entity, $storableFields, defaultSpan: $defaultSpan); + $components[] = $this->compileField($field, $entity, $storableFields, defaultSpan: $defaultSpan, conditionalTriggers: $conditionalTriggers); $index++; } @@ -345,7 +351,7 @@ protected function rulesForField(FieldDefinition $field): array /** * @param Collection $storableFields */ - protected function compileField(FieldDefinition $field, ?string $entity = null, ?Collection $storableFields = null, bool $insideTabs = false, ?int $defaultSpan = null): Component + protected function compileField(FieldDefinition $field, ?string $entity = null, ?Collection $storableFields = null, bool $insideTabs = false, ?int $defaultSpan = null, array $conditionalTriggers = []): Component { $storableFields ??= collect(); @@ -361,6 +367,18 @@ protected function compileField(FieldDefinition $field, ?string $entity = null, $component = $fieldType->formComponent($field); $component->columnSpan($field->columnSpan($defaultSpan)); + if (ConditionalLogic::isConfigured($field)) { + $component->visible(fn (Get $get): bool => ConditionalLogic::passesForm($field, $get)); + + if (($field->validation['required'] ?? false) === true) { + $component->required(fn (Get $get): bool => ConditionalLogic::passesForm($field, $get)); + } + } + + if (in_array($field->name, $conditionalTriggers, true) && $fieldType->storesValue()) { + $component->live(); + } + if ($insideTabs && $fieldType->storesValue()) { $component->dehydratedWhenHidden(true); } @@ -553,4 +571,17 @@ protected function applyScalarFieldDefaults( } } } + + /** + * @param Collection $fields + * @return list + */ + protected function conditionalTriggerNames(Collection $fields): array + { + return $fields + ->flatMap(fn (FieldDefinition $field): array => $field->conditionTriggers()) + ->unique() + ->values() + ->all(); + } } diff --git a/packages/builder/src/Data/FieldDefinition.php b/packages/builder/src/Data/FieldDefinition.php index 9a8c4ee06e..48583125d3 100644 --- a/packages/builder/src/Data/FieldDefinition.php +++ b/packages/builder/src/Data/FieldDefinition.php @@ -7,6 +7,7 @@ use Illuminate\Support\Collection; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldOption; +use Moox\Builder\Support\ConditionalLogic; use Moox\Builder\Support\FieldWidth; readonly class FieldDefinition @@ -94,6 +95,27 @@ public function isVisibleIn(string $context): bool return (bool) ($this->settings["visible_{$context}"] ?? true); } + /** + * @return array{enabled: bool, action: string, logic: string, rules: list} + */ + public function conditions(): array + { + return ConditionalLogic::normalizeSettings($this->settings['conditions'] ?? []); + } + + public function hasConditions(): bool + { + return ConditionalLogic::isConfigured($this); + } + + /** + * @return list + */ + public function conditionTriggers(): array + { + return ConditionalLogic::triggerFieldNames($this); + } + /** * Layout width fraction (full, 1/2, 1/3, …). Defaults to full width. */ diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 332e0deeca..c376f25deb 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -33,6 +33,7 @@ use Moox\Builder\Resources\FieldGroupResource\Pages\ListFieldGroups; use Moox\Builder\Services\FieldGroupPersistence; use Moox\Builder\Support\BuilderLocaleResolver; +use Moox\Builder\Support\ConditionalLogic; use Moox\Builder\Support\FieldGroupPlacement; use Moox\Builder\Support\FieldWidth; use Moox\Builder\Support\TypedValueColumns; @@ -195,6 +196,7 @@ public static function form(Schema $schema): Schema static::requirementAndWidthRow(), ...static::columnSettingsSchema(), ...static::visibilitySettingsSchema(), + ...static::conditionalLogicSchema(), ...static::optionFieldSections($registry), Section::make(fn (callable $get): string => $get('type') === 'tab' ? __('builder::builder.field.tab_content') @@ -698,6 +700,228 @@ protected static function columnSettingsSchema(): array ]; } + /** + * Dynamic show/hide rules based on sibling field values (root-level only in v1). + * + * @return list
+ */ + protected static function conditionalLogicSchema(): array + { + $enabled = fn (callable $get): bool => (bool) $get('settings.conditions.enabled'); + + return [ + Section::make(__('builder::builder.field.conditional_logic')) + ->description(__('builder::builder.field.conditional_logic_helper')) + ->icon(Heroicon::OutlinedAdjustmentsHorizontal) + ->collapsible() + ->collapsed() + ->visible(fn (callable $get): bool => static::fieldTypeSupportsRequired($get('type'))) + ->schema([ + Toggle::make('settings.conditions.enabled') + ->label(__('builder::builder.field.conditional_logic_enabled')) + ->inline(false) + ->live() + ->afterStateUpdated(function (mixed $state, callable $set, callable $get): void { + if (! $state) { + return; + } + + if (blank($get('settings.conditions.action'))) { + $set('settings.conditions.action', ConditionalLogic::ACTION_SHOW); + } + + if (blank($get('settings.conditions.logic'))) { + $set('settings.conditions.logic', ConditionalLogic::LOGIC_AND); + } + + if (! is_array($get('settings.conditions.rules'))) { + $set('settings.conditions.rules', []); + } + }), + Grid::make(2) + ->schema([ + Select::make('settings.conditions.action') + ->label(__('builder::builder.field.conditional_logic_action')) + ->options(static::conditionalLogicActionOptions()) + ->default(ConditionalLogic::ACTION_SHOW) + ->selectablePlaceholder(false) + ->native(false), + Select::make('settings.conditions.logic') + ->label(__('builder::builder.field.conditional_logic_logic')) + ->options(static::conditionalLogicLogicOptions()) + ->default(ConditionalLogic::LOGIC_AND) + ->selectablePlaceholder(false) + ->native(false), + ]) + ->visible($enabled), + Repeater::make('settings.conditions.rules') + ->label(__('builder::builder.field.conditional_logic_rules')) + ->helperText(__('builder::builder.field.conditional_logic_rules_helper')) + ->defaultItems(0) + ->collapsible() + ->collapsed() + ->visible($enabled) + ->schema([ + Select::make('field') + ->label(__('builder::builder.field.conditional_logic_field')) + ->options(fn (callable $get): array => static::siblingFieldOptions($get)) + ->required() + ->searchable() + ->native(false), + Select::make('operator') + ->label(__('builder::builder.field.conditional_logic_operator')) + ->options(static::conditionalLogicOperatorOptions()) + ->default('equals') + ->required() + ->live() + ->selectablePlaceholder(false) + ->native(false), + TextInput::make('value') + ->label(__('builder::builder.field.conditional_logic_value')) + ->visible(fn (callable $get): bool => in_array( + $get('operator'), + ['equals', 'not_equals', 'contains'], + true, + )), + ]) + ->columns(3), + ]), + ]; + } + + /** + * @return array + */ + protected static function conditionalLogicActionOptions(): array + { + return [ + ConditionalLogic::ACTION_SHOW => __('builder::builder.field.conditional_logic_action_show'), + ConditionalLogic::ACTION_HIDE => __('builder::builder.field.conditional_logic_action_hide'), + ]; + } + + /** + * @return array + */ + protected static function conditionalLogicLogicOptions(): array + { + return [ + ConditionalLogic::LOGIC_AND => __('builder::builder.field.conditional_logic_logic_and'), + ConditionalLogic::LOGIC_OR => __('builder::builder.field.conditional_logic_logic_or'), + ]; + } + + /** + * @return array + */ + protected static function conditionalLogicOperatorOptions(): array + { + return [ + 'equals' => __('builder::builder.field.conditional_logic_operator_equals'), + 'not_equals' => __('builder::builder.field.conditional_logic_operator_not_equals'), + 'empty' => __('builder::builder.field.conditional_logic_operator_empty'), + 'not_empty' => __('builder::builder.field.conditional_logic_operator_not_empty'), + 'contains' => __('builder::builder.field.conditional_logic_operator_contains'), + ]; + } + + /** + * Lists sibling root-level fields as conditional-logic trigger options. + * + * The rules Select lives several repeater levels deep, so instead of + * hard-counting "../" segments (brittle if the editor layout changes) we + * probe a range of depths and use the first that yields the fields + * collection: an array whose entries are field rows with a "name". + * + * @return array + */ + protected static function siblingFieldOptions(callable $get): array + { + $fields = static::resolveSiblingFields($get); + + if ($fields === []) { + return []; + } + + $currentName = static::resolveCurrentFieldName($get); + $options = []; + + foreach ($fields as $field) { + if (! is_array($field)) { + continue; + } + + $name = (string) ($field['name'] ?? ''); + + if ($name === '' || $name === $currentName) { + continue; + } + + if (! static::fieldTypeSupportsRequired((string) ($field['type'] ?? ''))) { + continue; + } + + $options[$name] = (string) ($field['label'] ?? $name); + } + + asort($options); + + return $options; + } + + /** + * @return list> + */ + protected static function resolveSiblingFields(callable $get): array + { + $prefix = ''; + + for ($depth = 0; $depth <= 8; $depth++) { + $candidate = $get($prefix); + + if (static::looksLikeFieldRows($candidate)) { + /** @var array> $candidate */ + return array_values($candidate); + } + + $prefix .= '../'; + } + + return []; + } + + protected static function resolveCurrentFieldName(callable $get): ?string + { + $prefix = ''; + + for ($depth = 0; $depth <= 8; $depth++) { + $name = $get($prefix.'name'); + + if (is_string($name) && $name !== '') { + return $name; + } + + $prefix .= '../'; + } + + return null; + } + + protected static function looksLikeFieldRows(mixed $value): bool + { + if (! is_array($value) || $value === []) { + return false; + } + + foreach ($value as $row) { + if (! is_array($row) || ! array_key_exists('name', $row) || ! array_key_exists('type', $row)) { + return false; + } + } + + return true; + } + /** * Per-context visibility (admin, frontend, API) as its own collapsible * section. Reused for both fields and – via the toggles – field groups. diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index 4e65a063d2..0a8d30aded 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -14,6 +14,7 @@ use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\BuilderLocaleResolver; +use Moox\Builder\Support\ConditionalLogic; use Moox\Builder\Support\FieldVisibility; use Moox\Builder\Support\MediaIntegration; use Moox\Builder\Support\OptionValueRules; @@ -301,9 +302,11 @@ public function saveValues(string $entity, Model $record, array $values, Collect $value = $values[$field->name]; - OptionValueRules::assertValid($field, $value); + if (ConditionalLogic::isVisibleForValues($field, $values)) { + OptionValueRules::assertValid($field, $value); - $this->fieldValueValidator->assertValid($field, $value, $record); + $this->fieldValueValidator->assertValid($field, $value, $record); + } $persisted = app(BuilderValuesResolver::class)->persistFieldValue($field, $value); $columns = TypedValueColumns::attributesFor($field->type, $persisted); diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index b913b1460a..73eb262374 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -13,6 +13,7 @@ use Moox\Builder\Models\FieldOption; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\BuilderLocaleResolver; +use Moox\Builder\Support\ConditionalLogic; use Moox\Builder\Support\DefinitionTranslator; use Moox\Builder\Support\FieldRelationTree; @@ -216,6 +217,7 @@ protected function mapFieldRows(Collection $fields, ?string $locale = null): arr 'visible_frontend' => (bool) ($field->settings['visible_frontend'] ?? true), 'visible_api' => (bool) ($field->settings['visible_api'] ?? true), 'width' => $field->settings['width'] ?? null, + 'conditions' => ConditionalLogic::normalizeSettings($field->settings['conditions'] ?? []), ], 'sort' => $field->sort, 'options' => $field->options->map(fn (FieldOption $option): array => [ @@ -325,6 +327,7 @@ protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldI 'visible_frontend' => (bool) ($row['settings']['visible_frontend'] ?? true), 'visible_api' => (bool) ($row['settings']['visible_api'] ?? true), 'width' => filled($row['settings']['width'] ?? null) ? (string) $row['settings']['width'] : null, + 'conditions' => ConditionalLogic::normalizeSettings($row['settings']['conditions'] ?? []), ]), 'sort' => $index, ]); diff --git a/packages/builder/src/Support/ConditionalLogic.php b/packages/builder/src/Support/ConditionalLogic.php new file mode 100644 index 0000000000..7239c93178 --- /dev/null +++ b/packages/builder/src/Support/ConditionalLogic.php @@ -0,0 +1,250 @@ + */ + public const OPERATORS = [ + 'equals', + 'not_equals', + 'empty', + 'not_empty', + 'contains', + ]; + + public static function isConfigured(FieldDefinition $field): bool + { + $conditions = $field->conditions(); + + if (! ($conditions['enabled'] ?? false)) { + return false; + } + + return self::normalizedRules($conditions) !== []; + } + + /** + * @return list + */ + public static function triggerFieldNames(FieldDefinition $field): array + { + if (! self::isConfigured($field)) { + return []; + } + + return collect(self::normalizedRules($field->conditions())) + ->pluck('field') + ->filter(fn (mixed $name): bool => filled($name)) + ->map(fn (mixed $name): string => (string) $name) + ->unique() + ->values() + ->all(); + } + + public static function passesForm(FieldDefinition $field, callable $get): bool + { + $values = []; + + foreach (self::triggerFieldNames($field) as $trigger) { + $values[$trigger] = $get($trigger); + } + + return self::isVisibleForValues($field, $values); + } + + /** + * @param array $values + */ + public static function isVisibleForValues(FieldDefinition $field, array $values): bool + { + $conditions = $field->conditions(); + + if (! ($conditions['enabled'] ?? false)) { + return true; + } + + $rules = self::normalizedRules($conditions); + + if ($rules === []) { + return true; + } + + $action = self::normalizeAction($conditions['action'] ?? self::ACTION_SHOW); + $logic = self::normalizeLogic($conditions['logic'] ?? self::LOGIC_AND); + $matches = self::combine( + collect($rules) + ->map(fn (array $rule): bool => self::matchesRule($rule, $values)) + ->all(), + $logic, + ); + + return $action === self::ACTION_HIDE ? ! $matches : $matches; + } + + /** + * @return array{enabled: bool, action: string, logic: string, rules: list} + */ + public static function normalizeSettings(mixed $conditions): array + { + if (! is_array($conditions)) { + $conditions = []; + } + + return [ + 'enabled' => (bool) ($conditions['enabled'] ?? false), + 'action' => self::normalizeAction($conditions['action'] ?? self::ACTION_SHOW), + 'logic' => self::normalizeLogic($conditions['logic'] ?? self::LOGIC_AND), + 'rules' => self::normalizedRules($conditions), + ]; + } + + /** + * @param array $conditions + * @return list + */ + protected static function normalizedRules(array $conditions): array + { + $rules = $conditions['rules'] ?? []; + + if (! is_array($rules)) { + return []; + } + + $normalized = []; + + foreach ($rules as $rule) { + if (! is_array($rule)) { + continue; + } + + $field = (string) ($rule['field'] ?? ''); + + if ($field === '') { + continue; + } + + $operator = self::normalizeOperator($rule['operator'] ?? 'equals'); + + $normalized[] = [ + 'field' => $field, + 'operator' => $operator, + 'value' => $rule['value'] ?? null, + ]; + } + + return $normalized; + } + + /** + * @param array{field: string, operator: string, value: mixed} $rule + * @param array $values + */ + protected static function matchesRule(array $rule, array $values): bool + { + $field = $rule['field']; + $operator = $rule['operator']; + $expected = $rule['value'] ?? null; + $actual = $values[$field] ?? null; + + return match ($operator) { + 'empty' => self::isEmptyValue($actual), + 'not_empty' => ! self::isEmptyValue($actual), + 'not_equals' => ! self::valuesEqual($actual, $expected), + 'contains' => self::valueContains($actual, $expected), + default => self::valuesEqual($actual, $expected), + }; + } + + /** + * @param list $results + */ + protected static function combine(array $results, string $logic): bool + { + if ($results === []) { + return false; + } + + if ($logic === self::LOGIC_OR) { + return in_array(true, $results, true); + } + + return ! in_array(false, $results, true); + } + + protected static function normalizeAction(mixed $action): string + { + return $action === self::ACTION_HIDE ? self::ACTION_HIDE : self::ACTION_SHOW; + } + + protected static function normalizeLogic(mixed $logic): string + { + return $logic === self::LOGIC_OR ? self::LOGIC_OR : self::LOGIC_AND; + } + + protected static function normalizeOperator(mixed $operator): string + { + $operator = (string) $operator; + + return in_array($operator, self::OPERATORS, true) ? $operator : 'equals'; + } + + protected static function isEmptyValue(mixed $value): bool + { + if ($value === null) { + return true; + } + + if (is_bool($value)) { + return $value === false; + } + + if (is_array($value)) { + return $value === []; + } + + return blank($value); + } + + protected static function valuesEqual(mixed $actual, mixed $expected): bool + { + if (is_bool($actual)) { + return $actual === filter_var($expected, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + } + + if (is_array($actual)) { + return in_array($expected, $actual, true); + } + + if (is_numeric($actual) && is_numeric($expected)) { + return (string) $actual === (string) $expected; + } + + return (string) $actual === (string) $expected; + } + + protected static function valueContains(mixed $actual, mixed $expected): bool + { + if (is_array($actual)) { + return in_array($expected, $actual, true) + || collect($actual)->contains(fn (mixed $item): bool => self::valuesEqual($item, $expected)); + } + + return self::valuesEqual($actual, $expected); + } +} From 07fc79abbf53fcc58e0f67b89d5e8ae6a7259de7 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:40:02 +0200 Subject: [PATCH 065/105] Fix narrow tab rendering in compiled custom field forms --- packages/builder/src/Compiler/SchemaCompiler.php | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 31df722ce2..bf80aa503d 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -216,6 +216,7 @@ protected function compileRootFields(Collection $fields, ?string $entity, Collec ->values(); $tabs = Tabs::make('custom_fields_tabs') + ->columnSpanFull() ->tabs(collect($tabPanels)->map( fn (array $panel): Tab => Tab::make($panel['label']) ->columns(FieldWidth::GRID_COLUMNS) From d603721935a1ef68081e0519d14b3e5a8c80b6ee Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:00:15 +0200 Subject: [PATCH 066/105] Harden builder custom fields and trim dead code --- .../builder/src/BuilderServiceProvider.php | 6 + .../FieldTypes/Types/RichTextFieldType.php | 13 ++ .../builder/src/Registry/EntityRegistry.php | 37 +++++- .../src/Services/CustomFieldsManager.php | 58 ++++---- .../src/Services/FieldGroupPersistence.php | 34 ++++- .../builder/src/Support/FieldVisibility.php | 8 -- packages/builder/src/Support/FieldWidth.php | 8 -- .../builder/src/Support/HtmlSanitizer.php | 125 ++++++++++++++++++ .../src/Support/RelationTargetResolver.php | 82 ++++++++++-- 9 files changed, 306 insertions(+), 65 deletions(-) create mode 100644 packages/builder/src/Support/HtmlSanitizer.php diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index c40f7d05f3..ba519c66ca 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -55,6 +55,7 @@ use Moox\Builder\Support\CustomFieldsFilamentHooks; use Moox\Builder\Support\EntityModelDeletionRegistrar; use Moox\Builder\Support\MediaIntegration; +use Moox\Builder\Support\RelationTargetResolver; use Moox\Core\MooxServiceProvider; use Spatie\LaravelPackageTools\Package; @@ -91,6 +92,11 @@ public function packageRegistered(): void }); $this->app->singleton(EntityRegistry::class); + + // Request-scoped so its relation-label memo is reused across table rows + // and re-renders within a request, while staying Octane-safe (reset per + // request, so target title changes are never served stale). + $this->app->scoped(RelationTargetResolver::class); } public function packageBooted(): void diff --git a/packages/builder/src/FieldTypes/Types/RichTextFieldType.php b/packages/builder/src/FieldTypes/Types/RichTextFieldType.php index c3cdf1f521..9d0a724b08 100644 --- a/packages/builder/src/FieldTypes/Types/RichTextFieldType.php +++ b/packages/builder/src/FieldTypes/Types/RichTextFieldType.php @@ -13,6 +13,7 @@ use Moox\Builder\FieldTypes\Capabilities\HelperText; use Moox\Builder\FieldTypes\Capabilities\MaxLength; use Moox\Builder\FieldTypes\FieldType; +use Moox\Builder\Support\HtmlSanitizer; use Moox\Builder\Support\RichTextValue; class RichTextFieldType extends FieldType @@ -22,6 +23,18 @@ public static function key(): string return 'rich_text'; } + public function persistValue(mixed $value, ?FieldDefinition $field = null): mixed + { + // Rich text is authored HTML and therefore untrusted; strip executable + // XSS vectors before storing so API/frontend consumers receive safe + // markup. Structured (JSON document) values are left untouched. + if (is_string($value)) { + return HtmlSanitizer::clean($value); + } + + return $value; + } + public function capabilities(): array { return [ diff --git a/packages/builder/src/Registry/EntityRegistry.php b/packages/builder/src/Registry/EntityRegistry.php index a9f69cee29..d637a75d86 100644 --- a/packages/builder/src/Registry/EntityRegistry.php +++ b/packages/builder/src/Registry/EntityRegistry.php @@ -12,11 +12,29 @@ class EntityRegistry { + /** + * Memoized entity/relatable discovery. The set of Filament resources is + * fixed once panels are booted, so scanning them once per request (this + * singleton's lifetime) avoids repeated, expensive panel traversals. + * + * @var array|null + */ + protected ?array $allCache = null; + + /** + * @var array|null + */ + protected ?array $relatableCache = null; + /** * @return array */ public function all(): array { + if ($this->allCache !== null) { + return $this->allCache; + } + $entities = []; foreach ($this->traitResources() as $resourceClass) { @@ -32,7 +50,7 @@ public function all(): array ]; } - return $entities; + return $this->allCache = $entities; } /** @@ -147,6 +165,10 @@ public function optionsForSelect(): array */ public function relatableResources(): array { + if ($this->relatableCache !== null) { + return $this->relatableCache; + } + $resources = []; $seenModels = []; @@ -186,7 +208,7 @@ public function relatableResources(): array ksort($resources); - return $resources; + return $this->relatableCache = $resources; } /** @@ -261,12 +283,21 @@ public function modelIsQueryable(string $modelClass): bool } } + /** + * @var array + */ + protected static array $usesCustomFieldsCache = []; + /** * @param class-string $resourceClass */ public function usesCustomFields(string $resourceClass): bool { - return in_array(HasCustomFields::class, class_uses_recursive($resourceClass), true); + return self::$usesCustomFieldsCache[$resourceClass] ??= in_array( + HasCustomFields::class, + class_uses_recursive($resourceClass), + true, + ); } /** diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index 0a8d30aded..ca8b5f8117 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -6,18 +6,17 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; -use Moox\Builder\Concerns\HasCustomFields; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Data\LocationContext; use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; +use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\BuilderLocaleResolver; use Moox\Builder\Support\ConditionalLogic; use Moox\Builder\Support\FieldVisibility; use Moox\Builder\Support\MediaIntegration; -use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Support\StorableFieldCollector; use Moox\Builder\Support\TypedValueColumns; @@ -34,6 +33,7 @@ public function __construct( protected FieldValueValidator $fieldValueValidator, protected StorableFieldCollector $storableFieldCollector, protected BuilderLocaleResolver $localeResolver, + protected EntityRegistry $entityRegistry, ) {} /** @@ -83,24 +83,6 @@ public function visibleFieldsForEntity(string $entity, string $context): Collect return $groups->flatMap(fn ($group) => $this->storableFieldCollector->definitionsFromList($group->fields))->values(); } - /** - * @return array - */ - public function loadForModel(Model $record, string $entity, bool $fresh = false): array - { - $fields = $this->fieldsForEntity($entity); - - if ($fields->isEmpty()) { - return []; - } - - if ($fresh) { - return $this->loadValues($entity, $record, $fields); - } - - return $this->loadCachedValues($entity, $record, $fields); - } - /** * @param class-string $resourceClass * @return array @@ -231,6 +213,26 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da $entity = $this->locationContextForResource($resourceClass)->entity; $locale = $this->localeResolver->valuesLocaleForResource($resourceClass); + // Only trust submitted values for fields that are actually part of the + // admin form. Admin-hidden fields must not be writable via crafted + // requests; they still receive their configured defaults below. + $adminVisibleNames = array_flip( + $this->visibleFieldsForEntity($entity, FieldVisibility::ADMIN)->pluck('name')->all(), + ); + $data = array_intersect_key($data, $adminVisibleNames); + + // Load the already-stored field names once instead of issuing an + // exists() query per field when deciding whether to apply defaults. + $storedFieldNames = $record->exists + ? array_flip( + FieldValue::query() + ->forRecord($entity, $record->getKey()) + ->forLocale($locale) + ->pluck('field_name') + ->all(), + ) + : []; + foreach ($fields as $field) { $fieldType = $this->fieldTypeRegistry->get($field->type); @@ -248,12 +250,7 @@ public function saveFromFormData(string $resourceClass, Model $record, array $da $value = $data[$field->name]; - $hasStoredValue = $record->exists - && FieldValue::query() - ->forRecord($entity, $record->getKey()) - ->forLocale($locale) - ->where('field_name', $field->name) - ->exists(); + $hasStoredValue = isset($storedFieldNames[$field->name]); if (! $hasStoredValue && $defaultValue->hasConfiguredDefault($field)) { if ($field->type === 'toggle' || $defaultValue->shouldApplyDefault($value, $field->type)) { @@ -303,9 +300,12 @@ public function saveValues(string $entity, Model $record, array $values, Collect $value = $values[$field->name]; if (ConditionalLogic::isVisibleForValues($field, $values)) { - OptionValueRules::assertValid($field, $value); - $this->fieldValueValidator->assertValid($field, $value, $record); + } else { + // A field hidden by conditional logic must never accept the + // submitted (untrusted, unvalidated) value. Clear it instead of + // persisting whatever the request contained. + $value = null; } $persisted = app(BuilderValuesResolver::class)->persistFieldValue($field, $value); @@ -371,6 +371,6 @@ protected function valuesCacheKey(string $entity, int|string $recordId, ?string */ public function usesCustomFields(string $resourceClass): bool { - return in_array(HasCustomFields::class, class_uses_recursive($resourceClass), true); + return $this->entityRegistry->usesCustomFields($resourceClass); } } diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 73eb262374..153ac75302 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -6,11 +6,11 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection; -use Illuminate\Support\Str; use Moox\Builder\FieldTypes\Capabilities\DisplayFormat; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldOption; +use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Support\BuilderLocaleResolver; use Moox\Builder\Support\ConditionalLogic; @@ -23,6 +23,7 @@ public function __construct( protected FieldValuePurger $fieldValuePurger, protected BuilderLocaleResolver $localeResolver, protected DefinitionTranslator $definitionTranslator, + protected EntityRegistry $entityRegistry, ) {} /** @@ -456,11 +457,6 @@ protected function syncOptionTranslation(FieldOption $option, string $label): vo $option->syncTranslationFallbackOnMainRecord($locale, ['label' => $label]); } - public function slugFromName(string $name): string - { - return Str::slug($name); - } - /** * @param Collection $existing * @param array $row @@ -533,9 +529,35 @@ protected function filterConfigForType(string $type, array $config): array $filtered['displayFormat'] = DisplayFormat::defaultFor($type); } + if ($type === 'relation' && array_key_exists('related_entity', $filtered)) { + $related = $this->sanitizeRelatedEntity($filtered['related_entity']); + + if ($related === null) { + unset($filtered['related_entity']); + } else { + $filtered['related_entity'] = $related; + } + } + return $filtered; } + /** + * Only persist a related entity that is an actually relatable, registered + * resource. Prevents storing an arbitrary target string that would let the + * relation picker query models outside the intended whitelist. + */ + protected function sanitizeRelatedEntity(mixed $relatedEntity): ?string + { + if (! is_string($relatedEntity) || $relatedEntity === '') { + return null; + } + + $allowed = array_keys($this->entityRegistry->relatableResources()); + + return in_array($relatedEntity, $allowed, true) ? $relatedEntity : null; + } + /** * @return list */ diff --git a/packages/builder/src/Support/FieldVisibility.php b/packages/builder/src/Support/FieldVisibility.php index f209a8bfa4..e2de45e289 100644 --- a/packages/builder/src/Support/FieldVisibility.php +++ b/packages/builder/src/Support/FieldVisibility.php @@ -27,14 +27,6 @@ final class FieldVisibility /** @var list */ public const CONTEXTS = [self::ADMIN, self::FRONTEND, self::API]; - /** - * @param array $settings - */ - public static function isVisible(array $settings, string $context): bool - { - return (bool) ($settings["visible_{$context}"] ?? true); - } - /** * Keep only the groups (and, cascading, the fields) visible in the given context. * diff --git a/packages/builder/src/Support/FieldWidth.php b/packages/builder/src/Support/FieldWidth.php index 25d9b75918..ca0b4fdf5c 100644 --- a/packages/builder/src/Support/FieldWidth.php +++ b/packages/builder/src/Support/FieldWidth.php @@ -76,12 +76,4 @@ public static function columnsToSpan(mixed $columns): int { return intdiv(self::GRID_COLUMNS, self::normalizeColumns($columns)); } - - /** - * @return list - */ - public static function values(): array - { - return array_keys(self::SPANS); - } } diff --git a/packages/builder/src/Support/HtmlSanitizer.php b/packages/builder/src/Support/HtmlSanitizer.php new file mode 100644 index 0000000000..5ead853705 --- /dev/null +++ b/packages/builder/src/Support/HtmlSanitizer.php @@ -0,0 +1,125 @@ + + */ + private const FORBIDDEN_ELEMENTS = [ + 'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', + 'button', 'textarea', 'select', 'option', 'link', 'meta', 'base', + 'svg', 'math', 'applet', 'frame', 'frameset', 'template', + ]; + + /** + * URL attributes whose value must not resolve to an executable scheme. + * + * @var list + */ + private const URL_ATTRIBUTES = ['href', 'src', 'xlink:href', 'action', 'formaction']; + + public static function clean(string $html): string + { + if (trim($html) === '') { + return $html; + } + + $document = new DOMDocument; + + $previous = libxml_use_internal_errors(true); + + $loaded = $document->loadHTML( + '
'.$html.'
', + LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NONET, + ); + + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + if ($loaded === false) { + return ''; + } + + $root = $document->getElementById('moox-sanitizer-root'); + + if (! $root instanceof DOMElement) { + return ''; + } + + self::sanitizeNode($root); + + $result = ''; + + foreach (iterator_to_array($root->childNodes) as $child) { + $result .= $document->saveHTML($child); + } + + return $result; + } + + private static function sanitizeNode(DOMNode $node): void + { + foreach (iterator_to_array($node->childNodes) as $child) { + if (! $child instanceof DOMElement) { + continue; + } + + if (in_array(strtolower($child->nodeName), self::FORBIDDEN_ELEMENTS, true)) { + $child->parentNode?->removeChild($child); + + continue; + } + + self::sanitizeAttributes($child); + self::sanitizeNode($child); + } + } + + private static function sanitizeAttributes(DOMElement $element): void + { + /** @var list $attributes */ + $attributes = iterator_to_array($element->attributes); + + foreach ($attributes as $attribute) { + $name = strtolower($attribute->nodeName); + + if (str_starts_with($name, 'on')) { + $element->removeAttribute($attribute->nodeName); + + continue; + } + + if (in_array($name, self::URL_ATTRIBUTES, true) && self::isDangerousUrl($attribute->value)) { + $element->removeAttribute($attribute->nodeName); + } + } + } + + private static function isDangerousUrl(string $value): bool + { + $normalized = strtolower(trim(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'))); + $normalized = preg_replace('/[\x00-\x20]+/', '', $normalized) ?? $normalized; + + return str_starts_with($normalized, 'javascript:') + || str_starts_with($normalized, 'vbscript:') + || str_starts_with($normalized, 'data:text/html'); + } +} diff --git a/packages/builder/src/Support/RelationTargetResolver.php b/packages/builder/src/Support/RelationTargetResolver.php index 6232271d9c..52d88b2c19 100644 --- a/packages/builder/src/Support/RelationTargetResolver.php +++ b/packages/builder/src/Support/RelationTargetResolver.php @@ -4,6 +4,7 @@ namespace Moox\Builder\Support; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\Schema; @@ -14,6 +15,16 @@ */ final class RelationTargetResolver { + /** + * Request-scoped label memo (entity => [id => label]) so repeated relation + * targets across table rows and Livewire re-renders resolve without extra + * queries. The resolver is bound as scoped(), so this never outlives a + * request. + * + * @var array> + */ + protected array $labelMemo = []; + public function __construct( protected EntityRegistry $entityRegistry, ) {} @@ -52,7 +63,7 @@ public function search(string $entity, string $term, int $limit = 50): array } try { - $query = $modelClass::query(); + $query = $this->scopedQuery($entity, $modelClass); if ($term !== '') { $query->where($titleAttribute, 'like', "%{$term}%"); @@ -88,21 +99,45 @@ public function labelsFor(string $entity, array $ids): array return []; } - $modelClass = $this->modelClass($entity); + $memo = $this->labelMemo[$entity] ?? []; + $missing = array_values(array_filter($ids, fn (mixed $id): bool => ! array_key_exists($id, $memo))); - if ($modelClass === null || ! $this->entityRegistry->modelIsQueryable($modelClass)) { - return []; + if ($missing !== []) { + $modelClass = $this->modelClass($entity); + + if ($modelClass === null || ! $this->entityRegistry->modelIsQueryable($modelClass)) { + return $this->pickLabels($memo, $ids); + } + + $model = new $modelClass; + + try { + foreach ($modelClass::query()->whereIn($model->getKeyName(), $missing)->get() as $record) { + $memo[$record->getKey()] = $this->titleFor($entity, $record); + } + + $this->labelMemo[$entity] = $memo; + } catch (QueryException) { + return $this->pickLabels($memo, $ids); + } } - $model = new $modelClass; + return $this->pickLabels($memo, $ids); + } + + /** + * @param array $memo + * @param list $ids + * @return array + */ + protected function pickLabels(array $memo, array $ids): array + { $labels = []; - try { - foreach ($modelClass::query()->whereIn($model->getKeyName(), $ids)->get() as $record) { - $labels[$record->getKey()] = $this->titleFor($entity, $record); + foreach ($ids as $id) { + if (array_key_exists($id, $memo)) { + $labels[$id] = $memo[$id]; } - } catch (QueryException) { - return []; } return $labels; @@ -142,12 +177,37 @@ public function recordExists(string $entity, int|string $id): bool } try { - return $modelClass::query()->whereKey($id)->exists(); + return $this->scopedQuery($entity, $modelClass)->whereKey($id)->exists(); } catch (QueryException) { return false; } } + /** + * Base query for enumerating/validating relation targets. Prefers the + * target resource's own Eloquent query so global/tenant/soft-delete scopes + * apply, preventing selection or validation of records that lie outside the + * resource's intended visibility. Falls back to the plain model query when + * no scoped resource query is available (e.g. outside a panel context). + * + * @param class-string $modelClass + * @return Builder + */ + protected function scopedQuery(string $entity, string $modelClass): Builder + { + $resource = $this->resourceClass($entity); + + if ($resource !== null && method_exists($resource, 'getEloquentQuery')) { + try { + return $resource::getEloquentQuery(); + } catch (\Throwable) { + // Fall back to the unscoped model query below. + } + } + + return $modelClass::query(); + } + protected function titleFor(string $entity, Model $record): string { $resource = $this->resourceClass($entity); From 8c2c37cee840f3b821dd91e4e26487068b6c85d2 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:24:37 +0200 Subject: [PATCH 067/105] update readme --- packages/builder/README.md | 113 +++++++++++++++++++++++++++++++++---- 1 file changed, 103 insertions(+), 10 deletions(-) diff --git a/packages/builder/README.md b/packages/builder/README.md index 75a0971326..86eda77d25 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -137,7 +137,7 @@ One row per value: | `value_date` | date | | `value_datetime` | datetime | | `value_boolean` | toggle | -| `value_json` | multiselect, checkbox_list, link, image, gallery, file, group, repeater, flexible_content | +| `value_json` | multiselect, checkbox_list, link, relation, image, gallery, file, group, repeater, flexible_content | | `locale` | Locale variant for this value (e.g. `en_US`, `de_CH`) | Unique: `(entity, record_id, field_name, locale)`. @@ -185,6 +185,7 @@ Each inner list = AND group, multiple groups = OR. The matcher currently only su 3. SchemaCompiler::compile() → one Filament section per group → layout fields: tabs, group, repeater, flexible content (Builder) + → conditional logic: visible() closures on fields with rules → afterStateHydrated loads values via CustomFieldsManager (one query per record, cached) → Filament Builder: hydrateItems() for UUID-based block keys ``` @@ -200,8 +201,9 @@ Each inner list = AND group, multiple groups = OR. The matcher currently only su → checks: does the resource use HasCustomFields? 4. CustomFieldsManager::saveFromFormData() - → extracts known field keys from $data - → FieldValueValidator + OptionValueRules + → only admin-visible fields accept submitted values (crafting hidden fields is ignored) + → FieldValueValidator (includes option/relation/media rules) + → fields hidden by conditional logic skip validation and are cleared on save → updateOrCreate in builder_field_values ``` @@ -317,6 +319,9 @@ Navigation: **Fields → Field Groups** | **Options** | For select/radio/multiselect/checkbox list | | **Subfields** | For group and repeater | | **Layouts** | For flexible content (layout key + subfields) | +| **Visibility** | Per context: admin, frontend, API (`visible_*` toggles) | +| **Conditional logic** | Show/hide rules based on sibling field values (root-level v1) | +| **Table column** | Optional list-column settings for scalar, media, and relation fields | Repeater rows are collapsed by default and show type, key, and required flag in the label. @@ -328,7 +333,7 @@ Package UI strings: `resources/lang/de/builder.php`, `en/builder.php`. ## Field Types & Capabilities -### Built-in field types (28 with `moox/media`, otherwise 25) +### Built-in field types (29 with `moox/media`, otherwise 26) | Category | Keys | |----------|------| @@ -337,6 +342,7 @@ Package UI strings: `resources/lang/de/builder.php`, `en/builder.php`. | **Choice** | `select`, `multiselect`, `checkbox_list`, `radio`, `button_group`, `toggle` | | **Date** | `date`, `datetime`, `time` | | **Other** | `color`, `link`, `message`, `oembed` | +| **Relation** | `relation` (link to other Moox Filament entities) | | **Media** *(requires `moox/media`)* | `image`, `gallery`, `file` | | **Layout** | `tab`, `section`, `group`, `repeater`, `flexible_content` | @@ -361,6 +367,53 @@ Types `image`, `gallery`, and `file` are only registered when `moox/media` is in API output uses `presentValue()` / `MediaItemResource` (URLs, thumbnails — no `internal_note`). See [API Serialization](#api-serialization). +### Relation fields + +Type `relation` links a custom field to records of another Moox entity (any Filament resource with a queryable model — not limited to resources that use custom fields). + +| Setting | Effect | +|---------|--------| +| `config.related_entity` | Target entity key (from `EntityRegistry::relatableResources()`) | +| `config.multiple` | Single select vs multi-select | +| `config.min` / `config.max` | Selection limits when `multiple` is enabled | + +**Runtime:** searchable `Select` with preloaded suggestions, scoped queries via the target resource's `getEloquentQuery()` (tenant/soft-delete scopes apply). + +**Storage:** pure IDs in `value_json` (single ID or array). + +**API:** `presentValue()` resolves `{id, label}` objects via `RelationTargetResolver`. + +Invalid or non-relatable `related_entity` values are stripped when field groups are saved. + +### Conditional logic (v1) + +Fields can be shown or hidden based on other field values in the **same field group** (root-level siblings only — not inside repeaters, groups, or flexible layouts). + +| Setting | Values | +|---------|--------| +| `settings.conditions.enabled` | Toggle rules on/off | +| `settings.conditions.action` | `show` or `hide` | +| `settings.conditions.logic` | `and` or `or` | +| `settings.conditions.rules` | `{field, operator, value}` per rule | + +**Operators:** `equals`, `not_equals`, `empty`, `not_empty`, `contains`. + +**Save behaviour:** hidden fields are not validated; any submitted value for a hidden field is cleared (not persisted). This matches ACF-style semantics and prevents crafted requests from writing unvalidated data. + +**Form behaviour:** `SchemaCompiler` applies Filament `visible()` closures that re-evaluate when trigger fields change (`->live()`). + +### Field visibility (contexts) + +Each field and field group can be toggled per context: + +| Context | Key | Wired at runtime | +|---------|-----|------------------| +| Admin | `visible_admin` | Yes — `HasCustomFields`, `saveFromFormData` | +| API | `visible_api` | Yes — `MergesCustomFields` | +| Frontend | `visible_frontend` | Configurable in admin; **no packaged frontend renderer yet** | + +Use `CustomFieldsManager::visibleFieldsForEntity($entity, FieldVisibility::API)` (or `ADMIN`) when building custom consumers. + ### Layout fields | Type | Filament component | Storage | @@ -402,6 +455,7 @@ Widths apply inside groups, tabs, sections, and repeaters. Defaulting to `full` | `MessageBody` | Info text (message) | | `RepeaterItems` | Min/max entries (repeater, flexible content) | | `GalleryFiles` | Min/max files (gallery) | +| `RelationSettings` | Target entity, multiple, min/max (relation) | Each field type implements `FieldType`: `key()`, `formComponent()`, `capabilities()`, optionally `castValue()`, `hasSubFields()`, `hasLayouts()`. @@ -410,6 +464,9 @@ Each field type implements `FieldType`: `key()`, `formComponent()`, `capabilitie - **Required fields** and **capabilities** are applied as Filament rules on the component. - **Nested values** (repeater, group, flexible content) are also validated by `FieldValueValidator` — including empty repeater rows and unknown layouts. - **Media fields:** existence, scope, MIME type (image vs file), and min/max files for gallery. +- **Relation fields:** target exists, scoped to the resource query, min/max when multiple. +- **Conditional logic:** required rules on hidden fields are skipped; hidden values are not persisted. +- **Rich text:** HTML strings are sanitized on persist (`HtmlSanitizer`); prefer rendering API output through Filament's `RichContentRenderer::toHtml()` / `Str::sanitizeHtml()` when converting TipTap JSON to HTML. --- @@ -627,6 +684,8 @@ return ItemApiResource::collection($items); | `group` | `{"subfield": ...}` (nested, presented) | | `repeater` | `[{...}, {...}]` | | `flexible_content` | `[{"type": "hero", "data": {...}}]` | +| `relation` (single) | `{"id": 1, "label": "Record title"}` | +| `relation` (multiple) | `[{"id": 1, "label": "..."}, ...]` | ### Without JsonResource @@ -663,7 +722,8 @@ packages/builder/ │ └── InteractsWithBuilderLocale.php ├── Compiler/ │ ├── LocationMatcher.php - │ └── SchemaCompiler.php + │ ├── SchemaCompiler.php + │ └── TableColumnCompiler.php ├── Data/ │ ├── FieldDefinition.php │ ├── FieldGroupDefinition.php @@ -702,12 +762,20 @@ packages/builder/ │ └── FieldValueValidator.php └── Support/ ├── BuilderLocaleResolver.php + ├── ConditionalLogic.php ├── CustomFieldsFilamentHooks.php + ├── CustomFieldsTranslatability.php ├── DefinitionTranslator.php ├── EntityModelDeletionRegistrar.php + ├── FieldVisibility.php + ├── FieldWidth.php + ├── HtmlSanitizer.php ├── MediaFieldValueSupport.php ├── MediaIntegration.php ├── OptionValueRules.php + ├── RelationTargetResolver.php + ├── RelationValueRules.php + ├── RichTextValue.php └── TypedValueColumns.php ``` @@ -752,6 +820,9 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc | Values in `builder_field_values` | typed columns filled | | Flexible content sortable | no errors after save/load | | Image/gallery/file (with `moox/media`) | library filtered, save/load works, `media_usables` updated | +| Relation field on a group | searchable select, save/load IDs, API shows labels | +| Conditional logic (show/hide) | field visibility updates live; hidden required fields do not block save | +| `?lang=` on translatable resource | values stored per `locale` column | --- @@ -760,29 +831,51 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc **Implemented:** - Nested fields via `parent_field_id` (group, repeater, flexible content) -- Layout fields: tab, group, repeater, flexible content +- Layout fields: tab, section, group, repeater, flexible content - Entity discovery via `HasCustomFields` in Filament panels - Nested validation (`FieldValueValidator`) - `InteractsWithCustomFields` on consumer models (`customFields()`, attribute access, queries, eager load) -- `MergesCustomFields` for API resources -- `FieldType::presentValue()` for API serialization (ISO dates, password masking, nested fields, media via `MediaItemResource`) +- `MergesCustomFields` for API resources (respects `visible_api`) +- `FieldType::presentValue()` for API serialization (ISO dates, password masking, nested fields, media, relations) - Repeater min/max (`RepeaterItems` capability) - Media fields: `image`, `gallery`, `file` (optional with `moox/media`) - `BuilderMediaPicker` with isolated modal per field and MIME filter in the library - `media_usables` sync and metadata snapshot updates for media fields +- **Relation fields** — link to Moox Filament entities, scoped search/validation, API `{id, label}` output +- **Conditional logic (v1)** — show/hide on root-level sibling fields; save-side enforcement +- **Per-context visibility** — `visible_admin`, `visible_api`, `visible_frontend` (admin + API wired) +- **Table columns** — opt-in list columns for scalar, media, and relation fields (`TableColumnCompiler`) +- **Field width grid** — 12-column layout per field (`FieldWidth`) +- **Sidebar placement** — `main` vs `sidebar` field group slots +- **Translations** — definition + value locales (Astrotomic + `builder_field_values.locale`) +- **Security hardening** — admin-hidden fields not writable via request; relation targets whitelisted and scoped; rich-text HTML sanitization on persist + +**v1 limitations (known):** + +- Location rules: only `entity` param (`==` / `!=`) — no taxonomy, template, user role, etc. +- Conditional logic: root-level siblings only — not inside repeaters/groups/flexible content +- `visible_frontend`: stored in admin, but no packaged Blade/theme renderer yet +- Custom `validation.rules`: supported in schema/DB, no admin UI (programmatic only) +- Relation targets: Filament-registered Moox resources only (not arbitrary Eloquent models) +- No field-group import/export (staging → production is manual today) +- No clone field type (ACF-style reusable field group in a field) **Not implemented yet:** -- Relational fields (post object, relationship, user, taxonomy) - Clone field type (ACF) - Location params beyond `entity` -- Conditional logic in forms +- Nested conditional logic (inside compound fields) +- Frontend rendering helper (`get_field()`-style Blade component) +- Field group definition import/export +- Custom validation rules UI in admin +- Package-level policies on field group management **Intentionally out of scope:** - WordPress/postmeta driver - JSON column on the model - Accordion as its own field type (tabs + sections are enough) +- Packaged HTTP REST routes (use `MergesCustomFields` / `BuilderValuesResolver` in your API layer) --- From 46cac70d41a8e93006727dc2d936da730be00c81 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:14:22 +0200 Subject: [PATCH 068/105] Sanitize TipTap JSON rich text on persist via Filament --- packages/builder/README.md | 3 +- .../FieldTypes/Types/RichTextFieldType.php | 10 +- .../builder/src/Support/HtmlSanitizer.php | 125 ------------------ .../builder/src/Support/RichTextValue.php | 28 ++++ 4 files changed, 30 insertions(+), 136 deletions(-) delete mode 100644 packages/builder/src/Support/HtmlSanitizer.php diff --git a/packages/builder/README.md b/packages/builder/README.md index 86eda77d25..945f841db7 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -466,7 +466,7 @@ Each field type implements `FieldType`: `key()`, `formComponent()`, `capabilitie - **Media fields:** existence, scope, MIME type (image vs file), and min/max files for gallery. - **Relation fields:** target exists, scoped to the resource query, min/max when multiple. - **Conditional logic:** required rules on hidden fields are skipped; hidden values are not persisted. -- **Rich text:** HTML strings are sanitized on persist (`HtmlSanitizer`); prefer rendering API output through Filament's `RichContentRenderer::toHtml()` / `Str::sanitizeHtml()` when converting TipTap JSON to HTML. +- **Rich text:** HTML strings and TipTap JSON documents are sanitized on persist via Filament's `Str::sanitizeHtml()` and `RichContentRenderer::toHtml()`; stored values are always safe HTML in `value_text`. --- @@ -769,7 +769,6 @@ packages/builder/ ├── EntityModelDeletionRegistrar.php ├── FieldVisibility.php ├── FieldWidth.php - ├── HtmlSanitizer.php ├── MediaFieldValueSupport.php ├── MediaIntegration.php ├── OptionValueRules.php diff --git a/packages/builder/src/FieldTypes/Types/RichTextFieldType.php b/packages/builder/src/FieldTypes/Types/RichTextFieldType.php index 9d0a724b08..c977990ac8 100644 --- a/packages/builder/src/FieldTypes/Types/RichTextFieldType.php +++ b/packages/builder/src/FieldTypes/Types/RichTextFieldType.php @@ -13,7 +13,6 @@ use Moox\Builder\FieldTypes\Capabilities\HelperText; use Moox\Builder\FieldTypes\Capabilities\MaxLength; use Moox\Builder\FieldTypes\FieldType; -use Moox\Builder\Support\HtmlSanitizer; use Moox\Builder\Support\RichTextValue; class RichTextFieldType extends FieldType @@ -25,14 +24,7 @@ public static function key(): string public function persistValue(mixed $value, ?FieldDefinition $field = null): mixed { - // Rich text is authored HTML and therefore untrusted; strip executable - // XSS vectors before storing so API/frontend consumers receive safe - // markup. Structured (JSON document) values are left untouched. - if (is_string($value)) { - return HtmlSanitizer::clean($value); - } - - return $value; + return RichTextValue::sanitizeForPersist($value); } public function capabilities(): array diff --git a/packages/builder/src/Support/HtmlSanitizer.php b/packages/builder/src/Support/HtmlSanitizer.php deleted file mode 100644 index 5ead853705..0000000000 --- a/packages/builder/src/Support/HtmlSanitizer.php +++ /dev/null @@ -1,125 +0,0 @@ - - */ - private const FORBIDDEN_ELEMENTS = [ - 'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', - 'button', 'textarea', 'select', 'option', 'link', 'meta', 'base', - 'svg', 'math', 'applet', 'frame', 'frameset', 'template', - ]; - - /** - * URL attributes whose value must not resolve to an executable scheme. - * - * @var list - */ - private const URL_ATTRIBUTES = ['href', 'src', 'xlink:href', 'action', 'formaction']; - - public static function clean(string $html): string - { - if (trim($html) === '') { - return $html; - } - - $document = new DOMDocument; - - $previous = libxml_use_internal_errors(true); - - $loaded = $document->loadHTML( - '
'.$html.'
', - LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NONET, - ); - - libxml_clear_errors(); - libxml_use_internal_errors($previous); - - if ($loaded === false) { - return ''; - } - - $root = $document->getElementById('moox-sanitizer-root'); - - if (! $root instanceof DOMElement) { - return ''; - } - - self::sanitizeNode($root); - - $result = ''; - - foreach (iterator_to_array($root->childNodes) as $child) { - $result .= $document->saveHTML($child); - } - - return $result; - } - - private static function sanitizeNode(DOMNode $node): void - { - foreach (iterator_to_array($node->childNodes) as $child) { - if (! $child instanceof DOMElement) { - continue; - } - - if (in_array(strtolower($child->nodeName), self::FORBIDDEN_ELEMENTS, true)) { - $child->parentNode?->removeChild($child); - - continue; - } - - self::sanitizeAttributes($child); - self::sanitizeNode($child); - } - } - - private static function sanitizeAttributes(DOMElement $element): void - { - /** @var list $attributes */ - $attributes = iterator_to_array($element->attributes); - - foreach ($attributes as $attribute) { - $name = strtolower($attribute->nodeName); - - if (str_starts_with($name, 'on')) { - $element->removeAttribute($attribute->nodeName); - - continue; - } - - if (in_array($name, self::URL_ATTRIBUTES, true) && self::isDangerousUrl($attribute->value)) { - $element->removeAttribute($attribute->nodeName); - } - } - } - - private static function isDangerousUrl(string $value): bool - { - $normalized = strtolower(trim(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'))); - $normalized = preg_replace('/[\x00-\x20]+/', '', $normalized) ?? $normalized; - - return str_starts_with($normalized, 'javascript:') - || str_starts_with($normalized, 'vbscript:') - || str_starts_with($normalized, 'data:text/html'); - } -} diff --git a/packages/builder/src/Support/RichTextValue.php b/packages/builder/src/Support/RichTextValue.php index 264fc8225b..b97bce2031 100644 --- a/packages/builder/src/Support/RichTextValue.php +++ b/packages/builder/src/Support/RichTextValue.php @@ -4,8 +4,36 @@ namespace Moox\Builder\Support; +use Filament\Forms\Components\RichEditor\RichContentRenderer; +use Illuminate\Support\Str; + final class RichTextValue { + public static function sanitizeForPersist(mixed $value): mixed + { + if ($value === null || $value === '') { + return $value; + } + + if (is_array($value) && self::isTipTapDocument($value)) { + return RichContentRenderer::make($value)->toHtml(); + } + + if (is_string($value)) { + return Str::sanitizeHtml($value); + } + + return $value; + } + + /** + * @param array $document + */ + public static function isTipTapDocument(array $document): bool + { + return ($document['type'] ?? null) === 'doc'; + } + public static function isEmpty(mixed $value): bool { if ($value === null || $value === '') { From 2cd3c748bbbba82ad88708f0a15ae398a199eddf Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:11:53 +0200 Subject: [PATCH 069/105] Add field group definition import/export in admin --- packages/builder/README.md | 3 +- .../builder/resources/lang/de/builder.php | 20 ++ .../builder/resources/lang/en/builder.php | 20 ++ packages/builder/src/Data/FieldDefinition.php | 18 ++ .../builder/src/Data/FieldGroupDefinition.php | 9 + .../Actions/FieldGroupDefinitionActions.php | 189 ++++++++++++ .../HasBuilderTranslatableAttributes.php | 6 +- .../src/Resources/FieldGroupResource.php | 8 +- .../Concerns/PersistsFieldGroupInAdmin.php | 29 ++ .../Pages/CreateFieldGroup.php | 7 +- .../Pages/EditFieldGroup.php | 8 +- .../Pages/ListFieldGroups.php | 2 + .../src/Services/FieldGroupExporter.php | 54 ++++ .../src/Services/FieldGroupImporter.php | 286 ++++++++++++++++++ .../src/Services/FieldGroupValidator.php | 15 +- .../src/Support/BuilderLocaleResolver.php | 24 ++ .../Support/FieldGroupDefinitionMapper.php | 140 +++++++++ .../src/Support/FieldGroupExportSchema.php | 12 + .../src/Support/FieldGroupSaveNotifier.php | 27 ++ 19 files changed, 860 insertions(+), 17 deletions(-) create mode 100644 packages/builder/src/Filament/Actions/FieldGroupDefinitionActions.php create mode 100644 packages/builder/src/Resources/FieldGroupResource/Pages/Concerns/PersistsFieldGroupInAdmin.php create mode 100644 packages/builder/src/Services/FieldGroupExporter.php create mode 100644 packages/builder/src/Services/FieldGroupImporter.php create mode 100644 packages/builder/src/Support/FieldGroupDefinitionMapper.php create mode 100644 packages/builder/src/Support/FieldGroupExportSchema.php create mode 100644 packages/builder/src/Support/FieldGroupSaveNotifier.php diff --git a/packages/builder/README.md b/packages/builder/README.md index 945f841db7..079c62d0ad 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -848,6 +848,7 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc - **Sidebar placement** — `main` vs `sidebar` field group slots - **Translations** — definition + value locales (Astrotomic + `builder_field_values.locale`) - **Security hardening** — admin-hidden fields not writable via request; relation targets whitelisted and scoped; rich-text HTML sanitization on persist +- **Field group import/export** — JSON definition export/import in admin (all locales, no record values) **v1 limitations (known):** @@ -856,7 +857,6 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc - `visible_frontend`: stored in admin, but no packaged Blade/theme renderer yet - Custom `validation.rules`: supported in schema/DB, no admin UI (programmatic only) - Relation targets: Filament-registered Moox resources only (not arbitrary Eloquent models) -- No field-group import/export (staging → production is manual today) - No clone field type (ACF-style reusable field group in a field) **Not implemented yet:** @@ -865,7 +865,6 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc - Location params beyond `entity` - Nested conditional logic (inside compound fields) - Frontend rendering helper (`get_field()`-style Blade component) -- Field group definition import/export - Custom validation rules UI in admin - Package-level policies on field group management diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 3cb125967a..137ce90f24 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -33,6 +33,26 @@ 'assigned_to' => 'Zugeordnet zu', 'visibility' => 'Sichtbarkeit', 'visibility_helper' => 'Ganze Gruppe je Kontext ausblenden. Damit werden auch alle enthaltenen Felder dort ausgeblendet.', + 'export' => 'Exportieren', + 'import' => 'Importieren', + 'import_heading' => 'Field Group importieren', + 'import_description' => 'JSON-Datei aus einer anderen Umgebung hochladen. Feldwerte auf Datensätzen sind nicht enthalten.', + 'import_file' => 'JSON-Datei', + 'import_conflict_notice' => 'Eine Field Group mit Slug „:slug“ existiert bereits.', + 'import_resolution' => 'Wie soll importiert werden?', + 'import_resolution_overwrite' => 'Bestehende Field Group überschreiben', + 'import_resolution_copy' => 'Als neue Kopie importieren (:slug, ohne Zuweisung)', + 'import_resolution_required' => 'Bitte wählen, ob überschrieben oder als Kopie importiert werden soll.', + 'import_success' => 'Field Group importiert', + 'import_failed' => 'Import fehlgeschlagen', + 'import_invalid_json' => 'Ungültiges JSON: :message', + 'import_invalid_schema' => 'Diese Datei ist kein Moox-Builder-Field-Group-Export.', + 'import_unsupported_version' => 'Nicht unterstützte Export-Version.', + 'import_missing_group' => 'Export enthält keine Gruppendefinition.', + 'import_missing_group_key' => 'Export fehlt erforderlicher Gruppen-Schlüssel: :key', + 'import_slug_exists' => 'Eine Field Group mit Slug „:slug“ existiert bereits.', + 'import_invalid_slug' => 'Der Import-Slug darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten.', + 'save_failed' => 'Field Group konnte nicht gespeichert werden', ], 'field' => [ diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 032d0d6998..4fede5077d 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -33,6 +33,26 @@ 'assigned_to' => 'Assigned to', 'visibility' => 'Visibility', 'visibility_helper' => 'Hide the whole group per context. Hiding it also hides all of its fields there.', + 'export' => 'Export', + 'import' => 'Import', + 'import_heading' => 'Import field group', + 'import_description' => 'Upload a JSON file exported from another environment. Field values on records are not included.', + 'import_file' => 'JSON file', + 'import_conflict_notice' => 'A field group with slug ":slug" already exists.', + 'import_resolution' => 'How should this import be handled?', + 'import_resolution_overwrite' => 'Overwrite the existing field group', + 'import_resolution_copy' => 'Import as a new copy (:slug, unassigned)', + 'import_resolution_required' => 'Choose whether to overwrite or import as a copy.', + 'import_success' => 'Field group imported', + 'import_failed' => 'Import failed', + 'import_invalid_json' => 'Invalid JSON: :message', + 'import_invalid_schema' => 'This file is not a Moox Builder field group export.', + 'import_unsupported_version' => 'Unsupported export version.', + 'import_missing_group' => 'Export is missing the group definition.', + 'import_missing_group_key' => 'Export is missing required group key: :key', + 'import_slug_exists' => 'A field group with slug ":slug" already exists.', + 'import_invalid_slug' => 'The import slug may only contain lowercase letters, numbers, and hyphens.', + 'save_failed' => 'Field group could not be saved', ], 'field' => [ diff --git a/packages/builder/src/Data/FieldDefinition.php b/packages/builder/src/Data/FieldDefinition.php index 48583125d3..2406a59553 100644 --- a/packages/builder/src/Data/FieldDefinition.php +++ b/packages/builder/src/Data/FieldDefinition.php @@ -7,7 +7,9 @@ use Illuminate\Support\Collection; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldOption; +use Moox\Builder\Support\BuilderLocaleResolver; use Moox\Builder\Support\ConditionalLogic; +use Moox\Builder\Support\DefinitionTranslator; use Moox\Builder\Support\FieldWidth; readonly class FieldDefinition @@ -215,6 +217,14 @@ protected static function mapFieldTranslations(Field $field): array ]; } + $defaultLocale = app(BuilderLocaleResolver::class)->defaultLocale(); + + $translations[$defaultLocale] = [ + 'label' => $field->label, + 'config' => app(DefinitionTranslator::class) + ->extractTranslatableConfig($field->config ?? []), + ]; + return $translations; } @@ -235,6 +245,14 @@ protected static function mapOptionTranslations(FieldOption $option): array ]; } + $defaultLocale = app(BuilderLocaleResolver::class)->defaultLocale(); + + if (filled($option->label)) { + $translations[$defaultLocale] = [ + 'label' => $option->label, + ]; + } + return $translations; } diff --git a/packages/builder/src/Data/FieldGroupDefinition.php b/packages/builder/src/Data/FieldGroupDefinition.php index 16f9a0147c..4446aa9ca8 100644 --- a/packages/builder/src/Data/FieldGroupDefinition.php +++ b/packages/builder/src/Data/FieldGroupDefinition.php @@ -7,6 +7,7 @@ use Illuminate\Support\Collection; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; +use Moox\Builder\Support\BuilderLocaleResolver; use Moox\Builder\Support\FieldGroupPlacement; use Moox\Builder\Support\FieldWidth; @@ -118,6 +119,14 @@ protected static function mapGroupTranslations(FieldGroup $group): array ]; } + $defaultLocale = app(BuilderLocaleResolver::class)->defaultLocale(); + + if (filled($group->name)) { + $translations[$defaultLocale] = [ + 'name' => $group->name, + ]; + } + return $translations; } diff --git a/packages/builder/src/Filament/Actions/FieldGroupDefinitionActions.php b/packages/builder/src/Filament/Actions/FieldGroupDefinitionActions.php new file mode 100644 index 0000000000..edb9573cb0 --- /dev/null +++ b/packages/builder/src/Filament/Actions/FieldGroupDefinitionActions.php @@ -0,0 +1,189 @@ +label(__('builder::builder.field_group.export')) + ->icon(Heroicon::OutlinedArrowDownTray) + ->color('gray') + ->action(function (?FieldGroup $actionRecord = null) use ($record): StreamedResponse { + $group = $actionRecord ?? $record; + + if (! $group instanceof FieldGroup) { + throw new \RuntimeException('Field group record is required for export.'); + } + + return app(FieldGroupExporter::class)->downloadResponse($group); + }); + } + + /** + * @param object{redirect: callable(string): void} $livewire + */ + public static function import(object $livewire, ?string $lang = null): Action + { + return Action::make('import') + ->label(__('builder::builder.field_group.import')) + ->icon(Heroicon::OutlinedArrowUpTray) + ->color('gray') + ->modalHeading(__('builder::builder.field_group.import_heading')) + ->modalDescription(__('builder::builder.field_group.import_description')) + ->schema([ + FileUpload::make('file') + ->label(__('builder::builder.field_group.import_file')) + ->acceptedFileTypes(['application/json', 'text/json', 'application/octet-stream']) + ->maxSize(1024) + ->required() + ->live() + ->storeFiles(false), + Placeholder::make('import_conflict_notice') + ->hiddenLabel() + ->content(function (callable $get): HtmlString { + $slug = self::slugFromUploadedFile($get('file')); + + return new HtmlString(e(__('builder::builder.field_group.import_conflict_notice', [ + 'slug' => $slug ?? '', + ]))); + }) + ->visible(fn (callable $get): bool => self::hasImportConflict($get('file'))), + Radio::make('resolution') + ->label(__('builder::builder.field_group.import_resolution')) + ->options(function (callable $get): array { + $slug = self::slugFromUploadedFile($get('file')); + + if ($slug === null) { + return []; + } + + $copySlug = app(FieldGroupImporter::class)->duplicateSlug($slug); + + return [ + 'overwrite' => __('builder::builder.field_group.import_resolution_overwrite'), + 'copy' => __('builder::builder.field_group.import_resolution_copy', [ + 'slug' => $copySlug, + ]), + ]; + }) + ->required(fn (callable $get): bool => self::hasImportConflict($get('file'))) + ->visible(fn (callable $get): bool => self::hasImportConflict($get('file'))), + ]) + ->action(function (array $data) use ($livewire, $lang): void { + $file = $data['file'] ?? null; + + if (! $file instanceof TemporaryUploadedFile) { + Notification::make() + ->title(__('builder::builder.field_group.import_failed')) + ->danger() + ->send(); + + return; + } + + $json = $file->get(); + + if (! is_string($json) || $json === '') { + Notification::make() + ->title(__('builder::builder.field_group.import_failed')) + ->danger() + ->send(); + + return; + } + + $importer = app(FieldGroupImporter::class); + + try { + $slug = $importer->slugFromJson($json); + $hasConflict = is_string($slug) && $importer->slugIsTaken($slug); + + if ($hasConflict) { + $resolution = $data['resolution'] ?? null; + + $group = match ($resolution) { + 'overwrite' => $importer->importFromJson($json, replaceExisting: true), + 'copy' => $importer->importFromJson( + $json, + slugOverride: $importer->duplicateSlug($slug), + ), + default => throw ValidationException::withMessages([ + 'resolution' => [__('builder::builder.field_group.import_resolution_required')], + ]), + }; + } else { + $group = $importer->importFromJson($json); + } + } catch (ValidationException $exception) { + $message = collect($exception->errors())->flatten()->first() + ?? __('builder::builder.field_group.import_failed'); + + Notification::make() + ->title(__('builder::builder.field_group.import_failed')) + ->body($message) + ->danger() + ->send(); + + throw $exception; + } + + Notification::make() + ->title(__('builder::builder.field_group.import_success')) + ->success() + ->send(); + + $parameters = ['record' => $group]; + + if (filled($lang)) { + $parameters['lang'] = $lang; + } + + $livewire->redirect(FieldGroupResource::getUrl('edit', $parameters)); + }); + } + + protected static function hasImportConflict(mixed $file): bool + { + $slug = self::slugFromUploadedFile($file); + + if ($slug === null) { + return false; + } + + return app(FieldGroupImporter::class)->slugIsTaken($slug); + } + + protected static function slugFromUploadedFile(mixed $file): ?string + { + if (! $file instanceof TemporaryUploadedFile) { + return null; + } + + $json = $file->get(); + + if (! is_string($json) || $json === '') { + return null; + } + + return app(FieldGroupImporter::class)->slugFromJson($json); + } +} diff --git a/packages/builder/src/Models/Concerns/HasBuilderTranslatableAttributes.php b/packages/builder/src/Models/Concerns/HasBuilderTranslatableAttributes.php index bd1805b85f..f8f46c8a18 100644 --- a/packages/builder/src/Models/Concerns/HasBuilderTranslatableAttributes.php +++ b/packages/builder/src/Models/Concerns/HasBuilderTranslatableAttributes.php @@ -85,11 +85,7 @@ public function syncTranslationFallbackOnMainRecord(string $locale, array $attri protected function locale(): string { - if ($this->getDefaultLocale()) { - return $this->getDefaultLocale(); - } - - return app(BuilderLocaleResolver::class)->current(); + return app(BuilderLocaleResolver::class)->defaultLocale(); } protected function mirrorDefaultLocaleTranslationsToMainRecord(): void diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index c376f25deb..b63c76c4b4 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -5,6 +5,7 @@ namespace Moox\Builder\Resources; use Filament\Actions\Action; +use Filament\Actions\EditAction; use Filament\Forms\Components\Component; use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Repeater; @@ -28,6 +29,7 @@ use Moox\Builder\Models\FieldGroup; use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Filament\Actions\FieldGroupDefinitionActions; use Moox\Builder\Resources\FieldGroupResource\Pages\CreateFieldGroup; use Moox\Builder\Resources\FieldGroupResource\Pages\EditFieldGroup; use Moox\Builder\Resources\FieldGroupResource\Pages\ListFieldGroups; @@ -1268,7 +1270,11 @@ public static function table(Table $table): Table ->toggleable(isToggledHiddenByDefault: true), ]) ->defaultSort('sort') - ->reorderable('sort'); + ->reorderable('sort') + ->recordActions([ + EditAction::make(), + FieldGroupDefinitionActions::export(), + ]); } public static function getPages(): array diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/Concerns/PersistsFieldGroupInAdmin.php b/packages/builder/src/Resources/FieldGroupResource/Pages/Concerns/PersistsFieldGroupInAdmin.php new file mode 100644 index 0000000000..3c57f68f86 --- /dev/null +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/Concerns/PersistsFieldGroupInAdmin.php @@ -0,0 +1,29 @@ + $data + */ + protected function persistFieldGroup(FieldGroup $group, array $data): FieldGroup + { + try { + app(FieldGroupPersistence::class)->sync($group, $data); + } catch (ValidationException $exception) { + FieldGroupSaveNotifier::notify($exception); + + throw $exception; + } + + return $group->fresh(['fields.options', 'translations']); + } +} diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/CreateFieldGroup.php b/packages/builder/src/Resources/FieldGroupResource/Pages/CreateFieldGroup.php index bbc6ef5362..ba2fed2b75 100644 --- a/packages/builder/src/Resources/FieldGroupResource/Pages/CreateFieldGroup.php +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/CreateFieldGroup.php @@ -8,11 +8,12 @@ use Moox\Builder\Models\FieldGroup; use Moox\Builder\Resources\FieldGroupResource; use Moox\Builder\Resources\FieldGroupResource\Pages\Concerns\InteractsWithFieldGroupLocale; -use Moox\Builder\Services\FieldGroupPersistence; +use Moox\Builder\Resources\FieldGroupResource\Pages\Concerns\PersistsFieldGroupInAdmin; class CreateFieldGroup extends CreateRecord { use InteractsWithFieldGroupLocale; + use PersistsFieldGroupInAdmin; protected static string $resource = FieldGroupResource::class; @@ -44,8 +45,6 @@ protected function handleRecordCreation(array $data): FieldGroup $group = new FieldGroup; $this->applyFieldGroupDefaultLocale($group); - app(FieldGroupPersistence::class)->sync($group, $data); - - return $group->fresh(['fields.options', 'translations']); + return $this->persistFieldGroup($group, $data); } } diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php index cb6347440a..abcdf457fc 100644 --- a/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php @@ -6,15 +6,18 @@ use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; +use Moox\Builder\Filament\Actions\FieldGroupDefinitionActions; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Resources\FieldGroupResource; use Moox\Builder\Resources\FieldGroupResource\Pages\Concerns\InteractsWithFieldGroupLocale; +use Moox\Builder\Resources\FieldGroupResource\Pages\Concerns\PersistsFieldGroupInAdmin; use Moox\Builder\Services\FieldGroupPersistence; use Moox\Builder\Support\FieldGroupPlacement; class EditFieldGroup extends EditRecord { use InteractsWithFieldGroupLocale; + use PersistsFieldGroupInAdmin; protected static string $resource = FieldGroupResource::class; @@ -36,6 +39,7 @@ protected function getHeaderActions(): array { return [ $this->getFieldGroupLanguageSelectorAction(), + FieldGroupDefinitionActions::export($this->getRecord()), DeleteAction::make(), ]; } @@ -73,8 +77,6 @@ protected function handleRecordUpdate($record, array $data): FieldGroup $this->syncLangToRequest(); $this->applyFieldGroupDefaultLocale($record); - app(FieldGroupPersistence::class)->sync($record, $data); - - return $record->fresh(['fields.options', 'translations']); + return $this->persistFieldGroup($record, $data); } } diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/ListFieldGroups.php b/packages/builder/src/Resources/FieldGroupResource/Pages/ListFieldGroups.php index 309e2517a3..7c47d65fc5 100644 --- a/packages/builder/src/Resources/FieldGroupResource/Pages/ListFieldGroups.php +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/ListFieldGroups.php @@ -7,6 +7,7 @@ use Filament\Actions\CreateAction; use Filament\Resources\Pages\ListRecords; use Illuminate\Database\Eloquent\Builder; +use Moox\Builder\Filament\Actions\FieldGroupDefinitionActions; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Resources\FieldGroupResource; use Moox\Builder\Resources\FieldGroupResource\Pages\Concerns\InteractsWithFieldGroupLocale; @@ -47,6 +48,7 @@ protected function getHeaderActions(): array { return [ $this->getFieldGroupLanguageSelectorAction(), + FieldGroupDefinitionActions::import($this, $this->lang !== '' ? $this->lang : null), CreateAction::make() ->url(fn (): string => FieldGroupResource::getUrl('create', [ 'lang' => $this->lang !== '' diff --git a/packages/builder/src/Services/FieldGroupExporter.php b/packages/builder/src/Services/FieldGroupExporter.php new file mode 100644 index 0000000000..db584960fc --- /dev/null +++ b/packages/builder/src/Services/FieldGroupExporter.php @@ -0,0 +1,54 @@ +} + */ + public function export(FieldGroup $group): array + { + $group->load(FieldRelationTree::eagerLoadForDefinition()); + $group->load('translations'); + + $definition = FieldGroupDefinition::fromModel($group); + + return [ + 'schema' => FieldGroupExportSchema::SCHEMA, + 'version' => FieldGroupExportSchema::VERSION, + 'exportedAt' => now()->toIso8601String(), + 'group' => [ + ...$definition->toArray(), + 'active' => (bool) $group->active, + 'sort' => (int) $group->sort, + ], + ]; + } + + public function downloadResponse(FieldGroup $group): StreamedResponse + { + $filename = Str::slug($group->slug).'.builder-field-group.json'; + $payload = $this->export($group); + + return response()->streamDownload( + static function () use ($payload): void { + echo json_encode( + $payload, + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, + ); + }, + $filename, + ['Content-Type' => 'application/json'], + ); + } +} diff --git a/packages/builder/src/Services/FieldGroupImporter.php b/packages/builder/src/Services/FieldGroupImporter.php new file mode 100644 index 0000000000..980094e17a --- /dev/null +++ b/packages/builder/src/Services/FieldGroupImporter.php @@ -0,0 +1,286 @@ + $payload + */ + public function import(array $payload, bool $replaceExisting = false, ?string $slugOverride = null): FieldGroup + { + $this->assertValidPayload($payload); + + /** @var array $groupData */ + $groupData = $payload['group']; + + if (array_key_exists('location_rules', $groupData) && ! array_key_exists('locationRules', $groupData)) { + $groupData['locationRules'] = $groupData['location_rules']; + } + + if (filled($slugOverride)) { + $groupData['slug'] = $slugOverride; + + if (! $replaceExisting) { + // A copied group must not inherit the same entity assignment, otherwise + // field-key uniqueness validation blocks the import immediately. + $groupData['locationRules'] = []; + } + } + + $definition = FieldGroupDefinition::fromArray($groupData); + $active = (bool) ($groupData['active'] ?? true); + $sort = (int) ($groupData['sort'] ?? 0); + + $existing = FieldGroup::query() + ->where('slug', $definition->slug) + ->first(); + + if ($existing instanceof FieldGroup && ! $replaceExisting) { + $this->validationError(__('builder::builder.field_group.import_slug_exists', [ + 'slug' => $definition->slug, + ])); + } + + $group = $existing ?? new FieldGroup; + $defaultLocale = $this->localeResolver->defaultLocale(); + + $this->localeResolver->using($defaultLocale, function () use ($group, $definition, $active, $sort, $defaultLocale): void { + $localized = $this->definitionTranslator->localizeGroup($definition, $defaultLocale); + $data = $this->definitionMapper->toPersistenceData($localized, $active, $sort); + + try { + $this->fieldGroupPersistence->sync( + $group->exists ? $group->fresh() ?? $group : $group, + $data, + ); + } catch (ValidationException $exception) { + $this->validationError( + collect($exception->errors())->flatten()->first() + ?? __('builder::builder.field_group.import_failed'), + ); + } + }); + + $group = FieldGroup::query()->where('slug', $definition->slug)->firstOrFail(); + $group->load(FieldRelationTree::eagerLoadForDefinition()); + + foreach ($this->definitionMapper->collectLocales($definition) as $locale) { + if ($locale === $defaultLocale) { + continue; + } + + $this->syncTranslationsOnly($group, $definition, $locale); + } + + return $group->fresh(FieldRelationTree::eagerLoadForDefinition()) ?? $group; + } + + public function importFromJson(string $json, bool $replaceExisting = false, ?string $slugOverride = null): FieldGroup + { + $payload = $this->parsePayloadFromJson($json); + + if (filled($slugOverride) && ! preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slugOverride)) { + $this->validationError(__('builder::builder.field_group.import_invalid_slug')); + } + + return $this->import($payload, $replaceExisting, $slugOverride); + } + + public function slugFromJson(string $json): ?string + { + try { + $payload = $this->parsePayloadFromJson($json); + } catch (ValidationException) { + return null; + } + + $slug = $payload['group']['slug'] ?? null; + + return is_string($slug) && $slug !== '' ? $slug : null; + } + + public function slugIsTaken(string $slug): bool + { + return FieldGroup::query()->where('slug', $slug)->exists(); + } + + public function duplicateSlug(string $slug): string + { + $candidates = [$slug.'-copy']; + + for ($index = 2; $index <= 50; $index++) { + $candidates[] = $slug.'-'.$index; + } + + foreach ($candidates as $candidate) { + if (! $this->slugIsTaken($candidate)) { + return $candidate; + } + } + + return $slug.'-'.substr(uniqid(), -6); + } + + /** + * @return array + */ + public function parsePayloadFromJson(string $json): array + { + try { + $payload = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + $this->validationError(__('builder::builder.field_group.import_invalid_json', [ + 'message' => $exception->getMessage(), + ])); + } + + if (! is_array($payload)) { + $this->validationError(__('builder::builder.field_group.import_invalid_json', [ + 'message' => 'root must be an object', + ])); + } + + return $payload; + } + + protected function syncTranslationsOnly(FieldGroup $group, FieldGroupDefinition $definition, string $locale): void + { + $groupName = $definition->translations[$locale]['name'] ?? null; + + if (is_string($groupName) && $groupName !== '') { + $group->translateOrNew($locale)->name = $groupName; + $group->saveTranslations(); + } + + $this->syncFieldTranslations($group->fields, $definition->fields, $locale); + } + + /** + * @param Collection $fields + * @param Collection $definitions + */ + protected function syncFieldTranslations(Collection $fields, Collection $definitions, string $locale): void + { + foreach ($definitions as $definition) { + $field = $fields->firstWhere('name', $definition->name); + + if (! $field instanceof Field) { + continue; + } + + $translation = $definition->translations[$locale] ?? null; + + if (is_array($translation)) { + $row = $field->translateOrNew($locale); + $row->label = (string) ($translation['label'] ?? $field->label); + + $config = $translation['config'] ?? null; + $row->config = is_array($config) && $config !== [] ? $config : null; + $field->saveTranslations(); + } + + foreach ($definition->options as $index => $optionDefinition) { + $option = $field->options->get($index) + ?? $field->options->firstWhere('value', $optionDefinition['value'] ?? null); + + if (! $option instanceof FieldOption) { + continue; + } + + $optionLabel = $optionDefinition['translations'][$locale]['label'] ?? null; + + if (is_string($optionLabel) && $optionLabel !== '') { + $option->translateOrNew($locale)->label = $optionLabel; + $option->saveTranslations(); + } + } + + if ($definition->type === 'flexible_content') { + $this->syncFieldTranslations( + $field->children, + $definition->layouts(), + $locale, + ); + + continue; + } + + if ($definition->children->isNotEmpty()) { + $this->syncFieldTranslations($field->children, $definition->children, $locale); + } + } + } + + /** + * @param array $payload + */ + protected function assertValidPayload(array $payload): void + { + if (($payload['schema'] ?? null) !== FieldGroupExportSchema::SCHEMA) { + $this->validationError(__('builder::builder.field_group.import_invalid_schema')); + } + + if ((int) ($payload['version'] ?? 0) !== FieldGroupExportSchema::VERSION) { + $this->validationError(__('builder::builder.field_group.import_unsupported_version')); + } + + $group = $payload['group'] ?? null; + + if (! is_array($group)) { + $this->validationError(__('builder::builder.field_group.import_missing_group')); + } + + foreach (['name', 'slug', 'placement'] as $requiredKey) { + if (blank($group[$requiredKey] ?? null)) { + $this->validationError(__('builder::builder.field_group.import_missing_group_key', [ + 'key' => $requiredKey, + ])); + } + } + + if (! is_array($group['fields'] ?? null)) { + $this->validationError(__('builder::builder.field_group.import_missing_group_key', [ + 'key' => 'fields', + ])); + } + + if (! is_array($group['locationRules'] ?? null) && ! is_array($group['location_rules'] ?? null)) { + $this->validationError(__('builder::builder.field_group.import_missing_group_key', [ + 'key' => 'locationRules', + ])); + } + } + + /** + * @return never + */ + protected function validationError(string $message): void + { + throw ValidationException::withMessages([ + 'file' => [$message], + ]); + } +} diff --git a/packages/builder/src/Services/FieldGroupValidator.php b/packages/builder/src/Services/FieldGroupValidator.php index 3bce1f8e74..068369f23f 100644 --- a/packages/builder/src/Services/FieldGroupValidator.php +++ b/packages/builder/src/Services/FieldGroupValidator.php @@ -38,12 +38,23 @@ public function validate(FieldGroup $group, array $data): void $entries = $this->storableFieldCollector->pathsFromRows($fieldRows); + $internalMessages = $this->internalDuplicateMessages($entries); + $externalMessages = $entities === [] ? [] : $this->externalConflictMessages($group, $entities, $entries); + $messages = array_merge( $messages, - $this->internalDuplicateMessages($entries), - $this->externalConflictMessages($group, $entities, $entries), + $internalMessages, + $externalMessages, ); + if ($externalMessages !== []) { + $summary = collect($externalMessages)->flatten()->first(); + + if (is_string($summary)) { + $messages['target_entities'] = [$summary]; + } + } + if ($messages !== []) { throw ValidationException::withMessages($messages); } diff --git a/packages/builder/src/Support/BuilderLocaleResolver.php b/packages/builder/src/Support/BuilderLocaleResolver.php index 58bbcde5c9..3d3f1cf5ab 100644 --- a/packages/builder/src/Support/BuilderLocaleResolver.php +++ b/packages/builder/src/Support/BuilderLocaleResolver.php @@ -11,6 +11,8 @@ final class BuilderLocaleResolver { public const ADMIN_SESSION_KEY = 'builder.admin_locale'; + private ?string $overrideLocale = null; + /** * @return list */ @@ -24,6 +26,10 @@ final class BuilderLocaleResolver public function current(?string $locale = null): string { + if ($this->overrideLocale !== null) { + return $this->overrideLocale; + } + if (is_string($locale) && $locale !== '') { return $locale; } @@ -107,6 +113,24 @@ public function fallbackChain(?string $locale = null): array ))); } + /** + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn + */ + public function using(string $locale, callable $callback): mixed + { + $previous = $this->overrideLocale; + $this->overrideLocale = $locale; + + try { + return $callback(); + } finally { + $this->overrideLocale = $previous; + } + } + public function valuesLocaleForEntity(string $entity, ?string $locale = null, ?string $modelClass = null): string { if (! app(CustomFieldsTranslatability::class)->valuesAreTranslatable($entity, $modelClass)) { diff --git a/packages/builder/src/Support/FieldGroupDefinitionMapper.php b/packages/builder/src/Support/FieldGroupDefinitionMapper.php new file mode 100644 index 0000000000..69ff48c276 --- /dev/null +++ b/packages/builder/src/Support/FieldGroupDefinitionMapper.php @@ -0,0 +1,140 @@ + + */ + public function toPersistenceData( + FieldGroupDefinition $definition, + bool $active, + int $sort, + ): array { + return [ + 'name' => $definition->name, + 'slug' => $definition->slug, + 'active' => $active, + 'sort' => $sort, + 'placement' => $definition->placement, + 'settings' => $definition->settings, + 'target_entities' => $this->fieldGroupPersistence->entitiesFromLocationRules( + $definition->locationRules, + ), + 'fields' => $this->mapFieldsToRows($definition->fields), + ]; + } + + /** + * @return list + */ + public function collectLocales(FieldGroupDefinition $definition): array + { + $locales = array_keys($definition->translations); + + foreach ($definition->fields as $field) { + $locales = array_merge($locales, $this->collectFieldLocales($field)); + } + + $default = $this->localeResolver->defaultLocale(); + $locales[] = $default; + + $unique = array_values(array_unique(array_filter( + $locales, + static fn (string $locale): bool => $locale !== '', + ))); + + usort($unique, static fn (string $a, string $b): int => match (true) { + $a === $default && $b !== $default => -1, + $b === $default && $a !== $default => 1, + default => strcmp($a, $b), + }); + + return $unique; + } + + /** + * @param \Illuminate\Support\Collection $fields + * @return list> + */ + protected function mapFieldsToRows($fields): array + { + return $fields + ->map(fn (FieldDefinition $field): array => $this->fieldToRow($field)) + ->values() + ->all(); + } + + /** + * @return array + */ + protected function fieldToRow(FieldDefinition $field): array + { + $row = [ + 'name' => $field->name, + 'label' => $field->label, + 'type' => $field->type, + 'required' => (bool) ($field->validation['required'] ?? false), + 'validation' => $field->validation, + 'config' => $field->config, + 'settings' => $field->settings, + 'sort' => $field->sort, + 'options' => collect($field->options) + ->values() + ->map(fn (array $option, int $index): array => [ + 'label' => $option['label'], + 'value' => $option['value'], + 'sort' => $index, + ]) + ->all(), + ]; + + if ($field->type === 'flexible_content') { + $row['layouts'] = $field->layouts() + ->map(fn (FieldDefinition $layout): array => [ + 'name' => $layout->name, + 'label' => $layout->label, + 'sort' => $layout->sort, + 'children' => $this->mapFieldsToRows($layout->children), + ]) + ->values() + ->all(); + } elseif ($this->fieldTypeRegistry->get($field->type)->hasSubFields()) { + $row['children'] = $this->mapFieldsToRows($field->children); + } + + return $row; + } + + /** + * @return list + */ + protected function collectFieldLocales(FieldDefinition $field): array + { + $locales = array_keys($field->translations); + + foreach ($field->options as $option) { + $locales = array_merge($locales, array_keys($option['translations'] ?? [])); + } + + foreach ($field->children as $child) { + $locales = array_merge($locales, $this->collectFieldLocales($child)); + } + + return $locales; + } +} diff --git a/packages/builder/src/Support/FieldGroupExportSchema.php b/packages/builder/src/Support/FieldGroupExportSchema.php new file mode 100644 index 0000000000..574f73efda --- /dev/null +++ b/packages/builder/src/Support/FieldGroupExportSchema.php @@ -0,0 +1,12 @@ +errors())->flatten()->first(); + + if (! is_string($message) || $message === '') { + return; + } + + Notification::make() + ->title(__('builder::builder.field_group.save_failed')) + ->body($message) + ->danger() + ->persistent() + ->send(); + } +} From 8fd913c2c523f6a2e4d1d50b094313b154ba6753 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:13:20 +0000 Subject: [PATCH 070/105] Fix styling --- packages/builder/src/Resources/FieldGroupResource.php | 2 +- packages/builder/src/Services/FieldGroupExporter.php | 2 +- packages/builder/src/Support/FieldGroupDefinitionMapper.php | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index b63c76c4b4..363f23704c 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -26,10 +26,10 @@ use Illuminate\Support\HtmlString; use Illuminate\Support\Str; use Moox\Builder\Exceptions\UnknownFieldTypeException; +use Moox\Builder\Filament\Actions\FieldGroupDefinitionActions; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Registry\FieldTypeRegistry; -use Moox\Builder\Filament\Actions\FieldGroupDefinitionActions; use Moox\Builder\Resources\FieldGroupResource\Pages\CreateFieldGroup; use Moox\Builder\Resources\FieldGroupResource\Pages\EditFieldGroup; use Moox\Builder\Resources\FieldGroupResource\Pages\ListFieldGroups; diff --git a/packages/builder/src/Services/FieldGroupExporter.php b/packages/builder/src/Services/FieldGroupExporter.php index db584960fc..1cbe917880 100644 --- a/packages/builder/src/Services/FieldGroupExporter.php +++ b/packages/builder/src/Services/FieldGroupExporter.php @@ -4,11 +4,11 @@ namespace Moox\Builder\Services; +use Illuminate\Support\Str; use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Support\FieldGroupExportSchema; use Moox\Builder\Support\FieldRelationTree; -use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\StreamedResponse; class FieldGroupExporter diff --git a/packages/builder/src/Support/FieldGroupDefinitionMapper.php b/packages/builder/src/Support/FieldGroupDefinitionMapper.php index 69ff48c276..baeaa363e0 100644 --- a/packages/builder/src/Support/FieldGroupDefinitionMapper.php +++ b/packages/builder/src/Support/FieldGroupDefinitionMapper.php @@ -4,6 +4,7 @@ namespace Moox\Builder\Support; +use Illuminate\Support\Collection; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\Registry\FieldTypeRegistry; @@ -68,7 +69,7 @@ public function collectLocales(FieldGroupDefinition $definition): array } /** - * @param \Illuminate\Support\Collection $fields + * @param Collection $fields * @return list> */ protected function mapFieldsToRows($fields): array From c7979d15795c241c5b0e408010d4110c364b6a31 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:22:37 +0200 Subject: [PATCH 071/105] Add sortable and searchable relation field table columns --- .../src/Compiler/TableColumnCompiler.php | 59 ++++- .../src/Resources/FieldGroupResource.php | 28 +- .../src/Support/RelationTableColumnQuery.php | 243 ++++++++++++++++++ .../src/Support/RelationTargetResolver.php | 26 ++ 4 files changed, 348 insertions(+), 8 deletions(-) create mode 100644 packages/builder/src/Support/RelationTableColumnQuery.php diff --git a/packages/builder/src/Compiler/TableColumnCompiler.php b/packages/builder/src/Compiler/TableColumnCompiler.php index 8779a7520c..c6781d7be6 100644 --- a/packages/builder/src/Compiler/TableColumnCompiler.php +++ b/packages/builder/src/Compiler/TableColumnCompiler.php @@ -19,6 +19,8 @@ use Moox\Builder\Services\BuilderValuesResolver; use Moox\Builder\Services\CustomFieldsManager; use Moox\Builder\Support\BuilderLocaleResolver; +use Moox\Builder\Support\RelationTableColumnQuery; +use Moox\Builder\Support\RelationValueRules; use Moox\Builder\Support\StorableFieldCollector; use Moox\Builder\Support\TypedValueColumns; @@ -29,6 +31,7 @@ public function __construct( protected StorableFieldCollector $storableFieldCollector, protected BuilderLocaleResolver $localeResolver, protected BuilderValuesResolver $valuesResolver, + protected RelationTableColumnQuery $relationTableColumnQuery, ) {} /** @@ -102,7 +105,7 @@ protected function compileColumn( } if ($this->isRelationColumn($field)) { - return $this->compileRelationColumn($field); + return $this->compileRelationColumn($field, $entity, $locale, $valuesTable); } $valueColumn = TypedValueColumns::columnForType($field->type); @@ -231,9 +234,13 @@ protected function compileImageColumn(FieldDefinition $field): Column return $column; } - protected function compileRelationColumn(FieldDefinition $field): Column - { - return TextColumn::make($field->name) + protected function compileRelationColumn( + FieldDefinition $field, + string $entity, + string $locale, + string $valuesTable, + ): Column { + $column = TextColumn::make($field->name) ->label($field->label) ->placeholder('—') ->getStateUsing(function (Model $record) use ($field): ?string { @@ -242,6 +249,50 @@ protected function compileRelationColumn(FieldDefinition $field): Column return $this->formatRelationColumnState($presented); }) ->toggleable(isToggledHiddenByDefault: $field->isColumnHiddenByDefault()); + + if ($field->columnBadge() && ! RelationValueRules::isMultiple($field)) { + $column->badge(); + } + + if ($this->relationTableColumnQuery->canQuery($field)) { + if ($field->isColumnSortable()) { + $column->sortable(query: function (Builder $query, string $direction) use ( + $field, + $entity, + $locale, + $valuesTable, + ): Builder { + return $this->relationTableColumnQuery->applySort( + $query, + $field, + $entity, + $locale, + $valuesTable, + $direction, + ); + }); + } + + if ($field->isColumnSearchable()) { + $column->searchable(query: function (Builder $query, string $search) use ( + $field, + $entity, + $locale, + $valuesTable, + ): Builder { + return $this->relationTableColumnQuery->applySearch( + $query, + $field, + $entity, + $locale, + $valuesTable, + $search, + ); + }); + } + } + + return $column; } protected function formatRelationColumnState(mixed $presented): ?string diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 363f23704c..f8e36bc362 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -597,6 +597,12 @@ protected static function fieldTypeSupportsAnyColumn(?string $type): bool || static::fieldTypeSupportsRelationColumn($type); } + protected static function fieldTypeSupportsSortableSearchableColumn(?string $type): bool + { + return static::fieldTypeSupportsColumn($type) + || static::fieldTypeSupportsRelationColumn($type); + } + /** * Presentation options (badge, color, icon) only apply to text-based columns, * not to the boolean icon column used for toggle fields. @@ -606,6 +612,18 @@ protected static function fieldTypeUsesTextColumn(?string $type): bool return static::fieldTypeSupportsColumn($type) && $type !== 'toggle'; } + protected static function fieldTypeSupportsRelationBadge(callable $get): bool + { + return $get('type') === 'relation' + && ! (bool) ($get('config.multiple') ?? false); + } + + protected static function fieldTypeSupportsTextColumnPresentation(callable $get): bool + { + return static::fieldTypeUsesTextColumn($get('type')) + || static::fieldTypeSupportsRelationBadge($get); + } + /** * @return array */ @@ -656,7 +674,7 @@ protected static function columnSettingsSchema(): array ->default(true), ]) ->visible(fn (callable $get): bool => $enabled($get) - && static::fieldTypeSupportsColumn($get('type'))), + && static::fieldTypeSupportsSortableSearchableColumn($get('type'))), Toggle::make('settings.hidden_by_default') ->label(__('builder::builder.field.column_hidden_by_default')) ->hintIcon(Heroicon::OutlinedQuestionMarkCircle, tooltip: __('builder::builder.field.column_hidden_by_default_helper')) @@ -674,14 +692,16 @@ protected static function columnSettingsSchema(): array ->label(__('builder::builder.field.column_color')) ->options(static::columnColorOptions()) ->placeholder(__('builder::builder.field.column_color_default')) - ->native(false), + ->native(false) + ->visible(fn (callable $get): bool => static::fieldTypeUsesTextColumn($get('type'))), TextInput::make('settings.icon') ->label(__('builder::builder.field.column_icon')) ->hintIcon(Heroicon::OutlinedQuestionMarkCircle, tooltip: __('builder::builder.field.column_icon_helper')) - ->placeholder('heroicon-o-star'), + ->placeholder('heroicon-o-star') + ->visible(fn (callable $get): bool => static::fieldTypeUsesTextColumn($get('type'))), ]) ->visible(fn (callable $get): bool => $enabled($get) - && static::fieldTypeUsesTextColumn($get('type'))), + && static::fieldTypeSupportsTextColumnPresentation($get)), Grid::make(2) ->schema([ Select::make('settings.image_shape') diff --git a/packages/builder/src/Support/RelationTableColumnQuery.php b/packages/builder/src/Support/RelationTableColumnQuery.php new file mode 100644 index 0000000000..79f8f24672 --- /dev/null +++ b/packages/builder/src/Support/RelationTableColumnQuery.php @@ -0,0 +1,243 @@ +target($field) !== null; + } + + public function applySort( + Builder $query, + FieldDefinition $field, + string $entity, + string $locale, + string $valuesTable, + string $direction, + ): Builder { + $target = $this->target($field); + + if ($target === null) { + return $query; + } + + $recordKey = $query->getModel()->getQualifiedKeyName(); + $subquery = $this->titleSubquery( + $field, + $entity, + $locale, + $valuesTable, + $recordKey, + $target, + ); + + if ($subquery === null) { + return $query; + } + + return $query->orderBy($subquery, $direction); + } + + public function applySearch( + Builder $query, + FieldDefinition $field, + string $entity, + string $locale, + string $valuesTable, + string $search, + ): Builder { + $target = $this->target($field); + + if ($target === null) { + return $query; + } + + $recordKey = $query->getModel()->getQualifiedKeyName(); + $like = '%'.addcslashes($search, '%_\\').'%'; + $relatedTable = $target['table']; + $relatedKey = $target['key']; + $titleColumn = $target['titleColumn']; + $alias = self::RELATED_ALIAS; + + return $query->whereExists(function ($subquery) use ( + $field, + $entity, + $locale, + $valuesTable, + $recordKey, + $target, + $relatedTable, + $relatedKey, + $titleColumn, + $alias, + $like, + ): void { + $subquery->from($valuesTable) + ->selectRaw('1') + ->whereColumn("{$valuesTable}.record_id", $recordKey) + ->where("{$valuesTable}.entity", $entity) + ->where("{$valuesTable}.field_name", $field->name) + ->where("{$valuesTable}.locale", $locale); + + if ($target['multiple']) { + $subquery->whereRaw( + $this->multipleRelationSearchSql($valuesTable, $relatedTable, $relatedKey, $titleColumn, $alias), + [$like], + ); + + return; + } + + $subquery + ->join("{$relatedTable} as {$alias}", function ($join) use ($valuesTable, $alias, $relatedKey): void { + $join->whereRaw($this->singleRelationJoinSql($valuesTable, $alias, $relatedKey)); + }) + ->where("{$alias}.{$titleColumn}", 'like', $like); + }); + } + + /** + * @param array{relatedEntity: string, modelClass: class-string, table: string, key: string, titleColumn: string, multiple: bool} $target + */ + protected function titleSubquery( + FieldDefinition $field, + string $entity, + string $locale, + string $valuesTable, + string $recordKey, + array $target, + ): ?\Illuminate\Database\Query\Builder { + $relatedTable = $target['table']; + $relatedKey = $target['key']; + $titleColumn = $target['titleColumn']; + $alias = self::RELATED_ALIAS; + + $query = DB::table($valuesTable) + ->whereColumn("{$valuesTable}.record_id", $recordKey) + ->where("{$valuesTable}.entity", $entity) + ->where("{$valuesTable}.field_name", $field->name) + ->where("{$valuesTable}.locale", $locale); + + if ($target['multiple']) { + return $this->multipleTitleSubquery($query, $valuesTable, $relatedTable, $relatedKey, $titleColumn, $alias); + } + + return $query + ->select("{$alias}.{$titleColumn}") + ->join("{$relatedTable} as {$alias}", function ($join) use ($valuesTable, $alias, $relatedKey): void { + $join->whereRaw($this->singleRelationJoinSql($valuesTable, $alias, $relatedKey)); + }) + ->limit(1); + } + + protected function singleRelationJoinSql(string $valuesTable, string $relatedAlias, string $relatedKey): string + { + $idExpression = $this->jsonScalarIdExpression("{$valuesTable}.value_json"); + + return "{$relatedAlias}.{$relatedKey} = {$idExpression}"; + } + + protected function jsonScalarIdExpression(string $column): string + { + return match (DB::connection()->getDriverName()) { + 'mysql' => "CAST(JSON_UNQUOTE(JSON_EXTRACT({$column}, '\$')) AS UNSIGNED)", + 'pgsql' => "CAST({$column} #>> '{}' AS BIGINT)", + default => "CAST(json_extract({$column}, '\$') AS INTEGER)", + }; + } + + /** + * @return \Illuminate\Database\Query\Builder + */ + protected function multipleTitleSubquery( + \Illuminate\Database\Query\Builder $query, + string $valuesTable, + string $relatedTable, + string $relatedKey, + string $titleColumn, + string $alias, + ) { + return match (DB::connection()->getDriverName()) { + 'mysql' => $query + ->selectRaw("MIN({$alias}.{$titleColumn})") + ->join(DB::raw( + "JSON_TABLE({$valuesTable}.value_json, '\$[*]' COLUMNS (value BIGINT PATH '\$')) AS relation_value" + ), DB::raw('1'), '=', DB::raw('1')) + ->join("{$relatedTable} as {$alias}", "{$alias}.{$relatedKey}", '=', 'relation_value.value') + ->limit(1), + default => $query + ->selectRaw("MIN({$alias}.{$titleColumn})") + ->join(DB::raw("json_each({$valuesTable}.value_json) as relation_value"), DB::raw('1'), '=', DB::raw('1')) + ->join("{$relatedTable} as {$alias}", "{$alias}.{$relatedKey}", '=', 'relation_value.value') + ->limit(1), + }; + } + + protected function multipleRelationSearchSql( + string $valuesTable, + string $relatedTable, + string $relatedKey, + string $titleColumn, + string $alias, + ): string { + return match (DB::connection()->getDriverName()) { + 'mysql' => "EXISTS ( + SELECT 1 + FROM JSON_TABLE({$valuesTable}.value_json, '\$[*]' COLUMNS (value BIGINT PATH '\$')) AS relation_value + INNER JOIN {$relatedTable} AS {$alias} + ON {$alias}.{$relatedKey} = relation_value.value + WHERE {$alias}.{$titleColumn} LIKE ? + )", + default => "EXISTS ( + SELECT 1 + FROM json_each({$valuesTable}.value_json) AS relation_value + INNER JOIN {$relatedTable} AS {$alias} + ON {$alias}.{$relatedKey} = relation_value.value + WHERE {$alias}.{$titleColumn} LIKE ? + )", + }; + } + + /** + * @return array{relatedEntity: string, modelClass: class-string, table: string, key: string, titleColumn: string, multiple: bool}|null + */ + protected function target(FieldDefinition $field): ?array + { + $relatedEntity = RelationValueRules::relatedEntity($field); + + if ($relatedEntity === null) { + return null; + } + + $queryTarget = $this->resolver->queryTarget($relatedEntity); + + if ($queryTarget === null) { + return null; + } + + return [ + 'relatedEntity' => $relatedEntity, + ...$queryTarget, + 'multiple' => RelationValueRules::isMultiple($field), + ]; + } +} diff --git a/packages/builder/src/Support/RelationTargetResolver.php b/packages/builder/src/Support/RelationTargetResolver.php index 52d88b2c19..7b34cda9f7 100644 --- a/packages/builder/src/Support/RelationTargetResolver.php +++ b/packages/builder/src/Support/RelationTargetResolver.php @@ -183,6 +183,32 @@ public function recordExists(string $entity, int|string $id): bool } } + /** + * @return array{modelClass: class-string, table: string, key: string, titleColumn: string}|null + */ + public function queryTarget(string $entity): ?array + { + $modelClass = $this->modelClass($entity); + + if ($modelClass === null || ! $this->entityRegistry->modelIsQueryable($modelClass)) { + return null; + } + + $model = new $modelClass; + $titleColumn = $this->titleAttributeFor($entity); + + if ($titleColumn !== 'id' && ! $this->modelHasColumn($modelClass, $titleColumn)) { + $titleColumn = 'id'; + } + + return [ + 'modelClass' => $modelClass, + 'table' => $model->getTable(), + 'key' => $model->getKeyName(), + 'titleColumn' => $titleColumn, + ]; + } + /** * Base query for enumerating/validating relation targets. Prefers the * target resource's own Eloquent query so global/tenant/soft-delete scopes From 858ca1ad93c2f420b1378a0037b5837d151e06db Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:14:56 +0200 Subject: [PATCH 072/105] Align field hint icons beside labels in admin forms --- .../builder/resources/lang/de/builder.php | 1 + .../builder/resources/lang/en/builder.php | 1 + .../src/Resources/FieldGroupResource.php | 57 ++++++++++++++----- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 137ce90f24..808fbe76dd 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -89,6 +89,7 @@ 'column_badge' => 'Badge', 'column_badge_helper' => 'Den Wert als farbiges Badge anzeigen.', 'column_color' => 'Farbe', + 'column_color_helper' => 'Badge-Farbe, wenn die Badge-Anzeige aktiv ist.', 'column_color_default' => 'Standard', 'column_color_primary' => 'Primär', 'column_color_gray' => 'Grau', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 4fede5077d..f714a0ad46 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -89,6 +89,7 @@ 'column_badge' => 'Badge', 'column_badge_helper' => 'Display the value as a colored badge.', 'column_color' => 'Color', + 'column_color_helper' => 'Badge color when badge display is enabled.', 'column_color_default' => 'Default', 'column_color_primary' => 'Primary', 'column_color_gray' => 'Gray', diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index f8e36bc362..e282737ba1 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -639,6 +639,24 @@ protected static function columnColorOptions(): array ]; } + /** + * @return array + */ + protected static function fieldHintWrapperAttributes(): array + { + return [ + 'class' => '[&_.fi-fo-field-label-ctn]:!items-center [&_.fi-fo-field-label-ctn]:!justify-start [&_.fi-fo-field-label-ctn]:gap-x-1 [&_.fi-sc-icon]:text-primary-500', + ]; + } + + protected static function configureFieldHint(Toggle|TextInput|Select $field, string $label, string $tooltip): Toggle|TextInput|Select + { + return $field + ->label($label) + ->hintIcon(Heroicon::OutlinedQuestionMarkCircle, tooltip: $tooltip) + ->extraFieldWrapperAttributes(static::fieldHintWrapperAttributes()); + } + /** * Table-column configuration grouped in its own collapsible section, mirroring * the existing "Settings"/"Options" sections. Only shown for columnable fields; @@ -675,28 +693,37 @@ protected static function columnSettingsSchema(): array ]) ->visible(fn (callable $get): bool => $enabled($get) && static::fieldTypeSupportsSortableSearchableColumn($get('type'))), - Toggle::make('settings.hidden_by_default') - ->label(__('builder::builder.field.column_hidden_by_default')) - ->hintIcon(Heroicon::OutlinedQuestionMarkCircle, tooltip: __('builder::builder.field.column_hidden_by_default_helper')) + static::configureFieldHint( + Toggle::make('settings.hidden_by_default'), + __('builder::builder.field.column_hidden_by_default'), + __('builder::builder.field.column_hidden_by_default_helper'), + ) ->inline(false) ->default(true) ->visible($enabled), Grid::make(3) ->schema([ - Toggle::make('settings.badge') - ->label(__('builder::builder.field.column_badge')) - ->hintIcon(Heroicon::OutlinedQuestionMarkCircle, tooltip: __('builder::builder.field.column_badge_helper')) + static::configureFieldHint( + Toggle::make('settings.badge'), + __('builder::builder.field.column_badge'), + __('builder::builder.field.column_badge_helper'), + ) ->inline(false) ->default(false), - Select::make('settings.color') - ->label(__('builder::builder.field.column_color')) + static::configureFieldHint( + Select::make('settings.color'), + __('builder::builder.field.column_color'), + __('builder::builder.field.column_color_helper'), + ) ->options(static::columnColorOptions()) ->placeholder(__('builder::builder.field.column_color_default')) ->native(false) ->visible(fn (callable $get): bool => static::fieldTypeUsesTextColumn($get('type'))), - TextInput::make('settings.icon') - ->label(__('builder::builder.field.column_icon')) - ->hintIcon(Heroicon::OutlinedQuestionMarkCircle, tooltip: __('builder::builder.field.column_icon_helper')) + static::configureFieldHint( + TextInput::make('settings.icon'), + __('builder::builder.field.column_icon'), + __('builder::builder.field.column_icon_helper'), + ) ->placeholder('heroicon-o-star') ->visible(fn (callable $get): bool => static::fieldTypeUsesTextColumn($get('type'))), ]) @@ -970,9 +997,11 @@ protected static function visibilityToggles(): array return [ Grid::make(3) ->schema([ - Toggle::make('settings.visible_admin') - ->label(__('builder::builder.visibility.admin')) - ->hintIcon(Heroicon::OutlinedQuestionMarkCircle, tooltip: __('builder::builder.visibility.admin_helper')) + static::configureFieldHint( + Toggle::make('settings.visible_admin'), + __('builder::builder.visibility.admin'), + __('builder::builder.visibility.admin_helper'), + ) ->inline(false) ->default(true), Toggle::make('settings.visible_frontend') From 4b712f92c098470a453ec678f6730407f4a080e0 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:36:32 +0200 Subject: [PATCH 073/105] Refine builder location conditions --- packages/builder/README.md | 40 +- .../builder/resources/lang/de/builder.php | 22 + .../builder/resources/lang/en/builder.php | 22 + .../builder/src/Compiler/LocationMatcher.php | 206 +++++++- .../builder/src/Compiler/SchemaCompiler.php | 13 + .../builder/src/Concerns/HasCustomFields.php | 19 +- .../Concerns/InteractsWithCustomFields.php | 8 + packages/builder/src/Data/LocationContext.php | 126 +++++ .../src/Registry/DefinitionRegistry.php | 14 +- .../src/Resources/FieldGroupResource.php | 206 ++++++++ .../Pages/EditFieldGroup.php | 4 + .../src/Services/CustomFieldsManager.php | 2 +- .../src/Services/FieldGroupPersistence.php | 193 ++++++- .../src/Services/FieldGroupValidator.php | 125 ++++- .../Support/FieldGroupDefinitionMapper.php | 21 +- .../src/Support/LocationConstraintOptions.php | 500 ++++++++++++++++++ 16 files changed, 1489 insertions(+), 32 deletions(-) create mode 100644 packages/builder/src/Support/LocationConstraintOptions.php diff --git a/packages/builder/README.md b/packages/builder/README.md index 079c62d0ad..d756dc9060 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -156,18 +156,41 @@ Field group names, field labels, option labels, and translatable field config (` **Not translated:** field keys (`name`), option values (`value`), structural `config`, location rules, validation rules. -### Location Rules (internal) +### Location Rules -In admin you choose **"Show on"** (multi-select). Internally this becomes: +In admin you choose **"Show on"** (multi-select) and optional **"Additional conditions"**. + +Internally this becomes OR groups with AND rules: ```json [ - [{ "param": "entity", "operator": "==", "value": "item" }], - [{ "param": "entity", "operator": "==", "value": "record" }] + [ + { "param": "entity", "operator": "==", "value": "draft" }, + { "param": "record_type", "operator": "==", "value": "page" } + ] ] ``` -Each inner list = AND group, multiple groups = OR. The matcher currently only supports `param: entity` with `==` / `!=`. Without assignment ("Show on" empty) the group appears in no form. +| Param | Example | Context source | +|-------|---------|----------------| +| `entity` | `item`, `draft` | Resource / model entity key | +| `record_type` | `page`, `article` | `$record->type` or `customFieldsLocationParams()` | +| `user_role` | `admin`, `editor` | Authenticated user roles (`in` / `not in` supported) | +| `taxonomy:{key}` | term ID `12` or `12,34` | Taxonomy IDs on the record (`HasModelTaxonomy`) | + +- Outer array = **OR** groups; inner array = **AND** rules. +- Empty rules = matches nothing (fail-closed). +- On create forms (no record yet), record/taxonomy rules are ignored; entity and user-role rules still apply. +- Per-record matching also runs on compiled form sections via `SchemaCompiler`. + +Override or extend auto-detected params on the resource/model: + +```php +protected static function customFieldsLocationParams(?Model $record): array +{ + return ['record_type' => $record?->type]; +} +``` --- @@ -843,6 +866,7 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc - **Relation fields** — link to Moox Filament entities, scoped search/validation, API `{id, label}` output - **Conditional logic (v1)** — show/hide on root-level sibling fields; save-side enforcement - **Per-context visibility** — `visible_admin`, `visible_api`, `visible_frontend` (admin + API wired) +- **Location rules** — entity, record type, user role, taxonomy term IDs (admin constraints + import/export) - **Table columns** — opt-in list columns for scalar, media, and relation fields (`TableColumnCompiler`) - **Field width grid** — 12-column layout per field (`FieldWidth`) - **Sidebar placement** — `main` vs `sidebar` field group slots @@ -852,9 +876,8 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc **v1 limitations (known):** -- Location rules: only `entity` param (`==` / `!=`) — no taxonomy, template, user role, etc. +- Location rules: no template/parent params yet; record/taxonomy rules need a saved record (ignored on create) - Conditional logic: root-level siblings only — not inside repeaters/groups/flexible content -- `visible_frontend`: stored in admin, but no packaged Blade/theme renderer yet - Custom `validation.rules`: supported in schema/DB, no admin UI (programmatic only) - Relation targets: Filament-registered Moox resources only (not arbitrary Eloquent models) - No clone field type (ACF-style reusable field group in a field) @@ -862,9 +885,8 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc **Not implemented yet:** - Clone field type (ACF) -- Location params beyond `entity` +- Location params beyond entity/record type/taxonomy/user role (e.g. template, parent) - Nested conditional logic (inside compound fields) -- Frontend rendering helper (`get_field()`-style Blade component) - Custom validation rules UI in admin - Package-level policies on field group management diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 808fbe76dd..a7f5199c77 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -28,6 +28,28 @@ 'target_entities_helper' => 'Diese Feldgruppe erscheint in den Formularen der ausgewählten Ressourcen. Leer lassen, um die Gruppe zu behalten, aber nirgends anzuzeigen.', 'target_entities_placeholder' => 'Ressource auswählen…', 'no_entities_registered' => 'Noch keine Ressource mit HasCustomFields. Trait an einer Filament-Resource hinzufügen.', + 'location_constraints' => 'Zusätzliche Bedingungen', + 'location_constraints_helper' => 'Optionale UND-Bedingungen für jede ausgewählte Ressource. Beispiele: Record-Typ, Benutzerrolle, Taxonomy-Term-IDs.', + 'location_constraints_add' => 'Bedingung hinzufügen', + 'location_param' => 'Parameter', + 'location_param_record_type' => 'Record-Typ', + 'location_param_user_role' => 'Benutzerrolle', + 'location_param_taxonomy' => 'Taxonomy-Term', + 'location_taxonomy' => 'Taxonomy-Schlüssel', + 'location_taxonomy_helper' => 'Taxonomy der ausgewählten Ressource wählen, z. B. category oder tag.', + 'location_operator' => 'Operator', + 'location_operator_equals' => 'ist gleich', + 'location_operator_not_equals' => 'ist nicht gleich', + 'location_operator_in' => 'ist einer von', + 'location_operator_not_in' => 'ist keiner von', + 'location_value' => 'Wert', + 'location_value_helper' => 'Einzelwert oder kommagetrennte Liste für „ist einer von“ / „ist keiner von“.', + 'location_value_taxonomy_helper' => 'Einen oder mehrere Taxonomy-Terme wählen. Anzeige per Name, intern wird die ID gespeichert.', + 'location_value_role_helper' => 'Eine oder mehrere Benutzerrollen wählen.', + 'location_value_role_unavailable_permissions' => 'Benutzerrollen sind nicht verfügbar, weil Rollen/Berechtigungen nicht konfiguriert sind.', + 'location_value_role_unavailable_empty' => 'Benutzerrollen sind verfügbar, aber es wurden noch keine Rollen angelegt.', + 'location_value_role_unavailable_user_model' => 'Benutzerrollen sind nicht verfügbar, weil das konfigurierte Auth-User-Model HasRoles nicht verwendet.', + 'location_value_record_type_helper' => 'Record-Typ, z. B. page oder article.', 'field_item' => 'Feld', 'fields_count' => 'Felder', 'assigned_to' => 'Zugeordnet zu', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index f714a0ad46..7e382f5607 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -28,6 +28,28 @@ 'target_entities_helper' => 'This field group appears in the forms of the selected resources. Leave empty to keep the group without showing it anywhere.', 'target_entities_placeholder' => 'Select a resource…', 'no_entities_registered' => 'No resources use HasCustomFields yet. Add the trait to a Filament resource.', + 'location_constraints' => 'Additional conditions', + 'location_constraints_helper' => 'Optional AND rules applied to every selected resource. Examples: record type, user role, taxonomy term IDs.', + 'location_constraints_add' => 'Add condition', + 'location_param' => 'Parameter', + 'location_param_record_type' => 'Record type', + 'location_param_user_role' => 'User role', + 'location_param_taxonomy' => 'Taxonomy term', + 'location_taxonomy' => 'Taxonomy key', + 'location_taxonomy_helper' => 'Choose the taxonomy configured on the selected resource, e.g. category or tag.', + 'location_operator' => 'Operator', + 'location_operator_equals' => 'equals', + 'location_operator_not_equals' => 'does not equal', + 'location_operator_in' => 'is one of', + 'location_operator_not_in' => 'is not one of', + 'location_value' => 'Value', + 'location_value_helper' => 'Single value or comma-separated list for "is one of" / "is not one of".', + 'location_value_taxonomy_helper' => 'Choose one or more taxonomy terms. Labels are shown; IDs are stored internally.', + 'location_value_role_helper' => 'Choose one or more user roles.', + 'location_value_role_unavailable_permissions' => 'User roles are not available because roles/permissions are not configured.', + 'location_value_role_unavailable_empty' => 'User roles are available, but no roles exist yet.', + 'location_value_role_unavailable_user_model' => 'User roles are not available because the configured auth user model does not use HasRoles.', + 'location_value_record_type_helper' => 'Record type value, e.g. page or article.', 'field_item' => 'Field', 'fields_count' => 'Fields', 'assigned_to' => 'Assigned to', diff --git a/packages/builder/src/Compiler/LocationMatcher.php b/packages/builder/src/Compiler/LocationMatcher.php index b27b7c57a1..f968cdbc24 100644 --- a/packages/builder/src/Compiler/LocationMatcher.php +++ b/packages/builder/src/Compiler/LocationMatcher.php @@ -27,18 +27,68 @@ public function matches(array $locationRules, LocationContext $context): bool return false; } + /** + * Entity-level prefilter for cached definition loading (no record yet). + * + * @param list> $locationRules + */ + public function matchesEntityScope(array $locationRules, string $entity): bool + { + if ($locationRules === []) { + return false; + } + + foreach ($locationRules as $andGroup) { + if ($this->matchesEntityAndGroup($andGroup, $entity)) { + return true; + } + } + + return false; + } + /** * @param list $andGroup */ protected function matchesAndGroup(array $andGroup, LocationContext $context): bool { + if ($andGroup === []) { + return false; + } + foreach ($andGroup as $rule) { if (! $this->matchesRule($rule, $context)) { return false; } } - return $andGroup !== []; + return true; + } + + /** + * @param list $andGroup + */ + protected function matchesEntityAndGroup(array $andGroup, string $entity): bool + { + if ($andGroup === []) { + return false; + } + + $hasEntityRule = false; + + foreach ($andGroup as $rule) { + if (($rule['param'] ?? '') !== 'entity') { + continue; + } + + $hasEntityRule = true; + + if (! $this->compare((string) $entity, (string) ($rule['value'] ?? ''), (string) ($rule['operator'] ?? '=='))) { + return false; + } + } + + return $hasEntityRule; } /** @@ -46,22 +96,156 @@ protected function matchesAndGroup(array $andGroup, LocationContext $context): b */ protected function matchesRule(array $rule, LocationContext $context): bool { - $param = $rule['param'] ?? ''; - $operator = $rule['operator'] ?? '=='; - $value = $rule['value'] ?? null; + $param = (string) ($rule['param'] ?? ''); + $operator = (string) ($rule['operator'] ?? '=='); + $expected = $rule['value'] ?? null; - if ($param !== 'entity') { - if (app()->bound('log')) { - Log::debug('Builder location rule skipped: unknown param.', ['param' => $param]); - } + if ($param === 'entity') { + return $this->compare((string) $context->entity, (string) $expected, $operator); + } - return false; + if ($this->requiresRecord($param) && ! $context->hasRecord()) { + return true; + } + + $actual = $this->resolveActualValue($param, $context); + + if ($actual === null && $this->requiresRecord($param)) { + return $operator === '!='; + } + + return match ($param) { + 'record_type', 'user_role' => $this->compare($actual, $expected, $operator), + default => $this->matchesDynamicParam($param, $actual, $expected, $operator), + }; + } + + protected function requiresRecord(string $param): bool + { + return $param === 'record_type' || str_starts_with($param, 'taxonomy:'); + } + + protected function resolveActualValue(string $param, LocationContext $context): mixed + { + if ($param === 'entity') { + return $context->entity; + } + + if (array_key_exists($param, $context->params)) { + return $context->get($param); + } + + if (str_starts_with($param, 'taxonomy:')) { + return $context->get($param); + } + + if ($param === 'record_type') { + return $context->get('record_type'); + } + + if ($param === 'user_role') { + return $context->get('user_role'); + } + + return null; + } + + protected function contextHas(LocationContext $context, string $param): bool + { + return array_key_exists($param, $context->params); + } + + protected function matchesDynamicParam(string $param, mixed $actual, mixed $expected, string $operator): bool + { + if (str_starts_with($param, 'taxonomy:')) { + return $this->compareList($actual, $expected, $operator); + } + + if (app()->bound('log')) { + Log::debug('Builder location rule skipped: unknown param.', ['param' => $param]); + } + + return false; + } + + protected function compare(mixed $actual, mixed $expected, string $operator): bool + { + if (is_array($actual)) { + return $this->compareList($actual, $expected, $operator); } return match ($operator) { - '==' => (string) $context->entity === (string) $value, - '!=' => (string) $context->entity !== (string) $value, + '==' => (string) $actual === (string) $expected, + '!=' => (string) $actual !== (string) $expected, + 'in' => $this->valueInList($expected, $actual), + 'not in' => ! $this->valueInList($expected, $actual), default => false, }; } + + protected function compareList(mixed $actual, mixed $expected, string $operator): bool + { + $actualValues = $this->normalizeList($actual); + $expectedValues = $this->normalizeList($expected); + + if ($actualValues === []) { + return match ($operator) { + '!=', 'not in' => true, + default => false, + }; + } + + return match ($operator) { + '==' => count($expectedValues) === 1 && $this->listsIntersect($actualValues, $expectedValues), + '!=' => count($expectedValues) === 1 && ! $this->listsIntersect($actualValues, $expectedValues), + 'in' => $this->listsIntersect($actualValues, $expectedValues), + 'not in' => ! $this->listsIntersect($actualValues, $expectedValues), + default => false, + }; + } + + /** + * @return list + */ + protected function normalizeList(mixed $value): array + { + if ($value === null || $value === '') { + return []; + } + + if (is_array($value)) { + return array_values(array_filter($value, static fn (mixed $item): bool => $item !== null && $item !== '')); + } + + if (is_string($value) && str_contains($value, ',')) { + return array_values(array_filter(array_map(trim(...), explode(',', $value)))); + } + + return [$value]; + } + + /** + * @param list $haystack + */ + protected function valueInList(mixed $list, mixed $needle): bool + { + return in_array($needle, $this->normalizeList($list), true); + } + + /** + * @param list $left + * @param list $right + */ + protected function listsIntersect(array $left, array $right): bool + { + foreach ($left as $value) { + foreach ($right as $expected) { + if ((string) $value === (string) $expected) { + return true; + } + } + } + + return false; + } } diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index bf80aa503d..8801c12bc8 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -16,6 +16,7 @@ use Illuminate\Support\Collection; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Data\FieldGroupDefinition; +use Moox\Builder\Data\LocationContext; use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\FieldTypes\Types\GroupFieldType; use Moox\Builder\Registry\FieldTypeRegistry; @@ -31,6 +32,7 @@ public function __construct( protected FieldTypeRegistry $fieldTypeRegistry, protected CustomFieldsManager $customFieldsManager, protected StorableFieldCollector $storableFieldCollector, + protected LocationMatcher $locationMatcher, ) {} /** @@ -82,6 +84,17 @@ function (Component $component, mixed $state, ?Model $record) use ($groupScalarF ); } + if ($resourceClass !== null && $group->locationRules !== []) { + $section = $section->visible( + function (?Model $record) use ($group, $resourceClass): bool { + return $this->locationMatcher->matches( + $group->locationRules, + LocationContext::forResource($resourceClass, $record), + ); + }, + ); + } + $sections[] = $section; } diff --git a/packages/builder/src/Concerns/HasCustomFields.php b/packages/builder/src/Concerns/HasCustomFields.php index 3e748232ea..7034cfd5c8 100644 --- a/packages/builder/src/Concerns/HasCustomFields.php +++ b/packages/builder/src/Concerns/HasCustomFields.php @@ -116,7 +116,24 @@ protected static function customFieldsModifyTableQuery(Builder $query): Builder public static function customFieldsLocationContext(): LocationContext { - return new LocationContext(static::resolveCustomFieldsEntityIdentifier()); + return LocationContext::forResource(static::class); + } + + /** + * Additional location params for the current record (merged with auto-detected + * record type, taxonomy IDs, and authenticated user roles). + * + * @return array + */ + public static function customFieldsLocationParams(?Model $record): array + { + $modelClass = static::getModel(); + + if (is_string($modelClass) && method_exists($modelClass, 'customFieldsLocationParams')) { + return $modelClass::customFieldsLocationParams($record); + } + + return []; } public static function resolveCustomFieldsEntityIdentifier(): string diff --git a/packages/builder/src/Concerns/InteractsWithCustomFields.php b/packages/builder/src/Concerns/InteractsWithCustomFields.php index bf2e8639d0..d4f78318bd 100644 --- a/packages/builder/src/Concerns/InteractsWithCustomFields.php +++ b/packages/builder/src/Concerns/InteractsWithCustomFields.php @@ -308,6 +308,14 @@ public function setAttribute($key, $value): mixed return parent::setAttribute($key, $value); } + /** + * @return array + */ + public static function customFieldsLocationParams(?Model $record): array + { + return []; + } + public static function resolveCustomFieldsEntity(): string { if (method_exists(static::class, 'customFieldsEntity')) { diff --git a/packages/builder/src/Data/LocationContext.php b/packages/builder/src/Data/LocationContext.php index 5d496859af..6929a0e010 100644 --- a/packages/builder/src/Data/LocationContext.php +++ b/packages/builder/src/Data/LocationContext.php @@ -4,9 +4,135 @@ namespace Moox\Builder\Data; +use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Database\Eloquent\Model; +use Moox\Builder\Concerns\HasCustomFields; +use Moox\Builder\Concerns\InteractsWithCustomFields; +use Moox\Core\Services\TaxonomyService; +use Moox\Core\Traits\Taxonomy\HasModelTaxonomy; + readonly class LocationContext { + /** + * @param array $params + */ public function __construct( public string $entity, + public array $params = [], + public ?Model $record = null, ) {} + + public function get(string $param, mixed $default = null): mixed + { + return $this->params[$param] ?? $default; + } + + public function hasRecord(): bool + { + return $this->record !== null; + } + + /** + * @param class-string $resourceClass + */ + public static function forResource(string $resourceClass, ?Model $record = null): self + { + /** @var class-string $resourceClass */ + $entity = $resourceClass::resolveCustomFieldsEntityIdentifier(); + $params = $resourceClass::customFieldsLocationParams($record); + + if ($record !== null) { + $params = array_merge(self::paramsFromRecord($record), $params); + } + + $params = array_merge($params, self::paramsFromAuthenticatedUser()); + + return new self($entity, $params, $record); + } + + public static function forEntity(string $entity, ?Model $record = null, ?string $modelClass = null): self + { + $params = []; + + if ($record !== null) { + $params = self::paramsFromRecord($record); + + if ($modelClass === null && in_array(InteractsWithCustomFields::class, class_uses_recursive($record), true)) { + $params = array_merge($params, $record::customFieldsLocationParams($record)); + } + } + + if ($modelClass !== null && is_subclass_of($modelClass, Model::class)) { + /** @var class-string $modelClass */ + if (method_exists($modelClass, 'customFieldsLocationParams')) { + $params = array_merge($params, $modelClass::customFieldsLocationParams($record)); + } + } + + $params = array_merge($params, self::paramsFromAuthenticatedUser()); + + return new self($entity, $params, $record); + } + + /** + * @return array + */ + protected static function paramsFromRecord(Model $record): array + { + $params = []; + + if ($record->offsetExists('type') && filled($record->getAttribute('type'))) { + $params['record_type'] = (string) $record->getAttribute('type'); + } + + if (in_array(HasModelTaxonomy::class, class_uses_recursive($record), true)) { + $resourceName = method_exists($record, 'getResourceName') + ? $record::getResourceName() + : null; + + if (filled($resourceName)) { + $taxonomyService = app(TaxonomyService::class); + $taxonomyService->setCurrentResource((string) $resourceName); + + foreach (array_keys($taxonomyService->getTaxonomies()) as $taxonomy) { + $relationQuery = $record->taxonomy($taxonomy); + $relationName = $relationQuery->getRelationName(); + + $ids = $record->relationLoaded($relationName) + ? collect($record->getRelation($relationName))->modelKeys() + : $relationQuery->pluck($relationQuery->getRelated()->getQualifiedKeyName())->all(); + + if ($ids !== []) { + $params['taxonomy:'.$taxonomy] = array_values(array_map('intval', $ids)); + } + } + } + } + + return $params; + } + + /** + * @return array + */ + protected static function paramsFromAuthenticatedUser(): array + { + $user = auth()->user(); + + if (! $user instanceof Authenticatable) { + return []; + } + + if (! method_exists($user, 'getRoleNames')) { + return []; + } + + $roles = $user->getRoleNames()->values()->all(); + + if ($roles === []) { + return []; + } + + return ['user_role' => $roles]; + } } diff --git a/packages/builder/src/Registry/DefinitionRegistry.php b/packages/builder/src/Registry/DefinitionRegistry.php index d454718286..1dc7eb4f9d 100644 --- a/packages/builder/src/Registry/DefinitionRegistry.php +++ b/packages/builder/src/Registry/DefinitionRegistry.php @@ -32,10 +32,16 @@ public function fieldGroupsFor(LocationContext $context, ?string $locale = null) $locale = $this->localeResolver->current($locale); return $this->allActiveGroups() - ->filter(fn (FieldGroupDefinition $group): bool => $this->locationMatcher->matches( - $group->locationRules, - $context, - )) + ->filter(function (FieldGroupDefinition $group) use ($context): bool { + if ($context->record === null) { + return $this->locationMatcher->matchesEntityScope( + $group->locationRules, + $context->entity, + ); + } + + return $this->locationMatcher->matches($group->locationRules, $context); + }) ->map(fn (FieldGroupDefinition $group): FieldGroupDefinition => $this->definitionTranslator->localizeGroup($group, $locale)) ->values(); } diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index e282737ba1..043107fbe5 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -38,6 +38,7 @@ use Moox\Builder\Support\ConditionalLogic; use Moox\Builder\Support\FieldGroupPlacement; use Moox\Builder\Support\FieldWidth; +use Moox\Builder\Support\LocationConstraintOptions; use Moox\Builder\Support\TypedValueColumns; class FieldGroupResource extends Resource @@ -138,7 +139,18 @@ public static function form(Schema $schema): Schema ->preload() ->placeholder(__('builder::builder.field_group.target_entities_placeholder')) ->disabled($entityOptions === []) + ->live() ->native(false), + Repeater::make('location_constraints') + ->label(__('builder::builder.field_group.location_constraints')) + ->helperText(__('builder::builder.field_group.location_constraints_helper')) + ->default([]) + ->addActionLabel(__('builder::builder.field_group.location_constraints_add')) + ->itemLabel(fn (array $state, Get $get): string => static::locationConstraintItemLabel($state, $get('../../target_entities'))) + ->schema(static::locationConstraintSchema()) + ->columns(1) + ->collapsible() + ->collapsed(), ]), Section::make(__('builder::builder.field_group.visibility')) ->description(__('builder::builder.field_group.visibility_helper')) @@ -1272,6 +1284,200 @@ protected static function fieldRepeaterHeaderActions(): array ]; } + /** + * @return list + */ + public static function locationConstraintSchema(): array + { + $targetEntitiesPath = '../../target_entities'; + + return [ + Grid::make() + ->schema([ + Select::make('param') + ->label(__('builder::builder.field_group.location_param')) + ->options(app(LocationConstraintOptions::class)->availableParamOptions()) + ->required() + ->live() + ->afterStateUpdated(function (callable $set): void { + $set('taxonomy', null); + $set('operator', '=='); + $set('value', null); + }) + ->native(false) + ->columnSpan(['default' => 1, 'md' => 4]), + Select::make('taxonomy') + ->label(__('builder::builder.field_group.location_taxonomy')) + ->helperText(__('builder::builder.field_group.location_taxonomy_helper')) + ->options(fn (Get $get): array => app(LocationConstraintOptions::class) + ->taxonomyKeysForEntities($get($targetEntitiesPath))) + ->visible(fn (Get $get): bool => $get('param') === 'taxonomy') + ->required(fn (Get $get): bool => $get('param') === 'taxonomy') + ->searchable() + ->preload() + ->live() + ->afterStateUpdated(fn (callable $set): mixed => $set('value', null)) + ->native(false) + ->columnSpan(['default' => 1, 'md' => 5]), + Select::make('operator') + ->label(__('builder::builder.field_group.location_operator')) + ->options(static::locationConstraintOperatorOptions()) + ->default('==') + ->required() + ->live() + ->disabled(fn (Get $get): bool => $get('param') === 'user_role' + && ! app(LocationConstraintOptions::class)->supportsUserRoles()) + ->native(false) + ->columnSpan(['default' => 1, 'md' => 3]), + ]) + ->columns(['default' => 1, 'md' => 12]), + Select::make('value') + ->label(__('builder::builder.field_group.location_value')) + ->helperText(fn (Get $get): string => match ($get('param')) { + 'taxonomy' => __('builder::builder.field_group.location_value_taxonomy_helper'), + 'record_type' => __('builder::builder.field_group.location_value_record_type_helper'), + 'user_role' => app(LocationConstraintOptions::class)->userRoleUnavailableReason() + ?? __('builder::builder.field_group.location_value_role_helper'), + default => __('builder::builder.field_group.location_value_helper'), + }) + ->options(function (Get $get) use ($targetEntitiesPath): array { + return match ($get('param')) { + 'taxonomy' => app(LocationConstraintOptions::class) + ->termOptionsForTaxonomy((string) $get('taxonomy'), $get($targetEntitiesPath)), + 'record_type' => app(LocationConstraintOptions::class)->recordTypeOptionsForEntities($get($targetEntitiesPath)), + 'user_role' => app(LocationConstraintOptions::class)->roleOptions(), + default => [], + }; + }) + ->visible(fn (Get $get): bool => in_array($get('param'), ['taxonomy', 'record_type', 'user_role'], true) + && ($get('param') !== 'taxonomy' || filled($get('taxonomy')))) + ->required(fn (Get $get): bool => in_array($get('param'), ['taxonomy', 'record_type', 'user_role'], true) + && ($get('param') !== 'user_role' || app(LocationConstraintOptions::class)->supportsUserRoles()) + && ($get('param') !== 'taxonomy' || filled($get('taxonomy')))) + ->disabled(fn (Get $get): bool => $get('param') === 'user_role' + && ! app(LocationConstraintOptions::class)->supportsUserRoles()) + ->multiple(fn (Get $get): bool => in_array($get('operator'), ['in', 'not in'], true)) + ->getOptionLabelUsing(function (mixed $value, Get $get) use ($targetEntitiesPath): ?string { + return match ($get('param')) { + 'taxonomy' => app(LocationConstraintOptions::class)->termLabelForValue( + (string) $get('taxonomy'), + $get($targetEntitiesPath), + $value, + ), + 'record_type' => app(LocationConstraintOptions::class)->recordTypeLabelForValue( + $get($targetEntitiesPath), + $value, + ), + 'user_role' => filled($value) ? (string) $value : null, + default => null, + }; + }) + ->getOptionLabelsUsing(function (array $values, Get $get) use ($targetEntitiesPath): array { + return match ($get('param')) { + 'taxonomy' => app(LocationConstraintOptions::class)->termLabelsForValues( + (string) $get('taxonomy'), + $get($targetEntitiesPath), + $values, + ), + 'record_type' => app(LocationConstraintOptions::class)->recordTypeLabelsForValues( + $get($targetEntitiesPath), + $values, + ), + 'user_role' => collect($values) + ->filter(fn (mixed $value): bool => filled($value)) + ->mapWithKeys(fn (mixed $value): array => [$value => (string) $value]) + ->all(), + default => [], + }; + }) + ->searchable() + ->preload() + ->native(false) + ->columnSpanFull(), + ]; + } + + /** + * @param array $state + */ + protected static function locationConstraintItemLabel(array $state, mixed $targetEntities): string + { + $param = (string) ($state['param'] ?? ''); + + if ($param === '') { + return __('builder::builder.field_group.location_constraints_add'); + } + + $paramLabel = static::locationConstraintParamOptions()[$param] ?? Str::headline($param); + + if ($param === 'taxonomy' && filled($state['taxonomy'] ?? null)) { + $paramLabel .= ': '.Str::headline((string) $state['taxonomy']); + } + + $operator = (string) ($state['operator'] ?? '=='); + $operatorLabel = static::locationConstraintOperatorOptions()[$operator] ?? $operator; + $valueLabel = static::locationConstraintValueLabel($state, $targetEntities); + + return trim(implode(' ', array_filter([$paramLabel, $operatorLabel, $valueLabel]))); + } + + /** + * @param array $state + */ + protected static function locationConstraintValueLabel(array $state, mixed $targetEntities): string + { + $value = $state['value'] ?? null; + + if ($value === null || $value === '') { + return ''; + } + + $options = app(LocationConstraintOptions::class); + + if (($state['param'] ?? null) === 'taxonomy' && filled($state['taxonomy'] ?? null)) { + if (is_array($value)) { + return implode(', ', $options->termLabelsForValues((string) $state['taxonomy'], $targetEntities, $value)); + } + + return $options->termLabelForValue((string) $state['taxonomy'], $targetEntities, $value) ?? (string) $value; + } + + if (($state['param'] ?? null) === 'record_type') { + if (is_array($value)) { + return implode(', ', $options->recordTypeLabelsForValues($targetEntities, $value)); + } + + return $options->recordTypeLabelForValue($targetEntities, $value) ?? (string) $value; + } + + if (is_array($value)) { + return implode(', ', array_map(static fn (mixed $item): string => (string) $item, $value)); + } + + return (string) $value; + } + + /** + * @return array + */ + public static function locationConstraintParamOptions(): array + { + return app(LocationConstraintOptions::class)->availableParamOptions(); + } + + /** + * @return array + */ + public static function locationConstraintOperatorOptions(): array + { + return [ + '==' => __('builder::builder.field_group.location_operator_equals'), + '!=' => __('builder::builder.field_group.location_operator_not_equals'), + 'in' => __('builder::builder.field_group.location_operator_in'), + 'not in' => __('builder::builder.field_group.location_operator_not_in'), + ]; + } + public static function table(Table $table): Table { $persistence = app(FieldGroupPersistence::class); diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php index abcdf457fc..7606fdcef3 100644 --- a/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php @@ -67,6 +67,10 @@ protected function mutateFormDataBeforeFill(array $data): array $record->location_rules ?? [], ); + $data['location_constraints'] = $persistence->constraintsFromLocationRules( + $record->location_rules ?? [], + ); + $data['fields'] = $persistence->fieldRowsForForm($record, $this->lang); return $data; diff --git a/packages/builder/src/Services/CustomFieldsManager.php b/packages/builder/src/Services/CustomFieldsManager.php index ca8b5f8117..fe7b0daf9b 100644 --- a/packages/builder/src/Services/CustomFieldsManager.php +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -41,7 +41,7 @@ public function __construct( */ public function locationContextForResource(string $resourceClass): LocationContext { - return new LocationContext($resourceClass::resolveCustomFieldsEntityIdentifier()); + return LocationContext::forResource($resourceClass); } /** diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 153ac75302..fe600a1f71 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -73,13 +73,204 @@ protected function syncGroupTranslation(FieldGroup $group, string $name): void */ public function resolveLocationRules(array $data): array { + if ($this->isNestedLocationRules($data['location_rules'] ?? null)) { + return $this->normalizeNestedLocationRules($data['location_rules']); + } + if (array_key_exists('target_entities', $data)) { - return $this->locationRulesFromEntities($data['target_entities'] ?? []); + return $this->mergeEntityRulesWithConstraints( + $data['target_entities'] ?? [], + $data['location_constraints'] ?? [], + ); } return $this->normalizeLocationRules($data['location_rules'] ?? []); } + /** + * @param list|string|null $entities + * @param list $constraints + * @return list> + */ + public function mergeEntityRulesWithConstraints(array|string|null $entities, array $constraints): array + { + $entityRules = $this->locationRulesFromEntities($entities); + $constraintRules = $this->constraintRulesFromForm($constraints); + + if ($constraintRules === []) { + return $entityRules; + } + + return array_map( + fn (array $group): array => array_merge($group, $constraintRules), + $entityRules, + ); + } + + /** + * @param list $rows + * @return list + */ + public function constraintRulesFromForm(array $rows): array + { + $rules = []; + + foreach ($rows as $row) { + $param = $this->resolveConstraintParam($row); + + if ($param === null) { + continue; + } + + $rules[] = [ + 'param' => $param, + 'operator' => (string) ($row['operator'] ?? '=='), + 'value' => $this->normalizeConstraintValue($row['value'] ?? null), + ]; + } + + return $rules; + } + + /** + * @param array{param?: string, operator?: string, value?: mixed, taxonomy?: string|null} $row + */ + protected function resolveConstraintParam(array $row): ?string + { + $param = (string) ($row['param'] ?? ''); + + if ($param === '') { + return null; + } + + if ($param === 'taxonomy') { + $taxonomy = trim((string) ($row['taxonomy'] ?? '')); + + return $taxonomy === '' ? null : 'taxonomy:'.$taxonomy; + } + + return $param; + } + + protected function normalizeConstraintValue(mixed $value): mixed + { + if (is_array($value)) { + return array_values(array_map( + fn (mixed $item): int|string => is_numeric($item) ? (int) $item : (string) $item, + array_filter($value, static fn (mixed $item): bool => $item !== null && $item !== ''), + )); + } + + if (! is_string($value)) { + return $value; + } + + $value = trim($value); + + if ($value === '') { + return null; + } + + if (is_numeric($value)) { + return (int) $value; + } + + if (str_contains($value, ',')) { + return array_values(array_filter(array_map( + static fn (string $item): int|string => is_numeric(trim($item)) ? (int) trim($item) : trim($item), + explode(',', $value), + ))); + } + + return $value; + } + + /** + * @param list> $rules + * @return list + */ + public function constraintsFromLocationRules(array $rules): array + { + if ($rules === []) { + return []; + } + + $constraints = []; + + foreach ($rules[0] as $rule) { + $param = (string) ($rule['param'] ?? ''); + + if ($param === '' || $param === 'entity') { + continue; + } + + if (str_starts_with($param, 'taxonomy:')) { + $constraints[] = [ + 'param' => 'taxonomy', + 'taxonomy' => substr($param, strlen('taxonomy:')), + 'operator' => (string) ($rule['operator'] ?? '=='), + 'value' => $rule['value'] ?? null, + ]; + + continue; + } + + $constraints[] = [ + 'param' => $param, + 'operator' => (string) ($rule['operator'] ?? '=='), + 'value' => $rule['value'] ?? null, + ]; + } + + return $constraints; + } + + protected function isNestedLocationRules(mixed $rules): bool + { + if (! is_array($rules) || $rules === []) { + return false; + } + + $first = $rules[0] ?? null; + + return is_array($first) && array_is_list($first); + } + + /** + * @param list> $rules + * @return list> + */ + public function normalizeNestedLocationRules(array $rules): array + { + $normalized = []; + + foreach ($rules as $andGroup) { + if (! is_array($andGroup)) { + continue; + } + + $group = []; + + foreach ($andGroup as $rule) { + if (! is_array($rule) || blank($rule['param'] ?? null)) { + continue; + } + + $group[] = [ + 'param' => (string) $rule['param'], + 'operator' => (string) ($rule['operator'] ?? '=='), + 'value' => $rule['value'] ?? null, + ]; + } + + if ($group !== []) { + $normalized[] = $group; + } + } + + return $normalized; + } + /** * @param list|string|null $entities * @return list> diff --git a/packages/builder/src/Services/FieldGroupValidator.php b/packages/builder/src/Services/FieldGroupValidator.php index 068369f23f..9a0c1b6f7a 100644 --- a/packages/builder/src/Services/FieldGroupValidator.php +++ b/packages/builder/src/Services/FieldGroupValidator.php @@ -4,11 +4,13 @@ namespace Moox\Builder\Services; +use Illuminate\Support\Arr; use Illuminate\Validation\ValidationException; use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Support\FieldRelationTree; +use Moox\Builder\Support\LocationConstraintOptions; use Moox\Builder\Support\StorableFieldCollector; class FieldGroupValidator @@ -16,6 +18,7 @@ class FieldGroupValidator public function __construct( protected FieldGroupPersistence $fieldGroupPersistence, protected StorableFieldCollector $storableFieldCollector, + protected LocationConstraintOptions $locationConstraintOptions, ) {} /** @@ -26,7 +29,13 @@ public function validate(FieldGroup $group, array $data): void $locationRules = $this->fieldGroupPersistence->resolveLocationRules($data); $entities = $this->fieldGroupPersistence->entitiesFromLocationRules($locationRules); $fieldRows = $data['fields'] ?? []; - $messages = $this->rangeBoundsMessages($fieldRows); + $messages = array_merge( + $this->rangeBoundsMessages($fieldRows), + $this->locationConstraintMessages( + is_array($data['location_constraints'] ?? null) ? $data['location_constraints'] : [], + $data['target_entities'] ?? [], + ), + ); if ($entities === [] || $fieldRows === []) { if ($messages !== []) { @@ -228,4 +237,118 @@ protected function rangeMaxIsNotGreaterThanMin(mixed $min, mixed $max): bool return $max + 0 <= $min + 0; } + + /** + * @param list> $rows + * @return array> + */ + protected function locationConstraintMessages(array $rows, mixed $entities): array + { + $messages = []; + $availableParams = array_keys($this->locationConstraintOptions->availableParamOptions()); + $availableOperators = ['==', '!=', 'in', 'not in']; + + foreach ($rows as $index => $row) { + if (! is_array($row)) { + continue; + } + + $param = (string) ($row['param'] ?? ''); + $operator = (string) ($row['operator'] ?? '=='); + $basePath = "location_constraints.{$index}"; + + if (! in_array($param, $availableParams, true)) { + $messages["{$basePath}.param"] = [__('builder::builder.validation.invalid_option')]; + + continue; + } + + if (! in_array($operator, $availableOperators, true)) { + $messages["{$basePath}.operator"] = [__('builder::builder.validation.invalid_option')]; + } + + if ($param === 'taxonomy') { + $taxonomy = (string) ($row['taxonomy'] ?? ''); + + if ($taxonomy === '' || ! array_key_exists($taxonomy, $this->locationConstraintOptions->taxonomyKeysForEntities($entities))) { + $messages["{$basePath}.taxonomy"] = [__('builder::builder.validation.invalid_option')]; + + continue; + } + + $valueMessages = $this->invalidConstraintValueMessages( + Arr::wrap($row['value'] ?? null), + array_keys($this->locationConstraintOptions->termOptionsForTaxonomy($taxonomy, $entities)), + ); + + if ($valueMessages !== []) { + $messages["{$basePath}.value"] = $valueMessages; + } + + continue; + } + + if ($param === 'record_type') { + $valueMessages = $this->invalidConstraintValueMessages( + Arr::wrap($row['value'] ?? null), + array_keys($this->locationConstraintOptions->recordTypeOptionsForEntities($entities)), + ); + + if ($valueMessages !== []) { + $messages["{$basePath}.value"] = $valueMessages; + } + + continue; + } + + if ($param === 'user_role') { + if (! $this->locationConstraintOptions->supportsUserRoles()) { + $messages["{$basePath}.value"] = [ + $this->locationConstraintOptions->userRoleUnavailableReason() + ?? __('builder::builder.validation.invalid_option'), + ]; + + continue; + } + + $valueMessages = $this->invalidConstraintValueMessages( + Arr::wrap($row['value'] ?? null), + array_keys($this->locationConstraintOptions->roleOptions()), + ); + + if ($valueMessages !== []) { + $messages["{$basePath}.value"] = $valueMessages; + } + } + } + + return $messages; + } + + /** + * @param list $values + * @param list $allowed + * @return list + */ + protected function invalidConstraintValueMessages(array $values, array $allowed): array + { + $normalizedValues = array_values(array_filter( + array_map(static fn (mixed $value): string => trim((string) $value), $values), + static fn (string $value): bool => $value !== '', + )); + + if ($normalizedValues === []) { + return []; + } + + $allowedLookup = array_fill_keys(array_map(static fn (mixed $value): string => (string) $value, $allowed), true); + + foreach ($normalizedValues as $value) { + if (! isset($allowedLookup[$value])) { + return [__('builder::builder.validation.invalid_option')]; + } + } + + return []; + } } diff --git a/packages/builder/src/Support/FieldGroupDefinitionMapper.php b/packages/builder/src/Support/FieldGroupDefinitionMapper.php index baeaa363e0..630685d7bc 100644 --- a/packages/builder/src/Support/FieldGroupDefinitionMapper.php +++ b/packages/builder/src/Support/FieldGroupDefinitionMapper.php @@ -26,18 +26,31 @@ public function toPersistenceData( bool $active, int $sort, ): array { - return [ + $entities = $this->fieldGroupPersistence->entitiesFromLocationRules( + $definition->locationRules, + ); + $constraints = $this->fieldGroupPersistence->constraintsFromLocationRules( + $definition->locationRules, + ); + + $payload = [ 'name' => $definition->name, 'slug' => $definition->slug, 'active' => $active, 'sort' => $sort, 'placement' => $definition->placement, 'settings' => $definition->settings, - 'target_entities' => $this->fieldGroupPersistence->entitiesFromLocationRules( - $definition->locationRules, - ), 'fields' => $this->mapFieldsToRows($definition->fields), ]; + + if ($constraints === []) { + $payload['target_entities'] = $entities; + } else { + $payload['target_entities'] = $entities; + $payload['location_constraints'] = $constraints; + } + + return $payload; } /** diff --git a/packages/builder/src/Support/LocationConstraintOptions.php b/packages/builder/src/Support/LocationConstraintOptions.php new file mode 100644 index 0000000000..6979fbd4a2 --- /dev/null +++ b/packages/builder/src/Support/LocationConstraintOptions.php @@ -0,0 +1,500 @@ + + */ + public function taxonomyKeysForEntities(mixed $entities): array + { + $options = []; + + foreach ($this->normalizeEntities($entities) as $entity) { + $configKey = $this->configKeyForEntity($entity); + + if ($configKey === null) { + continue; + } + + $this->taxonomyService->setCurrentResource($configKey); + + foreach ($this->taxonomyService->getTaxonomies() as $key => $definition) { + $options[(string) $key] = $this->taxonomyLabel((string) $key, $definition); + } + } + + asort($options); + + return $options; + } + + /** + * @return array + */ + public function termOptionsForTaxonomy(string $taxonomy, mixed $entities): array + { + if ($taxonomy === '') { + return []; + } + + foreach ($this->normalizeEntities($entities) as $entity) { + $configKey = $this->configKeyForEntity($entity); + + if ($configKey === null) { + continue; + } + + $this->taxonomyService->setCurrentResource($configKey); + + if (! array_key_exists($taxonomy, $this->taxonomyService->getTaxonomies())) { + continue; + } + + $modelClass = $this->taxonomyService->getTaxonomyModel($taxonomy); + + if (! is_string($modelClass) || ! class_exists($modelClass)) { + continue; + } + + return $this->termOptionsForModel($modelClass); + } + + return []; + } + + /** + * @return array + */ + public function roleOptions(): array + { + if (! $this->supportsUserRoles()) { + return []; + } + + $rolesTable = (string) config('permission.table_names.roles'); + + return DB::table($rolesTable) + ->orderBy('name') + ->pluck('name', 'name') + ->mapWithKeys(fn (mixed $name, mixed $key): array => [(string) $key => (string) $name]) + ->all(); + } + + /** + * @return array + */ + public function availableParamOptions(): array + { + return [ + 'record_type' => __('builder::builder.field_group.location_param_record_type'), + 'user_role' => __('builder::builder.field_group.location_param_user_role'), + 'taxonomy' => __('builder::builder.field_group.location_param_taxonomy'), + ]; + } + + public function supportsUserRoles(): bool + { + if (! class_exists(Role::class) || ! trait_exists(HasRoles::class)) { + return false; + } + + $rolesTable = config('permission.table_names.roles'); + + if (! is_string($rolesTable) || $rolesTable === '') { + return false; + } + + if (! Schema::hasTable($rolesTable) || ! DB::table($rolesTable)->exists()) { + return false; + } + + $userModel = $this->authUserModel(); + + if ($userModel === null || ! class_exists($userModel)) { + return false; + } + + return in_array(HasRoles::class, class_uses_recursive($userModel), true); + } + + public function userRoleUnavailableReason(): ?string + { + if (! class_exists(Role::class) || ! trait_exists(HasRoles::class)) { + return __('builder::builder.field_group.location_value_role_unavailable_permissions'); + } + + $rolesTable = config('permission.table_names.roles'); + + if (! is_string($rolesTable) || $rolesTable === '' || ! Schema::hasTable($rolesTable)) { + return __('builder::builder.field_group.location_value_role_unavailable_permissions'); + } + + if (! DB::table($rolesTable)->exists()) { + return __('builder::builder.field_group.location_value_role_unavailable_empty'); + } + + $userModel = $this->authUserModel(); + + if ($userModel === null || ! class_exists($userModel) || ! in_array(HasRoles::class, class_uses_recursive($userModel), true)) { + return __('builder::builder.field_group.location_value_role_unavailable_user_model'); + } + + return null; + } + + /** + * @return array + */ + public function recordTypeOptionsForEntities(mixed $entities): array + { + $options = []; + + foreach ($this->normalizeEntities($entities) as $entity) { + $options = array_replace($options, $this->recordTypeOptionsForEntity($entity)); + + $modelClass = $this->entityRegistry->modelFor($entity); + + if (! is_string($modelClass) || ! class_exists($modelClass)) { + continue; + } + + $options = array_replace($options, $this->recordTypeOptionsForModel($modelClass)); + } + + asort($options); + + return $options; + } + + /** + * @return array + */ + protected function recordTypeOptionsForEntity(string $entity): array + { + $resourceClass = $this->entityRegistry->resourceFor($entity); + + if (! is_string($resourceClass) || ! class_exists($resourceClass) || ! method_exists($resourceClass, 'getTypeSelect')) { + return []; + } + + $options = $resourceClass::getTypeSelect()->getOptions(); + + if ($options instanceof \Closure) { + $options = $options(); + } + + if (! is_array($options)) { + return []; + } + + return collect($options) + ->filter(fn (mixed $label, mixed $value): bool => filled($value)) + ->mapWithKeys(fn (mixed $label, mixed $value): array => [ + (string) $value => filled($label) ? (string) $label : $this->recordTypeLabel((string) $value), + ]) + ->all(); + } + + public function termLabelForValue(string $taxonomy, mixed $entities, mixed $value): ?string + { + if (! filled($value)) { + return null; + } + + $options = $this->termOptionsForTaxonomy($taxonomy, $entities); + + return $options[$value] + ?? $options[(int) $value] + ?? $options[(string) $value] + ?? null; + } + + public function recordTypeLabelForValue(mixed $entities, mixed $value): ?string + { + if (! filled($value)) { + return null; + } + + $options = $this->recordTypeOptionsForEntities($entities); + + return $options[$value] + ?? $options[(string) $value] + ?? $this->recordTypeLabel((string) $value); + } + + /** + * @param array $values + * @return array + */ + public function termLabelsForValues(string $taxonomy, mixed $entities, array $values): array + { + $options = $this->termOptionsForTaxonomy($taxonomy, $entities); + $labels = []; + + foreach ($values as $value) { + $label = $options[$value] + ?? $options[(int) $value] + ?? $options[(string) $value] + ?? null; + + if ($label !== null) { + $labels[$value] = $label; + } + } + + return $labels; + } + + /** + * @param array $values + * @return array + */ + public function recordTypeLabelsForValues(mixed $entities, array $values): array + { + $options = $this->recordTypeOptionsForEntities($entities); + $labels = []; + + foreach ($values as $value) { + if (! filled($value)) { + continue; + } + + $labels[$value] = $options[$value] + ?? $options[(string) $value] + ?? $this->recordTypeLabel((string) $value); + } + + return $labels; + } + + /** + * @param class-string $modelClass + * @return array + */ + protected function termOptionsForModel(string $modelClass): array + { + $query = $modelClass::query() + ->orderBy($modelClass::query()->getModel()->getKeyName()); + + $model = $query->getModel(); + + if (method_exists($model, 'translations')) { + $query->with('translations'); + } + + return $query + ->get() + ->mapWithKeys(fn (Model $term): array => [ + (string) $term->getKey() => $this->termLabel($term), + ]) + ->all(); + } + + /** + * @param class-string $modelClass + * @return array + */ + protected function recordTypeOptionsForModel(string $modelClass): array + { + $model = $modelClass::query()->getModel(); + $table = $model->getTable(); + + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'type')) { + return []; + } + + return $modelClass::query() + ->whereNotNull('type') + ->where('type', '!=', '') + ->distinct() + ->orderBy('type') + ->pluck('type', 'type') + ->mapWithKeys(fn (mixed $type): array => [ + (string) $type => $this->recordTypeLabel((string) $type), + ]) + ->all(); + } + + protected function termLabel(Model $term): string + { + $translatedTitle = $this->translatedTermAttribute($term, 'title'); + + if (filled($translatedTitle)) { + return $translatedTitle; + } + + $translatedName = $this->translatedTermAttribute($term, 'name'); + + if (filled($translatedName)) { + return $translatedName; + } + + if (method_exists($term, 'getDisplayTitleAttribute')) { + $displayTitle = $term->display_title; + + if (filled($displayTitle) && ! str_starts_with((string) $displayTitle, 'ID: ')) { + return (string) $displayTitle; + } + } + + foreach (['title', 'name', 'label', 'slug'] as $attribute) { + $value = $term->getAttribute($attribute); + + if (filled($value)) { + return (string) $value; + } + } + + return '#'.$term->getKey(); + } + + protected function recordTypeLabel(string $value): string + { + return Str::headline($value); + } + + /** + * @return class-string|null + */ + protected function authUserModel(): ?string + { + $guard = (string) config('auth.defaults.guard', 'web'); + $provider = config("auth.guards.{$guard}.provider") + ?? config('auth.guards.web.provider') + ?? config('auth.defaults.provider'); + + if (! is_string($provider) || $provider === '') { + return null; + } + + $model = Arr::get(config('auth.providers'), "{$provider}.model"); + + return is_string($model) && $model !== '' ? $model : null; + } + + protected function translatedTermAttribute(Model $term, string $attribute): ?string + { + if (! method_exists($term, 'translate')) { + return null; + } + + foreach ($this->localeResolver->fallbackChain() as $locale) { + $translation = $this->translationForLocale($term, (string) $locale); + + if ($translation !== null && filled($translation->{$attribute} ?? null)) { + return (string) $translation->{$attribute}; + } + } + + foreach ($this->translationsForTerm($term) as $translation) { + if (filled($translation->{$attribute} ?? null)) { + return (string) $translation->{$attribute}; + } + } + + return null; + } + + protected function translationForLocale(Model $term, string $locale): ?object + { + if ($term->relationLoaded('translations')) { + $match = $term->getRelation('translations')->firstWhere('locale', $locale); + + if ($match !== null) { + return $match; + } + } + + $translation = $term->translate($locale, false); + + return $translation ?: null; + } + + /** + * @return iterable + */ + protected function translationsForTerm(Model $term): iterable + { + if ($term->relationLoaded('translations')) { + return $term->getRelation('translations'); + } + + if (! method_exists($term, 'translations')) { + return []; + } + + return $term->translations()->get(); + } + + /** + * @param array $definition + */ + protected function taxonomyLabel(string $key, array $definition): string + { + $label = $definition['label'] ?? null; + + if (is_string($label) && str_starts_with($label, 'trans//')) { + $translationKey = substr($label, strlen('trans//')); + + if ($translationKey !== '') { + return __($translationKey); + } + } + + if (is_string($label) && $label !== '') { + return $label; + } + + return Str::headline($key); + } + + protected function configKeyForEntity(string $entity): ?string + { + $modelClass = $this->entityRegistry->modelFor($entity); + + if ($modelClass !== null && method_exists($modelClass, 'getResourceName')) { + return $modelClass::getResourceName(); + } + + return $entity; + } + + /** + * @return list + */ + protected function normalizeEntities(mixed $entities): array + { + if (is_string($entities) && $entities !== '') { + return [$entities]; + } + + if (! is_array($entities)) { + return []; + } + + return array_values(array_filter( + $entities, + static fn (mixed $entity): bool => is_string($entity) && $entity !== '', + )); + } +} From 007d8c9311fbee18a9131a777ca51d474218f3be Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:04:00 +0200 Subject: [PATCH 074/105] Improve builder field validation --- packages/builder/README.md | 13 +- .../builder/resources/lang/de/builder.php | 17 ++ .../builder/resources/lang/en/builder.php | 17 ++ .../src/Resources/FieldGroupResource.php | 75 ++++++ .../src/Services/FieldGroupPersistence.php | 9 +- .../src/Services/FieldValueValidator.php | 33 +++ .../src/Support/FieldValidationRules.php | 239 ++++++++++++++++++ 7 files changed, 398 insertions(+), 5 deletions(-) create mode 100644 packages/builder/src/Support/FieldValidationRules.php diff --git a/packages/builder/README.md b/packages/builder/README.md index d756dc9060..53cc30fab8 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -160,6 +160,13 @@ Field group names, field labels, option labels, and translatable field config (` In admin you choose **"Show on"** (multi-select) and optional **"Additional conditions"**. +The admin UI stores the same `location_rules` structure as before, but the inputs are safer and more guided now: + +- **Taxonomy** conditions use selects for taxonomy keys and taxonomy terms. Labels are shown, internal IDs are stored. +- **Record type** conditions use known type options from the target resource (`getTypeSelect()`) and existing records. If no records exist yet, resource-defined type options still appear. +- **User role** remains visible in the UI, but becomes disabled with an explanation when roles are not configured, no roles exist yet, or the configured auth user model does not use `HasRoles`. +- Changing the selected condition parameter resets dependent fields (`taxonomy`, operator default, value) to avoid stale form state. + Internally this becomes OR groups with AND rules: ```json @@ -175,13 +182,15 @@ Internally this becomes OR groups with AND rules: |-------|---------|----------------| | `entity` | `item`, `draft` | Resource / model entity key | | `record_type` | `page`, `article` | `$record->type` or `customFieldsLocationParams()` | -| `user_role` | `admin`, `editor` | Authenticated user roles (`in` / `not in` supported) | +| `user_role` | `admin`, `editor` | Authenticated user roles (`in` / `not in` supported when roles are available) | | `taxonomy:{key}` | term ID `12` or `12,34` | Taxonomy IDs on the record (`HasModelTaxonomy`) | - Outer array = **OR** groups; inner array = **AND** rules. - Empty rules = matches nothing (fail-closed). - On create forms (no record yet), record/taxonomy rules are ignored; entity and user-role rules still apply. - Per-record matching also runs on compiled form sections via `SchemaCompiler`. +- `LocationConstraintOptions` resolves taxonomy term labels with locale fallback: active/admin locale → default locale → English → first available translation. +- `FieldGroupValidator` re-validates condition params, operators, and submitted values server-side, so manipulated requests cannot persist invalid location constraints. Override or extend auto-detected params on the resource/model: @@ -336,7 +345,7 @@ Navigation: **Fields → Field Groups** | Section | Content | |---------|---------| | **General** | Name, technical key, active, sort order | -| **Assignment** | "Show on" multi-select (auto-discovered resources) | +| **Assignment** | "Show on" multi-select (auto-discovered resources) + optional additional conditions (`record_type`, `user_role`, taxonomy) | | **Fields** | Repeater: label, type, field key, required | | **Settings** | Capability fields (only when the type has them) | | **Options** | For select/radio/multiselect/checkbox list | diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index a7f5199c77..0e297ab66e 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -85,6 +85,23 @@ 'type' => 'Feldtyp', 'required' => 'Pflichtfeld', 'required_badge' => 'Pflicht', + 'validation' => 'Validierung', + 'validation_helper' => 'Zusätzliche Validierungsregeln für unterstützte Text- und Zahlenfelder.', + 'validation_rules' => 'Validierungsregeln', + 'validation_rules_helper' => 'Strukturierte Regeln für häufige Fälle. Gespeichert werden Laravel-Validierungsregeln.', + 'validation_rule' => 'Regel', + 'validation_value' => 'Wert', + 'validation_raw_rules' => 'Erweiterte Rohregeln', + 'validation_raw_rules_helper' => 'Optionale Laravel-Validierungsregeln, eine pro Zeile, für fortgeschrittene Fälle außerhalb der Auswahl oben.', + 'validation_rule_min' => 'Minimum', + 'validation_rule_max' => 'Maximum', + 'validation_rule_regex' => 'Regulärer Ausdruck', + 'validation_rule_alpha_dash' => 'Alpha-Dash', + 'validation_rule_alpha_num' => 'Alpha-Numerisch', + 'validation_rule_gt' => 'Größer als', + 'validation_rule_gte' => 'Größer oder gleich', + 'validation_rule_lt' => 'Kleiner als', + 'validation_rule_lte' => 'Kleiner oder gleich', 'width' => 'Breite', 'width_helper' => 'Feldbreite. „Automatisch" folgt dem Spalten-Layout der Gruppe; ein Bruch fixiert eine feste Breite.', 'width_auto' => 'Automatisch (folgt Gruppe)', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 7e382f5607..81dc528cd8 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -85,6 +85,23 @@ 'type' => 'Field type', 'required' => 'Required', 'required_badge' => 'Required', + 'validation' => 'Validation', + 'validation_helper' => 'Additional validation rules for supported text and numeric field types.', + 'validation_rules' => 'Validation rules', + 'validation_rules_helper' => 'Use structured rules for common cases. Rules are stored as Laravel validation rules.', + 'validation_rule' => 'Rule', + 'validation_value' => 'Value', + 'validation_raw_rules' => 'Advanced raw rules', + 'validation_raw_rules_helper' => 'Optional Laravel validation rules, one per line, for advanced cases not covered above.', + 'validation_rule_min' => 'Minimum', + 'validation_rule_max' => 'Maximum', + 'validation_rule_regex' => 'Regular expression', + 'validation_rule_alpha_dash' => 'Alpha dash', + 'validation_rule_alpha_num' => 'Alpha numeric', + 'validation_rule_gt' => 'Greater than', + 'validation_rule_gte' => 'Greater than or equal', + 'validation_rule_lt' => 'Less than', + 'validation_rule_lte' => 'Less than or equal', 'width' => 'Width', 'width_helper' => 'Field width. "Automatic" follows the group column layout; a fraction pins a fixed width.', 'width_auto' => 'Automatic (follows group)', diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 043107fbe5..18e6819618 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -10,6 +10,7 @@ use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Resources\Resource; @@ -39,6 +40,7 @@ use Moox\Builder\Support\FieldGroupPlacement; use Moox\Builder\Support\FieldWidth; use Moox\Builder\Support\LocationConstraintOptions; +use Moox\Builder\Support\FieldValidationRules; use Moox\Builder\Support\TypedValueColumns; class FieldGroupResource extends Resource @@ -208,6 +210,7 @@ public static function form(Schema $schema): Schema ->regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/') ->live(onBlur: true), static::requirementAndWidthRow(), + ...static::validationSettingsSchema(), ...static::columnSettingsSchema(), ...static::visibilitySettingsSchema(), ...static::conditionalLogicSchema(), @@ -399,6 +402,7 @@ protected static function layoutSchema(FieldTypeRegistry $registry): array ->maxLength(255) ->regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/') ->live(onBlur: true), + ...static::validationSettingsSchema(), Repeater::make('children') ->label(__('builder::builder.field.subfields')) ->orderColumn('sort') @@ -523,6 +527,7 @@ protected static function subFieldSchema(FieldTypeRegistry $registry): array ->regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/') ->live(onBlur: true), static::requirementAndWidthRow(), + ...static::validationSettingsSchema(), ...static::optionFieldSections($registry), ]; } @@ -1089,6 +1094,76 @@ protected static function widthOptions(): array ]; } + /** + * @return list
+ */ + protected static function validationSettingsSchema(): array + { + $rules = app(FieldValidationRules::class); + + return [ + Section::make(__('builder::builder.field.validation')) + ->description(__('builder::builder.field.validation_helper')) + ->icon(Heroicon::OutlinedShieldCheck) + ->collapsible() + ->collapsed() + ->visible(fn (callable $get): bool => $rules->supportsType($get('type'))) + ->schema([ + Repeater::make('validation.rule_rows') + ->label(__('builder::builder.field.validation_rules')) + ->helperText(__('builder::builder.field.validation_rules_helper')) + ->defaultItems(0) + ->collapsible() + ->collapsed() + ->itemLabel(fn (array $state): string => static::validationRuleItemLabel($state)) + ->schema([ + Select::make('rule') + ->label(__('builder::builder.field.validation_rule')) + ->options(fn (callable $get): array => app(FieldValidationRules::class)->availableRulesForType(static::resolveValidationRuleFieldType($get))) + ->required() + ->live() + ->native(false), + TextInput::make('value') + ->label(__('builder::builder.field.validation_value')) + ->visible(fn (callable $get): bool => app(FieldValidationRules::class)->ruleNeedsValue((string) $get('rule'))), + ]) + ->columns(2), + Textarea::make('validation.raw_rules') + ->label(__('builder::builder.field.validation_raw_rules')) + ->helperText(__('builder::builder.field.validation_raw_rules_helper')) + ->rows(3) + ->placeholder("starts_with:foo\nends_with:bar"), + ]), + ]; + } + + /** + * @param array $state + */ + protected static function validationRuleItemLabel(array $state): string + { + $rule = filled($state['rule'] ?? null) + ? __('builder::builder.field.validation_rule_'.Str::snake((string) $state['rule'])) + : __('builder::builder.field.validation_rule'); + + $value = filled($state['value'] ?? null) ? ': '.(string) $state['value'] : ''; + + return $rule.$value; + } + + protected static function resolveValidationRuleFieldType(callable $get): ?string + { + foreach (['../../type', '../../../type', '../../../../type'] as $path) { + $type = $get($path); + + if (is_string($type) && $type !== '') { + return $type; + } + } + + return null; + } + /** * Column-count options for a field group's layout (1–4 columns). * diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index fe600a1f71..66b5fde0e5 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -16,6 +16,7 @@ use Moox\Builder\Support\ConditionalLogic; use Moox\Builder\Support\DefinitionTranslator; use Moox\Builder\Support\FieldRelationTree; +use Moox\Builder\Support\FieldValidationRules; class FieldGroupPersistence { @@ -24,6 +25,7 @@ public function __construct( protected BuilderLocaleResolver $localeResolver, protected DefinitionTranslator $definitionTranslator, protected EntityRegistry $entityRegistry, + protected FieldValidationRules $fieldValidationRules, ) {} /** @@ -394,6 +396,7 @@ protected function mapFieldRows(Collection $fields, ?string $locale = null): arr 'name' => $field->name, 'type' => $field->type, 'required' => (bool) ($field->validation['required'] ?? false), + 'validation' => $this->fieldValidationRules->formStateFor($field->validation ?? [], $field->type), 'config' => $this->definitionTranslator->translatedFieldConfig($field, $locale), 'settings' => [ 'show_in_table' => (bool) ($field->settings['show_in_table'] ?? false), @@ -501,10 +504,10 @@ protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldI array_intersect_key($field->config ?? [], array_flip(BuilderLocaleResolver::TRANSLATABLE_CONFIG_KEYS)), $structuralConfig, ), - 'validation' => [ + 'validation' => $this->fieldValidationRules->compileValidation([ + ...((is_array($row['validation'] ?? null) ? $row['validation'] : [])), 'required' => (bool) ($row['required'] ?? false), - 'rules' => $row['validation']['rules'] ?? [], - ], + ], (string) $row['type']), 'settings' => array_merge($field->settings ?? [], [ 'show_in_table' => (bool) ($row['settings']['show_in_table'] ?? false), 'sortable' => (bool) ($row['settings']['sortable'] ?? true), diff --git a/packages/builder/src/Services/FieldValueValidator.php b/packages/builder/src/Services/FieldValueValidator.php index 778d533b8d..30be643e68 100644 --- a/packages/builder/src/Services/FieldValueValidator.php +++ b/packages/builder/src/Services/FieldValueValidator.php @@ -9,6 +9,7 @@ use Illuminate\Validation\ValidationException; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Support\FieldValidationRules; use Moox\Builder\Support\MediaFieldValueSupport; use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Support\RelationValueRules; @@ -18,6 +19,7 @@ class FieldValueValidator { public function __construct( protected FieldTypeRegistry $fieldTypeRegistry, + protected FieldValidationRules $fieldValidationRules, ) {} /** @@ -222,6 +224,8 @@ protected function messagesForLeaf(FieldDefinition $field, mixed $value, string } } + $messages = array_merge($messages, $this->messagesForCustomRules($field, $value, $path)); + return $messages; } @@ -323,6 +327,35 @@ protected function messagesForGallery(FieldDefinition $field, mixed $value, stri return $messages; } + /** + * @return array> + */ + protected function messagesForCustomRules(FieldDefinition $field, mixed $value, string $path): array + { + $rules = $this->fieldValidationRules->runtimeRulesFor($field); + + if ($rules === []) { + return []; + } + + $context = $this->fieldValidationRules->validatorContextForType($field->type, $value); + + if ($context === null) { + return []; + } + + $validator = Validator::make( + ['value' => $context['value']], + ['value' => array_merge([$context['type']], $rules)], + ); + + if (! $validator->fails()) { + return []; + } + + return [$path => $validator->errors()->get('value')]; + } + /** * @return array> */ diff --git a/packages/builder/src/Support/FieldValidationRules.php b/packages/builder/src/Support/FieldValidationRules.php new file mode 100644 index 0000000000..9f89504e99 --- /dev/null +++ b/packages/builder/src/Support/FieldValidationRules.php @@ -0,0 +1,239 @@ + + */ + public function availableRulesForType(?string $type): array + { + return match ($this->categoryForType($type)) { + 'text' => [ + 'min' => __('builder::builder.field.validation_rule_min'), + 'max' => __('builder::builder.field.validation_rule_max'), + 'regex' => __('builder::builder.field.validation_rule_regex'), + 'alpha_dash' => __('builder::builder.field.validation_rule_alpha_dash'), + 'alpha_num' => __('builder::builder.field.validation_rule_alpha_num'), + ], + 'numeric' => [ + 'min' => __('builder::builder.field.validation_rule_min'), + 'max' => __('builder::builder.field.validation_rule_max'), + 'gt' => __('builder::builder.field.validation_rule_gt'), + 'gte' => __('builder::builder.field.validation_rule_gte'), + 'lt' => __('builder::builder.field.validation_rule_lt'), + 'lte' => __('builder::builder.field.validation_rule_lte'), + ], + default => [], + }; + } + + public function supportsType(?string $type): bool + { + return $this->availableRulesForType($type) !== []; + } + + /** + * @param array $validation + * @return array + */ + public function formStateFor(array $validation, ?string $type): array + { + $supportedRules = array_keys($this->availableRulesForType($type)); + $rows = []; + $rawRules = []; + + foreach ($this->normalizeRules($validation['rules'] ?? []) as $rule) { + $parsed = $this->parseRule($rule, $supportedRules); + + if ($parsed === null) { + $rawRules[] = $rule; + + continue; + } + + $rows[] = $parsed; + } + + $validation['rule_rows'] = $rows; + $validation['raw_rules'] = implode(PHP_EOL, $rawRules); + + return $validation; + } + + /** + * @param array $validation + * @return array + */ + public function compileValidation(array $validation, ?string $type): array + { + if (! $this->supportsType($type)) { + return [ + 'required' => (bool) ($validation['required'] ?? false), + 'rules' => $this->normalizeRules($validation['rules'] ?? []), + ]; + } + + $supportedRules = array_keys($this->availableRulesForType($type)); + $rules = []; + + foreach ($this->normalizeRuleRows($validation['rule_rows'] ?? []) as $row) { + $rule = (string) ($row['rule'] ?? ''); + + if (! in_array($rule, $supportedRules, true)) { + continue; + } + + if ($this->ruleNeedsValue($rule)) { + $value = trim((string) ($row['value'] ?? '')); + + if ($value === '') { + continue; + } + + $rules[] = "{$rule}:{$value}"; + + continue; + } + + $rules[] = $rule; + } + + foreach ($this->rawRulesFromText($validation['raw_rules'] ?? null) as $rule) { + $rules[] = $rule; + } + + return [ + 'required' => (bool) ($validation['required'] ?? false), + 'rules' => array_values(array_unique($rules)), + ]; + } + + /** + * @return list + */ + public function runtimeRulesFor(FieldDefinition $field): array + { + if (! $this->supportsType($field->type)) { + return []; + } + + return $this->normalizeRules($field->validation['rules'] ?? []); + } + + /** + * @return array{type: string, value: mixed}|null + */ + public function validatorContextForType(string $type, mixed $value): ?array + { + return match ($this->categoryForType($type)) { + 'text' => ['type' => 'string', 'value' => is_string($value) ? $value : (string) $value], + 'numeric' => ['type' => 'numeric', 'value' => $value], + default => null, + }; + } + + public function ruleNeedsValue(string $rule): bool + { + return ! in_array($rule, ['alpha_dash', 'alpha_num'], true); + } + + /** + * @param mixed $rules + * @return list + */ + protected function normalizeRules(mixed $rules): array + { + if (! is_array($rules)) { + return []; + } + + return array_values(array_filter( + array_map(static fn (mixed $rule): string => trim((string) $rule), $rules), + static fn (string $rule): bool => $rule !== '', + )); + } + + /** + * @param mixed $rows + * @return list + */ + protected function normalizeRuleRows(mixed $rows): array + { + if (! is_array($rows)) { + return []; + } + + return array_values(array_filter( + array_map(static function (mixed $row): ?array { + if (! is_array($row)) { + return null; + } + + $rule = trim((string) ($row['rule'] ?? '')); + + if ($rule === '') { + return null; + } + + return [ + 'rule' => $rule, + 'value' => Arr::get($row, 'value'), + ]; + }, $rows), + )); + } + + /** + * @param list $supportedRules + * @return array{rule: string, value?: string}|null + */ + protected function parseRule(string $rule, array $supportedRules): ?array + { + if (in_array($rule, ['alpha_dash', 'alpha_num'], true) && in_array($rule, $supportedRules, true)) { + return ['rule' => $rule]; + } + + [$name, $value] = array_pad(explode(':', $rule, 2), 2, null); + + if (! in_array($name, $supportedRules, true) || $value === null) { + return null; + } + + return [ + 'rule' => $name, + 'value' => $value, + ]; + } + + /** + * @return list + */ + protected function rawRulesFromText(mixed $rawRules): array + { + if (! is_string($rawRules) || trim($rawRules) === '') { + return []; + } + + return array_values(array_filter( + array_map(static fn (string $rule): string => trim($rule), preg_split('/\r\n|\r|\n/', $rawRules) ?: []), + static fn (string $rule): bool => $rule !== '', + )); + } + + protected function categoryForType(?string $type): ?string + { + return match ($type) { + 'text', 'textarea', 'email', 'password', 'url', 'oembed', 'rich_text' => 'text', + 'number', 'range' => 'numeric', + default => null, + }; + } +} From 6b5725335857243ace0cfae98bb54dd0c0d8cd28 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:05:19 +0000 Subject: [PATCH 075/105] Fix styling --- packages/builder/src/Resources/FieldGroupResource.php | 2 +- packages/builder/src/Support/FieldValidationRules.php | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 18e6819618..ce6242b507 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -38,9 +38,9 @@ use Moox\Builder\Support\BuilderLocaleResolver; use Moox\Builder\Support\ConditionalLogic; use Moox\Builder\Support\FieldGroupPlacement; +use Moox\Builder\Support\FieldValidationRules; use Moox\Builder\Support\FieldWidth; use Moox\Builder\Support\LocationConstraintOptions; -use Moox\Builder\Support\FieldValidationRules; use Moox\Builder\Support\TypedValueColumns; class FieldGroupResource extends Resource diff --git a/packages/builder/src/Support/FieldValidationRules.php b/packages/builder/src/Support/FieldValidationRules.php index 9f89504e99..357b855101 100644 --- a/packages/builder/src/Support/FieldValidationRules.php +++ b/packages/builder/src/Support/FieldValidationRules.php @@ -5,7 +5,6 @@ namespace Moox\Builder\Support; use Illuminate\Support\Arr; -use Illuminate\Support\Str; use Moox\Builder\Data\FieldDefinition; final class FieldValidationRules @@ -146,7 +145,6 @@ public function ruleNeedsValue(string $rule): bool } /** - * @param mixed $rules * @return list */ protected function normalizeRules(mixed $rules): array @@ -162,7 +160,6 @@ protected function normalizeRules(mixed $rules): array } /** - * @param mixed $rows * @return list */ protected function normalizeRuleRows(mixed $rows): array From eeaf3d81a467ce650a8d0a5d52f259fe7c72dd63 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:08:47 +0200 Subject: [PATCH 076/105] improve color field --- packages/builder/src/Compiler/TableColumnCompiler.php | 7 ++++++- packages/builder/src/Resources/FieldGroupResource.php | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/builder/src/Compiler/TableColumnCompiler.php b/packages/builder/src/Compiler/TableColumnCompiler.php index c6781d7be6..abe5e3060d 100644 --- a/packages/builder/src/Compiler/TableColumnCompiler.php +++ b/packages/builder/src/Compiler/TableColumnCompiler.php @@ -5,6 +5,7 @@ namespace Moox\Builder\Compiler; use Filament\Tables\Columns\Column; +use Filament\Tables\Columns\ColorColumn; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; @@ -111,7 +112,11 @@ protected function compileColumn( $valueColumn = TypedValueColumns::columnForType($field->type); $name = $field->name; - if ($field->type === 'toggle') { + if ($field->type === 'color') { + $column = ColorColumn::make($name) + ->label($field->label) + ->getStateUsing(fn (Model $record): ?string => $this->resolvePresentedValue($field, $record)); + } elseif ($field->type === 'toggle') { $column = IconColumn::make($name) ->label($field->label) ->boolean() diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index ce6242b507..07f5850cdf 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -626,7 +626,7 @@ protected static function fieldTypeSupportsSortableSearchableColumn(?string $typ */ protected static function fieldTypeUsesTextColumn(?string $type): bool { - return static::fieldTypeSupportsColumn($type) && $type !== 'toggle'; + return static::fieldTypeSupportsColumn($type) && ! in_array($type, ['toggle', 'color'], true); } protected static function fieldTypeSupportsRelationBadge(callable $get): bool From 710a4c1ade6103b18c5cfb3cc864a621a5bc204f Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:10:03 +0000 Subject: [PATCH 077/105] Fix styling --- packages/builder/src/Compiler/TableColumnCompiler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/Compiler/TableColumnCompiler.php b/packages/builder/src/Compiler/TableColumnCompiler.php index abe5e3060d..a819b24fca 100644 --- a/packages/builder/src/Compiler/TableColumnCompiler.php +++ b/packages/builder/src/Compiler/TableColumnCompiler.php @@ -4,8 +4,8 @@ namespace Moox\Builder\Compiler; -use Filament\Tables\Columns\Column; use Filament\Tables\Columns\ColorColumn; +use Filament\Tables\Columns\Column; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; From 369116cea87652773a1c9d93e926a5b70cfef8e2 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:16:14 +0200 Subject: [PATCH 078/105] Refine builder table columns --- .../src/Compiler/TableColumnCompiler.php | 15 ++++++++----- .../src/Resources/FieldGroupResource.php | 21 +++++++++++++------ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/builder/src/Compiler/TableColumnCompiler.php b/packages/builder/src/Compiler/TableColumnCompiler.php index a819b24fca..688a08527a 100644 --- a/packages/builder/src/Compiler/TableColumnCompiler.php +++ b/packages/builder/src/Compiler/TableColumnCompiler.php @@ -81,7 +81,7 @@ public function compile(Collection $fieldGroups, ?string $resourceClass = null): protected function isColumnable(FieldDefinition $field): bool { - return TypedValueColumns::isColumnableType($field->type); + return TypedValueColumns::isColumnableType($field->type) && $field->type !== 'rich_text'; } protected function isImageColumn(FieldDefinition $field): bool @@ -130,15 +130,15 @@ protected function compileColumn( $column->limit(50); } - if ($field->columnBadge()) { + if ($this->supportsTextColumnPresentation($field) && $field->columnBadge()) { $column->badge(); } - if (($color = $field->columnColor()) !== null) { + if ($this->supportsTextColumnPresentation($field) && ($color = $field->columnColor()) !== null) { $column->color($color); } - if (($icon = $field->columnIcon()) !== null) { + if ($this->supportsTextColumnPresentation($field) && ($icon = $field->columnIcon()) !== null) { $column->icon($icon); } @@ -171,7 +171,7 @@ protected function compileColumn( }); } - if ($field->isColumnSearchable()) { + if ($field->isColumnSearchable() && $field->type !== 'toggle') { $column->searchable(query: function (Builder $query, string $search) use ($name): Builder { return $query->where($name, 'like', "%{$search}%"); }); @@ -193,6 +193,11 @@ protected function applyTextColumnPresentation(TextColumn $column, FieldDefiniti }; } + protected function supportsTextColumnPresentation(FieldDefinition $field): bool + { + return $field->type !== 'rich_text'; + } + protected function numericDecimalPlaces(FieldDefinition $field): ?int { $step = $field->config['step'] ?? null; diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 07f5850cdf..bb416f2014 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -552,7 +552,7 @@ protected static function fieldTypeSupportsRequired(?string $type): bool protected static function fieldTypeSupportsColumn(?string $type): bool { - if (blank($type) || $type === 'password') { + if (blank($type) || in_array($type, ['password', 'rich_text'], true)) { return false; } @@ -614,12 +614,18 @@ protected static function fieldTypeSupportsAnyColumn(?string $type): bool || static::fieldTypeSupportsRelationColumn($type); } - protected static function fieldTypeSupportsSortableSearchableColumn(?string $type): bool + protected static function fieldTypeSupportsSortableColumn(?string $type): bool { return static::fieldTypeSupportsColumn($type) || static::fieldTypeSupportsRelationColumn($type); } + protected static function fieldTypeSupportsSearchableColumn(?string $type): bool + { + return (static::fieldTypeSupportsColumn($type) && $type !== 'toggle') + || static::fieldTypeSupportsRelationColumn($type); + } + /** * Presentation options (badge, color, icon) only apply to text-based columns, * not to the boolean icon column used for toggle fields. @@ -637,7 +643,7 @@ protected static function fieldTypeSupportsRelationBadge(callable $get): bool protected static function fieldTypeSupportsTextColumnPresentation(callable $get): bool { - return static::fieldTypeUsesTextColumn($get('type')) + return static::fieldTypeUsesTextColumn($get('type')) && $get('type') !== 'rich_text' || static::fieldTypeSupportsRelationBadge($get); } @@ -702,14 +708,17 @@ protected static function columnSettingsSchema(): array Toggle::make('settings.sortable') ->label(__('builder::builder.field.column_sortable')) ->inline(false) - ->default(true), + ->default(true) + ->visible(fn (callable $get): bool => static::fieldTypeSupportsSortableColumn($get('type'))), Toggle::make('settings.searchable') ->label(__('builder::builder.field.column_searchable')) ->inline(false) - ->default(true), + ->default(true) + ->visible(fn (callable $get): bool => static::fieldTypeSupportsSearchableColumn($get('type'))), ]) ->visible(fn (callable $get): bool => $enabled($get) - && static::fieldTypeSupportsSortableSearchableColumn($get('type'))), + && (static::fieldTypeSupportsSortableColumn($get('type')) + || static::fieldTypeSupportsSearchableColumn($get('type')))), static::configureFieldHint( Toggle::make('settings.hidden_by_default'), __('builder::builder.field.column_hidden_by_default'), From a581a1e592c40b7fa1a274747ef3382afcc5529a Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:44:26 +0200 Subject: [PATCH 079/105] Fix stale assignment conditions when target entities change --- .../src/Resources/FieldGroupResource.php | 61 ++++++++++++++++++- .../src/Services/FieldGroupValidator.php | 2 +- .../src/Support/LocationConstraintOptions.php | 23 +++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index bb416f2014..21ebcc7b17 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -142,6 +142,15 @@ public static function form(Schema $schema): Schema ->placeholder(__('builder::builder.field_group.target_entities_placeholder')) ->disabled($entityOptions === []) ->live() + ->afterStateUpdated(function (mixed $state, callable $set, callable $get): void { + $set( + 'location_constraints', + static::sanitizeLocationConstraintsForEntities( + is_array($get('location_constraints')) ? $get('location_constraints') : [], + $state, + ), + ); + }) ->native(false), Repeater::make('location_constraints') ->label(__('builder::builder.field_group.location_constraints')) @@ -1380,7 +1389,7 @@ public static function locationConstraintSchema(): array ->schema([ Select::make('param') ->label(__('builder::builder.field_group.location_param')) - ->options(app(LocationConstraintOptions::class)->availableParamOptions()) + ->options(fn (Get $get): array => app(LocationConstraintOptions::class)->availableParamOptionsForEntities($get($targetEntitiesPath))) ->required() ->live() ->afterStateUpdated(function (callable $set): void { @@ -1549,6 +1558,56 @@ public static function locationConstraintParamOptions(): array return app(LocationConstraintOptions::class)->availableParamOptions(); } + /** + * @param list> $constraints + * @return list> + */ + protected static function sanitizeLocationConstraintsForEntities(array $constraints, mixed $entities): array + { + $options = app(LocationConstraintOptions::class); + $allowedParams = array_keys($options->availableParamOptionsForEntities($entities)); + $allowedTaxonomies = $options->taxonomyKeysForEntities($entities); + $recordTypeOptions = $options->recordTypeOptionsForEntities($entities); + + return array_values(array_map( + static function (array $constraint) use ($allowedParams, $allowedTaxonomies, $recordTypeOptions): array { + $param = (string) ($constraint['param'] ?? ''); + + if ($param === '' || ! in_array($param, $allowedParams, true)) { + return [ + ...$constraint, + 'param' => null, + 'taxonomy' => null, + 'operator' => '==', + 'value' => null, + ]; + } + + if ($param === 'taxonomy') { + $taxonomy = (string) ($constraint['taxonomy'] ?? ''); + + if ($taxonomy === '' || ! array_key_exists($taxonomy, $allowedTaxonomies)) { + return [ + ...$constraint, + 'taxonomy' => null, + 'value' => null, + ]; + } + } + + if ($param === 'record_type' && $recordTypeOptions === []) { + return [ + ...$constraint, + 'value' => null, + ]; + } + + return $constraint; + }, + $constraints, + )); + } + /** * @return array */ diff --git a/packages/builder/src/Services/FieldGroupValidator.php b/packages/builder/src/Services/FieldGroupValidator.php index 9a0c1b6f7a..aa9f35447b 100644 --- a/packages/builder/src/Services/FieldGroupValidator.php +++ b/packages/builder/src/Services/FieldGroupValidator.php @@ -245,7 +245,7 @@ protected function rangeMaxIsNotGreaterThanMin(mixed $min, mixed $max): bool protected function locationConstraintMessages(array $rows, mixed $entities): array { $messages = []; - $availableParams = array_keys($this->locationConstraintOptions->availableParamOptions()); + $availableParams = array_keys($this->locationConstraintOptions->availableParamOptionsForEntities($entities)); $availableOperators = ['==', '!=', 'in', 'not in']; foreach ($rows as $index => $row) { diff --git a/packages/builder/src/Support/LocationConstraintOptions.php b/packages/builder/src/Support/LocationConstraintOptions.php index 6979fbd4a2..1f1af5814f 100644 --- a/packages/builder/src/Support/LocationConstraintOptions.php +++ b/packages/builder/src/Support/LocationConstraintOptions.php @@ -112,6 +112,29 @@ public function availableParamOptions(): array ]; } + /** + * @return array + */ + public function availableParamOptionsForEntities(mixed $entities): array + { + $options = [ + 'user_role' => __('builder::builder.field_group.location_param_user_role'), + ]; + + if ($this->recordTypeOptionsForEntities($entities) !== []) { + $options = [ + 'record_type' => __('builder::builder.field_group.location_param_record_type'), + ...$options, + ]; + } + + if ($this->taxonomyKeysForEntities($entities) !== []) { + $options['taxonomy'] = __('builder::builder.field_group.location_param_taxonomy'); + } + + return $options; + } + public function supportsUserRoles(): bool { if (! class_exists(Role::class) || ! trait_exists(HasRoles::class)) { From d25f33aae787e55f92704d7a2f5ce5d212292bcf Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:58:04 +0200 Subject: [PATCH 080/105] Harden builder definition imports --- .../src/Services/FieldGroupImporter.php | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/builder/src/Services/FieldGroupImporter.php b/packages/builder/src/Services/FieldGroupImporter.php index 980094e17a..ee0c01a2b1 100644 --- a/packages/builder/src/Services/FieldGroupImporter.php +++ b/packages/builder/src/Services/FieldGroupImporter.php @@ -50,6 +50,8 @@ public function import(array $payload, bool $replaceExisting = false, ?string $s } } + $groupData = $this->canonicalizeGroupData($groupData); + $definition = FieldGroupDefinition::fromArray($groupData); $active = (bool) ($groupData['active'] ?? true); $sort = (int) ($groupData['sort'] ?? 0); @@ -267,13 +269,89 @@ protected function assertValidPayload(array $payload): void ])); } - if (! is_array($group['locationRules'] ?? null) && ! is_array($group['location_rules'] ?? null)) { + if ( + ! is_array($group['locationRules'] ?? null) + && ! is_array($group['location_rules'] ?? null) + && ! array_key_exists('target_entities', $group) + ) { $this->validationError(__('builder::builder.field_group.import_missing_group_key', [ 'key' => 'locationRules', ])); } } + /** + * @param array $groupData + * @return array + */ + protected function canonicalizeGroupData(array $groupData): array + { + if ( + ! array_key_exists('locationRules', $groupData) + && ! array_key_exists('location_rules', $groupData) + && ( + array_key_exists('target_entities', $groupData) + || array_key_exists('location_constraints', $groupData) + ) + ) { + $groupData['locationRules'] = $this->fieldGroupPersistence->resolveLocationRules([ + 'target_entities' => $groupData['target_entities'] ?? [], + 'location_constraints' => $groupData['location_constraints'] ?? [], + ]); + } + + $groupData['fields'] = $this->canonicalizeFieldRows($groupData['fields'] ?? []); + + return $groupData; + } + + /** + * @param mixed $rows + * @return list> + */ + protected function canonicalizeFieldRows(mixed $rows): array + { + if (! is_array($rows)) { + return []; + } + + return array_values(array_map( + fn (array $row): array => $this->canonicalizeFieldRow($row), + array_filter($rows, 'is_array'), + )); + } + + /** + * @param array $row + * @return array + */ + protected function canonicalizeFieldRow(array $row): array + { + $validation = is_array($row['validation'] ?? null) ? $row['validation'] : []; + + if (array_key_exists('required', $row) && ! array_key_exists('required', $validation)) { + $validation['required'] = (bool) $row['required']; + } + + $children = $this->canonicalizeFieldRows($row['children'] ?? []); + + if ($children === [] && is_array($row['layouts'] ?? null)) { + $children = array_map( + fn (array $layout): array => [ + ...$this->canonicalizeFieldRow($layout), + 'type' => 'flexible_layout', + ], + $this->canonicalizeFieldRows($row['layouts']), + ); + } + + return [ + ...$row, + 'validation' => $validation, + 'children' => $children, + ]; + } + /** * @return never */ From 2726f27185e4d3565e3b1b2892d3bb992d1c549b Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:04:46 +0200 Subject: [PATCH 081/105] Improve builder condition editor feedback --- .../builder/resources/lang/de/builder.php | 7 +++ .../builder/resources/lang/en/builder.php | 7 +++ .../src/Resources/FieldGroupResource.php | 44 ++++++++++++++++++- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 0e297ab66e..b4377f80fc 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -30,8 +30,13 @@ 'no_entities_registered' => 'Noch keine Ressource mit HasCustomFields. Trait an einer Filament-Resource hinzufügen.', 'location_constraints' => 'Zusätzliche Bedingungen', 'location_constraints_helper' => 'Optionale UND-Bedingungen für jede ausgewählte Ressource. Beispiele: Record-Typ, Benutzerrolle, Taxonomy-Term-IDs.', + 'location_constraints_helper_empty_entities' => 'Wähle zuerst mindestens eine Ressource bei „Anzeigen bei“, bevor du zusätzliche Bedingungen konfigurierst.', + 'location_constraints_helper_roles_only' => 'Für die ausgewählten Ressourcen sind derzeit nur Bedingungen zur Benutzerrolle verfügbar.', 'location_constraints_add' => 'Bedingung hinzufügen', 'location_param' => 'Parameter', + 'location_param_helper' => 'Wähle, was diese Bedingung für die ausgewählten Ressourcen prüfen soll.', + 'location_param_helper_empty_entities' => 'Wähle zuerst eine oder mehrere Ressourcen, damit Builder passende Bedingungs-Parameter anbieten kann.', + 'location_param_helper_roles_only' => 'Hier ist nur die Benutzerrolle verfügbar, weil die ausgewählten Ressourcen keine Record-Typen oder Taxonomies bereitstellen.', 'location_param_record_type' => 'Record-Typ', 'location_param_user_role' => 'Benutzerrolle', 'location_param_taxonomy' => 'Taxonomy-Term', @@ -50,6 +55,8 @@ 'location_value_role_unavailable_empty' => 'Benutzerrollen sind verfügbar, aber es wurden noch keine Rollen angelegt.', 'location_value_role_unavailable_user_model' => 'Benutzerrollen sind nicht verfügbar, weil das konfigurierte Auth-User-Model HasRoles nicht verwendet.', 'location_value_record_type_helper' => 'Record-Typ, z. B. page oder article.', + 'location_constraint_incomplete_taxonomy' => 'Taxonomy wählen', + 'location_constraint_incomplete_value' => 'Wert wählen', 'field_item' => 'Feld', 'fields_count' => 'Felder', 'assigned_to' => 'Zugeordnet zu', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 81dc528cd8..01d5cabd96 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -30,8 +30,13 @@ 'no_entities_registered' => 'No resources use HasCustomFields yet. Add the trait to a Filament resource.', 'location_constraints' => 'Additional conditions', 'location_constraints_helper' => 'Optional AND rules applied to every selected resource. Examples: record type, user role, taxonomy term IDs.', + 'location_constraints_helper_empty_entities' => 'Select at least one resource in "Show on" before configuring additional conditions.', + 'location_constraints_helper_roles_only' => 'For the selected resources, only user role conditions are currently available.', 'location_constraints_add' => 'Add condition', 'location_param' => 'Parameter', + 'location_param_helper' => 'Choose what this condition should check for the selected resources.', + 'location_param_helper_empty_entities' => 'Choose one or more resources first so Builder can offer matching condition parameters.', + 'location_param_helper_roles_only' => 'Only user role is available here because the selected resources do not expose record types or taxonomies.', 'location_param_record_type' => 'Record type', 'location_param_user_role' => 'User role', 'location_param_taxonomy' => 'Taxonomy term', @@ -50,6 +55,8 @@ 'location_value_role_unavailable_empty' => 'User roles are available, but no roles exist yet.', 'location_value_role_unavailable_user_model' => 'User roles are not available because the configured auth user model does not use HasRoles.', 'location_value_record_type_helper' => 'Record type value, e.g. page or article.', + 'location_constraint_incomplete_taxonomy' => 'choose taxonomy', + 'location_constraint_incomplete_value' => 'choose value', 'field_item' => 'Field', 'fields_count' => 'Fields', 'assigned_to' => 'Assigned to', diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 21ebcc7b17..fb1f863fdf 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -154,7 +154,7 @@ public static function form(Schema $schema): Schema ->native(false), Repeater::make('location_constraints') ->label(__('builder::builder.field_group.location_constraints')) - ->helperText(__('builder::builder.field_group.location_constraints_helper')) + ->helperText(fn (Get $get): string => static::locationConstraintsHelperText($get('target_entities'))) ->default([]) ->addActionLabel(__('builder::builder.field_group.location_constraints_add')) ->itemLabel(fn (array $state, Get $get): string => static::locationConstraintItemLabel($state, $get('../../target_entities'))) @@ -1390,6 +1390,7 @@ public static function locationConstraintSchema(): array Select::make('param') ->label(__('builder::builder.field_group.location_param')) ->options(fn (Get $get): array => app(LocationConstraintOptions::class)->availableParamOptionsForEntities($get($targetEntitiesPath))) + ->helperText(fn (Get $get): string => static::locationConstraintParamHelperText($get($targetEntitiesPath))) ->required() ->live() ->afterStateUpdated(function (callable $set): void { @@ -1507,9 +1508,18 @@ protected static function locationConstraintItemLabel(array $state, mixed $targe $paramLabel .= ': '.Str::headline((string) $state['taxonomy']); } + $valueLabel = static::locationConstraintValueLabel($state, $targetEntities); + + if ($param === 'taxonomy' && blank($state['taxonomy'] ?? null)) { + return trim($paramLabel.' '.__('builder::builder.field_group.location_constraint_incomplete_taxonomy')); + } + + if ($valueLabel === '') { + return trim($paramLabel.' '.__('builder::builder.field_group.location_constraint_incomplete_value')); + } + $operator = (string) ($state['operator'] ?? '=='); $operatorLabel = static::locationConstraintOperatorOptions()[$operator] ?? $operator; - $valueLabel = static::locationConstraintValueLabel($state, $targetEntities); return trim(implode(' ', array_filter([$paramLabel, $operatorLabel, $valueLabel]))); } @@ -1558,6 +1568,36 @@ public static function locationConstraintParamOptions(): array return app(LocationConstraintOptions::class)->availableParamOptions(); } + protected static function locationConstraintsHelperText(mixed $entities): string + { + $options = app(LocationConstraintOptions::class)->availableParamOptionsForEntities($entities); + + if ($entities === null || $entities === [] || $entities === '') { + return __('builder::builder.field_group.location_constraints_helper_empty_entities'); + } + + if (array_keys($options) === ['user_role']) { + return __('builder::builder.field_group.location_constraints_helper_roles_only'); + } + + return __('builder::builder.field_group.location_constraints_helper'); + } + + protected static function locationConstraintParamHelperText(mixed $entities): string + { + $options = app(LocationConstraintOptions::class)->availableParamOptionsForEntities($entities); + + if ($entities === null || $entities === [] || $entities === '') { + return __('builder::builder.field_group.location_param_helper_empty_entities'); + } + + if (array_keys($options) === ['user_role']) { + return __('builder::builder.field_group.location_param_helper_roles_only'); + } + + return __('builder::builder.field_group.location_param_helper'); + } + /** * @param list> $constraints * @return list> From f761944cebbcf17d8b28f1e65724026223b5eab0 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:59:09 +0000 Subject: [PATCH 082/105] Fix styling --- packages/builder/src/Services/FieldGroupImporter.php | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/builder/src/Services/FieldGroupImporter.php b/packages/builder/src/Services/FieldGroupImporter.php index ee0c01a2b1..45edea554a 100644 --- a/packages/builder/src/Services/FieldGroupImporter.php +++ b/packages/builder/src/Services/FieldGroupImporter.php @@ -306,7 +306,6 @@ protected function canonicalizeGroupData(array $groupData): array } /** - * @param mixed $rows * @return list> */ protected function canonicalizeFieldRows(mixed $rows): array From 9c336490c78493d1be31d8db198b412a5e5ed8d7 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:14:49 +0200 Subject: [PATCH 083/105] Refine builder conditions and validation workflows --- .../builder/resources/lang/de/builder.php | 4 + .../builder/resources/lang/en/builder.php | 4 + .../src/Services/FieldGroupValidator.php | 90 +++++++++++++++++++ .../src/Support/FieldValidationRules.php | 8 ++ 4 files changed, 106 insertions(+) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index b4377f80fc..a43abb2415 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -103,6 +103,8 @@ 'validation_rule_min' => 'Minimum', 'validation_rule_max' => 'Maximum', 'validation_rule_regex' => 'Regulärer Ausdruck', + 'validation_rule_starts_with' => 'Beginnt mit', + 'validation_rule_ends_with' => 'Endet mit', 'validation_rule_alpha_dash' => 'Alpha-Dash', 'validation_rule_alpha_num' => 'Alpha-Numerisch', 'validation_rule_gt' => 'Größer als', @@ -313,6 +315,8 @@ 'validation' => [ 'invalid_option' => 'Der gewählte Wert ist keine gültige Option.', + 'validation_rule_value_required' => 'Diese Validierungsregel benötigt einen Wert.', + 'validation_rule_value_numeric' => 'Diese Validierungsregel benötigt einen numerischen Wert.', 'invalid_email_default' => 'Bitte eine gültige E-Mail-Adresse als Standardwert eingeben.', 'invalid_url_default' => 'Bitte eine gültige URL als Standardwert eingeben.', 'invalid_link_url' => 'Bitte eine gültige URL eingeben.', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 01d5cabd96..40a225ddba 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -103,6 +103,8 @@ 'validation_rule_min' => 'Minimum', 'validation_rule_max' => 'Maximum', 'validation_rule_regex' => 'Regular expression', + 'validation_rule_starts_with' => 'Starts with', + 'validation_rule_ends_with' => 'Ends with', 'validation_rule_alpha_dash' => 'Alpha dash', 'validation_rule_alpha_num' => 'Alpha numeric', 'validation_rule_gt' => 'Greater than', @@ -313,6 +315,8 @@ 'validation' => [ 'invalid_option' => 'The selected value is not a valid option.', + 'validation_rule_value_required' => 'This validation rule needs a value.', + 'validation_rule_value_numeric' => 'This validation rule needs a numeric value.', 'invalid_email_default' => 'Please enter a valid email address as the default value.', 'invalid_url_default' => 'Please enter a valid URL as the default value.', 'invalid_link_url' => 'Please enter a valid URL.', diff --git a/packages/builder/src/Services/FieldGroupValidator.php b/packages/builder/src/Services/FieldGroupValidator.php index aa9f35447b..1891b6829b 100644 --- a/packages/builder/src/Services/FieldGroupValidator.php +++ b/packages/builder/src/Services/FieldGroupValidator.php @@ -9,6 +9,7 @@ use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\Models\FieldGroup; +use Moox\Builder\Support\FieldValidationRules; use Moox\Builder\Support\FieldRelationTree; use Moox\Builder\Support\LocationConstraintOptions; use Moox\Builder\Support\StorableFieldCollector; @@ -19,6 +20,7 @@ public function __construct( protected FieldGroupPersistence $fieldGroupPersistence, protected StorableFieldCollector $storableFieldCollector, protected LocationConstraintOptions $locationConstraintOptions, + protected FieldValidationRules $fieldValidationRules, ) {} /** @@ -31,6 +33,7 @@ public function validate(FieldGroup $group, array $data): void $fieldRows = $data['fields'] ?? []; $messages = array_merge( $this->rangeBoundsMessages($fieldRows), + $this->validationRuleMessages($fieldRows), $this->locationConstraintMessages( is_array($data['location_constraints'] ?? null) ? $data['location_constraints'] : [], $data['target_entities'] ?? [], @@ -238,6 +241,93 @@ protected function rangeMaxIsNotGreaterThanMin(mixed $min, mixed $max): bool return $max + 0 <= $min + 0; } + /** + * @param list> $rows + * @return array> + */ + protected function validationRuleMessages(array $rows, string $prefix = 'fields'): array + { + $messages = []; + + foreach ($rows as $index => $row) { + if (! is_array($row)) { + continue; + } + + $basePath = "{$prefix}.{$index}"; + $type = (string) ($row['type'] ?? ''); + $validation = is_array($row['validation'] ?? null) ? $row['validation'] : []; + $availableRules = $this->fieldValidationRules->availableRulesForType($type); + + foreach (($validation['rule_rows'] ?? []) as $ruleIndex => $ruleRow) { + if (! is_array($ruleRow)) { + continue; + } + + $rule = trim((string) ($ruleRow['rule'] ?? '')); + $rulePath = "{$basePath}.validation.rule_rows.{$ruleIndex}"; + + if ($rule === '' || ! array_key_exists($rule, $availableRules)) { + $messages["{$rulePath}.rule"] = [__('builder::builder.validation.invalid_option')]; + + continue; + } + + if (! $this->fieldValidationRules->ruleNeedsValue($rule)) { + continue; + } + + $value = trim((string) ($ruleRow['value'] ?? '')); + + if ($value === '') { + $messages["{$rulePath}.value"] = [__('builder::builder.validation.validation_rule_value_required')]; + + continue; + } + + if ($this->fieldValidationRules->ruleExpectsNumericValue($type, $rule) && ! is_numeric($value)) { + $messages["{$rulePath}.value"] = [__('builder::builder.validation.validation_rule_value_numeric')]; + } + } + + if ($type === 'tab') { + $messages = array_merge( + $messages, + $this->validationRuleMessages($row['children'] ?? [], "{$basePath}.children"), + ); + + continue; + } + + if ($type === 'flexible_content') { + foreach (($row['layouts'] ?? []) as $layoutIndex => $layout) { + if (! is_array($layout)) { + continue; + } + + $messages = array_merge( + $messages, + $this->validationRuleMessages( + $layout['children'] ?? [], + "{$basePath}.layouts.{$layoutIndex}.children", + ), + ); + } + + continue; + } + + if (in_array($type, ['group', 'repeater'], true)) { + $messages = array_merge( + $messages, + $this->validationRuleMessages($row['children'] ?? [], "{$basePath}.children"), + ); + } + } + + return $messages; + } + /** * @param list> $rows * @return array> diff --git a/packages/builder/src/Support/FieldValidationRules.php b/packages/builder/src/Support/FieldValidationRules.php index 357b855101..ce4a1437fa 100644 --- a/packages/builder/src/Support/FieldValidationRules.php +++ b/packages/builder/src/Support/FieldValidationRules.php @@ -19,6 +19,8 @@ public function availableRulesForType(?string $type): array 'min' => __('builder::builder.field.validation_rule_min'), 'max' => __('builder::builder.field.validation_rule_max'), 'regex' => __('builder::builder.field.validation_rule_regex'), + 'starts_with' => __('builder::builder.field.validation_rule_starts_with'), + 'ends_with' => __('builder::builder.field.validation_rule_ends_with'), 'alpha_dash' => __('builder::builder.field.validation_rule_alpha_dash'), 'alpha_num' => __('builder::builder.field.validation_rule_alpha_num'), ], @@ -144,6 +146,12 @@ public function ruleNeedsValue(string $rule): bool return ! in_array($rule, ['alpha_dash', 'alpha_num'], true); } + public function ruleExpectsNumericValue(string $type, string $rule): bool + { + return $this->categoryForType($type) === 'numeric' + && in_array($rule, ['min', 'max', 'gt', 'gte', 'lt', 'lte'], true); + } + /** * @return list */ From b2535fd6b2af44fa937ee8df955afa46bdc76fc3 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:16:06 +0000 Subject: [PATCH 084/105] Fix styling --- packages/builder/src/Services/FieldGroupValidator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/Services/FieldGroupValidator.php b/packages/builder/src/Services/FieldGroupValidator.php index 1891b6829b..08e45c6162 100644 --- a/packages/builder/src/Services/FieldGroupValidator.php +++ b/packages/builder/src/Services/FieldGroupValidator.php @@ -9,8 +9,8 @@ use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\FieldTypes\Capabilities\DefaultValue; use Moox\Builder\Models\FieldGroup; -use Moox\Builder\Support\FieldValidationRules; use Moox\Builder\Support\FieldRelationTree; +use Moox\Builder\Support\FieldValidationRules; use Moox\Builder\Support\LocationConstraintOptions; use Moox\Builder\Support\StorableFieldCollector; From e47d334dfa2e013ca5992136ac8d4fdb077f8b82 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:11:27 +0200 Subject: [PATCH 085/105] Reduce builder query load for field groups and custom field resources. --- .../builder/src/BuilderServiceProvider.php | 11 + .../Concerns/InteractsWithBuilderLocale.php | 19 +- .../Middleware/ResolveBuilderAdminLocale.php | 13 +- .../builder/src/Registry/EntityRegistry.php | 133 ++++++++- .../src/Resources/FieldGroupResource.php | 76 +++-- .../Pages/EditFieldGroup.php | 1 - .../src/Services/FieldGroupPersistence.php | 3 +- .../BuilderAdminLocalizationCatalog.php | 125 ++++++++ .../src/Support/BuilderLocaleResolver.php | 54 +++- .../src/Support/CustomFieldsFilamentHooks.php | 8 +- .../src/Support/DefinitionTranslator.php | 32 +- .../src/Support/LocationConstraintOptions.php | 281 +++++++++++++++--- .../src/Support/RelationTargetResolver.php | 3 +- 13 files changed, 648 insertions(+), 111 deletions(-) create mode 100644 packages/builder/src/Support/BuilderAdminLocalizationCatalog.php diff --git a/packages/builder/src/BuilderServiceProvider.php b/packages/builder/src/BuilderServiceProvider.php index ba519c66ca..860ddf95d6 100644 --- a/packages/builder/src/BuilderServiceProvider.php +++ b/packages/builder/src/BuilderServiceProvider.php @@ -52,8 +52,12 @@ use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Registry\FieldTypeRegistry; use Moox\Builder\Services\BuilderFieldValueMediaMetadataSync; +use Moox\Builder\Support\BuilderAdminLocalizationCatalog; +use Moox\Builder\Support\BuilderLocaleResolver; use Moox\Builder\Support\CustomFieldsFilamentHooks; +use Moox\Builder\Support\DefinitionTranslator; use Moox\Builder\Support\EntityModelDeletionRegistrar; +use Moox\Builder\Support\LocationConstraintOptions; use Moox\Builder\Support\MediaIntegration; use Moox\Builder\Support\RelationTargetResolver; use Moox\Core\MooxServiceProvider; @@ -93,10 +97,17 @@ public function packageRegistered(): void $this->app->singleton(EntityRegistry::class); + // Request-scoped: memoizes default locale / fallback chain (thousands of + // translate() calls during field group editor hydration otherwise each hit DB). + $this->app->scoped(BuilderLocaleResolver::class); + $this->app->scoped(DefinitionTranslator::class); + $this->app->scoped(BuilderAdminLocalizationCatalog::class); + // Request-scoped so its relation-label memo is reused across table rows // and re-renders within a request, while staying Octane-safe (reset per // request, so target title changes are never served stale). $this->app->scoped(RelationTargetResolver::class); + $this->app->scoped(LocationConstraintOptions::class); } public function packageBooted(): void diff --git a/packages/builder/src/Filament/Resources/Pages/Concerns/InteractsWithBuilderLocale.php b/packages/builder/src/Filament/Resources/Pages/Concerns/InteractsWithBuilderLocale.php index dec8e5d662..ed2a445e74 100644 --- a/packages/builder/src/Filament/Resources/Pages/Concerns/InteractsWithBuilderLocale.php +++ b/packages/builder/src/Filament/Resources/Pages/Concerns/InteractsWithBuilderLocale.php @@ -5,9 +5,8 @@ namespace Moox\Builder\Filament\Resources\Pages\Concerns; use Filament\Actions\Action; -use Illuminate\Support\Facades\Schema; +use Moox\Builder\Support\BuilderAdminLocalizationCatalog; use Moox\Builder\Support\BuilderLocaleResolver; -use Moox\Localization\Models\Localization; trait InteractsWithBuilderLocale { @@ -37,16 +36,13 @@ public function syncLangToRequest(): void protected function guardBuilderAdminLocale(): void { - if ($this->lang === '' || ! class_exists(Localization::class) || ! Schema::hasTable('localizations')) { + $catalog = app(BuilderAdminLocalizationCatalog::class); + + if ($this->lang === '' || ! $catalog->isAvailable()) { return; } - $isAllowed = Localization::query() - ->where('locale_variant', $this->lang) - ->where('is_active_admin', true) - ->exists(); - - if ($isAllowed) { + if ($catalog->isAllowedAdminLocale($this->lang)) { return; } @@ -75,6 +71,11 @@ protected function getBuilderLanguageSelectorAction(): Action ->view('localization::lang-selector'); } + if (view()->exists('builder::lang-selector')) { + return Action::make('language_selector') + ->view('builder::lang-selector'); + } + return Action::make('language_selector') ->label($this->lang !== '' ? $this->lang : app(BuilderLocaleResolver::class)->adminDefaultLocale()) ->disabled(); diff --git a/packages/builder/src/Http/Middleware/ResolveBuilderAdminLocale.php b/packages/builder/src/Http/Middleware/ResolveBuilderAdminLocale.php index 04e969910b..ba999cdaef 100644 --- a/packages/builder/src/Http/Middleware/ResolveBuilderAdminLocale.php +++ b/packages/builder/src/Http/Middleware/ResolveBuilderAdminLocale.php @@ -6,11 +6,10 @@ use Closure; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Schema; use Moox\Builder\Registry\EntityRegistry; +use Moox\Builder\Support\BuilderAdminLocalizationCatalog; use Moox\Builder\Support\BuilderLocaleResolver; use Moox\Builder\Support\CustomFieldsTranslatability; -use Moox\Localization\Models\Localization; use Symfony\Component\HttpFoundation\Response; final class ResolveBuilderAdminLocale @@ -19,6 +18,7 @@ final class ResolveBuilderAdminLocale public function __construct( protected BuilderLocaleResolver $localeResolver, + protected BuilderAdminLocalizationCatalog $localizationCatalog, protected EntityRegistry $entityRegistry, protected CustomFieldsTranslatability $translatability, ) {} @@ -65,14 +65,7 @@ public function handle(Request $request, Closure $next): Response protected function isAllowedAdminLocale(string $locale): bool { - if (! class_exists(Localization::class) || ! Schema::hasTable('localizations')) { - return true; - } - - return Localization::query() - ->where('locale_variant', $locale) - ->where('is_active_admin', true) - ->exists(); + return $this->localizationCatalog->isAllowedAdminLocale($locale); } protected function shouldResolveLocale(Request $request): bool diff --git a/packages/builder/src/Registry/EntityRegistry.php b/packages/builder/src/Registry/EntityRegistry.php index d637a75d86..7b3e0cd45f 100644 --- a/packages/builder/src/Registry/EntityRegistry.php +++ b/packages/builder/src/Registry/EntityRegistry.php @@ -6,6 +6,7 @@ use Filament\Facades\Filament; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Moox\Builder\Concerns\HasCustomFields; @@ -26,6 +27,21 @@ class EntityRegistry */ protected ?array $relatableCache = null; + /** + * @var array, bool> + */ + protected array $queryableModelCache = []; + + /** + * @var array + */ + protected array $tableExistsCache = []; + + /** + * @var array> + */ + protected array $tableColumnsListingCache = []; + /** * @return array */ @@ -175,6 +191,10 @@ public function relatableResources(): array $panelResources = $this->panelResources(); sort($panelResources); + // Avoid N individual information_schema queries by preloading existence + // for all candidate model tables at once. + $candidateModelTables = []; + foreach ($panelResources as $resourceClass) { if (! method_exists($resourceClass, 'getModel')) { continue; @@ -182,13 +202,44 @@ public function relatableResources(): array $model = $resourceClass::getModel(); - if (! is_string($model) || $model === '' || ! $this->modelIsQueryable($model)) { + if (! is_string($model) || $model === '' || isset($candidateModelTables[$model])) { + continue; + } + + if (! is_subclass_of($model, Model::class)) { continue; } - // A relation always points at a model, so multiple resources that - // expose the same model represent the same target: keep only one. - if (isset($seenModels[$model])) { + try { + $table = (new $model)->getTable(); + } catch (\Throwable) { + continue; + } + + if (! filled($table)) { + continue; + } + + $candidateModelTables[$model] = $table; + } + + $this->preloadTableExistence(array_values($candidateModelTables)); + + foreach ($panelResources as $resourceClass) { + if (! method_exists($resourceClass, 'getModel')) { + continue; + } + + $model = $resourceClass::getModel(); + + if (! is_string($model) || $model === '' || isset($seenModels[$model])) { + continue; + } + + $table = $candidateModelTables[$model] ?? null; + + // Model table missing -> not queryable -> skip. + if ($table === null || ! $this->tableExists($table)) { continue; } @@ -270,17 +321,87 @@ public function relatedResourceFor(string $key): ?string */ public function modelIsQueryable(string $modelClass): bool { + if (array_key_exists($modelClass, $this->queryableModelCache)) { + return $this->queryableModelCache[$modelClass]; + } + if (! is_subclass_of($modelClass, Model::class)) { - return false; + return $this->queryableModelCache[$modelClass] = false; } try { $table = (new $modelClass)->getTable(); - return filled($table) && Schema::hasTable($table); + return $this->queryableModelCache[$modelClass] = filled($table) && $this->tableExists($table); } catch (\Throwable) { + return $this->queryableModelCache[$modelClass] = false; + } + } + + protected function tableExists(string $table): bool + { + return $this->tableExistsCache[$table] ??= Schema::hasTable($table); + } + + /** + * @param array $tables + */ + protected function preloadTableExistence(array $tables): void + { + $tables = array_values(array_unique(array_filter($tables, static fn (mixed $table): bool => filled($table)))); + + if ($tables === []) { + return; + } + + // The information_schema approach is MySQL-specific. In tests we often + // run against SQLite where information_schema tables do not exist. + if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) { + foreach ($tables as $table) { + $this->tableExistsCache[$table] = Schema::hasTable($table); + } + + return; + } + + foreach (array_chunk($tables, 200) as $chunk) { + $existingTables = DB::table('information_schema.tables') + ->selectRaw('TABLE_NAME as table_name') + ->whereRaw('TABLE_SCHEMA = schema()') + ->whereIn('TABLE_TYPE', ['BASE TABLE', 'SYSTEM VERSIONED']) + ->whereIn('TABLE_NAME', $chunk) + ->pluck('table_name') + ->all(); + + $existingLookup = array_fill_keys($existingTables, true); + + foreach ($chunk as $table) { + $this->tableExistsCache[$table] = isset($existingLookup[$table]); + } + } + } + + public function databaseTableExists(string $table): bool + { + return $this->tableExists($table); + } + + public function databaseTableHasColumn(string $table, string $column): bool + { + if (! $this->tableExists($table)) { return false; } + + if (! isset($this->tableColumnsListingCache[$table])) { + try { + $columns = Schema::getColumnListing($table); + $this->tableColumnsListingCache[$table] = array_fill_keys($columns, true); + } catch (\Throwable) { + $this->tableColumnsListingCache[$table] = []; + } + } + + return isset($this->tableColumnsListingCache[$table][$column]); } /** diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index fb1f863fdf..f6c734a14c 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -45,6 +45,14 @@ class FieldGroupResource extends Resource { + /** + * @var array + */ + protected static array $fieldTypeIconSvgCache = []; + + /** @var array> */ + protected static array $typeSettingsCapabilityCache = []; + protected static ?string $model = FieldGroup::class; protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-squares-2x2'; @@ -320,8 +328,7 @@ protected static function fieldRepeaterItemLabel(FieldTypeRegistry $registry, ar */ protected static function fieldItemLabelHtml(FieldTypeRegistry $registry, ?string $type, string $title, array $meta): HtmlString { - $icon = static::fieldTypeIcon($registry, $type); - $iconSvg = svg($icon, 'moox-builder-field-item__icon')->toHtml(); + $iconSvg = static::fieldTypeIconSvg($registry, $type); $metaHtml = $meta === [] ? '' @@ -336,6 +343,20 @@ protected static function fieldItemLabelHtml(FieldTypeRegistry $registry, ?strin ); } + protected static function fieldTypeIconSvg(FieldTypeRegistry $registry, ?string $type): string + { + $cacheKey = $type ?? '__empty__'; + + if (! array_key_exists($cacheKey, static::$fieldTypeIconSvgCache)) { + static::$fieldTypeIconSvgCache[$cacheKey] = svg( + static::fieldTypeIcon($registry, $type), + 'moox-builder-field-item__icon', + )->toHtml(); + } + + return static::$fieldTypeIconSvgCache[$cacheKey]; + } + protected static function fieldTypeIcon(FieldTypeRegistry $registry, ?string $type): string { if (blank($type)) { @@ -543,7 +564,15 @@ protected static function subFieldSchema(FieldTypeRegistry $registry): array protected static function typeHasSettings(?string $type): bool { - return static::typeSettingsSchema($type) !== []; + if (blank($type)) { + return false; + } + + try { + return app(FieldTypeRegistry::class)->get($type)->capabilities() !== []; + } catch (UnknownFieldTypeException) { + return false; + } } protected static function fieldTypeSupportsRequired(?string $type): bool @@ -914,8 +943,7 @@ protected static function conditionalLogicOperatorOptions(): array * * The rules Select lives several repeater levels deep, so instead of * hard-counting "../" segments (brittle if the editor layout changes) we - * probe a range of depths and use the first that yields the fields - * collection: an array whose entries are field rows with a "name". + * walk up the state path and return the first ancestor `fields` collection. * * @return array */ @@ -958,17 +986,13 @@ protected static function siblingFieldOptions(callable $get): array */ protected static function resolveSiblingFields(callable $get): array { - $prefix = ''; - - for ($depth = 0; $depth <= 8; $depth++) { - $candidate = $get($prefix); + for ($depth = 1; $depth <= 10; $depth++) { + $candidate = $get(str_repeat('../', $depth).'fields'); if (static::looksLikeFieldRows($candidate)) { /** @var array> $candidate */ return array_values($candidate); } - - $prefix .= '../'; } return []; @@ -1336,10 +1360,19 @@ protected static function typeSettingsSchema(?string $type): array return []; } - $fieldType = app(FieldTypeRegistry::class)->get($type); + if (! array_key_exists($type, static::$typeSettingsCapabilityCache)) { + try { + static::$typeSettingsCapabilityCache[$type] = app(FieldTypeRegistry::class) + ->get($type) + ->capabilities(); + } catch (UnknownFieldTypeException) { + static::$typeSettingsCapabilityCache[$type] = []; + } + } + $components = []; - foreach ($fieldType->capabilities() as $capabilityClass) { + foreach (static::$typeSettingsCapabilityCache[$type] as $capabilityClass) { $components = array_merge($components, app($capabilityClass)->builderFieldsFor($type)); } @@ -1436,13 +1469,22 @@ public static function locationConstraintSchema(): array }) ->options(function (Get $get) use ($targetEntitiesPath): array { return match ($get('param')) { - 'taxonomy' => app(LocationConstraintOptions::class) - ->termOptionsForTaxonomy((string) $get('taxonomy'), $get($targetEntitiesPath)), 'record_type' => app(LocationConstraintOptions::class)->recordTypeOptionsForEntities($get($targetEntitiesPath)), 'user_role' => app(LocationConstraintOptions::class)->roleOptions(), default => [], }; }) + ->getSearchResultsUsing(function (string $search, Get $get) use ($targetEntitiesPath): array { + if ($get('param') !== 'taxonomy' || blank($get('taxonomy'))) { + return []; + } + + return app(LocationConstraintOptions::class)->searchTermOptionsForTaxonomy( + (string) $get('taxonomy'), + $get($targetEntitiesPath), + $search, + ); + }) ->visible(fn (Get $get): bool => in_array($get('param'), ['taxonomy', 'record_type', 'user_role'], true) && ($get('param') !== 'taxonomy' || filled($get('taxonomy')))) ->required(fn (Get $get): bool => in_array($get('param'), ['taxonomy', 'record_type', 'user_role'], true) @@ -1484,8 +1526,8 @@ public static function locationConstraintSchema(): array default => [], }; }) - ->searchable() - ->preload() + ->searchable(fn (Get $get): bool => in_array($get('param'), ['taxonomy', 'record_type', 'user_role'], true)) + ->preload(fn (Get $get): bool => $get('param') !== 'taxonomy') ->native(false) ->columnSpanFull(), ]; diff --git a/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php index 7606fdcef3..5ee7cea145 100644 --- a/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php @@ -53,7 +53,6 @@ protected function mutateFormDataBeforeFill(array $data): array /** @var FieldGroup $record */ $record = $this->getRecord(); - $record->load(['fields.options', 'translations']); $persistence = app(FieldGroupPersistence::class); diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 66b5fde0e5..00507cf0ed 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -370,8 +370,7 @@ public function flattenLocationRulesForForm(array $rules): array */ public function fieldRowsForForm(FieldGroup $group, ?string $locale = null): array { - $group->load(FieldRelationTree::eagerLoadForDefinition()); - $group->load('translations'); + $group->loadMissing(FieldRelationTree::eagerLoadForDefinition()); return $this->mapFieldRows($group->fields, $locale); } diff --git a/packages/builder/src/Support/BuilderAdminLocalizationCatalog.php b/packages/builder/src/Support/BuilderAdminLocalizationCatalog.php new file mode 100644 index 0000000000..3a92ff46fe --- /dev/null +++ b/packages/builder/src/Support/BuilderAdminLocalizationCatalog.php @@ -0,0 +1,125 @@ +|null */ + private ?Collection $adminLocalizations = null; + + public function __construct( + protected BuilderLocaleResolver $localeResolver, + ) {} + + public function isAvailable(): bool + { + return class_exists(Localization::class) && $this->localeResolver->localizationsTableExists(); + } + + /** + * @return Collection + */ + public function adminLocalizations(): Collection + { + if ($this->adminLocalizations !== null) { + return $this->adminLocalizations; + } + + if (! $this->isAvailable()) { + return $this->adminLocalizations = collect(); + } + + return $this->adminLocalizations = Localization::query() + ->with('language') + ->where('is_active_admin', true) + ->orderBy('language_id') + ->orderBy('locale_variant') + ->get(); + } + + public function find(string $localeVariant): ?Localization + { + if ($localeVariant === '') { + return null; + } + + return $this->adminLocalizations()->firstWhere('locale_variant', $localeVariant); + } + + public function isAllowedAdminLocale(string $localeVariant): bool + { + if (! $this->isAvailable()) { + return true; + } + + return $this->find($localeVariant) !== null; + } + + public function labelFor(?Localization $localization, ?string $fallbackLocale = null): string + { + if ($localization === null) { + return $fallbackLocale ?? ''; + } + + if (filled($localization->title)) { + return (string) $localization->title; + } + + $language = $localization->relationLoaded('language') ? $localization->language : null; + + if ($language !== null && filled($language->common_name)) { + return (string) $language->common_name; + } + + return (string) ($localization->locale_variant ?? $fallbackLocale ?? ''); + } + + public function flagFor(?Localization $localization, ?string $fallbackLocale = null): string + { + if ($localization === null) { + return 'flag-'.strtolower((string) $fallbackLocale); + } + + $language = $localization->relationLoaded('language') ? $localization->language : null; + + if ($language !== null && filled($language->flag_icon)) { + if (! $localization->use_country_icon) { + return (string) $language->flag_icon; + } + + $countryCode = $this->countryCodeFromLocaleVariant((string) $localization->locale_variant); + + if ($countryCode !== '') { + return 'flag-'.$countryCode; + } + + return (string) $language->flag_icon; + } + + $countryCode = $this->countryCodeFromLocaleVariant((string) $localization->locale_variant); + + return $countryCode !== '' ? 'flag-'.$countryCode : 'flag-'.strtolower((string) $fallbackLocale); + } + + protected function countryCodeFromLocaleVariant(string $localeVariant): string + { + if (! str_contains($localeVariant, '_')) { + return ''; + } + + $parts = explode('_', $localeVariant, 2); + + return strtolower($parts[1] ?? ''); + } +} diff --git a/packages/builder/src/Support/BuilderLocaleResolver.php b/packages/builder/src/Support/BuilderLocaleResolver.php index 3d3f1cf5ab..0d78954047 100644 --- a/packages/builder/src/Support/BuilderLocaleResolver.php +++ b/packages/builder/src/Support/BuilderLocaleResolver.php @@ -13,6 +13,15 @@ final class BuilderLocaleResolver private ?string $overrideLocale = null; + private ?bool $hasLocalizationsTable = null; + + private ?string $resolvedDefaultLocale = null; + + private ?string $resolvedAdminDefaultLocale = null; + + /** @var array> */ + private array $resolvedFallbackChains = []; + /** * @return list */ @@ -51,22 +60,30 @@ public function current(?string $locale = null): string public function defaultLocale(): string { - if (class_exists(Localization::class) && Schema::hasTable('localizations')) { + if ($this->resolvedDefaultLocale !== null) { + return $this->resolvedDefaultLocale; + } + + if ($this->localizationsTableExists()) { $localization = Localization::query() ->where('is_default', true) ->first(); if ($localization !== null && filled($localization->getAttribute('locale_variant'))) { - return (string) $localization->getAttribute('locale_variant'); + return $this->resolvedDefaultLocale = (string) $localization->getAttribute('locale_variant'); } } - return (string) config('builder.default_locale', config('app.locale', 'en_US')); + return $this->resolvedDefaultLocale = (string) config('builder.default_locale', config('app.locale', 'en_US')); } public function adminDefaultLocale(): string { - if (class_exists(Localization::class) && Schema::hasTable('localizations')) { + if ($this->resolvedAdminDefaultLocale !== null) { + return $this->resolvedAdminDefaultLocale; + } + + if ($this->localizationsTableExists()) { $defaultLocale = Localization::query() ->where('is_default', true) ->where('is_active_admin', true) @@ -76,7 +93,7 @@ public function adminDefaultLocale(): string $variant = $defaultLocale->getAttribute('locale_variant'); if (filled($variant)) { - return (string) $variant; + return $this->resolvedAdminDefaultLocale = (string) $variant; } } @@ -88,12 +105,12 @@ public function adminDefaultLocale(): string $variant = $firstActiveLocale->getAttribute('locale_variant'); if (filled($variant)) { - return (string) $variant; + return $this->resolvedAdminDefaultLocale = (string) $variant; } } } - return $this->defaultLocale(); + return $this->resolvedAdminDefaultLocale = $this->defaultLocale(); } /** @@ -101,18 +118,37 @@ public function adminDefaultLocale(): string */ public function fallbackChain(?string $locale = null): array { + $cacheKey = $this->current($locale); + + if (array_key_exists($cacheKey, $this->resolvedFallbackChains)) { + return $this->resolvedFallbackChains[$cacheKey]; + } + $chain = [ - $this->current($locale), + $cacheKey, $this->defaultLocale(), 'en_US', ]; - return array_values(array_unique(array_filter( + return $this->resolvedFallbackChains[$cacheKey] = array_values(array_unique(array_filter( $chain, static fn (string $value): bool => $value !== '', ))); } + public function localizationsTableExists(): bool + { + if ($this->hasLocalizationsTable !== null) { + return $this->hasLocalizationsTable; + } + + if (! class_exists(Localization::class)) { + return $this->hasLocalizationsTable = false; + } + + return $this->hasLocalizationsTable = Schema::hasTable('localizations'); + } + /** * @template TReturn * diff --git a/packages/builder/src/Support/CustomFieldsFilamentHooks.php b/packages/builder/src/Support/CustomFieldsFilamentHooks.php index 2fd9c175b4..bc871d74be 100644 --- a/packages/builder/src/Support/CustomFieldsFilamentHooks.php +++ b/packages/builder/src/Support/CustomFieldsFilamentHooks.php @@ -69,14 +69,14 @@ private static function registerLanguageSelectorHooks(): void FilamentView::registerRenderHook( TablesRenderHook::TOOLBAR_SEARCH_BEFORE, static function (): string { - if (view()->exists('builder::lang-selector')) { - return view('builder::lang-selector')->render(); - } - if (view()->exists('localization::lang-selector')) { return Blade::render('@include("localization::lang-selector")'); } + if (view()->exists('builder::lang-selector')) { + return view('builder::lang-selector')->render(); + } + return ''; }, scopes: $pageClass, diff --git a/packages/builder/src/Support/DefinitionTranslator.php b/packages/builder/src/Support/DefinitionTranslator.php index 6811593501..3ea3456d5b 100644 --- a/packages/builder/src/Support/DefinitionTranslator.php +++ b/packages/builder/src/Support/DefinitionTranslator.php @@ -96,7 +96,7 @@ public function translatedFieldConfig(Field $field, ?string $locale = null): arr ); foreach ($this->localeResolver->fallbackChain($locale) as $candidate) { - $translation = $field->translate($candidate, false); + $translation = $this->translationRow($field, $candidate); $translatedConfig = is_object($translation) ? ($translation->config ?? null) : null; if (is_array($translatedConfig) && $translatedConfig !== []) { @@ -181,7 +181,7 @@ protected function translatedAttribute(Model $model, string $attribute, string $ $locale = $this->localeResolver->current($locale); foreach ($this->localeResolver->fallbackChain($locale) as $candidate) { - $value = $model->translate($candidate, false)?->getAttribute($attribute); + $value = $this->translationAttribute($model, $candidate, $attribute); if (is_string($value) && $value !== '') { return $value; @@ -191,6 +191,34 @@ protected function translatedAttribute(Model $model, string $attribute, string $ return $fallback; } + /** + * @param Model&TranslatableContract $model + */ + protected function translationAttribute(Model $model, string $locale, string $attribute): mixed + { + if ($model->relationLoaded('translations')) { + $localeKey = $model->getLocaleKey(); + + return $model->translations->firstWhere($localeKey, $locale)?->getAttribute($attribute); + } + + return $model->translate($locale, false)?->getAttribute($attribute); + } + + /** + * @param Model&TranslatableContract $model + */ + protected function translationRow(Model $model, string $locale): ?Model + { + if ($model->relationLoaded('translations')) { + $localeKey = $model->getLocaleKey(); + + return $model->translations->firstWhere($localeKey, $locale); + } + + return $model->translate($locale, false); + } + /** * @param array> $translations */ diff --git a/packages/builder/src/Support/LocationConstraintOptions.php b/packages/builder/src/Support/LocationConstraintOptions.php index 1f1af5814f..ad70deca35 100644 --- a/packages/builder/src/Support/LocationConstraintOptions.php +++ b/packages/builder/src/Support/LocationConstraintOptions.php @@ -7,7 +7,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Moox\Builder\Registry\EntityRegistry; use Moox\Core\Services\TaxonomyService; @@ -16,6 +15,42 @@ final class LocationConstraintOptions { + /** + * @var array> + */ + protected array $taxonomyKeysCache = []; + + /** + * @var array> + */ + protected array $recordTypeOptionsCache = []; + + /** + * @var array> + */ + protected array $termOptionsCache = []; + + /** + * @var array> + */ + protected array $availableParamOptionsCache = []; + + protected ?bool $supportsUserRolesCache = null; + + protected bool $userRoleUnavailableReasonResolved = false; + + protected ?string $userRoleUnavailableReasonCache = null; + + /** + * @var array|null + */ + protected ?array $roleOptionsCache = null; + + /** + * @var array, array> + */ + protected array $recordTypeOptionsByModelCache = []; + public function __construct( protected EntityRegistry $entityRegistry, protected TaxonomyService $taxonomyService, @@ -27,6 +62,12 @@ public function __construct( */ public function taxonomyKeysForEntities(mixed $entities): array { + $cacheKey = $this->entitiesCacheKey($entities); + + if (array_key_exists($cacheKey, $this->taxonomyKeysCache)) { + return $this->taxonomyKeysCache[$cacheKey]; + } + $options = []; foreach ($this->normalizeEntities($entities) as $entity) { @@ -45,7 +86,7 @@ public function taxonomyKeysForEntities(mixed $entities): array asort($options); - return $options; + return $this->taxonomyKeysCache[$cacheKey] = $options; } /** @@ -57,29 +98,72 @@ public function termOptionsForTaxonomy(string $taxonomy, mixed $entities): array return []; } - foreach ($this->normalizeEntities($entities) as $entity) { - $configKey = $this->configKeyForEntity($entity); + $cacheKey = $this->entitiesCacheKey($entities).'|'.$taxonomy; - if ($configKey === null) { - continue; - } + if (array_key_exists($cacheKey, $this->termOptionsCache)) { + return $this->termOptionsCache[$cacheKey]; + } - $this->taxonomyService->setCurrentResource($configKey); + $modelClass = $this->taxonomyModelFor($taxonomy, $entities); - if (! array_key_exists($taxonomy, $this->taxonomyService->getTaxonomies())) { - continue; - } + if ($modelClass === null) { + return $this->termOptionsCache[$cacheKey] = []; + } - $modelClass = $this->taxonomyService->getTaxonomyModel($taxonomy); + return $this->termOptionsCache[$cacheKey] = $this->termOptionsForModel($modelClass); + } - if (! is_string($modelClass) || ! class_exists($modelClass)) { - continue; - } + /** + * @return array + */ + public function searchTermOptionsForTaxonomy(string $taxonomy, mixed $entities, string $search): array + { + $search = trim($search); - return $this->termOptionsForModel($modelClass); + if ($taxonomy === '' || $search === '') { + return []; + } + + $modelClass = $this->taxonomyModelFor($taxonomy, $entities); + + if ($modelClass === null) { + return []; } - return []; + $model = $modelClass::query()->getModel(); + $query = $modelClass::query() + ->limit(50) + ->orderBy($model->getKeyName()); + + if (method_exists($model, 'translations')) { + $query->with('translations'); + } + + $like = '%'.addcslashes($search, '%_\\').'%'; + + $query->where(function ($builder) use ($model, $like): void { + foreach (['title', 'name', 'label', 'slug'] as $column) { + if ($this->entityRegistry->databaseTableHasColumn($model->getTable(), $column)) { + $builder->orWhere($column, 'like', $like); + } + } + + if (method_exists($model, 'translations')) { + $builder->orWhereHas('translations', function ($translationQuery) use ($like): void { + $translationQuery + ->where('title', 'like', $like) + ->orWhere('name', 'like', $like) + ->orWhere('label', 'like', $like); + }); + } + }); + + return $query + ->get() + ->mapWithKeys(fn (Model $term): array => [ + (string) $term->getKey() => $this->termLabel($term), + ]) + ->all(); } /** @@ -87,13 +171,17 @@ public function termOptionsForTaxonomy(string $taxonomy, mixed $entities): array */ public function roleOptions(): array { + if ($this->roleOptionsCache !== null) { + return $this->roleOptionsCache; + } + if (! $this->supportsUserRoles()) { - return []; + return $this->roleOptionsCache = []; } $rolesTable = (string) config('permission.table_names.roles'); - return DB::table($rolesTable) + return $this->roleOptionsCache = DB::table($rolesTable) ->orderBy('name') ->pluck('name', 'name') ->mapWithKeys(fn (mixed $name, mixed $key): array => [(string) $key => (string) $name]) @@ -117,6 +205,12 @@ public function availableParamOptions(): array */ public function availableParamOptionsForEntities(mixed $entities): array { + $cacheKey = $this->entitiesCacheKey($entities); + + if (array_key_exists($cacheKey, $this->availableParamOptionsCache)) { + return $this->availableParamOptionsCache[$cacheKey]; + } + $options = [ 'user_role' => __('builder::builder.field_group.location_param_user_role'), ]; @@ -132,57 +226,67 @@ public function availableParamOptionsForEntities(mixed $entities): array $options['taxonomy'] = __('builder::builder.field_group.location_param_taxonomy'); } - return $options; + return $this->availableParamOptionsCache[$cacheKey] = $options; } public function supportsUserRoles(): bool { + if ($this->supportsUserRolesCache !== null) { + return $this->supportsUserRolesCache; + } + if (! class_exists(Role::class) || ! trait_exists(HasRoles::class)) { - return false; + return $this->supportsUserRolesCache = false; } $rolesTable = config('permission.table_names.roles'); if (! is_string($rolesTable) || $rolesTable === '') { - return false; + return $this->supportsUserRolesCache = false; } - if (! Schema::hasTable($rolesTable) || ! DB::table($rolesTable)->exists()) { - return false; + if (! $this->entityRegistry->databaseTableExists($rolesTable) || ! DB::table($rolesTable)->exists()) { + return $this->supportsUserRolesCache = false; } $userModel = $this->authUserModel(); if ($userModel === null || ! class_exists($userModel)) { - return false; + return $this->supportsUserRolesCache = false; } - return in_array(HasRoles::class, class_uses_recursive($userModel), true); + return $this->supportsUserRolesCache = in_array(HasRoles::class, class_uses_recursive($userModel), true); } public function userRoleUnavailableReason(): ?string { + if ($this->userRoleUnavailableReasonResolved) { + return $this->userRoleUnavailableReasonCache; + } + + $this->userRoleUnavailableReasonResolved = true; + if (! class_exists(Role::class) || ! trait_exists(HasRoles::class)) { - return __('builder::builder.field_group.location_value_role_unavailable_permissions'); + return $this->userRoleUnavailableReasonCache = __('builder::builder.field_group.location_value_role_unavailable_permissions'); } $rolesTable = config('permission.table_names.roles'); - if (! is_string($rolesTable) || $rolesTable === '' || ! Schema::hasTable($rolesTable)) { - return __('builder::builder.field_group.location_value_role_unavailable_permissions'); + if (! is_string($rolesTable) || $rolesTable === '' || ! $this->entityRegistry->databaseTableExists($rolesTable)) { + return $this->userRoleUnavailableReasonCache = __('builder::builder.field_group.location_value_role_unavailable_permissions'); } if (! DB::table($rolesTable)->exists()) { - return __('builder::builder.field_group.location_value_role_unavailable_empty'); + return $this->userRoleUnavailableReasonCache = __('builder::builder.field_group.location_value_role_unavailable_empty'); } $userModel = $this->authUserModel(); if ($userModel === null || ! class_exists($userModel) || ! in_array(HasRoles::class, class_uses_recursive($userModel), true)) { - return __('builder::builder.field_group.location_value_role_unavailable_user_model'); + return $this->userRoleUnavailableReasonCache = __('builder::builder.field_group.location_value_role_unavailable_user_model'); } - return null; + return $this->userRoleUnavailableReasonCache = null; } /** @@ -190,6 +294,12 @@ public function userRoleUnavailableReason(): ?string */ public function recordTypeOptionsForEntities(mixed $entities): array { + $cacheKey = $this->entitiesCacheKey($entities); + + if (array_key_exists($cacheKey, $this->recordTypeOptionsCache)) { + return $this->recordTypeOptionsCache[$cacheKey]; + } + $options = []; foreach ($this->normalizeEntities($entities) as $entity) { @@ -206,7 +316,7 @@ public function recordTypeOptionsForEntities(mixed $entities): array asort($options); - return $options; + return $this->recordTypeOptionsCache[$cacheKey] = $options; } /** @@ -244,12 +354,21 @@ public function termLabelForValue(string $taxonomy, mixed $entities, mixed $valu return null; } - $options = $this->termOptionsForTaxonomy($taxonomy, $entities); + $modelClass = $this->taxonomyModelFor($taxonomy, $entities); - return $options[$value] - ?? $options[(int) $value] - ?? $options[(string) $value] - ?? null; + if ($modelClass === null) { + return null; + } + + $query = $modelClass::query(); + + if (method_exists($modelClass, 'translations')) { + $query->with('translations'); + } + + $term = $query->find($value); + + return $term instanceof Model ? $this->termLabel($term) : null; } public function recordTypeLabelForValue(mixed $entities, mixed $value): ?string @@ -271,17 +390,41 @@ public function recordTypeLabelForValue(mixed $entities, mixed $value): ?string */ public function termLabelsForValues(string $taxonomy, mixed $entities, array $values): array { - $options = $this->termOptionsForTaxonomy($taxonomy, $entities); + $modelClass = $this->taxonomyModelFor($taxonomy, $entities); + + if ($modelClass === null || $values === []) { + return []; + } + + $normalizedValues = array_values(array_filter( + $values, + static fn (mixed $value): bool => filled($value), + )); + + if ($normalizedValues === []) { + return []; + } + + $query = $modelClass::query()->whereIn( + $modelClass::query()->getModel()->getKeyName(), + $normalizedValues, + ); + + if (method_exists($modelClass, 'translations')) { + $query->with('translations'); + } + + $termsById = $query->get()->keyBy( + fn (Model $term): string => (string) $term->getKey(), + ); + $labels = []; - foreach ($values as $value) { - $label = $options[$value] - ?? $options[(int) $value] - ?? $options[(string) $value] - ?? null; + foreach ($normalizedValues as $value) { + $term = $termsById[(string) $value] ?? null; - if ($label !== null) { - $labels[$value] = $label; + if ($term instanceof Model) { + $labels[$value] = $this->termLabel($term); } } @@ -339,14 +482,18 @@ protected function termOptionsForModel(string $modelClass): array */ protected function recordTypeOptionsForModel(string $modelClass): array { + if (array_key_exists($modelClass, $this->recordTypeOptionsByModelCache)) { + return $this->recordTypeOptionsByModelCache[$modelClass]; + } + $model = $modelClass::query()->getModel(); $table = $model->getTable(); - if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'type')) { - return []; + if (! $this->entityRegistry->databaseTableExists($table) || ! $this->entityRegistry->databaseTableHasColumn($table, 'type')) { + return $this->recordTypeOptionsByModelCache[$modelClass] = []; } - return $modelClass::query() + return $this->recordTypeOptionsByModelCache[$modelClass] = $modelClass::query() ->whereNotNull('type') ->where('type', '!=', '') ->distinct() @@ -502,6 +649,42 @@ protected function configKeyForEntity(string $entity): ?string return $entity; } + /** + * @return class-string|null + */ + protected function taxonomyModelFor(string $taxonomy, mixed $entities): ?string + { + foreach ($this->normalizeEntities($entities) as $entity) { + $configKey = $this->configKeyForEntity($entity); + + if ($configKey === null) { + continue; + } + + $this->taxonomyService->setCurrentResource($configKey); + + if (! array_key_exists($taxonomy, $this->taxonomyService->getTaxonomies())) { + continue; + } + + $modelClass = $this->taxonomyService->getTaxonomyModel($taxonomy); + + if (is_string($modelClass) && class_exists($modelClass)) { + return $modelClass; + } + } + + return null; + } + + protected function entitiesCacheKey(mixed $entities): string + { + $normalized = $this->normalizeEntities($entities); + sort($normalized); + + return implode(',', $normalized); + } + /** * @return list */ diff --git a/packages/builder/src/Support/RelationTargetResolver.php b/packages/builder/src/Support/RelationTargetResolver.php index 7b34cda9f7..f11149ea52 100644 --- a/packages/builder/src/Support/RelationTargetResolver.php +++ b/packages/builder/src/Support/RelationTargetResolver.php @@ -7,7 +7,6 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\QueryException; -use Illuminate\Support\Facades\Schema; use Moox\Builder\Registry\EntityRegistry; /** @@ -337,7 +336,7 @@ protected function modelHasColumn(string $modelClass, string $column): bool try { $table = (new $modelClass)->getTable(); - return filled($table) && Schema::hasTable($table) && Schema::hasColumn($table, $column); + return filled($table) && $this->entityRegistry->databaseTableHasColumn($table, $column); } catch (\Throwable) { return false; } From ec6cb0e7573a5e46d87526b9b799f8d772997b83 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:03:31 +0200 Subject: [PATCH 086/105] Fix location rules when record params are missing --- packages/builder/src/Compiler/LocationMatcher.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/builder/src/Compiler/LocationMatcher.php b/packages/builder/src/Compiler/LocationMatcher.php index f968cdbc24..f9a4436643 100644 --- a/packages/builder/src/Compiler/LocationMatcher.php +++ b/packages/builder/src/Compiler/LocationMatcher.php @@ -111,7 +111,10 @@ protected function matchesRule(array $rule, LocationContext $context): bool $actual = $this->resolveActualValue($param, $context); if ($actual === null && $this->requiresRecord($param)) { - return $operator === '!='; + // Parameter is not available on this record (e.g. items have no + // `type`, so `record_type` cannot be evaluated). Treat the rule as + // non-applicable instead of excluding the entity. + return true; } return match ($param) { @@ -225,7 +228,7 @@ protected function normalizeList(mixed $value): array } /** - * @param list $haystack + * @param list $list */ protected function valueInList(mixed $list, mixed $needle): bool { From 5cc939e7605f86da0d1862748024d2e78f81ae42 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:15:38 +0200 Subject: [PATCH 087/105] Harden builder validation rules and remove dead field-type APIs --- packages/builder/README.md | 2 +- .../builder/src/Compiler/SchemaCompiler.php | 4 - packages/builder/src/FieldTypes/FieldType.php | 10 -- .../Types/FlexibleContentFieldType.php | 5 - .../src/Registry/FieldTypeRegistry.php | 2 +- .../src/Services/FieldGroupValidator.php | 10 ++ .../src/Services/FieldValueValidator.php | 4 - .../src/Support/FieldValidationRules.php | 100 +++++++++++++++--- .../src/Support/StorableFieldCollector.php | 2 +- 9 files changed, 96 insertions(+), 43 deletions(-) diff --git a/packages/builder/README.md b/packages/builder/README.md index 53cc30fab8..efa8749bea 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -489,7 +489,7 @@ Widths apply inside groups, tabs, sections, and repeaters. Defaulting to `full` | `GalleryFiles` | Min/max files (gallery) | | `RelationSettings` | Target entity, multiple, min/max (relation) | -Each field type implements `FieldType`: `key()`, `formComponent()`, `capabilities()`, optionally `castValue()`, `hasSubFields()`, `hasLayouts()`. +Each field type implements `FieldType`: `key()`, `formComponent()`, `capabilities()`, optionally `castValue()`, `hasSubFields()`. ### Validation diff --git a/packages/builder/src/Compiler/SchemaCompiler.php b/packages/builder/src/Compiler/SchemaCompiler.php index 8801c12bc8..aab8f96b47 100644 --- a/packages/builder/src/Compiler/SchemaCompiler.php +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -282,10 +282,6 @@ protected function rulesForFieldTree(FieldDefinition $field, string $prefix = '' $rules = []; $fieldType = $this->fieldTypeRegistry->get($field->type); - if ($fieldType->isLayoutMarker()) { - return []; - } - if (in_array($field->type, ['tab', 'section'], true)) { $rules = []; diff --git a/packages/builder/src/FieldTypes/FieldType.php b/packages/builder/src/FieldTypes/FieldType.php index e98077d278..3768ebc4b5 100644 --- a/packages/builder/src/FieldTypes/FieldType.php +++ b/packages/builder/src/FieldTypes/FieldType.php @@ -92,21 +92,11 @@ public function storesValue(): bool return true; } - public function isLayoutMarker(): bool - { - return false; - } - public function hasSubFields(): bool { return false; } - public function hasLayouts(): bool - { - return false; - } - public function isInternal(): bool { return false; diff --git a/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php b/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php index f1d7ca3a91..3d1206fff7 100644 --- a/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php +++ b/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php @@ -26,11 +26,6 @@ public function hasSubFields(): bool return true; } - public function hasLayouts(): bool - { - return true; - } - public function capabilities(): array { return [ diff --git a/packages/builder/src/Registry/FieldTypeRegistry.php b/packages/builder/src/Registry/FieldTypeRegistry.php index b2fb721791..2a476d0433 100644 --- a/packages/builder/src/Registry/FieldTypeRegistry.php +++ b/packages/builder/src/Registry/FieldTypeRegistry.php @@ -86,7 +86,7 @@ public function optionsForSubFields(): array $options = []; foreach ($this->types as $key => $type) { - if ($type->isLayoutMarker() || $type->hasSubFields() || $type->isInternal()) { + if ($type->hasSubFields() || $type->isInternal()) { continue; } diff --git a/packages/builder/src/Services/FieldGroupValidator.php b/packages/builder/src/Services/FieldGroupValidator.php index 08e45c6162..11b0119fb0 100644 --- a/packages/builder/src/Services/FieldGroupValidator.php +++ b/packages/builder/src/Services/FieldGroupValidator.php @@ -290,6 +290,16 @@ protected function validationRuleMessages(array $rows, string $prefix = 'fields' } } + foreach ($this->fieldValidationRules->rawRulesFromText($validation['raw_rules'] ?? null) as $rawRuleIndex => $rawRule) { + $message = $this->fieldValidationRules->validateRuleExpression($type, $rawRule); + + if ($message !== null) { + $messages["{$basePath}.validation.raw_rules"] = [$message]; + + break; + } + } + if ($type === 'tab') { $messages = array_merge( $messages, diff --git a/packages/builder/src/Services/FieldValueValidator.php b/packages/builder/src/Services/FieldValueValidator.php index 30be643e68..8a8a7cc75d 100644 --- a/packages/builder/src/Services/FieldValueValidator.php +++ b/packages/builder/src/Services/FieldValueValidator.php @@ -35,10 +35,6 @@ public function messagesFor(FieldDefinition $field, mixed $value, ?string $path return []; } - if ($fieldType->isLayoutMarker()) { - return []; - } - if ($fieldType->hasSubFields()) { return $this->messagesForCompound($field, $value, $path, $record); } diff --git a/packages/builder/src/Support/FieldValidationRules.php b/packages/builder/src/Support/FieldValidationRules.php index ce4a1437fa..dd50052dfb 100644 --- a/packages/builder/src/Support/FieldValidationRules.php +++ b/packages/builder/src/Support/FieldValidationRules.php @@ -107,7 +107,7 @@ public function compileValidation(array $validation, ?string $type): array $rules[] = $rule; } - foreach ($this->rawRulesFromText($validation['raw_rules'] ?? null) as $rule) { + foreach ($this->allowedRawRulesFromText($validation['raw_rules'] ?? null, $supportedRules, $type) as $rule) { $rules[] = $rule; } @@ -126,7 +126,88 @@ public function runtimeRulesFor(FieldDefinition $field): array return []; } - return $this->normalizeRules($field->validation['rules'] ?? []); + return array_values(array_filter( + $this->normalizeRules($field->validation['rules'] ?? []), + fn (string $rule): bool => $this->validateRuleExpression($field->type, $rule) === null, + )); + } + + public function validateRuleExpression(?string $type, string $rule): ?string + { + if (! $this->supportsType($type)) { + return __('builder::builder.validation.invalid_option'); + } + + $supportedRules = array_keys($this->availableRulesForType($type)); + $parsed = $this->parseRule($rule, $supportedRules); + + if ($parsed === null) { + return __('builder::builder.validation.invalid_option'); + } + + $ruleName = $parsed['rule']; + + if (! $this->ruleNeedsValue($ruleName)) { + return null; + } + + $value = trim((string) ($parsed['value'] ?? '')); + + if ($value === '') { + return __('builder::builder.validation.validation_rule_value_required'); + } + + if ($this->ruleExpectsNumericValue((string) $type, $ruleName) && ! is_numeric($value)) { + return __('builder::builder.validation.validation_rule_value_numeric'); + } + + return null; + } + + /** + * @return list + */ + public function rawRulesFromText(mixed $rawRules): array + { + if (! is_string($rawRules) || trim($rawRules) === '') { + return []; + } + + return array_values(array_filter( + array_map(static fn (string $rule): string => trim($rule), preg_split('/\r\n|\r|\n/', $rawRules) ?: []), + static fn (string $rule): bool => $rule !== '', + )); + } + + /** + * @param list $supportedRules + * @return list + */ + protected function allowedRawRulesFromText(mixed $rawRules, array $supportedRules, ?string $type): array + { + $rules = []; + + foreach ($this->rawRulesFromText($rawRules) as $rule) { + if ($this->validateRuleExpression($type, $rule) !== null) { + continue; + } + + $parsed = $this->parseRule($rule, $supportedRules); + + if ($parsed === null) { + continue; + } + + if ($this->ruleNeedsValue($parsed['rule'])) { + $rules[] = "{$parsed['rule']}:{$parsed['value']}"; + + continue; + } + + $rules[] = $parsed['rule']; + } + + return $rules; } /** @@ -218,21 +299,6 @@ protected function parseRule(string $rule, array $supportedRules): ?array ]; } - /** - * @return list - */ - protected function rawRulesFromText(mixed $rawRules): array - { - if (! is_string($rawRules) || trim($rawRules) === '') { - return []; - } - - return array_values(array_filter( - array_map(static fn (string $rule): string => trim($rule), preg_split('/\r\n|\r|\n/', $rawRules) ?: []), - static fn (string $rule): bool => $rule !== '', - )); - } - protected function categoryForType(?string $type): ?string { return match ($type) { diff --git a/packages/builder/src/Support/StorableFieldCollector.php b/packages/builder/src/Support/StorableFieldCollector.php index acaa2ca601..4193b8ec82 100644 --- a/packages/builder/src/Support/StorableFieldCollector.php +++ b/packages/builder/src/Support/StorableFieldCollector.php @@ -34,7 +34,7 @@ public function definitionsFor(FieldDefinition $field): Collection $fieldType = $this->fieldTypeRegistry->get($field->type); - if ($fieldType->isLayoutMarker() || ! $fieldType->storesValue()) { + if (! $fieldType->storesValue()) { return collect(); } From e3ac62cc0a2a3e9c5e49821dec89519fbf9f38ec Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:41:28 +0200 Subject: [PATCH 088/105] Add Quick Start onboarding section to builder README --- packages/builder/README.md | 102 ++++++++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 12 deletions(-) diff --git a/packages/builder/README.md b/packages/builder/README.md index efa8749bea..ca9df67b79 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -13,17 +13,18 @@ Admins define fields in the panel. Values are stored in typed `builder_field_val 3. [Database](#database) 4. [Runtime Flow](#runtime-flow) 5. [Installation](#installation) -6. [Connect a Resource](#connect-a-resource) -7. [Field Groups in Admin](#field-groups-in-admin) -8. [Field Types & Capabilities](#field-types--capabilities) -9. [Translations](#translations) -10. [Configuration](#configuration) -11. [Extension](#extension) -12. [Model API](#model-api) -13. [API Serialization](#api-serialization) -14. [Package Structure](#package-structure) -15. [Testing](#testing) -16. [Limits & Roadmap](#limits--roadmap) +6. [Quick Start](#quick-start) +7. [Connect a Resource](#connect-a-resource) +8. [Field Groups in Admin](#field-groups-in-admin) +9. [Field Types & Capabilities](#field-types--capabilities) +10. [Translations](#translations) +11. [Configuration](#configuration) +12. [Extension](#extension) +13. [Model API](#model-api) +14. [API Serialization](#api-serialization) +15. [Package Structure](#package-structure) +16. [Testing](#testing) +17. [Limits & Roadmap](#limits--roadmap) --- @@ -284,12 +285,89 @@ $panel->plugins([ --- +## Quick Start + +Get custom fields on a Filament resource in a few minutes. No JSON column on the model, no per-field migrations. + +### 1. Install (once per project) + +Follow [Installation](#installation) above: package, migrations, `BuilderPlugin` in the panel. After that, **Fields → Field Groups** appears in the admin. + +### 2. Wire the Filament resource (required) + +Add the trait and spread compiled sections into your form schema: + +```php +use Moox\Core\Traits\HasCustomFields; // Moox monorepo alias → moox/builder +// Or: use Moox\Builder\Concerns\HasCustomFields; + +class ItemResource extends BaseItemResource +{ + use HasCustomFields; + + public static function form(Schema $schema): Schema + { + return $schema->components([ + // your own fields … + ...static::customFieldComponents(), // main area (default) + ...static::customFieldComponents('sidebar'), // optional sidebar slot + ]); + } +} +``` + +Loading and saving run automatically via Filament `RecordSaved` — no `afterCreate`, no custom listeners. + +The **entity key** defaults to the model basename (`Item` → `item`). Override only when needed — see [Connect a Resource](#connect-a-resource). + +### 3. Create a field group in admin (required) + +**Fields → Field Groups → Create** + +| Setting | Action | +|---------|--------| +| **Show on** | Select your resource (e.g. Items) — auto-discovered when the resource uses `HasCustomFields` | +| **Fields** | Add label, type, and technical key (`farbe`, `preis`, …) | +| **Active** | On | + +Open the resource create/edit form — custom field sections should appear. + +### 4. Optional next steps + +| Goal | What to add | +|------|-------------| +| Read/write in PHP, queries, Tinker | `InteractsWithCustomFields` on the model — [Model API](#model-api) | +| List table columns | `...static::customFieldColumns()` in `table()` (enable **Show in table** on the field) | +| REST / JSON API | `MergesCustomFields` on your `JsonResource` — [API Serialization](#api-serialization) | +| Per-locale values | Translatable model, or `customFieldsAreTranslatable(): true` — [Translations](#translations) | +| Show only on some records | Location rules in the field group — [Location Rules](#location-rules) | + +### Live examples in this monorepo + +- Resource: `packages/item/src/Resources/ItemResource.php` +- Model: `packages/item/src/Models/Item.php` +- Tests: `packages/builder/tests/Support/TestItemResource.php` + +### Troubleshooting + +| Symptom | Likely cause | Fix | +|---------|--------------|-----| +| Resource missing from **Show on** | Resource has no `HasCustomFields` | Add trait; ensure resource is in a registered Filament panel | +| No sections on the form | Inactive group, wrong entity, or location rules | Check group is active, **Show on**, and [location rules](#location-rules) | +| Sidebar empty | Group placement is `sidebar` but form has no sidebar call | Add `...static::customFieldComponents('sidebar')` in the sidebar column | +| `$item->feld` does not work | Model missing trait | Add `InteractsWithCustomFields` to the model | +| Image/gallery/file types missing | `moox/media` not installed | Install media package (optional integration) | + +Details and overrides: [Connect a Resource](#connect-a-resource). Manual verification: [Testing → Checklist](#checklist). + +--- + ## Connect a Resource ### Step 1: Trait on the Filament resource ```php -use Moox\Builder\Concerns\HasCustomFields; +use Moox\Core\Traits\HasCustomFields; // Moox monorepo — or Moox\Builder\Concerns\HasCustomFields class ItemResource extends Resource { From 2a13cedd985cca1c55296454a956ba1ac158cf7a Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:47:54 +0200 Subject: [PATCH 089/105] Remove deprecated ReflectionProperty::setAccessible call --- packages/core/src/Traits/HasTranslatableConfig.php | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/Traits/HasTranslatableConfig.php b/packages/core/src/Traits/HasTranslatableConfig.php index 3464037a89..c80c623347 100644 --- a/packages/core/src/Traits/HasTranslatableConfig.php +++ b/packages/core/src/Traits/HasTranslatableConfig.php @@ -46,7 +46,6 @@ protected function resetTranslatorLoadedGroups(): void $reflection = new \ReflectionClass($translator); $property = $reflection->getProperty('loaded'); - $property->setAccessible(true); $property->setValue($translator, []); } } From bdcfd6e3ecc56a1efb72f12750d547ffcc7b84c3 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:00:12 +0200 Subject: [PATCH 090/105] Skip Swiss German (gsw) variant during static data import --- packages/data/src/Jobs/ImportStaticDataJob.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/data/src/Jobs/ImportStaticDataJob.php b/packages/data/src/Jobs/ImportStaticDataJob.php index c02c3270f5..294359187e 100644 --- a/packages/data/src/Jobs/ImportStaticDataJob.php +++ b/packages/data/src/Jobs/ImportStaticDataJob.php @@ -190,12 +190,11 @@ public function handle() 'zul' => 'zu', // Zulu ]; - // Regional variants that map to a parent alpha2 via $alpha3ToAlpha2. - // They may still get a locale, but must not overwrite the parent language record. - // Codes in config('rest-countries.separate_language_codes') are excluded from this list. - $skipVariantCodes = []; - - $separateLanguageCodes = config('rest-countries.separate_language_codes', ['gsw']); + // Variant language codes to skip during import + // These are regional variants/creoles that aren't needed + $skipVariantCodes = [ + 'gsw', // skip german swiss variant for example + ]; try { Log::channel('daily')->info('Attempting to fetch data from REST Countries API...'); From b160f0bbbd5a27200a609b00373407e9af8d570f Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:14:04 +0200 Subject: [PATCH 091/105] Clean up user-session install defaults --- packages/user-session/config/user-session.php | 12 ------------ .../user-session/src/UserSessionServiceProvider.php | 1 - 2 files changed, 13 deletions(-) diff --git a/packages/user-session/config/user-session.php b/packages/user-session/config/user-session.php index 488ff43920..59554b66a9 100644 --- a/packages/user-session/config/user-session.php +++ b/packages/user-session/config/user-session.php @@ -60,18 +60,6 @@ 'icon' => 'gmdi-filter-list', 'query' => [], ], - 'user' => [ - 'label' => 'Alf Sessions', - 'icon' => 'gmdi-account-circle', - 'query' => [ - [ - 'field' => 'user_name', - 'relation' => 'user', - 'operator' => '!=', - 'value' => 'Alf', - ], - ], - ], ], ], ], diff --git a/packages/user-session/src/UserSessionServiceProvider.php b/packages/user-session/src/UserSessionServiceProvider.php index 215d6aa217..42b5e996cb 100644 --- a/packages/user-session/src/UserSessionServiceProvider.php +++ b/packages/user-session/src/UserSessionServiceProvider.php @@ -19,7 +19,6 @@ public function configureMoox(Package $package): void ->hasConfigFile() ->hasTranslations() ->hasMigrations([ - 'create_sessions_table', 'extend_sessions_table', ]) ->hasCommand(InstallCommand::class); From 23ea5e258d1b15aef697a80a8ec9b7f420f9cbd1 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:21:57 +0200 Subject: [PATCH 092/105] Add comprehensive builder test suite for custom fields runtime --- .../tests/Feature/HasCustomFieldsTest.php | 22 +- packages/builder/tests/Pest.php | 4 - .../tests/Support/MediaTestHelpers.php | 85 ++ packages/builder/tests/Support/TestItem.php | 8 + .../tests/Support/TestItemResource.php | 7 + .../Support/TestLocalizationLikeResource.php | 19 + .../tests/Support/TestTranslatableItem.php | 18 + packages/builder/tests/TestCase.php | 20 + .../BuilderAdminLocalizationCatalogTest.php | 51 + ...BuilderFieldValueMediaMetadataSyncTest.php | 130 +++ .../tests/Unit/BuilderMediaUsageSyncTest.php | 152 +++ .../tests/Unit/BuilderTranslationsTest.php | 205 ++++ .../Unit/BuilderValuePresentationTest.php | 222 ++++ .../builder/tests/Unit/CapabilitiesTest.php | 94 ++ .../builder/tests/Unit/ColorFieldTypeTest.php | 64 ++ .../Unit/CompoundFieldValueMigratorTest.php | 404 ++++++++ .../Unit/ConditionalLogicFieldOptionsTest.php | 88 ++ .../tests/Unit/ConditionalLogicTest.php | 174 ++++ .../tests/Unit/CustomFieldsManagerTest.php | 123 +++ .../Unit/CustomFieldsTranslatabilityTest.php | 23 + .../tests/Unit/DefaultValueCapabilityTest.php | 616 +++++++++++ .../tests/Unit/DefinitionRegistryTest.php | 19 +- .../builder/tests/Unit/EntityRegistryTest.php | 183 +++- .../tests/Unit/FieldGroupImportExportTest.php | 354 +++++++ .../tests/Unit/FieldGroupPersistenceTest.php | 222 +++- .../tests/Unit/FieldGroupPlacementTest.php | 114 ++ .../tests/Unit/FieldGroupValidatorTest.php | 439 ++++++++ .../tests/Unit/FieldTypeRegistryTest.php | 40 +- .../tests/Unit/FieldValidationRulesTest.php | 93 ++ .../tests/Unit/FieldValueIntegrityTest.php | 57 +- .../tests/Unit/FieldValueValidatorTest.php | 105 ++ .../tests/Unit/FieldVisibilityTest.php | 183 ++++ .../builder/tests/Unit/FileFieldTypeTest.php | 173 +++ .../Unit/FlexibleContentFieldTypeTest.php | 227 ++++ .../tests/Unit/GalleryFieldTypeTest.php | 242 +++++ .../builder/tests/Unit/ImageFieldTypeTest.php | 226 ++++ .../Unit/InteractsWithCustomFieldsTest.php | 272 +++++ .../tests/Unit/LayoutFieldTypesTest.php | 265 +++++ .../tests/Unit/LayoutWidthAndSectionTest.php | 274 +++++ .../Unit/LocationConstraintOptionsTest.php | 353 +++++++ .../tests/Unit/LocationMatcherTest.php | 157 ++- .../tests/Unit/MessageOembedFieldTypeTest.php | 55 + .../tests/Unit/OptionValueRulesTest.php | 4 +- .../tests/Unit/PasswordFieldTypeTest.php | 171 +++ .../builder/tests/Unit/RadioFieldTypeTest.php | 179 ++++ .../builder/tests/Unit/RangeFieldTypeTest.php | 254 +++++ .../tests/Unit/RelationFieldTypeTest.php | 317 ++++++ .../tests/Unit/RepeaterFieldTypeTest.php | 182 ++++ .../Unit/ResolveBuilderAdminLocaleTest.php | 73 ++ .../tests/Unit/RichTextFieldTypeTest.php | 213 ++++ .../builder/tests/Unit/SchemaCompilerTest.php | 69 +- .../tests/Unit/SelectFieldTypeTest.php | 203 ++++ .../tests/Unit/TabFieldDefaultsTest.php | 200 ++++ .../builder/tests/Unit/TabFieldTypeTest.php | 180 ++++ .../tests/Unit/TableColumnCompilerTest.php | 981 ++++++++++++++++++ .../builder/tests/Unit/TimeFieldTypeTest.php | 208 ++++ .../tests/Unit/TypedValueDriverTest.php | 74 -- .../tests/Unit/ValueStoreResolverTest.php | 19 - packages/builder/tests/bootstrap.php | 11 +- 59 files changed, 9757 insertions(+), 163 deletions(-) create mode 100644 packages/builder/tests/Support/MediaTestHelpers.php create mode 100644 packages/builder/tests/Support/TestLocalizationLikeResource.php create mode 100644 packages/builder/tests/Support/TestTranslatableItem.php create mode 100644 packages/builder/tests/Unit/BuilderAdminLocalizationCatalogTest.php create mode 100644 packages/builder/tests/Unit/BuilderFieldValueMediaMetadataSyncTest.php create mode 100644 packages/builder/tests/Unit/BuilderMediaUsageSyncTest.php create mode 100644 packages/builder/tests/Unit/BuilderTranslationsTest.php create mode 100644 packages/builder/tests/Unit/BuilderValuePresentationTest.php create mode 100644 packages/builder/tests/Unit/ColorFieldTypeTest.php create mode 100644 packages/builder/tests/Unit/CompoundFieldValueMigratorTest.php create mode 100644 packages/builder/tests/Unit/ConditionalLogicFieldOptionsTest.php create mode 100644 packages/builder/tests/Unit/ConditionalLogicTest.php create mode 100644 packages/builder/tests/Unit/CustomFieldsManagerTest.php create mode 100644 packages/builder/tests/Unit/CustomFieldsTranslatabilityTest.php create mode 100644 packages/builder/tests/Unit/DefaultValueCapabilityTest.php create mode 100644 packages/builder/tests/Unit/FieldGroupImportExportTest.php create mode 100644 packages/builder/tests/Unit/FieldGroupPlacementTest.php create mode 100644 packages/builder/tests/Unit/FieldGroupValidatorTest.php create mode 100644 packages/builder/tests/Unit/FieldValidationRulesTest.php create mode 100644 packages/builder/tests/Unit/FieldValueValidatorTest.php create mode 100644 packages/builder/tests/Unit/FieldVisibilityTest.php create mode 100644 packages/builder/tests/Unit/FileFieldTypeTest.php create mode 100644 packages/builder/tests/Unit/FlexibleContentFieldTypeTest.php create mode 100644 packages/builder/tests/Unit/GalleryFieldTypeTest.php create mode 100644 packages/builder/tests/Unit/ImageFieldTypeTest.php create mode 100644 packages/builder/tests/Unit/InteractsWithCustomFieldsTest.php create mode 100644 packages/builder/tests/Unit/LayoutFieldTypesTest.php create mode 100644 packages/builder/tests/Unit/LayoutWidthAndSectionTest.php create mode 100644 packages/builder/tests/Unit/LocationConstraintOptionsTest.php create mode 100644 packages/builder/tests/Unit/MessageOembedFieldTypeTest.php create mode 100644 packages/builder/tests/Unit/PasswordFieldTypeTest.php create mode 100644 packages/builder/tests/Unit/RadioFieldTypeTest.php create mode 100644 packages/builder/tests/Unit/RangeFieldTypeTest.php create mode 100644 packages/builder/tests/Unit/RelationFieldTypeTest.php create mode 100644 packages/builder/tests/Unit/RepeaterFieldTypeTest.php create mode 100644 packages/builder/tests/Unit/ResolveBuilderAdminLocaleTest.php create mode 100644 packages/builder/tests/Unit/RichTextFieldTypeTest.php create mode 100644 packages/builder/tests/Unit/SelectFieldTypeTest.php create mode 100644 packages/builder/tests/Unit/TabFieldDefaultsTest.php create mode 100644 packages/builder/tests/Unit/TabFieldTypeTest.php create mode 100644 packages/builder/tests/Unit/TableColumnCompilerTest.php create mode 100644 packages/builder/tests/Unit/TimeFieldTypeTest.php delete mode 100644 packages/builder/tests/Unit/TypedValueDriverTest.php delete mode 100644 packages/builder/tests/Unit/ValueStoreResolverTest.php diff --git a/packages/builder/tests/Feature/HasCustomFieldsTest.php b/packages/builder/tests/Feature/HasCustomFieldsTest.php index 988be569de..bd7d35164d 100644 --- a/packages/builder/tests/Feature/HasCustomFieldsTest.php +++ b/packages/builder/tests/Feature/HasCustomFieldsTest.php @@ -6,8 +6,6 @@ require_once __DIR__.'/../Support/TestItem.php'; require_once __DIR__.'/../Support/TestItemResource.php'; -uses(TestCase::class); - use Filament\Resources\Events\RecordSaved; use Filament\Resources\Pages\Page; use Filament\Schemas\Components\Section; @@ -18,20 +16,16 @@ use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Services\CustomFieldsManager; +use Moox\Builder\Support\BuilderLocaleResolver; use Moox\Builder\Tests\Support\TestItem; use Moox\Builder\Tests\Support\TestItemResource; use Moox\Builder\Tests\TestCase; +uses(TestCase::class); + beforeEach(function (): void { $this->createItemsTable(); - config()->set('builder.entities', [ - 'item' => [ - 'resource' => TestItemResource::class, - 'label' => 'Item', - ], - ]); - Cache::forget(DefinitionRegistry::CACHE_KEY); $group = FieldGroup::query()->create([ @@ -63,8 +57,8 @@ ->and($components[0])->toBeInstanceOf(Section::class); }); -it('returns no components when the resource is not registered', function (): void { - config()->set('builder.entities', []); +it('returns no components when no field groups match the entity', function (): void { + FieldGroup::query()->delete(); expect(TestItemResource::customFieldComponents())->toBe([]); }); @@ -98,6 +92,9 @@ it('persists custom fields when filament dispatches record saved', function (): void { $record = TestItem::query()->create(['title' => 'Demo']); + session([BuilderLocaleResolver::ADMIN_SESSION_KEY => 'de_CH']); + request()->merge(['lang' => 'de_CH']); + $page = new class extends Page { protected static string $resource = TestItemResource::class; @@ -118,5 +115,6 @@ ->keyBy('field_name'); expect($values['fahrzeugtyp-modell']->value_string)->toBe('Polo') - ->and((float) $values['bruttolistenpreis']->value_decimal)->toBe(24990.0); + ->and((float) $values['bruttolistenpreis']->value_decimal)->toBe(24990.0) + ->and($values['fahrzeugtyp-modell']->locale)->toBe(app(BuilderLocaleResolver::class)->defaultLocale()); }); diff --git a/packages/builder/tests/Pest.php b/packages/builder/tests/Pest.php index 4f017508bc..367baefc51 100644 --- a/packages/builder/tests/Pest.php +++ b/packages/builder/tests/Pest.php @@ -2,8 +2,4 @@ declare(strict_types=1); -use Moox\Builder\Tests\TestCase as BuilderPackageTestCase; - require_once __DIR__.'/TestCase.php'; - -uses(BuilderPackageTestCase::class)->in(__DIR__.'/Unit', __DIR__.'/Feature'); diff --git a/packages/builder/tests/Support/MediaTestHelpers.php b/packages/builder/tests/Support/MediaTestHelpers.php new file mode 100644 index 0000000000..f38147e1bf --- /dev/null +++ b/packages/builder/tests/Support/MediaTestHelpers.php @@ -0,0 +1,85 @@ +id(); + $table->string('file_name'); + $table->string('mime_type')->nullable(); + $table->string('scope')->nullable(); + $table->string('disk'); + $table->unsignedBigInteger('size')->default(0); + $table->json('manipulations'); + $table->json('custom_properties')->nullable(); + $table->json('generated_conversions')->nullable(); + $table->json('responsive_images')->nullable(); + $table->timestamps(); + }); + } + + public static function seedMedia( + int $id, + string $fileName = 'test.jpg', + string $mimeType = 'image/jpeg', + ?string $scope = null, + ): void { + self::ensureMediaTableExists(); + + if (DB::table('media')->where('id', $id)->exists()) { + DB::table('media')->where('id', $id)->update([ + 'file_name' => $fileName, + 'mime_type' => $mimeType, + 'scope' => $scope, + 'updated_at' => now(), + ]); + + return; + } + + DB::table('media')->insert([ + 'id' => $id, + 'file_name' => $fileName, + 'mime_type' => $mimeType, + 'scope' => $scope, + 'disk' => 'public', + 'size' => 0, + 'manipulations' => '[]', + 'custom_properties' => '[]', + 'generated_conversions' => '[]', + 'responsive_images' => '[]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + protected static function ensureMediaColumns(): void + { + if (! Schema::hasColumn('media', 'mime_type')) { + Schema::table('media', function (Blueprint $table): void { + $table->string('mime_type')->nullable(); + }); + } + + if (! Schema::hasColumn('media', 'scope')) { + Schema::table('media', function (Blueprint $table): void { + $table->string('scope')->nullable(); + }); + } + } +} diff --git a/packages/builder/tests/Support/TestItem.php b/packages/builder/tests/Support/TestItem.php index 173bd1dbd0..9d3148fccb 100644 --- a/packages/builder/tests/Support/TestItem.php +++ b/packages/builder/tests/Support/TestItem.php @@ -5,10 +5,18 @@ namespace Moox\Builder\Tests\Support; use Illuminate\Database\Eloquent\Model; +use Moox\Builder\Concerns\InteractsWithCustomFields; class TestItem extends Model { + use InteractsWithCustomFields; + protected $table = 'items'; protected $guarded = []; + + protected static function customFieldsEntity(): ?string + { + return 'item'; + } } diff --git a/packages/builder/tests/Support/TestItemResource.php b/packages/builder/tests/Support/TestItemResource.php index 329fe6ec68..03392dd8d0 100644 --- a/packages/builder/tests/Support/TestItemResource.php +++ b/packages/builder/tests/Support/TestItemResource.php @@ -12,4 +12,11 @@ class TestItemResource extends Resource use HasCustomFields; protected static ?string $model = TestItem::class; + + protected static ?string $recordTitleAttribute = 'title'; + + protected static function customFieldsEntity(): ?string + { + return 'item'; + } } diff --git a/packages/builder/tests/Support/TestLocalizationLikeResource.php b/packages/builder/tests/Support/TestLocalizationLikeResource.php new file mode 100644 index 0000000000..5749ab2092 --- /dev/null +++ b/packages/builder/tests/Support/TestLocalizationLikeResource.php @@ -0,0 +1,19 @@ +set('cache.default', 'array'); + $this->applyTranslatableConfig(config()); } protected function getPackageProviders($app): array { return [ + TranslatableServiceProvider::class, + LivewireServiceProvider::class, + SupportServiceProvider::class, BuilderServiceProvider::class, ]; } + protected function applyTranslatableConfig(Repository $config): void + { + $config->set('app.locale', 'en_US'); + $config->set('builder.default_locale', 'en_US'); + $config->set('translatable.locales', ['en_US', 'de_CH']); + $config->set('translatable.use_fallback', true); + } + protected function getEnvironmentSetUp($app): void { $app['config']->set('database.default', 'testing'); @@ -39,6 +55,7 @@ protected function getEnvironmentSetUp($app): void ]); $app['config']->set('cache.default', 'array'); $app['config']->set('app.key', 'base64:'.base64_encode(random_bytes(32))); + $this->applyTranslatableConfig($app['config']); } protected function defineDatabaseMigrations(): void @@ -53,6 +70,9 @@ protected function loadPackageMigrations(): void 'create_builder_fields_table', 'create_builder_field_options_table', 'create_builder_field_values_table', + 'create_builder_field_group_translations_table', + 'create_builder_field_translations_table', + 'create_builder_field_option_translations_table', ] as $migration) { $path = dirname(__DIR__)."/database/migrations/{$migration}.php.stub"; diff --git a/packages/builder/tests/Unit/BuilderAdminLocalizationCatalogTest.php b/packages/builder/tests/Unit/BuilderAdminLocalizationCatalogTest.php new file mode 100644 index 0000000000..d3c922f3ba --- /dev/null +++ b/packages/builder/tests/Unit/BuilderAdminLocalizationCatalogTest.php @@ -0,0 +1,51 @@ +isAvailable()) { + expect(true)->toBeTrue(); + + return; + } + + DB::enableQueryLog(); + + $first = $catalog->adminLocalizations(); + $second = $catalog->adminLocalizations(); + $allowed = $catalog->isAllowedAdminLocale((string) $first->first()?->locale_variant); + $label = $catalog->labelFor($first->first()); + $flag = $catalog->flagFor($first->first()); + + $localizationQueries = collect(DB::getQueryLog())->filter( + fn (array $query): bool => str_contains($query['query'], 'localizations'), + ); + + expect($second)->toBe($first) + ->and($allowed)->toBeTrue() + ->and($label)->not->toBe('') + ->and($flag)->toStartWith('flag-') + ->and($localizationQueries)->toHaveCount(1); +}); + +it('treats unknown locales as not allowed when catalog is available', function (): void { + $catalog = app(BuilderAdminLocalizationCatalog::class); + + if (! $catalog->isAvailable()) { + expect(true)->toBeTrue(); + + return; + } + + expect($catalog->isAllowedAdminLocale('does-not-exist-xx'))->toBeFalse(); +}); diff --git a/packages/builder/tests/Unit/BuilderFieldValueMediaMetadataSyncTest.php b/packages/builder/tests/Unit/BuilderFieldValueMediaMetadataSyncTest.php new file mode 100644 index 0000000000..1fcc4d9e66 --- /dev/null +++ b/packages/builder/tests/Unit/BuilderFieldValueMediaMetadataSyncTest.php @@ -0,0 +1,130 @@ +markTestSkipped('moox/media is not installed.'); + } + + $this->createItemsTable(); + MediaTestHelpers::ensureMediaTableExists(); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Media', + 'slug' => 'media', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'hero_image', + 'label' => 'Hero image', + 'type' => 'image', + 'sort' => 0, + ]); +}); + +it('updates builder field value snapshots when media metadata changes', function (): void { + MediaTestHelpers::seedMedia(10, 'hero.jpg'); + + $item = TestItem::query()->create(['title' => 'Post']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $item->getKey(), + 'field_name' => 'hero_image', + 'value_json' => [ + 'id' => 10, + 'file_name' => 'hero.jpg', + 'title' => 'Old title', + 'alt' => 'Old alt', + 'description' => null, + 'internal_note' => null, + ], + ]); + + DB::table('media')->where('id', 10)->update(['file_name' => 'hero-v2.jpg']); + + app(BuilderFieldValueMediaMetadataSync::class)->syncForMedia(10); + + expect(FieldValue::query()->first()?->value_json)->toMatchArray([ + 'id' => 10, + 'file_name' => 'hero-v2.jpg', + ]); +}); + +it('invalidates the custom fields manager cache after metadata sync', function (): void { + MediaTestHelpers::seedMedia(11, 'cover.jpg'); + + $item = TestItem::query()->create(['title' => 'Post']); + $fields = collect([new FieldDefinition('hero_image', 'Hero image', 'image')]); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $item->getKey(), + 'field_name' => 'hero_image', + 'value_json' => [ + 'id' => 11, + 'file_name' => 'cover.jpg', + 'title' => 'Old', + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ], + ]); + + app(CustomFieldsManager::class)->loadCachedValues('item', $item, $fields); + + DB::table('media')->where('id', 11)->update(['file_name' => 'cover-v2.jpg']); + + app(BuilderFieldValueMediaMetadataSync::class)->syncForMedia(11); + + $loaded = app(CustomFieldsManager::class)->loadCachedValues('item', $item, $fields); + + expect($loaded['hero_image']['file_name'])->toBe('cover-v2.jpg'); +}); + +it('updates gallery snapshots when media metadata changes', function (): void { + MediaTestHelpers::seedMedia(20, 'one.jpg'); + MediaTestHelpers::seedMedia(21, 'two.jpg'); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => 1, + 'field_name' => 'photos', + 'value_json' => [ + '1' => ['id' => 20, 'file_name' => 'one.jpg', 'title' => null, 'alt' => null, 'description' => null, 'internal_note' => null], + '2' => ['id' => 21, 'file_name' => 'two.jpg', 'title' => 'Old', 'alt' => null, 'description' => null, 'internal_note' => null], + ], + ]); + + DB::table('media')->where('id', 21)->update(['file_name' => 'two-v2.jpg']); + + app(BuilderFieldValueMediaMetadataSync::class)->syncForMedia(21); + + expect(FieldValue::query()->first()?->value_json['2']['file_name'])->toBe('two-v2.jpg'); +}); diff --git a/packages/builder/tests/Unit/BuilderMediaUsageSyncTest.php b/packages/builder/tests/Unit/BuilderMediaUsageSyncTest.php new file mode 100644 index 0000000000..76e4be7385 --- /dev/null +++ b/packages/builder/tests/Unit/BuilderMediaUsageSyncTest.php @@ -0,0 +1,152 @@ +markTestSkipped('moox/media is not installed.'); + } + + $this->createItemsTable(); + + Schema::dropIfExists('media_usables'); + + Schema::create('media_usables', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('media_id'); + $table->morphs('media_usable'); + $table->timestamps(); + }); +}); + +it('creates media usables when an image custom field is saved', function (): void { + MediaTestHelpers::seedMedia(42, 'hero.jpg'); + + $item = TestItem::query()->create(['title' => 'Post']); + $field = new FieldDefinition('hero_image', 'Hero image', 'image'); + + app(CustomFieldsManager::class)->saveValues('item', $item, [ + 'hero_image' => [ + 'id' => 42, + 'file_name' => 'hero.jpg', + 'title' => null, + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ], + ], collect([$field])); + + expect(MediaUsable::query() + ->where('media_id', 42) + ->where('media_usable_id', $item->getKey()) + ->where('media_usable_type', TestItem::class) + ->exists())->toBeTrue(); + + $usable = MediaUsable::query()->first(); + + expect(MediaUsable::query()->count())->toBe(1) + ->and($usable?->media_id)->toBe(42) + ->and($usable?->media_usable_id)->toBe($item->getKey()) + ->and($usable?->media_usable_type)->toBe(TestItem::class); +}); + +it('creates media usables when a file custom field is saved', function (): void { + MediaTestHelpers::seedMedia(43, 'brochure.pdf', 'application/pdf'); + + $item = TestItem::query()->create(['title' => 'Post']); + $field = new FieldDefinition('attachment', 'Attachment', 'file'); + + app(CustomFieldsManager::class)->saveValues('item', $item, [ + 'attachment' => [43], + ], collect([$field])); + + expect(MediaUsable::query() + ->where('media_id', 43) + ->where('media_usable_id', $item->getKey()) + ->where('media_usable_type', TestItem::class) + ->exists())->toBeTrue(); +}); + +it('removes stale media usables when an image field is cleared', function (): void { + MediaTestHelpers::seedMedia(42, 'hero.jpg'); + + $item = TestItem::query()->create(['title' => 'Post']); + $field = new FieldDefinition('hero_image', 'Hero image', 'image'); + + MediaUsable::query()->create([ + 'media_id' => 42, + 'media_usable_id' => $item->getKey(), + 'media_usable_type' => TestItem::class, + ]); + + app(CustomFieldsManager::class)->saveValues('item', $item, [ + 'hero_image' => [], + ], collect([$field])); + + expect(MediaUsable::query()->count())->toBe(0); +}); + +it('purges media usables when a record is deleted', function (): void { + $item = TestItem::query()->create(['title' => 'Post']); + + MediaUsable::query()->create([ + 'media_id' => 7, + 'media_usable_id' => $item->getKey(), + 'media_usable_type' => TestItem::class, + ]); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $item->getKey(), + 'field_name' => 'hero_image', + 'value_json' => ['id' => 7, 'file_name' => 'hero.jpg'], + ]); + + app(FieldValuePurger::class)->purgeForRecord('item', $item->getKey(), $item); + + expect(MediaUsable::query()->count())->toBe(0); +}); + +it('collects media ids from all image fields on a record', function (): void { + $item = TestItem::query()->create(['title' => 'Post']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $item->getKey(), + 'field_name' => 'hero_image', + 'value_json' => ['id' => 1, 'file_name' => 'a.jpg'], + ]); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $item->getKey(), + 'field_name' => 'thumbnail', + 'value_json' => ['id' => 2, 'file_name' => 'b.jpg'], + ]); + + app(BuilderMediaUsageSync::class)->syncForRecord('item', $item, collect([ + new FieldDefinition('hero_image', 'Hero', 'image'), + new FieldDefinition('thumbnail', 'Thumb', 'image'), + ])); + + expect(MediaUsable::query()->pluck('media_id')->all())->toBe([1, 2]); +}); diff --git a/packages/builder/tests/Unit/BuilderTranslationsTest.php b/packages/builder/tests/Unit/BuilderTranslationsTest.php new file mode 100644 index 0000000000..d6c9f139a0 --- /dev/null +++ b/packages/builder/tests/Unit/BuilderTranslationsTest.php @@ -0,0 +1,205 @@ +createItemsTable(); + + FieldGroup::query()->delete(); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); +}); + +it('stores field values per locale for translatable entities', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Basics', + 'slug' => 'basics', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'translatable-item']]], + 'active' => true, + ]); + + $group->translateOrNew(app(BuilderLocaleResolver::class)->defaultLocale())->name = 'Basics'; + $group->saveTranslations(); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'color', + 'label' => 'Color', + 'type' => 'text', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + $record = TestTranslatableItem::query()->create(['title' => 'Demo']); + + request()->merge(['lang' => 'de_CH']); + + app(CustomFieldsManager::class)->saveValues('translatable-item', $record, [ + 'color' => 'Blau', + ], app(CustomFieldsManager::class)->fieldsForEntity('translatable-item')); + + request()->merge(['lang' => 'en_US']); + + app(CustomFieldsManager::class)->saveValues('translatable-item', $record, [ + 'color' => 'Blue', + ], app(CustomFieldsManager::class)->fieldsForEntity('translatable-item')); + + expect(FieldValue::query()->count())->toBe(2) + ->and(FieldValue::query()->where('locale', 'de_CH')->value('value_string'))->toBe('Blau') + ->and(FieldValue::query()->where('locale', 'en_US')->value('value_string'))->toBe('Blue'); +}); + +it('resolves localized field labels from translation tables', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Basics', + 'slug' => 'basics', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + $group->translateOrNew('en_US')->name = 'Basics'; + $group->translateOrNew('de_CH')->name = 'Grundlagen'; + $group->saveTranslations(); + + $field = Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'color', + 'label' => 'Color', + 'type' => 'text', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + $field->translateOrNew('en_US')->label = 'Color'; + $field->translateOrNew('de_CH')->label = 'Farbe'; + $field->saveTranslations(); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + request()->merge(['lang' => 'de_CH']); + + $definition = app(DefinitionRegistry::class) + ->fieldGroupsFor(new LocationContext('item')) + ->first(); + + expect($definition)->not->toBeNull() + ->and($definition->name)->toBe('Grundlagen') + ->and($definition->fields->first()?->label)->toBe('Farbe'); +}); + +it('updates structural config on a non-default locale while keeping translatable fallbacks', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Basics', + 'slug' => 'basics', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + $baseData = [ + 'name' => 'Basics', + 'slug' => 'basics', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'note', + 'label' => 'Note', + 'type' => 'text', + 'required' => false, + 'config' => ['maxLength' => 100, 'helperText' => 'English helper'], + ], + ], + ]; + + app(FieldGroupPersistence::class)->sync($group, $baseData); + + request()->merge(['lang' => 'de_CH']); + + $deData = $baseData; + $deData['fields'][0]['config'] = ['maxLength' => 250, 'helperText' => 'Deutscher Hilfetext']; + + app(FieldGroupPersistence::class)->sync($group->fresh(), $deData); + + $config = Field::query()->where('name', 'note')->value('config'); + + expect($config['maxLength'])->toBe(250) + ->and($config['helperText'])->toBe('English helper'); +}); + +it('resolves localized group names for admin list locale', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Basics', + 'slug' => 'basics', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + $group->translateOrNew('en_US')->name = 'Basics'; + $group->translateOrNew('de_CH')->name = 'Grundlagen'; + $group->saveTranslations(); + + request()->merge(['lang' => 'de_CH']); + + $name = app(FieldGroupPersistence::class) + ->localizedGroupName($group->fresh('translations'), 'de_CH'); + + expect($name)->toBe('Grundlagen'); +}); + +it('falls back to config default locale when localization table is missing', function (): void { + expect(app(BuilderLocaleResolver::class)->defaultLocale())->toBe('en_US') + ->and(app(BuilderLocaleResolver::class)->adminDefaultLocale())->toBe('en_US'); +}); + +it('falls back to default locale field values when translation is missing on non-translatable entities', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Basics', + 'slug' => 'basics', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'color', + 'label' => 'Color', + 'type' => 'text', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + $record = TestItem::query()->create(['title' => 'Demo']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + 'field_name' => 'color', + 'locale' => app(BuilderLocaleResolver::class)->defaultLocale(), + 'value_string' => 'Blue', + ]); + + request()->merge(['lang' => 'de_CH']); + + expect($record->customFields(fresh: true)['color'])->toBe('Blue'); +}); diff --git a/packages/builder/tests/Unit/BuilderValuePresentationTest.php b/packages/builder/tests/Unit/BuilderValuePresentationTest.php new file mode 100644 index 0000000000..6eee9074e3 --- /dev/null +++ b/packages/builder/tests/Unit/BuilderValuePresentationTest.php @@ -0,0 +1,222 @@ +createItemsTable(); + + FieldGroup::query()->delete(); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); +}); + +it('presents date and datetime values as iso strings for api output', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Scheduling', + 'slug' => 'scheduling', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'starts_on', + 'label' => 'Starts on', + 'type' => 'date', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'starts_at', + 'label' => 'Starts at', + 'type' => 'datetime', + 'sort' => 1, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'opens_at', + 'label' => 'Opens at', + 'type' => 'time', + 'sort' => 2, + 'validation' => ['required' => false, 'rules' => []], + ]); + + $record = TestItem::query()->create(['title' => 'Demo']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + 'field_name' => 'starts_on', + 'value_date' => '2026-06-16', + ]); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + 'field_name' => 'starts_at', + 'value_datetime' => '2026-06-16 14:30:00', + ]); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + 'field_name' => 'opens_at', + 'value_string' => '14:30', + ]); + + $record->flushCustomFieldsCache(); + + $resource = new class($record) extends JsonResource + { + use MergesCustomFields; + + public function toArray($request): array + { + return $this->mergeCustomFields([]); + } + }; + + $api = $resource->resolve(); + + expect($api['starts_on'])->toBe('2026-06-16') + ->and($api['starts_at'])->toBe(Carbon::parse('2026-06-16 14:30:00')->toIso8601String()) + ->and($api['opens_at'])->toBe('14:30'); + + $raw = $record->customFields(fresh: true); + + expect($raw['starts_on'])->toBeInstanceOf(Carbon::class) + ->and($raw['starts_at'])->toBeInstanceOf(Carbon::class); +}); + +it('masks password values for api output while keeping plaintext in internal arrays', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Secrets', + 'slug' => 'secrets', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'secret', + 'label' => 'Secret', + 'type' => 'password', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + $record = TestItem::query()->create(['title' => 'Demo']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + 'field_name' => 'secret', + 'value_string' => Hash::make('super-secret'), + ]); + + $record->flushCustomFieldsCache(); + + $resource = new class($record) extends JsonResource + { + use MergesCustomFields; + + public function toArray($request): array + { + return $this->mergeCustomFields([]); + } + }; + + expect($resource->resolve()['secret'])->toBe(['has_value' => true]) + ->and($record->toArray()['secret'])->toBeNull(); +}); + +it('presents nested group values recursively for api output', function (): void { + $field = new FieldDefinition( + name: 'contact', + label: 'Contact', + type: 'group', + children: collect([ + new FieldDefinition(name: 'birthday', label: 'Birthday', type: 'date'), + new FieldDefinition(name: 'pin', label: 'PIN', type: 'password'), + ]), + ); + + $presented = app(BuilderValuesResolver::class)->present( + collect([$field]), + [ + 'contact' => [ + 'birthday' => '1990-05-01', + 'pin' => '1234', + ], + ], + ); + + expect($presented['contact'])->toMatchArray([ + 'birthday' => '1990-05-01', + 'pin' => ['has_value' => true], + ]); +}); + +it('merges presented custom fields into api resources', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Basics', + 'slug' => 'basics', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'farbe', + 'label' => 'Farbe', + 'type' => 'text', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + $record = TestItem::query()->create(['title' => 'Demo']); + $record->setCustomField('farbe', 'Blau'); + + $resource = new class($record) extends JsonResource + { + use MergesCustomFields; + + public function toArray($request): array + { + return $this->mergeCustomFields([ + 'title' => $this->title, + ]); + } + }; + + expect($resource->resolve())->toMatchArray([ + 'title' => 'Demo', + 'farbe' => 'Blau', + ]) + ->and($record->customFields()['farbe'])->toBe('Blau'); +}); diff --git a/packages/builder/tests/Unit/CapabilitiesTest.php b/packages/builder/tests/Unit/CapabilitiesTest.php index efea68649f..38c53e4790 100644 --- a/packages/builder/tests/Unit/CapabilitiesTest.php +++ b/packages/builder/tests/Unit/CapabilitiesTest.php @@ -2,13 +2,27 @@ declare(strict_types=1); +require_once __DIR__.'/../TestCase.php'; + use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\TimePicker; use Moox\Builder\Data\FieldDefinition; +use Moox\Builder\FieldTypes\Capabilities\DefaultValue; +use Moox\Builder\FieldTypes\Capabilities\DisplayFormat; +use Moox\Builder\FieldTypes\Capabilities\HelperText; use Moox\Builder\FieldTypes\Capabilities\MaxLength; use Moox\Builder\FieldTypes\Capabilities\MaxValue; use Moox\Builder\FieldTypes\Capabilities\MinValue; use Moox\Builder\FieldTypes\Capabilities\Placeholder; use Moox\Builder\FieldTypes\Capabilities\PrefixSuffix; +use Moox\Builder\FieldTypes\Types\ColorFieldType; +use Moox\Builder\FieldTypes\Types\DateFieldType; +use Moox\Builder\FieldTypes\Types\DatetimeFieldType; +use Moox\Builder\FieldTypes\Types\EmailFieldType; +use Moox\Builder\FieldTypes\Types\TimeFieldType; +use Moox\Builder\Tests\TestCase; + +uses(TestCase::class); it('applies max length capability to a component', function (): void { $field = new FieldDefinition( @@ -58,3 +72,83 @@ ->and($component->getPrefixLabel())->toBe('€') ->and($component->getSuffixLabel())->toBe('EUR'); }); + +it('includes helper text capability on color fields', function (): void { + expect((new ColorFieldType)->capabilities()) + ->toContain(HelperText::class); +}); + +it('includes helper text capability on date fields', function (): void { + expect((new DateFieldType)->capabilities()) + ->toContain(HelperText::class); +}); + +it('includes helper text capability on datetime fields', function (): void { + expect((new DatetimeFieldType)->capabilities()) + ->toContain(HelperText::class); +}); + +it('includes helper text capability on email fields', function (): void { + expect((new EmailFieldType)->capabilities()) + ->toContain(HelperText::class); +}); + +it('offers date-only display formats for date fields', function (): void { + $select = app(DisplayFormat::class)->builderFieldsFor('date')[0]; + + expect(array_keys($select->getOptions()))->toBe(['d.m.Y', 'd/m/Y', 'Y-m-d', 'm/d/Y']); +}); + +it('offers datetime display formats for datetime fields', function (): void { + $select = app(DisplayFormat::class)->builderFieldsFor('datetime')[0]; + + expect(array_keys($select->getOptions()))->toBe([ + 'd.m.Y H:i', + 'd.m.Y H:i:s', + 'Y-m-d H:i', + 'Y-m-d H:i:s', + ]); +}); + +it('offers time display formats for time fields', function (): void { + $select = app(DisplayFormat::class)->builderFieldsFor('time')[0]; + + expect(array_keys($select->getOptions()))->toBe(['H:i', 'H:i:s', 'g:i A']); +}); + +it('includes display format capability on time fields', function (): void { + expect((new TimeFieldType)->capabilities())->toBe([ + DisplayFormat::class, + DefaultValue::class, + ]); +}); + +it('applies seconds on native runtime time components', function (): void { + $field = new FieldDefinition( + name: 'besichtigungszeit', + label: 'Bevorzugte Besichtigungszeit', + type: 'time', + config: ['displayFormat' => 'H:i:s'], + ); + + $component = app(DisplayFormat::class) + ->apply(TimePicker::make('besichtigungszeit')->native(true), $field); + + expect($component->isNative())->toBeTrue() + ->and($component->hasSeconds())->toBeTrue(); +}); + +it('omits custom display format styling on native runtime time components', function (): void { + $field = new FieldDefinition( + name: 'besichtigungszeit', + label: 'Bevorzugte Besichtigungszeit', + type: 'time', + config: ['displayFormat' => 'g:i A'], + ); + + $component = app(DisplayFormat::class) + ->apply(TimePicker::make('besichtigungszeit')->native(true), $field); + + expect($component->isNative())->toBeTrue() + ->and($component->hasSeconds())->toBeFalse(); +}); diff --git a/packages/builder/tests/Unit/ColorFieldTypeTest.php b/packages/builder/tests/Unit/ColorFieldTypeTest.php new file mode 100644 index 0000000000..259fa9660c --- /dev/null +++ b/packages/builder/tests/Unit/ColorFieldTypeTest.php @@ -0,0 +1,64 @@ + 'ff0000'], + ); + + $component = (new ColorFieldType)->formComponent($field); + + expect($component->getDefaultState())->toBe('#ff0000') + ->and($component->isLive())->toBeTrue(); +}); + +it('syncs color default values through field group persistence', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Colors', + 'slug' => 'colors', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Colors', + 'slug' => 'colors', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'lackfarbe', + 'label' => 'Lackfarbe', + 'type' => 'color', + 'required' => false, + 'config' => ['default' => 'ff0000'], + ], + ], + ]); + + expect($group->fields()->where('name', 'lackfarbe')->value('config'))->toMatchArray([ + 'default' => 'ff0000', + ]) + ->and(app(DefaultValue::class)->resolveForField(new FieldDefinition( + name: 'lackfarbe', + label: 'Lackfarbe', + type: 'color', + config: ['default' => 'ff0000'], + )))->toBe('#ff0000'); +}); diff --git a/packages/builder/tests/Unit/CompoundFieldValueMigratorTest.php b/packages/builder/tests/Unit/CompoundFieldValueMigratorTest.php new file mode 100644 index 0000000000..4a02053776 --- /dev/null +++ b/packages/builder/tests/Unit/CompoundFieldValueMigratorTest.php @@ -0,0 +1,404 @@ +createItemsTable(); +}); + +function seedGroupValue(string $fieldName, string $type, mixed $value): void +{ + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => 1, + ...TypedValueColumns::attributesFor($type, $value), + 'field_name' => $fieldName, + ]); +} + +it('renames nested group subfield keys inside stored json', function (): void { + TestItem::query()->create(['id' => 1, 'title' => 'Demo']); + + $group = FieldGroup::query()->create([ + 'name' => 'Group', + 'slug' => 'group-rename', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Group', + 'slug' => 'group-rename', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'standort', + 'label' => 'Standort', + 'type' => 'group', + 'children' => [ + ['id' => null, 'name' => 'stadt', 'label' => 'Stadt', 'type' => 'text', 'required' => false], + ], + ], + ], + ]); + + $standort = Field::query() + ->where('field_group_id', $group->getKey()) + ->whereNull('parent_field_id') + ->where('name', 'standort') + ->first(); + + $stadt = Field::query() + ->where('parent_field_id', $standort?->getKey()) + ->where('name', 'stadt') + ->first(); + + $stadtId = $stadt->getKey(); + + seedGroupValue('standort', 'group', ['stadt' => 'Berlin', 'plz' => '10115']); + + app(FieldGroupPersistence::class)->sync($group->fresh(), [ + 'name' => 'Group', + 'slug' => 'group-rename', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'id' => $standort->getKey(), + 'name' => 'standort', + 'label' => 'Standort', + 'type' => 'group', + 'children' => [ + ['id' => $stadtId, 'name' => 'city', 'label' => 'City', 'type' => 'text', 'required' => false], + ['name' => 'plz', 'label' => 'PLZ', 'type' => 'text', 'required' => false], + ], + ], + ], + ]); + + $stored = FieldValue::query()->where('field_name', 'standort')->first(); + + expect($stored?->value_json)->toMatchArray([ + 'city' => 'Berlin', + 'plz' => '10115', + ]); +}); + +it('removes nested group subfield keys when deleted from the schema', function (): void { + TestItem::query()->create(['id' => 1, 'title' => 'Demo']); + + $group = FieldGroup::query()->create([ + 'name' => 'Group', + 'slug' => 'group-delete', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Group', + 'slug' => 'group-delete', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'standort', + 'label' => 'Standort', + 'type' => 'group', + 'children' => [ + ['name' => 'stadt', 'label' => 'Stadt', 'type' => 'text', 'required' => false], + ['name' => 'plz', 'label' => 'PLZ', 'type' => 'text', 'required' => false], + ], + ], + ], + ]); + + seedGroupValue('standort', 'group', ['stadt' => 'Berlin', 'plz' => '10115']); + + $standortId = Field::query() + ->where('field_group_id', $group->getKey()) + ->whereNull('parent_field_id') + ->where('name', 'standort') + ->value('id'); + + $stadtId = Field::query() + ->where('parent_field_id', $standortId) + ->where('name', 'stadt') + ->value('id'); + + app(FieldGroupPersistence::class)->sync($group->fresh(), [ + 'name' => 'Group', + 'slug' => 'group-delete', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'id' => $standortId, + 'name' => 'standort', + 'label' => 'Standort', + 'type' => 'group', + 'children' => [ + ['id' => $stadtId, 'name' => 'stadt', 'label' => 'Stadt', 'type' => 'text', 'required' => false], + ], + ], + ], + ]); + + expect(FieldValue::query()->where('field_name', 'standort')->value('value_json')) + ->toBe(['stadt' => 'Berlin']); +}); + +it('renames nested repeater subfield keys inside stored json', function (): void { + TestItem::query()->create(['id' => 1, 'title' => 'Demo']); + + $group = FieldGroup::query()->create([ + 'name' => 'Repeater', + 'slug' => 'repeater-rename', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Repeater', + 'slug' => 'repeater-rename', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'ausstattung', + 'label' => 'Ausstattung', + 'type' => 'repeater', + 'children' => [ + ['name' => 'merkmal', 'label' => 'Merkmal', 'type' => 'text', 'required' => false], + ], + ], + ], + ]); + + $repeater = Field::query() + ->where('field_group_id', $group->getKey()) + ->whereNull('parent_field_id') + ->where('name', 'ausstattung') + ->first(); + + $merkmalId = Field::query() + ->where('parent_field_id', $repeater?->getKey()) + ->where('name', 'merkmal') + ->value('id'); + + $repeaterId = $repeater->getKey(); + + seedGroupValue('ausstattung', 'repeater', [ + ['merkmal' => 'Sitzheizung'], + ['merkmal' => 'Navi'], + ]); + + app(FieldGroupPersistence::class)->sync($group->fresh(), [ + 'name' => 'Repeater', + 'slug' => 'repeater-rename', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'id' => $repeaterId, + 'name' => 'ausstattung', + 'label' => 'Ausstattung', + 'type' => 'repeater', + 'children' => [ + ['id' => $merkmalId, 'name' => 'feature', 'label' => 'Feature', 'type' => 'text', 'required' => false], + ], + ], + ], + ]); + + expect(FieldValue::query()->where('field_name', 'ausstattung')->value('value_json'))->toBe([ + ['feature' => 'Sitzheizung'], + ['feature' => 'Navi'], + ]); +}); + +it('renames nested flexible content subfield keys inside stored json', function (): void { + TestItem::query()->create(['id' => 1, 'title' => 'Demo']); + + $group = FieldGroup::query()->create([ + 'name' => 'Flexible', + 'slug' => 'flex-rename', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Flexible', + 'slug' => 'flex-rename', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'content', + 'label' => 'Content', + 'type' => 'flexible_content', + 'layouts' => [ + [ + 'name' => 'hero', + 'label' => 'Hero', + 'children' => [ + ['name' => 'titel', 'label' => 'Titel', 'type' => 'text', 'required' => false], + ], + ], + ], + ], + ], + ]); + + $content = Field::query() + ->where('field_group_id', $group->getKey()) + ->whereNull('parent_field_id') + ->where('name', 'content') + ->first(); + + $hero = Field::query() + ->where('parent_field_id', $content?->getKey()) + ->where('name', 'hero') + ->first(); + + $contentId = $content->getKey(); + $titelId = Field::query() + ->where('parent_field_id', $hero?->getKey()) + ->where('name', 'titel') + ->value('id'); + + seedGroupValue('content', 'flexible_content', [ + ['type' => 'hero', 'data' => ['titel' => 'Hello']], + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Flexible', + 'slug' => 'flex-rename', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'id' => $contentId, + 'name' => 'content', + 'label' => 'Content', + 'type' => 'flexible_content', + 'layouts' => [ + [ + 'id' => $hero?->getKey(), + 'name' => 'hero', + 'label' => 'Hero', + 'children' => [ + ['id' => $titelId, 'name' => 'title', 'label' => 'Title', 'type' => 'text', 'required' => false], + ], + ], + ], + ], + ], + ]); + + expect(FieldValue::query()->where('field_name', 'content')->value('value_json'))->toBe([ + ['type' => 'hero', 'data' => ['title' => 'Hello']], + ]); +}); + +it('still purges root field rows when a top level field is renamed', function (): void { + TestItem::query()->create(['id' => 1, 'title' => 'Demo']); + + $group = FieldGroup::query()->create([ + 'name' => 'Root', + 'slug' => 'root-rename', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Root', + 'slug' => 'root-rename', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + ['name' => 'preis', 'label' => 'Preis', 'type' => 'number', 'required' => false], + ], + ]); + + seedGroupValue('preis', 'number', 1000); + + $fieldId = Field::query() + ->where('field_group_id', $group->getKey()) + ->whereNull('parent_field_id') + ->where('name', 'preis') + ->value('id'); + + app(FieldGroupPersistence::class)->sync($group->fresh(), [ + 'name' => 'Root', + 'slug' => 'root-rename', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + ['id' => $fieldId, 'name' => 'price', 'label' => 'Price', 'type' => 'number', 'required' => false], + ], + ]); + + expect(FieldValue::query()->where('field_name', 'preis')->exists())->toBeFalse() + ->and(FieldValue::query()->where('field_name', 'price')->exists())->toBeFalse(); +}); + +it('migrates nested subfields directly through the migrator service', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Direct', + 'slug' => 'direct', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + $parent = $group->fields()->create([ + 'name' => 'standort', + 'label' => 'Standort', + 'type' => 'group', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + $child = $group->fields()->create([ + 'parent_field_id' => $parent->getKey(), + 'name' => 'stadt', + 'label' => 'Stadt', + 'type' => 'text', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + seedGroupValue('standort', 'group', ['stadt' => 'Berlin']); + + $child->name = 'city'; + $child->save(); + + app(CompoundFieldValueMigrator::class)->renameNestedSubfield($child->fresh(), 'stadt', ['item']); + + expect(FieldValue::query()->where('field_name', 'standort')->value('value_json')) + ->toBe(['city' => 'Berlin']); +}); diff --git a/packages/builder/tests/Unit/ConditionalLogicFieldOptionsTest.php b/packages/builder/tests/Unit/ConditionalLogicFieldOptionsTest.php new file mode 100644 index 0000000000..8fdddd53ed --- /dev/null +++ b/packages/builder/tests/Unit/ConditionalLogicFieldOptionsTest.php @@ -0,0 +1,88 @@ + data_get($state, $resolve($path)); + + $method = new ReflectionMethod(FieldGroupResource::class, 'siblingFieldOptions'); + $method->setAccessible(true); + + /** @var array $options */ + $options = $method->invoke(null, $get); + + return $options; +} + +it('lists value-storing sibling fields as conditional-logic options', function (): void { + $state = [ + 'fields' => [ + 'a' => [ + 'name' => 'kundentyp', + 'type' => 'select', + 'label' => 'Kundentyp', + 'settings' => [ + 'conditions' => [ + 'rules' => [ + 'r1' => ['field' => null, 'operator' => 'equals', 'value' => 'business'], + ], + ], + ], + ], + 'b' => ['name' => 'firmenname', 'type' => 'text', 'label' => 'Firmenname'], + 'c' => ['name' => 'section', 'type' => 'tab', 'label' => 'Section'], + ], + ]; + + $options = conditionalLogicFieldOptions($state, 'fields.a.settings.conditions.rules.r1'); + + expect($options)->toBe(['firmenname' => 'Firmenname']); +}); + +it('returns no options when no sibling fields exist yet', function (): void { + $state = [ + 'fields' => [ + 'a' => [ + 'name' => 'kundentyp', + 'type' => 'select', + 'label' => 'Kundentyp', + 'settings' => ['conditions' => ['rules' => ['r1' => ['field' => null]]]], + ], + ], + ]; + + $options = conditionalLogicFieldOptions($state, 'fields.a.settings.conditions.rules.r1'); + + expect($options)->toBe([]); +}); diff --git a/packages/builder/tests/Unit/ConditionalLogicTest.php b/packages/builder/tests/Unit/ConditionalLogicTest.php new file mode 100644 index 0000000000..174b6cd37f --- /dev/null +++ b/packages/builder/tests/Unit/ConditionalLogicTest.php @@ -0,0 +1,174 @@ + [ + 'enabled' => true, + 'action' => ConditionalLogic::ACTION_SHOW, + 'logic' => ConditionalLogic::LOGIC_AND, + 'rules' => [ + ['field' => 'customer_type', 'operator' => 'equals', 'value' => 'business'], + ], + ], + ], $overrides); +} + +it('treats fields without conditions as always visible', function (): void { + $field = conditionalField(); + + expect(ConditionalLogic::isConfigured($field))->toBeFalse() + ->and(ConditionalLogic::isVisibleForValues($field, []))->toBeTrue(); +}); + +it('evaluates equals and not equals operators', function (): void { + $field = conditionalField(conditionalSettings()); + + expect(ConditionalLogic::isVisibleForValues($field, ['customer_type' => 'business']))->toBeTrue() + ->and(ConditionalLogic::isVisibleForValues($field, ['customer_type' => 'private']))->toBeFalse(); +}); + +it('evaluates empty and not empty operators', function (): void { + $field = conditionalField(conditionalSettings([ + 'conditions' => [ + 'rules' => [ + ['field' => 'coupon', 'operator' => 'not_empty', 'value' => null], + ], + ], + ])); + + expect(ConditionalLogic::isVisibleForValues($field, ['coupon' => 'SAVE10']))->toBeTrue() + ->and(ConditionalLogic::isVisibleForValues($field, ['coupon' => '']))->toBeFalse() + ->and(ConditionalLogic::isVisibleForValues($field, ['coupon' => null]))->toBeFalse(); +}); + +it('evaluates contains for array values', function (): void { + $field = conditionalField(conditionalSettings([ + 'conditions' => [ + 'rules' => [ + ['field' => 'tags', 'operator' => 'contains', 'value' => 'vip'], + ], + ], + ])); + + expect(ConditionalLogic::isVisibleForValues($field, ['tags' => ['vip', 'news']]))->toBeTrue() + ->and(ConditionalLogic::isVisibleForValues($field, ['tags' => ['news']]))->toBeFalse(); +}); + +it('combines rules with and logic', function (): void { + $field = conditionalField(conditionalSettings([ + 'conditions' => [ + 'logic' => ConditionalLogic::LOGIC_AND, + 'rules' => [ + ['field' => 'customer_type', 'operator' => 'equals', 'value' => 'business'], + ['field' => 'country', 'operator' => 'equals', 'value' => 'de'], + ], + ], + ])); + + expect(ConditionalLogic::isVisibleForValues($field, [ + 'customer_type' => 'business', + 'country' => 'de', + ]))->toBeTrue() + ->and(ConditionalLogic::isVisibleForValues($field, [ + 'customer_type' => 'business', + 'country' => 'at', + ]))->toBeFalse(); +}); + +it('combines rules with or logic', function (): void { + $field = conditionalField(conditionalSettings([ + 'conditions' => [ + 'logic' => ConditionalLogic::LOGIC_OR, + 'rules' => [ + ['field' => 'customer_type', 'operator' => 'equals', 'value' => 'business'], + ['field' => 'country', 'operator' => 'equals', 'value' => 'de'], + ], + ], + ])); + + expect(ConditionalLogic::isVisibleForValues($field, [ + 'customer_type' => 'private', + 'country' => 'de', + ]))->toBeTrue(); +}); + +it('inverts visibility for hide actions', function (): void { + $field = conditionalField(conditionalSettings([ + 'conditions' => [ + 'action' => ConditionalLogic::ACTION_HIDE, + ], + ])); + + expect(ConditionalLogic::isVisibleForValues($field, ['customer_type' => 'business']))->toBeFalse() + ->and(ConditionalLogic::isVisibleForValues($field, ['customer_type' => 'private']))->toBeTrue(); +}); + +it('treats missing trigger values as unmatched rules', function (): void { + $field = conditionalField(conditionalSettings()); + + expect(ConditionalLogic::isVisibleForValues($field, []))->toBeFalse(); +}); + +it('resolves trigger field names from configured rules', function (): void { + $field = conditionalField(conditionalSettings([ + 'conditions' => [ + 'rules' => [ + ['field' => 'customer_type', 'operator' => 'equals', 'value' => 'business'], + ['field' => 'country', 'operator' => 'equals', 'value' => 'de'], + ], + ], + ])); + + expect($field->hasConditions())->toBeTrue() + ->and($field->conditionTriggers())->toBe(['customer_type', 'country']); +}); + +it('evaluates passesForm using trigger field values from get', function (): void { + $field = conditionalField(conditionalSettings()); + $get = fn (string $path): mixed => match ($path) { + 'customer_type' => 'business', + default => null, + }; + + expect(ConditionalLogic::passesForm($field, $get))->toBeTrue(); +}); + +it('normalizes condition settings for persistence', function (): void { + expect(ConditionalLogic::normalizeSettings([ + 'enabled' => 1, + 'action' => 'hide', + 'logic' => 'or', + 'rules' => [ + ['field' => 'type', 'operator' => 'invalid', 'value' => 'x'], + ['field' => '', 'operator' => 'equals', 'value' => 'y'], + ], + ]))->toBe([ + 'enabled' => true, + 'action' => ConditionalLogic::ACTION_HIDE, + 'logic' => ConditionalLogic::LOGIC_OR, + 'rules' => [ + ['field' => 'type', 'operator' => 'equals', 'value' => 'x'], + ], + ]); +}); diff --git a/packages/builder/tests/Unit/CustomFieldsManagerTest.php b/packages/builder/tests/Unit/CustomFieldsManagerTest.php new file mode 100644 index 0000000000..3caf2d06e2 --- /dev/null +++ b/packages/builder/tests/Unit/CustomFieldsManagerTest.php @@ -0,0 +1,123 @@ +createItemsTable(); +}); + +it('loads custom field values once per record when using the hydration cache', function (): void { + $record = TestItem::query()->create(['id' => 1, 'title' => 'Demo']); + + foreach (['first', 'second', 'third'] as $name) { + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + ...TypedValueColumns::attributesFor('text', "value-{$name}"), + 'field_name' => $name, + ]); + } + + $fields = collect([ + new FieldDefinition(name: 'first', label: 'First', type: 'text'), + new FieldDefinition(name: 'second', label: 'Second', type: 'text'), + new FieldDefinition(name: 'third', label: 'Third', type: 'text'), + ]); + + $manager = app(CustomFieldsManager::class); + + DB::enableQueryLog(); + + $manager->loadCachedValues('item', $record, $fields); + $manager->loadCachedValues('item', $record, $fields); + + $valueQueries = collect(DB::getQueryLog()) + ->filter(fn (array $query): bool => str_contains($query['query'], 'builder_field_values')); + + expect($valueQueries)->toHaveCount(1) + ->and($manager->loadCachedValues('item', $record, $fields))->toMatchArray([ + 'first' => 'value-first', + 'second' => 'value-second', + 'third' => 'value-third', + ]); +}); + +it('invalidates the hydration cache after saving values', function (): void { + $record = TestItem::query()->create(['id' => 1, 'title' => 'Demo']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + ...TypedValueColumns::attributesFor('text', 'old'), + 'field_name' => 'title', + ]); + + $field = new FieldDefinition(name: 'title', label: 'Title', type: 'text'); + $fields = collect([$field]); + $manager = app(CustomFieldsManager::class); + + expect($manager->loadCachedValues('item', $record, $fields))->toBe(['title' => 'old']); + + $manager->saveValues('item', $record, ['title' => 'new'], $fields); + + expect($manager->loadCachedValues('item', $record, $fields))->toBe(['title' => 'new']); +}); + +it('skips validation for fields hidden by conditional logic', function (): void { + $record = TestItem::query()->create(['id' => 1, 'title' => 'Demo']); + + $fields = collect([ + new FieldDefinition( + name: 'customer_type', + label: 'Customer type', + type: 'text', + ), + new FieldDefinition( + name: 'company', + label: 'Company', + type: 'text', + validation: ['required' => true, 'rules' => []], + settings: [ + 'conditions' => [ + 'enabled' => true, + 'action' => 'show', + 'logic' => 'and', + 'rules' => [ + ['field' => 'customer_type', 'operator' => 'equals', 'value' => 'business'], + ], + ], + ], + ), + ]); + + $manager = app(CustomFieldsManager::class); + + $manager->saveValues('item', $record, [ + 'customer_type' => 'private', + 'company' => 'injected-value', + ], $fields); + + expect(FieldValue::query()->forRecord('item', $record->getKey())->pluck('field_name')->all()) + ->toContain('customer_type', 'company'); + + // Hidden field must not persist the submitted (unvalidated) value. + $companyRow = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'company') + ->first(); + + expect($companyRow?->value_string)->toBeNull(); +}); diff --git a/packages/builder/tests/Unit/CustomFieldsTranslatabilityTest.php b/packages/builder/tests/Unit/CustomFieldsTranslatabilityTest.php new file mode 100644 index 0000000000..debe9f4e89 --- /dev/null +++ b/packages/builder/tests/Unit/CustomFieldsTranslatabilityTest.php @@ -0,0 +1,23 @@ +forResource(TestItemResource::class))->toBeFalse() + ->and(app(CustomFieldsTranslatability::class)->forEntity('item'))->toBeFalse() + ->and(app(CustomFieldsTranslatability::class)->forModel(TestItemResource::getModel()))->toBeFalse(); +}); + +it('detects translatable models via astrotomic contract', function (): void { + expect(app(CustomFieldsTranslatability::class)->forModel(FieldGroup::class))->toBeTrue(); +}); diff --git a/packages/builder/tests/Unit/DefaultValueCapabilityTest.php b/packages/builder/tests/Unit/DefaultValueCapabilityTest.php new file mode 100644 index 0000000000..bd8246df2c --- /dev/null +++ b/packages/builder/tests/Unit/DefaultValueCapabilityTest.php @@ -0,0 +1,616 @@ +builderFieldsFor('toggle'); + + expect($fields)->toHaveCount(1) + ->and($fields[0])->toBeInstanceOf(Toggle::class); +}); + +it('builds a select for option field default values in the admin', function (): void { + $fields = app(DefaultValue::class)->builderFieldsFor('select'); + + expect($fields)->toHaveCount(1) + ->and($fields[0])->toBeInstanceOf(Select::class) + ->and($fields[0]->isLive())->toBeTrue(); +}); + +it('builds a multi select for multiselect and checkbox list default values in the admin', function (): void { + $capability = app(DefaultValue::class); + + $multiselect = $capability->builderFieldsFor('multiselect')[0]; + $checkboxList = $capability->builderFieldsFor('checkbox_list')[0]; + + expect($multiselect)->toBeInstanceOf(Select::class) + ->and($checkboxList)->toBeInstanceOf(Select::class) + ->and($multiselect->isMultiple())->toBeTrue() + ->and($checkboxList->isMultiple())->toBeTrue(); +}); + +it('resolves legacy string defaults for toggle fields at runtime', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'active', + label: 'Active', + type: 'toggle', + config: ['default' => 'true'], + ); + + $component = Toggle::make('active'); + $result = $capability->apply($component, $field); + + expect($result->getDefaultState())->toBeTrue(); +}); + +it('applies boolean defaults for toggle fields at runtime', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'active', + label: 'Active', + type: 'toggle', + config: ['default' => true], + ); + + $component = Toggle::make('active'); + $result = $capability->apply($component, $field); + + expect($result->getDefaultState())->toBeTrue(); +}); + +it('applies numeric defaults for number fields at runtime', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'price', + label: 'Price', + type: 'number', + config: ['default' => '42.5'], + ); + + $component = TextInput::make('price'); + $result = $capability->apply($component, $field); + + expect($result->getDefaultState())->toBe(42.5); +}); + +it('applies array defaults for multiselect fields at runtime', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'fuel', + label: 'Fuel', + type: 'multiselect', + config: ['default' => ['petrol', 'diesel']], + ); + + $component = Select::make('fuel')->multiple(); + $result = $capability->apply($component, $field); + + expect($result->getDefaultState())->toBe(['petrol', 'diesel']); +}); + +it('applies option defaults for button group fields at runtime', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'getriebe', + label: 'Getriebe', + type: 'button_group', + config: ['default' => 'manual'], + options: [ + ['label' => 'Schaltgetriebe', 'value' => 'manual'], + ['label' => 'Automatik', 'value' => 'automatic'], + ], + ); + + $component = ToggleButtons::make('getriebe') + ->options(['manual' => 'Schaltgetriebe', 'automatic' => 'Automatik']); + + $result = $capability->apply($component, $field); + + expect($result->getDefaultState())->toBe('manual') + ->and($capability->resolveForField($field))->toBe('manual'); +}); + +it('normalizes color defaults to hex values', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'lackfarbe', + label: 'Lackfarbe', + type: 'color', + config: ['default' => 'ff0000'], + ); + + expect($capability->resolveForField($field))->toBe('#ff0000') + ->and($capability->normalizeColorValue('ff0000'))->toBe('#ff0000') + ->and($capability->normalizeColorValue('#336699'))->toBe('#336699'); +}); + +it('builds a live color picker for color field default values in the admin', function (): void { + $fields = app(DefaultValue::class)->builderFieldsFor('color'); + + expect($fields)->toHaveCount(1) + ->and($fields[0])->toBeInstanceOf(ColorPicker::class) + ->and($fields[0]->isLive())->toBeTrue(); +}); + +it('applies color defaults on runtime components', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'lackfarbe', + label: 'Lackfarbe', + type: 'color', + config: ['default' => '#1a1a1a'], + ); + + $component = ColorPicker::make('lackfarbe'); + $result = $capability->apply($component, $field); + + expect($result->getDefaultState())->toBe('#1a1a1a'); +}); + +it('applies array defaults for checkbox list fields at runtime', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'extras', + label: 'Extras', + type: 'checkbox_list', + config: ['default' => ['nav', 'heated_seats']], + ); + + $component = CheckboxList::make('extras'); + $result = $capability->apply($component, $field); + + expect($result->getDefaultState())->toBe(['nav', 'heated_seats']); +}); + +it('builds an email input for email field default values in the admin', function (): void { + $fields = app(DefaultValue::class)->builderFieldsFor('email'); + + expect($fields)->toHaveCount(1) + ->and($fields[0])->toBeInstanceOf(TextInput::class) + ->and($fields[0]->getType())->toBe('text'); +}); + +it('builds a url-validated input for url and oembed field default values in the admin', function (): void { + $capability = app(DefaultValue::class); + + $url = $capability->builderFieldsFor('url')[0]; + $oembed = $capability->builderFieldsFor('oembed')[0]; + + expect($url)->toBeInstanceOf(TextInput::class) + ->and($oembed)->toBeInstanceOf(TextInput::class); +}); + +it('ignores invalid url defaults at runtime for url and oembed fields', function (): void { + $capability = app(DefaultValue::class); + + $urlField = new FieldDefinition( + name: 'hersteller-seite', + label: 'Herstellerseite', + type: 'url', + config: ['default' => 'keine-url'], + ); + + $oembedField = new FieldDefinition( + name: 'fahrzeugvideo', + label: 'Fahrzeugvideo', + type: 'oembed', + config: ['default' => 'youtube'], + ); + + expect($capability->resolveForField($urlField))->toBeNull() + ->and($capability->resolveForField($oembedField))->toBeNull(); +}); + +it('resolves valid url defaults at runtime for url and oembed fields', function (): void { + $capability = app(DefaultValue::class); + + $url = 'https://www.youtube.com/watch?v=demo'; + + expect($capability->resolveForField(new FieldDefinition( + name: 'hersteller-seite', + label: 'Herstellerseite', + type: 'url', + config: ['default' => $url], + )))->toBe($url) + ->and($capability->resolveForField(new FieldDefinition( + name: 'fahrzeugvideo', + label: 'Fahrzeugvideo', + type: 'oembed', + config: ['default' => $url], + )))->toBe($url); +}); + +it('ignores text defaults that exceed the configured max length at runtime', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'plz', + label: 'PLZ', + type: 'text', + config: ['default' => '123456', 'maxLength' => 5], + ); + + expect($capability->resolveForField($field))->toBeNull() + ->and($capability->resolveForField(new FieldDefinition( + name: 'plz', + label: 'PLZ', + type: 'text', + config: ['default' => '12345', 'maxLength' => 5], + )))->toBe('12345'); +}); + +it('builds max-length-aware default inputs for text fields in the admin', function (): void { + $text = app(DefaultValue::class)->builderFieldsFor('text')[0]; + $textarea = app(DefaultValue::class)->builderFieldsFor('textarea')[0]; + $email = app(DefaultValue::class)->builderFieldsFor('email')[0]; + + expect($text)->toBeInstanceOf(TextInput::class) + ->and($textarea)->toBeInstanceOf(Textarea::class) + ->and($email)->toBeInstanceOf(TextInput::class); +}); + +it('builds a numeric input for number field default values in the admin', function (): void { + $fields = app(DefaultValue::class)->builderFieldsFor('number'); + + expect($fields)->toHaveCount(1) + ->and($fields[0])->toBeInstanceOf(TextInput::class) + ->and($fields[0]->isNumeric())->toBeTrue(); +}); + +it('ignores number defaults outside configured min and max at runtime', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'price', + label: 'Price', + type: 'number', + config: ['default' => 150, 'min' => 0, 'max' => 100], + ); + + expect($capability->resolveForField($field))->toBeNull() + ->and($capability->resolveForField(new FieldDefinition( + name: 'price', + label: 'Price', + type: 'number', + config: ['default' => 50, 'min' => 0, 'max' => 100], + )))->toBe(50); +}); + +it('builds temporal default controls for date fields in the admin', function (): void { + $fields = app(DefaultValue::class)->builderFieldsFor('date'); + + expect($fields)->toHaveCount(2) + ->and($fields[0])->toBeInstanceOf(Toggle::class) + ->and($fields[1])->toBeInstanceOf(DatePicker::class); +}); + +it('builds temporal default controls for datetime fields in the admin', function (): void { + $fields = app(DefaultValue::class)->builderFieldsFor('datetime'); + + expect($fields)->toHaveCount(2) + ->and($fields[0])->toBeInstanceOf(Toggle::class) + ->and($fields[1])->toBeInstanceOf(DateTimePicker::class); +}); + +it('resolves date defaults to carbon instances', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'erstzulassung', + label: 'Erstzulassung', + type: 'date', + config: ['default' => '2022-06-15', 'displayFormat' => 'd.m.Y'], + ); + + $resolved = $capability->resolveForField($field); + + expect($resolved)->toBeInstanceOf(Carbon::class) + ->and($resolved->toDateString())->toBe('2022-06-15'); +}); + +it('resolves current date when default now is enabled', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'erstzulassung', + label: 'Erstzulassung', + type: 'date', + config: ['defaultNow' => true], + ); + + $resolved = $capability->resolveForField($field); + + expect($resolved)->toBeInstanceOf(Carbon::class) + ->and($resolved->toDateString())->toBe(now()->toDateString()); +}); + +it('resolves datetime defaults to carbon instances', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'letzte-wartung', + label: 'Letzte Wartung', + type: 'datetime', + config: ['default' => '2024-03-01 09:30:00', 'displayFormat' => 'd.m.Y H:i'], + ); + + $resolved = $capability->resolveForField($field); + + expect($resolved)->toBeInstanceOf(Carbon::class) + ->and($resolved->format('Y-m-d H:i'))->toBe('2024-03-01 09:30'); +}); + +it('resolves current datetime when default now is enabled', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'letzte-wartung', + label: 'Letzte Wartung', + type: 'datetime', + config: ['defaultNow' => true], + ); + + $resolved = $capability->resolveForField($field); + + expect($resolved)->toBeInstanceOf(Carbon::class) + ->and($resolved->format('Y-m-d H:i'))->toBe(now()->format('Y-m-d H:i')); +}); + +it('applies date defaults on runtime components', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'erstzulassung', + label: 'Erstzulassung', + type: 'date', + config: ['default' => '2022-06-15'], + ); + + $component = DatePicker::make('erstzulassung')->native(false); + $result = $capability->apply($component, $field); + + expect($result->getDefaultState())->toBeInstanceOf(Carbon::class) + ->and($result->getDefaultState()->toDateString())->toBe('2022-06-15'); +}); + +it('applies datetime defaults on runtime components', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'letzte-wartung', + label: 'Letzte Wartung', + type: 'datetime', + config: ['default' => '2024-03-01 09:30:00'], + ); + + $component = DateTimePicker::make('letzte-wartung')->native(false); + $result = $capability->apply($component, $field); + + expect($result->getDefaultState())->toBeInstanceOf(Carbon::class) + ->and($result->getDefaultState()->format('Y-m-d H:i'))->toBe('2024-03-01 09:30'); +}); + +it('applies display and storage formats on runtime datetime components', function (): void { + $field = new FieldDefinition( + name: 'letzte-wartung', + label: 'Letzte Wartung', + type: 'datetime', + config: ['displayFormat' => 'd.m.Y H:i:s'], + ); + + $component = app(DisplayFormat::class) + ->apply(DateTimePicker::make('letzte-wartung')->native(false), $field); + + expect($component->getDisplayFormat())->toBe('d.m.Y H:i:s') + ->and($component->getFormat())->toBe('Y-m-d H:i:s'); +}); + +it('collects option choices from field option rows', function (): void { + $capability = app(DefaultValue::class); + + $choices = (new ReflectionClass($capability)) + ->getMethod('optionChoices') + ->invoke($capability, [ + ['label' => 'Benzin', 'value' => 'petrol'], + ['label' => 'Diesel', 'value' => 'diesel'], + ['label' => '', 'value' => ''], + ]); + + expect($choices)->toBe([ + 'petrol' => 'Benzin', + 'diesel' => 'Diesel', + ]); +}); + +it('merges defaults into flexible content block data', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'content', + label: 'Content', + type: 'flexible_content', + children: collect([ + new FieldDefinition( + name: 'hero', + label: 'Hero', + type: 'flexible_layout', + children: collect([ + new FieldDefinition( + name: 'title', + label: 'Title', + type: 'text', + config: ['default' => 'Default title'], + ), + new FieldDefinition( + name: 'subtitle', + label: 'Subtitle', + type: 'text', + config: ['default' => 'Default subtitle'], + ), + ]), + ), + ]), + ); + + $merged = $capability->mergeCompoundDefaults($field, [ + ['type' => 'hero', 'data' => ['title' => 'Saved title']], + ['type' => 'hero', 'data' => ['title' => '', 'subtitle' => '']], + ]); + + expect($merged)->toBe([ + ['type' => 'hero', 'data' => ['title' => 'Saved title', 'subtitle' => 'Default subtitle']], + ['type' => 'hero', 'data' => ['title' => 'Default title', 'subtitle' => 'Default subtitle']], + ]); +}); + +it('merges defaults into repeater rows', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'items', + label: 'Items', + type: 'repeater', + children: collect([ + new FieldDefinition( + name: 'label', + label: 'Label', + type: 'text', + config: ['default' => 'Item'], + ), + ]), + ); + + $merged = $capability->mergeCompoundDefaults($field, [ + ['label' => 'Custom'], + ['label' => ''], + ]); + + expect($merged)->toBe([ + ['label' => 'Custom'], + ['label' => 'Item'], + ]); +}); + +it('builds default data for flexible layout children', function (): void { + $capability = app(DefaultValue::class); + + $children = collect([ + new FieldDefinition( + name: 'layout-checkbox', + label: 'Layout checkbox', + type: 'checkbox_list', + config: ['default' => ['option-a']], + options: [ + ['label' => 'Checkbox layout', 'value' => 'option-a'], + ['label' => 'Checkbox layout 2', 'value' => 'option-b'], + ], + ), + new FieldDefinition( + name: 'flexible-1', + label: 'Flexible 1', + type: 'button_group', + config: ['default' => 'button-flexible-1'], + options: [ + ['label' => 'Button flexible 1', 'value' => 'button-flexible-1'], + ['label' => 'Button flexible 2', 'value' => 'button-flexible-2'], + ], + ), + new FieldDefinition( + name: 'lackfarbe', + label: 'Lackfarbe', + type: 'color', + config: ['default' => '1a1a1a'], + ), + ]); + + expect($capability->defaultDataForChildren($children))->toBe([ + 'layout-checkbox' => ['option-a'], + 'flexible-1' => 'button-flexible-1', + 'lackfarbe' => '#1a1a1a', + ]); +}); + +it('merges color defaults into group rows', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'details', + label: 'Details', + type: 'group', + children: collect([ + new FieldDefinition( + name: 'lackfarbe', + label: 'Lackfarbe', + type: 'color', + config: ['default' => '#336699'], + ), + ]), + ); + + $merged = $capability->mergeCompoundDefaults($field, [ + ['lackfarbe' => ''], + ]); + + expect($merged)->toBe([ + ['lackfarbe' => '#336699'], + ]); +}); + +it('merges color defaults into flexible content block data', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'content', + label: 'Content', + type: 'flexible_content', + children: collect([ + new FieldDefinition( + name: 'hero', + label: 'Hero', + type: 'flexible_layout', + children: collect([ + new FieldDefinition( + name: 'lackfarbe', + label: 'Lackfarbe', + type: 'color', + config: ['default' => 'ff0000'], + ), + ]), + ), + ]), + ); + + $merged = $capability->mergeCompoundDefaults($field, [ + ['type' => 'hero', 'data' => []], + ]); + + expect($merged)->toBe([ + ['type' => 'hero', 'data' => ['lackfarbe' => '#ff0000']], + ]); +}); diff --git a/packages/builder/tests/Unit/DefinitionRegistryTest.php b/packages/builder/tests/Unit/DefinitionRegistryTest.php index 132bbd6ffd..21efe3644b 100644 --- a/packages/builder/tests/Unit/DefinitionRegistryTest.php +++ b/packages/builder/tests/Unit/DefinitionRegistryTest.php @@ -4,8 +4,6 @@ require_once __DIR__.'/../TestCase.php'; -uses(TestCase::class); - use Illuminate\Support\Facades\Cache; use Moox\Builder\Data\LocationContext; use Moox\Builder\Models\Field; @@ -13,6 +11,8 @@ use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Tests\TestCase; +uses(TestCase::class); + it('caches definitions and invalidates on save', function (): void { Cache::forget(DefinitionRegistry::CACHE_KEY); @@ -106,3 +106,18 @@ expect($groups->first()->fields->pluck('name')->all())->toBe(['first', 'second', 'third']); }); + +it('excludes field groups without location rules from matching resources', function (): void { + Cache::forget(DefinitionRegistry::CACHE_KEY); + + FieldGroup::query()->create([ + 'name' => 'Unassigned', + 'slug' => 'unassigned', + 'location_rules' => [], + 'active' => true, + ]); + + $groups = app(DefinitionRegistry::class)->fieldGroupsFor(new LocationContext('item')); + + expect($groups)->toHaveCount(0); +}); diff --git a/packages/builder/tests/Unit/EntityRegistryTest.php b/packages/builder/tests/Unit/EntityRegistryTest.php index 6735065163..aa9017bcb8 100644 --- a/packages/builder/tests/Unit/EntityRegistryTest.php +++ b/packages/builder/tests/Unit/EntityRegistryTest.php @@ -5,33 +5,184 @@ require_once __DIR__.'/../TestCase.php'; require_once __DIR__.'/../Support/TestItemResource.php'; -uses(TestCase::class); - +use Filament\Resources\Resource; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Moox\Builder\Registry\EntityRegistry; +use Moox\Builder\Tests\Support\TestItem; use Moox\Builder\Tests\Support\TestItemResource; use Moox\Builder\Tests\TestCase; -it('resolves entity keys from registered resources', function (): void { - config()->set('builder.entities', [ - 'item' => [ - 'resource' => TestItemResource::class, - 'label' => 'Items', - ], - ]); +uses(TestCase::class); + +beforeEach(function (): void { + Schema::dropIfExists('relation_target_plain'); + Schema::dropIfExists('relation_target_second'); + Schema::dropIfExists('items'); + + Schema::create('relation_target_plain', function (Blueprint $table): void { + $table->id(); + }); + + Schema::create('relation_target_second', function (Blueprint $table): void { + $table->id(); + }); + + Schema::create('items', function (Blueprint $table): void { + $table->id(); + $table->string('title')->nullable(); + $table->timestamps(); + }); +}); + +class RelationTargetPlainModel extends Model +{ + protected $table = 'relation_target_plain'; +} + +class RelationTargetSecondModel extends Model +{ + protected $table = 'relation_target_second'; +} + +class RelationTargetPlainResource extends Resource +{ + protected static ?string $model = RelationTargetPlainModel::class; +} + +// A second resource wrapping the same model as TestItemResource: it must be +// deduplicated because both point at the identical relation target. +class RelationTargetDuplicateResource extends Resource +{ + protected static ?string $model = TestItem::class; +} +// Two resources with distinct models but a colliding plural label. +class RelationCategoriesOneResource extends Resource +{ + protected static ?string $model = RelationTargetPlainModel::class; + + protected static ?string $pluralModelLabel = 'Categories'; +} + +class RelationCategoriesTwoResource extends Resource +{ + protected static ?string $model = RelationTargetSecondModel::class; + + protected static ?string $pluralModelLabel = 'Categories'; +} + +it('resolves entity keys from resources that use HasCustomFields', function (): void { $registry = app(EntityRegistry::class); expect($registry->resolveForResource(TestItemResource::class))->toBe('item') - ->and($registry->resourceFor('item'))->toBe(TestItemResource::class) - ->and($registry->optionsForSelect())->toBe(['item' => 'Items']) - ->and($registry->isRegisteredResource(TestItemResource::class))->toBeTrue(); + ->and($registry->usesCustomFields(TestItemResource::class))->toBeTrue(); +}); + +it('returns null for resources without the trait', function (): void { + $plainResource = new class extends Resource + { + protected static ?string $model = TestItem::class; + }; + + $registry = app(EntityRegistry::class); + + expect($registry->resolveForResource($plainResource::class))->toBeNull() + ->and($registry->usesCustomFields($plainResource::class))->toBeFalse(); +}); + +it('enumerates all panel resources as relation targets, not just custom field ones', function (): void { + $registry = new class extends EntityRegistry + { + protected function panelResources(): array + { + return [TestItemResource::class, RelationTargetPlainResource::class]; + } + }; + + $resources = $registry->relatableResources(); + + expect($resources)->toHaveKey('item') + ->and($resources['item'])->toBe(TestItemResource::class) + ->and($resources)->toHaveKey('relation_target_plain') + ->and($resources['relation_target_plain'])->toBe(RelationTargetPlainResource::class) + ->and($registry->relatedModelFor('item'))->toBe(TestItem::class) + ->and($registry->relatedResourceFor('relation_target_plain'))->toBe(RelationTargetPlainResource::class) + ->and($registry->relatableOptions())->toHaveKeys(['item', 'relation_target_plain']); +}); + +it('deduplicates resources that share the same model', function (): void { + $registry = new class extends EntityRegistry + { + protected function panelResources(): array + { + return [TestItemResource::class, RelationTargetDuplicateResource::class]; + } + }; + + $resources = collect($registry->relatableResources()) + ->filter(fn (string $resource): bool => $resource::getModel() === TestItem::class); + + expect($resources)->toHaveCount(1); }); -it('returns null for unregistered resources', function (): void { - config()->set('builder.entities', []); +it('qualifies options that would otherwise share a label', function (): void { + $registry = new class extends EntityRegistry + { + protected function panelResources(): array + { + return [RelationCategoriesOneResource::class, RelationCategoriesTwoResource::class]; + } + }; + $labels = array_values($registry->relatableOptions()); + + expect($labels)->toHaveCount(2) + ->and($labels)->each->toStartWith('Categories (') + ->and(count(array_unique($labels)))->toBe(2); +}); + +it('excludes resources whose database table is missing', function (): void { + Schema::dropIfExists('relation_target_second'); + + $registry = new class extends EntityRegistry + { + protected function panelResources(): array + { + return [RelationTargetPlainResource::class, RelationCategoriesTwoResource::class]; + } + }; + + expect($registry->relatableResources())->toHaveKey('relation_target_plain') + ->and($registry->relatableResources())->not->toHaveKey('relation_target_second'); +}); + +it('memoizes modelIsQueryable schema checks', function (): void { $registry = app(EntityRegistry::class); - expect($registry->resolveForResource(TestItemResource::class))->toBeNull() - ->and($registry->isRegisteredResource(TestItemResource::class))->toBeFalse(); + DB::flushQueryLog(); + DB::enableQueryLog(); + + expect($registry->modelIsQueryable(TestItem::class))->toBeTrue(); + + $queriesAfterFirst = count(DB::getQueryLog()); + + expect($registry->modelIsQueryable(TestItem::class))->toBeTrue() + ->and(count(DB::getQueryLog()))->toBe($queriesAfterFirst); +}); + +it('memoizes database table column checks', function (): void { + $registry = app(EntityRegistry::class); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + expect($registry->databaseTableHasColumn('items', 'title'))->toBeTrue(); + + $queriesAfterFirst = count(DB::getQueryLog()); + + expect($registry->databaseTableHasColumn('items', 'title'))->toBeTrue() + ->and(count(DB::getQueryLog()))->toBe($queriesAfterFirst); }); diff --git a/packages/builder/tests/Unit/FieldGroupImportExportTest.php b/packages/builder/tests/Unit/FieldGroupImportExportTest.php new file mode 100644 index 0000000000..c962e14389 --- /dev/null +++ b/packages/builder/tests/Unit/FieldGroupImportExportTest.php @@ -0,0 +1,354 @@ +create([ + 'name' => 'Content', + 'slug' => 'content-export', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'placement' => 'main', + 'settings' => ['columns' => 2, 'visible_admin' => true], + 'sort' => 5, + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Content', + 'slug' => 'content-export', + 'active' => true, + 'sort' => 5, + 'placement' => 'main', + 'settings' => ['columns' => 2, 'visible_admin' => true], + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'headline', + 'label' => 'Headline', + 'type' => 'text', + 'required' => true, + 'config' => ['maxLength' => 120], + 'settings' => ['visible_api' => true], + ], + [ + 'name' => 'category', + 'label' => 'Category', + 'type' => 'select', + 'required' => false, + 'options' => [ + ['label' => 'News', 'value' => 'news'], + ['label' => 'Blog', 'value' => 'blog'], + ], + ], + ], + ]); + + $payload = app(FieldGroupExporter::class)->export($group->fresh()); + + expect($payload) + ->toMatchArray([ + 'schema' => FieldGroupExportSchema::SCHEMA, + 'version' => FieldGroupExportSchema::VERSION, + ]) + ->and($payload['group']['slug'])->toBe('content-export') + ->and($payload['group']['sort'])->toBe(5) + ->and($payload['group']['fields'])->toHaveCount(2); + + FieldGroup::query()->whereKey($group->getKey())->delete(); + + $imported = app(FieldGroupImporter::class)->import($payload); + + expect($imported->slug)->toBe('content-export') + ->and($imported->sort)->toBe(5) + ->and($imported->settings)->toMatchArray(['columns' => 2, 'visible_admin' => true]) + ->and($imported->fields()->whereNull('parent_field_id')->pluck('name')->all()) + ->toBe(['headline', 'category']); + + $headline = Field::query()->where('field_group_id', $imported->getKey())->where('name', 'headline')->first(); + + expect($headline?->validation['required'] ?? null)->toBeTrue() + ->and($headline?->config)->toMatchArray(['maxLength' => 120]); +}); + +it('imports definitions with explicit locale translations in the payload', function (): void { + config()->set('builder.default_locale', 'en_US'); + + $payload = [ + 'schema' => FieldGroupExportSchema::SCHEMA, + 'version' => FieldGroupExportSchema::VERSION, + 'exportedAt' => now()->toIso8601String(), + 'group' => [ + 'name' => 'Content EN', + 'slug' => 'localized-import', + 'placement' => 'main', + 'locationRules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'settings' => [], + 'translations' => [ + 'en_US' => ['name' => 'Content EN'], + 'de_CH' => ['name' => 'Inhalt DE'], + ], + 'fields' => [ + [ + 'name' => 'title', + 'label' => 'Title EN', + 'type' => 'text', + 'sort' => 0, + 'config' => [], + 'validation' => ['required' => false, 'rules' => []], + 'settings' => [], + 'options' => [], + 'children' => [], + 'translations' => [ + 'en_US' => ['label' => 'Title EN', 'config' => []], + 'de_CH' => ['label' => 'Titel DE', 'config' => []], + ], + ], + ], + 'active' => true, + 'sort' => 0, + ], + ]; + + $imported = app(FieldGroupImporter::class)->import($payload); + + expect($imported->name)->toBe('Content EN') + ->and($imported->translate('de_CH')?->name)->toBe('Inhalt DE') + ->and($imported->fields()->first()?->translate('de_CH')?->label)->toBe('Titel DE'); +}); + +it('imports builder persistence-shaped payloads into canonical definitions', function (): void { + $payload = [ + 'schema' => FieldGroupExportSchema::SCHEMA, + 'version' => FieldGroupExportSchema::VERSION, + 'exportedAt' => now()->toIso8601String(), + 'group' => [ + 'name' => 'Compat import', + 'slug' => 'compat-import', + 'placement' => 'main', + 'target_entities' => ['item'], + 'settings' => [], + 'translations' => [], + 'fields' => [ + [ + 'name' => 'headline', + 'label' => 'Headline', + 'type' => 'text', + 'required' => true, + 'validation' => [ + 'rule_rows' => [ + ['rule' => 'min', 'value' => '3'], + ], + 'raw_rules' => 'starts_with:foo', + ], + 'settings' => [], + 'options' => [], + ], + [ + 'name' => 'content_blocks', + 'label' => 'Content blocks', + 'type' => 'flexible_content', + 'settings' => [], + 'layouts' => [ + [ + 'name' => 'hero', + 'label' => 'Hero', + 'children' => [ + [ + 'name' => 'title', + 'label' => 'Title', + 'type' => 'text', + 'required' => false, + 'settings' => [], + 'options' => [], + ], + ], + ], + ], + ], + ], + 'active' => true, + 'sort' => 0, + ], + ]; + + $imported = app(FieldGroupImporter::class)->import($payload); + + expect($imported->location_rules)->toBe([ + [[ + 'param' => 'entity', + 'operator' => '==', + 'value' => 'item', + ]], + ]); + + $headline = $imported->fields()->where('name', 'headline')->first(); + $contentBlocks = $imported->fields()->where('name', 'content_blocks')->first(); + $heroLayout = $contentBlocks?->children()->where('type', 'flexible_layout')->where('name', 'hero')->first(); + + expect($headline?->validation)->toBe([ + 'required' => true, + 'rules' => ['min:3', 'starts_with:foo'], + ])->and($heroLayout)->not->toBeNull() + ->and($heroLayout?->children()->where('name', 'title')->exists())->toBeTrue(); +}); + +it('generates duplicate slugs for copies', function (): void { + FieldGroup::query()->create([ + 'name' => 'Original', + 'slug' => 'test', + 'location_rules' => [], + 'active' => true, + ]); + + expect(app(FieldGroupImporter::class)->duplicateSlug('test'))->toBe('test-copy'); + + FieldGroup::query()->create([ + 'name' => 'Copy', + 'slug' => 'test-copy', + 'location_rules' => [], + 'active' => true, + ]); + + expect(app(FieldGroupImporter::class)->duplicateSlug('test'))->toBe('test-2'); +}); + +it('imports as a duplicate slug without replacing the original group', function (): void { + FieldGroup::query()->create([ + 'name' => 'Original', + 'slug' => 'test', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync( + FieldGroup::query()->where('slug', 'test')->firstOrFail(), + [ + 'name' => 'Original', + 'slug' => 'test', + 'active' => true, + 'sort' => 0, + 'placement' => 'main', + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'buttongroup', + 'label' => 'Button group', + 'type' => 'button_group', + 'required' => false, + 'options' => [ + ['label' => 'One', 'value' => 'one'], + ], + ], + ], + ], + ); + + $payload = [ + 'schema' => FieldGroupExportSchema::SCHEMA, + 'version' => FieldGroupExportSchema::VERSION, + 'exportedAt' => now()->toIso8601String(), + 'group' => [ + 'name' => 'Copy', + 'slug' => 'test', + 'placement' => 'main', + 'locationRules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'settings' => [], + 'translations' => [], + 'fields' => [ + [ + 'name' => 'buttongroup', + 'label' => 'Button group', + 'type' => 'button_group', + 'sort' => 0, + 'config' => [], + 'validation' => ['required' => false, 'rules' => []], + 'settings' => [], + 'options' => [ + ['label' => 'One', 'value' => 'one'], + ], + 'children' => [], + 'translations' => [], + ], + ], + 'active' => true, + 'sort' => 0, + ], + ]; + + $importer = app(FieldGroupImporter::class); + + $imported = $importer->import( + $payload, + slugOverride: $importer->duplicateSlug('test'), + ); + + expect($imported->slug)->toBe('test-copy') + ->and($imported->location_rules)->toBe([]) + ->and(FieldGroup::query()->where('slug', 'test')->exists())->toBeTrue() + ->and(FieldGroup::query()->where('slug', 'test-copy')->exists())->toBeTrue() + ->and($imported->fields()->where('name', 'buttongroup')->exists())->toBeTrue(); +}); + +it('rejects imports when slug exists unless replace is enabled', function (): void { + FieldGroup::query()->create([ + 'name' => 'Existing', + 'slug' => 'duplicate-slug', + 'location_rules' => [], + 'active' => true, + ]); + + $payload = [ + 'schema' => FieldGroupExportSchema::SCHEMA, + 'version' => FieldGroupExportSchema::VERSION, + 'exportedAt' => now()->toIso8601String(), + 'group' => [ + 'name' => 'Imported', + 'slug' => 'duplicate-slug', + 'placement' => 'main', + 'locationRules' => [], + 'settings' => [], + 'translations' => [], + 'fields' => [], + 'active' => true, + 'sort' => 0, + ], + ]; + + try { + app(FieldGroupImporter::class)->import($payload); + expect(false)->toBeTrue('Expected import to fail for duplicate slug'); + } catch (ValidationException $exception) { + expect($exception->errors())->toHaveKey('file'); + } + + $imported = app(FieldGroupImporter::class)->import($payload, replaceExisting: true); + + expect($imported->name)->toBe('Imported') + ->and(FieldGroup::query()->where('slug', 'duplicate-slug')->count())->toBe(1); +}); + +it('rejects invalid export payloads', function (): void { + expect(fn () => app(FieldGroupImporter::class)->importFromJson('{not-json')) + ->toThrow(ValidationException::class); + + expect(fn () => app(FieldGroupImporter::class)->import([ + 'schema' => 'other', + 'version' => 1, + 'group' => [], + ]))->toThrow(ValidationException::class); +}); diff --git a/packages/builder/tests/Unit/FieldGroupPersistenceTest.php b/packages/builder/tests/Unit/FieldGroupPersistenceTest.php index 65966c54ce..07fca77c4e 100644 --- a/packages/builder/tests/Unit/FieldGroupPersistenceTest.php +++ b/packages/builder/tests/Unit/FieldGroupPersistenceTest.php @@ -3,12 +3,16 @@ declare(strict_types=1); require_once __DIR__.'/../TestCase.php'; +require_once __DIR__.'/../Support/TestItemResource.php'; -uses(TestCase::class); - +use Moox\Builder\Models\FieldGroup; +use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Services\FieldGroupPersistence; +use Moox\Builder\Tests\Support\TestItemResource; use Moox\Builder\Tests\TestCase; +uses(TestCase::class); + it('converts target entities to location rules and back', function (): void { $persistence = app(FieldGroupPersistence::class); @@ -33,3 +37,217 @@ expect($rules)->toHaveCount(1) ->and($rules[0][0]['value'])->toBe('item'); }); + +it('resolves empty target entities to no location rules', function (): void { + $persistence = app(FieldGroupPersistence::class); + + expect($persistence->resolveLocationRules(['target_entities' => []]))->toBe([]) + ->and($persistence->resolveLocationRules(['target_entities' => null]))->toBe([]); +}); + +it('updates existing fields by name when the form row id is missing', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Sync', + 'slug' => 'sync-by-name', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + $persistence = app(FieldGroupPersistence::class); + + $persistence->sync($group, [ + 'name' => 'Sync', + 'slug' => 'sync-by-name', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'checkbox-list', + 'label' => 'Checkbox List', + 'type' => 'checkbox_list', + 'required' => false, + 'config' => ['default' => ['one']], + 'options' => [ + ['label' => 'One', 'value' => 'one'], + ['label' => 'Two', 'value' => 'two'], + ], + ], + ], + ]); + + $fieldId = $group->fields()->where('name', 'checkbox-list')->value('id'); + + $persistence->sync($group->fresh(), [ + 'name' => 'Sync', + 'slug' => 'sync-by-name', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'checkbox-list', + 'label' => 'Checkbox List', + 'type' => 'checkbox_list', + 'required' => false, + 'config' => ['default' => ['one', 'two'], 'helperText' => 'Checkbox Helper'], + 'options' => [ + ['label' => 'One', 'value' => 'one'], + ['label' => 'Two', 'value' => 'two'], + ], + ], + ], + ]); + + expect($group->fields()->where('name', 'checkbox-list')->count())->toBe(1) + ->and($group->fields()->where('name', 'checkbox-list')->value('id'))->toBe($fieldId) + ->and($group->fields()->where('name', 'checkbox-list')->value('config'))->toMatchArray([ + 'default' => ['one', 'two'], + 'helperText' => 'Checkbox Helper', + ]); +}); + +it('drops config keys that do not belong to the saved field type', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Sync', + 'slug' => 'sync-config-filter', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + $persistence = app(FieldGroupPersistence::class); + + $persistence->sync($group, [ + 'name' => 'Sync', + 'slug' => 'sync-config-filter', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'variants', + 'label' => 'Variants', + 'type' => 'repeater', + 'required' => false, + 'config' => [ + 'min' => 50, + 'max' => 500, + 'step' => 5, + 'default' => 245, + ], + 'children' => [ + [ + 'name' => 'title', + 'label' => 'Title', + 'type' => 'text', + 'required' => false, + ], + ], + ], + ], + ]); + + expect($group->fields()->where('name', 'variants')->value('config'))->toBe([]); +}); + +it('only persists relation targets that are relatable resources', function (): void { + $registry = new class extends EntityRegistry + { + public function relatableResources(): array + { + return ['item' => TestItemResource::class]; + } + }; + app()->instance(EntityRegistry::class, $registry); + + $group = FieldGroup::query()->create([ + 'name' => 'Relations', + 'slug' => 'relation-whitelist', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Relations', + 'slug' => 'relation-whitelist', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'valid-relation', + 'label' => 'Valid relation', + 'type' => 'relation', + 'required' => false, + 'config' => ['related_entity' => 'item', 'multiple' => false], + ], + [ + 'name' => 'forged-relation', + 'label' => 'Forged relation', + 'type' => 'relation', + 'required' => false, + 'config' => ['related_entity' => 'user', 'multiple' => false], + ], + ], + ]); + + expect($group->fields()->where('name', 'valid-relation')->value('config')) + ->toMatchArray(['related_entity' => 'item']) + ->and($group->fields()->where('name', 'forged-relation')->value('config')) + ->not->toHaveKey('related_entity'); +}); + +it('round trips conditional logic settings through field group persistence', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Conditional', + 'slug' => 'conditional-settings', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + $persistence = app(FieldGroupPersistence::class); + + $persistence->sync($group, [ + 'name' => 'Conditional', + 'slug' => 'conditional-settings', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'customer_type', + 'label' => 'Customer type', + 'type' => 'text', + 'required' => false, + ], + [ + 'name' => 'company', + 'label' => 'Company', + 'type' => 'text', + 'required' => true, + 'settings' => [ + 'conditions' => [ + 'enabled' => true, + 'action' => 'show', + 'logic' => 'or', + 'rules' => [ + ['field' => 'customer_type', 'operator' => 'equals', 'value' => 'business'], + ], + ], + ], + ], + ], + ]); + + $rows = $persistence->fieldRowsForForm($group->fresh()); + $company = collect($rows)->firstWhere('name', 'company'); + + expect($company['settings']['conditions'] ?? null)->toMatchArray([ + 'enabled' => true, + 'action' => 'show', + 'logic' => 'or', + 'rules' => [ + ['field' => 'customer_type', 'operator' => 'equals', 'value' => 'business'], + ], + ]); +}); diff --git a/packages/builder/tests/Unit/FieldGroupPlacementTest.php b/packages/builder/tests/Unit/FieldGroupPlacementTest.php new file mode 100644 index 0000000000..9321f17ce1 --- /dev/null +++ b/packages/builder/tests/Unit/FieldGroupPlacementTest.php @@ -0,0 +1,114 @@ +toBe('main') + ->and(FieldGroupPlacement::normalize('default'))->toBe('main') + ->and(FieldGroupPlacement::normalize('bogus'))->toBe('main') + ->and(FieldGroupPlacement::normalize('sidebar'))->toBe('sidebar'); +}); + +it('matches the legacy default placement against main on the definition', function (): void { + $legacy = FieldGroupDefinition::fromArray(['name' => 'G', 'slug' => 'g', 'placement' => 'default', 'fields' => []]); + $sidebar = FieldGroupDefinition::fromArray(['name' => 'S', 'slug' => 's', 'placement' => 'sidebar', 'fields' => []]); + + expect($legacy->hasPlacement('main'))->toBeTrue() + ->and($legacy->hasPlacement('sidebar'))->toBeFalse() + ->and($sidebar->hasPlacement('sidebar'))->toBeTrue() + ->and($sidebar->hasPlacement('main'))->toBeFalse(); +}); + +it('splits compiled form sections by placement', function (): void { + FieldGroup::query()->delete(); + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $main = FieldGroup::query()->create([ + 'name' => 'Vehicle data', + 'slug' => 'vehicle-data', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'placement' => 'default', + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $main->getKey(), + 'name' => 'model', + 'label' => 'Model', + 'type' => 'text', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + $sidebar = FieldGroup::query()->create([ + 'name' => 'Internal notes', + 'slug' => 'internal-notes', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'placement' => 'sidebar', + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $sidebar->getKey(), + 'name' => 'note', + 'label' => 'Note', + 'type' => 'textarea', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $mainSections = TestItemResource::customFieldComponents(); + $sidebarSections = TestItemResource::customFieldComponents('sidebar'); + + expect($mainSections)->toHaveCount(1) + ->and($mainSections[0])->toBeInstanceOf(Section::class) + ->and($mainSections[0]->getHeading())->toBe('Vehicle data') + ->and($sidebarSections)->toHaveCount(1) + ->and($sidebarSections[0])->toBeInstanceOf(Section::class) + ->and($sidebarSections[0]->getHeading())->toBe('Internal notes'); +}); + +it('includes all placements in table columns regardless of placement', function (): void { + FieldGroup::query()->delete(); + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $sidebar = FieldGroup::query()->create([ + 'name' => 'Internal notes', + 'slug' => 'internal-notes', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'placement' => 'sidebar', + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $sidebar->getKey(), + 'name' => 'note', + 'label' => 'Note', + 'type' => 'text', + 'sort' => 0, + 'settings' => ['show_in_table' => true], + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + expect(TestItemResource::customFieldColumns())->toHaveCount(1); +}); diff --git a/packages/builder/tests/Unit/FieldGroupValidatorTest.php b/packages/builder/tests/Unit/FieldGroupValidatorTest.php new file mode 100644 index 0000000000..48729e60a9 --- /dev/null +++ b/packages/builder/tests/Unit/FieldGroupValidatorTest.php @@ -0,0 +1,439 @@ +namesFromList(collect([ + new FieldDefinition(name: 'hinweis', label: 'Hinweis', type: 'message'), + new FieldDefinition( + name: 'tab-one', + label: 'One', + type: 'tab', + children: collect([ + new FieldDefinition(name: 'email', label: 'Email', type: 'email'), + ]), + ), + new FieldDefinition( + name: 'standort', + label: 'Standort', + type: 'group', + children: collect([ + new FieldDefinition(name: 'stadt', label: 'Stadt', type: 'text'), + ]), + ), + ])); + + expect($names)->toBe(['email', 'standort']); +}); + +it('rejects duplicate storable names across tabs', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Tabs', + 'slug' => 'tabs-dup', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + expect(fn () => app(FieldGroupValidator::class)->validate($group, [ + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'tab-a', + 'label' => 'A', + 'type' => 'tab', + 'children' => [ + ['name' => 'email', 'label' => 'Email', 'type' => 'email'], + ], + ], + [ + 'name' => 'tab-b', + 'label' => 'B', + 'type' => 'tab', + 'children' => [ + ['name' => 'email', 'label' => 'Email 2', 'type' => 'email'], + ], + ], + ], + ]))->toThrow(ValidationException::class); +}); + +it('rejects duplicate storable names between root and tab children', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Mixed', + 'slug' => 'mixed-dup', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + expect(fn () => app(FieldGroupValidator::class)->validate($group, [ + 'target_entities' => ['item'], + 'fields' => [ + ['name' => 'preis', 'label' => 'Preis', 'type' => 'number'], + [ + 'name' => 'tab-a', + 'label' => 'A', + 'type' => 'tab', + 'children' => [ + ['name' => 'preis', 'label' => 'Preis 2', 'type' => 'number'], + ], + ], + ], + ]))->toThrow(ValidationException::class); +}); + +it('allows nested subfield names inside compound fields', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Compound', + 'slug' => 'compound-ok', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupValidator::class)->validate($group, [ + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'standort', + 'label' => 'Standort', + 'type' => 'group', + 'children' => [ + ['name' => 'stadt', 'label' => 'Stadt', 'type' => 'text'], + ], + ], + [ + 'name' => 'kontakt', + 'label' => 'Kontakt', + 'type' => 'group', + 'children' => [ + ['name' => 'stadt', 'label' => 'Stadt', 'type' => 'text'], + ], + ], + ], + ]); + + expect(true)->toBeTrue(); +}); + +it('does not treat nested subfield names as external conflicts', function (): void { + $existing = FieldGroup::query()->create([ + 'name' => 'Existing', + 'slug' => 'existing-nested', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($existing, [ + 'name' => 'Existing', + 'slug' => 'existing-nested', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'standort', + 'label' => 'Standort', + 'type' => 'group', + 'children' => [ + ['name' => 'stadt', 'label' => 'Stadt', 'type' => 'text'], + ], + ], + ], + ]); + + $incoming = FieldGroup::query()->create([ + 'name' => 'Incoming', + 'slug' => 'incoming-root', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupValidator::class)->validate($incoming, [ + 'target_entities' => ['item'], + 'fields' => [ + ['name' => 'stadt', 'label' => 'Stadt', 'type' => 'text'], + ], + ]); + + expect(true)->toBeTrue(); +}); + +it('detects external conflicts for flattened storable names across groups', function (): void { + $existing = FieldGroup::query()->create([ + 'name' => 'Existing', + 'slug' => 'existing-email', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($existing, [ + 'name' => 'Existing', + 'slug' => 'existing-email', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'tab-a', + 'label' => 'A', + 'type' => 'tab', + 'children' => [ + ['name' => 'email', 'label' => 'Email', 'type' => 'email'], + ], + ], + ], + ]); + + $incoming = FieldGroup::query()->create([ + 'name' => 'Incoming', + 'slug' => 'incoming-email', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + expect(fn () => app(FieldGroupValidator::class)->validate($incoming, [ + 'target_entities' => ['item'], + 'fields' => [ + ['name' => 'email', 'label' => 'Email', 'type' => 'email'], + ], + ]))->toThrow(function (ValidationException $exception): void { + expect($exception->errors()) + ->toHaveKey('fields.0.name') + ->toHaveKey('target_entities'); + }); +}); + +it('rejects unknown location constraint params', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Rules', + 'slug' => 'rules-invalid-param', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + expect(fn () => app(FieldGroupValidator::class)->validate($group, [ + 'target_entities' => ['item'], + 'location_constraints' => [ + ['param' => 'template', 'operator' => '==', 'value' => 'landing'], + ], + 'fields' => [ + ['name' => 'title', 'label' => 'Title', 'type' => 'text'], + ], + ]))->toThrow(function (ValidationException $exception): void { + expect($exception->errors())->toHaveKey('location_constraints.0.param'); + }); +}); + +it('rejects unknown structured validation rules', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Rules', + 'slug' => 'rules-invalid-validation-rule', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + expect(fn () => app(FieldGroupValidator::class)->validate($group, [ + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'title', + 'label' => 'Title', + 'type' => 'text', + 'validation' => [ + 'rule_rows' => [ + ['rule' => 'integer'], + ], + ], + ], + ], + ]))->toThrow(function (ValidationException $exception): void { + expect($exception->errors())->toHaveKey('fields.0.validation.rule_rows.0.rule'); + }); +}); + +it('rejects unknown raw validation rules', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Rules', + 'slug' => 'rules-invalid-raw-validation-rule', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + expect(fn () => app(FieldGroupValidator::class)->validate($group, [ + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'title', + 'label' => 'Title', + 'type' => 'text', + 'validation' => [ + 'raw_rules' => "starts_with:foo\nprohibited", + ], + ], + ], + ]))->toThrow(function (ValidationException $exception): void { + expect($exception->errors())->toHaveKey('fields.0.validation.raw_rules'); + }); +}); + +it('rejects missing values for structured validation rules', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Rules', + 'slug' => 'rules-missing-validation-value', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + expect(fn () => app(FieldGroupValidator::class)->validate($group, [ + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'title', + 'label' => 'Title', + 'type' => 'text', + 'validation' => [ + 'rule_rows' => [ + ['rule' => 'starts_with', 'value' => ''], + ], + ], + ], + ], + ]))->toThrow(function (ValidationException $exception): void { + expect($exception->errors())->toHaveKey('fields.0.validation.rule_rows.0.value'); + }); +}); + +it('rejects non numeric values for numeric validation rules', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Rules', + 'slug' => 'rules-nonnumeric-validation-value', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + expect(fn () => app(FieldGroupValidator::class)->validate($group, [ + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'price', + 'label' => 'Price', + 'type' => 'number', + 'validation' => [ + 'rule_rows' => [ + ['rule' => 'gte', 'value' => 'abc'], + ], + ], + ], + ], + ]))->toThrow(function (ValidationException $exception): void { + expect($exception->errors())->toHaveKey('fields.0.validation.rule_rows.0.value'); + }); +}); + +it('rejects record type constraints when selected entities do not support them', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Rules', + 'slug' => 'rules-invalid-record-type', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + expect(fn () => app(FieldGroupValidator::class)->validate($group, [ + 'target_entities' => ['item'], + 'location_constraints' => [ + ['param' => 'record_type', 'operator' => '==', 'value' => 'page'], + ], + 'fields' => [ + ['name' => 'title', 'label' => 'Title', 'type' => 'text'], + ], + ]))->toThrow(function (ValidationException $exception): void { + expect($exception->errors())->toHaveKey('location_constraints.0.param'); + }); +}); + +it('rejects user role constraints when roles are unavailable', function (): void { + config()->set('permission.table_names.roles', 'roles'); + config()->set('auth.providers.users.model', ValidatorNoRolesUser::class); + + $group = FieldGroup::query()->create([ + 'name' => 'Rules', + 'slug' => 'rules-invalid-role', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + expect(fn () => app(FieldGroupValidator::class)->validate($group, [ + 'target_entities' => ['item'], + 'location_constraints' => [ + ['param' => 'user_role', 'operator' => '==', 'value' => 'admin'], + ], + 'fields' => [ + ['name' => 'title', 'label' => 'Title', 'type' => 'text'], + ], + ]))->toThrow(function (ValidationException $exception): void { + expect($exception->errors())->toHaveKey('location_constraints.0.value'); + }); +}); + +it('rejects unknown user role values', function (): void { + config()->set('permission.table_names.roles', 'roles'); + config()->set('auth.providers.users.model', ValidatorRolesUser::class); + + Schema::create('roles', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + }); + + DB::table('roles')->insert([ + 'name' => 'editor', + 'guard_name' => 'web', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $group = FieldGroup::query()->create([ + 'name' => 'Rules', + 'slug' => 'rules-unknown-role', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + expect(fn () => app(FieldGroupValidator::class)->validate($group, [ + 'target_entities' => ['item'], + 'location_constraints' => [ + ['param' => 'user_role', 'operator' => '==', 'value' => 'admin'], + ], + 'fields' => [ + ['name' => 'title', 'label' => 'Title', 'type' => 'text'], + ], + ]))->toThrow(function (ValidationException $exception): void { + expect($exception->errors())->toHaveKey('location_constraints.0.value'); + }); +}); + +class ValidatorNoRolesUser extends Model {} + +class ValidatorRolesUser extends Model +{ + use HasRoles; +} diff --git a/packages/builder/tests/Unit/FieldTypeRegistryTest.php b/packages/builder/tests/Unit/FieldTypeRegistryTest.php index 84ccb08462..0cd43efd77 100644 --- a/packages/builder/tests/Unit/FieldTypeRegistryTest.php +++ b/packages/builder/tests/Unit/FieldTypeRegistryTest.php @@ -4,13 +4,14 @@ require_once __DIR__.'/../TestCase.php'; -uses(TestCase::class); - use Moox\Builder\Exceptions\UnknownFieldTypeException; use Moox\Builder\FieldTypes\Types\TextFieldType; use Moox\Builder\Registry\FieldTypeRegistry; +use Moox\Builder\Support\MediaIntegration; use Moox\Builder\Tests\TestCase; +uses(TestCase::class); + it('registers and resolves field types', function (): void { $registry = new FieldTypeRegistry; $registry->register(new TextFieldType); @@ -25,16 +26,49 @@ $registry->get('missing'); })->throws(UnknownFieldTypeException::class); -it('resolves all fifteen default field types', function (): void { +it('resolves all default field types', function (): void { $registry = app(FieldTypeRegistry::class); $keys = [ 'text', 'textarea', 'number', 'email', 'url', 'password', 'select', 'multiselect', 'checkbox_list', 'radio', 'toggle', 'date', 'datetime', 'time', 'color', + 'range', 'button_group', 'link', ]; + if (MediaIntegration::isAvailable()) { + $keys[] = 'image'; + $keys[] = 'gallery'; + $keys[] = 'file'; + } + + $keys = array_merge($keys, [ + 'rich_text', + 'message', 'oembed', + 'tab', 'group', 'repeater', 'flexible_content', 'flexible_layout', + ]); + foreach ($keys as $key) { expect($registry->get($key)::key())->toBe($key); } }); + +it('exposes compound field types for tab children but not nested tabs', function (): void { + $registry = app(FieldTypeRegistry::class); + + $options = $registry->optionsForTabChildren(); + + expect($options)->toHaveKeys(['repeater', 'group', 'flexible_content', 'text']) + ->and($options)->not->toHaveKey('tab') + ->and($options)->not->toHaveKey('flexible_layout'); +}); + +it('limits subfield options to leaf field types', function (): void { + $registry = app(FieldTypeRegistry::class); + + $options = $registry->optionsForSubFields(); + + expect($options)->toHaveKey('text') + ->and($options)->not->toHaveKey('repeater') + ->and($options)->not->toHaveKey('group'); +}); diff --git a/packages/builder/tests/Unit/FieldValidationRulesTest.php b/packages/builder/tests/Unit/FieldValidationRulesTest.php new file mode 100644 index 0000000000..197febd31f --- /dev/null +++ b/packages/builder/tests/Unit/FieldValidationRulesTest.php @@ -0,0 +1,93 @@ +formStateFor([ + 'required' => true, + 'rules' => ['min:3', 'regex:/^[a-z-]+$/', 'starts_with:foo'], + ], 'text'); + + expect($state['rule_rows'])->toBe([ + ['rule' => 'min', 'value' => '3'], + ['rule' => 'regex', 'value' => '/^[a-z-]+$/'], + ['rule' => 'starts_with', 'value' => 'foo'], + ])->and($state['raw_rules'])->toBe(''); +}); + +it('compiles structured validation rows back to laravel rules', function (): void { + $rules = app(FieldValidationRules::class); + + $validation = $rules->compileValidation([ + 'required' => true, + 'rule_rows' => [ + ['rule' => 'min', 'value' => '3'], + ['rule' => 'alpha_dash'], + ['rule' => 'starts_with', 'value' => 'foo'], + ], + 'raw_rules' => "starts_with:foo\nends_with:bar", + ], 'text'); + + expect($validation)->toBe([ + 'required' => true, + 'rules' => ['min:3', 'alpha_dash', 'starts_with:foo', 'ends_with:bar'], + ]); +}); + +it('ignores unsupported raw validation rules during compile', function (): void { + $rules = app(FieldValidationRules::class); + + $validation = $rules->compileValidation([ + 'required' => false, + 'raw_rules' => "starts_with:foo\nprohibited\ninteger", + ], 'text'); + + expect($validation['rules'])->toBe(['starts_with:foo']); +}); + +it('filters unsupported persisted validation rules at runtime', function (): void { + $field = new FieldDefinition( + name: 'slug', + label: 'Slug', + type: 'text', + validation: ['required' => false, 'rules' => ['min:3', 'prohibited', 'alpha_dash']], + ); + + expect(app(FieldValidationRules::class)->runtimeRulesFor($field)) + ->toBe(['min:3', 'alpha_dash']); +}); + +it('enforces custom text validation rules server side', function (): void { + $field = new FieldDefinition( + name: 'slug', + label: 'Slug', + type: 'text', + validation: ['required' => false, 'rules' => ['min:3', 'alpha_dash']], + ); + + expect(fn () => app(\Moox\Builder\Services\FieldValueValidator::class)->assertValid($field, 'a!')) + ->toThrow(ValidationException::class); +}); + +it('enforces custom numeric validation rules server side', function (): void { + $field = new FieldDefinition( + name: 'price', + label: 'Price', + type: 'number', + validation: ['required' => false, 'rules' => ['gte:10']], + ); + + expect(fn () => app(\Moox\Builder\Services\FieldValueValidator::class)->assertValid($field, 5)) + ->toThrow(ValidationException::class); +}); diff --git a/packages/builder/tests/Unit/FieldValueIntegrityTest.php b/packages/builder/tests/Unit/FieldValueIntegrityTest.php index c360b1a724..7603d65edd 100644 --- a/packages/builder/tests/Unit/FieldValueIntegrityTest.php +++ b/packages/builder/tests/Unit/FieldValueIntegrityTest.php @@ -6,27 +6,23 @@ require_once __DIR__.'/../Support/TestItem.php'; require_once __DIR__.'/../Support/TestItemResource.php'; -uses(TestCase::class); - +use Illuminate\Support\Facades\Hash; use Illuminate\Validation\ValidationException; use Moox\Builder\Data\FieldDefinition; +use Moox\Builder\FieldTypes\Value\StoredPassword; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldValue; +use Moox\Builder\Services\CustomFieldsManager; use Moox\Builder\Services\FieldGroupPersistence; use Moox\Builder\Services\FieldValuePurger; -use Moox\Builder\Storage\TypedValueDriver; -use Moox\Builder\Support\EntityModelDeletionRegistrar; use Moox\Builder\Tests\Support\TestItem; -use Moox\Builder\Tests\Support\TestItemResource; use Moox\Builder\Tests\TestCase; +uses(TestCase::class); + beforeEach(function (): void { $this->createItemsTable(); - - config()->set('builder.entities', [ - 'item' => ['resource' => TestItemResource::class], - ]); }); it('purges values when a record is deleted', function (): void { @@ -39,7 +35,9 @@ 'value_string' => 'red', ]); - app(EntityModelDeletionRegistrar::class)->register(); + TestItem::deleted(function (TestItem $deleted): void { + app(FieldValuePurger::class)->purgeForRecord('item', $deleted->getKey()); + }); $record->delete(); @@ -138,23 +136,42 @@ ]))->toThrow(ValidationException::class); }); -it('hashes password values on save', function (): void { +it('stores password values hashed', function (): void { $record = TestItem::query()->create(['title' => 'Demo']); - $driver = app(TypedValueDriver::class); + $fields = collect([ + new FieldDefinition('secret', 'Secret', 'password'), + ]); - $driver->save('item', $record, [ + app(CustomFieldsManager::class)->saveValues('item', $record, [ 'secret' => 'plain-text', - ], collect([ - new FieldDefinition('secret', 'Secret', 'password'), - ])); + ], $fields); $stored = FieldValue::query()->forRecord('item', $record->getKey())->first(); expect($stored?->value_string)->not->toBe('plain-text') - ->and(password_verify('plain-text', (string) $stored?->value_string))->toBeTrue(); + ->and(Hash::check('plain-text', (string) $stored?->value_string))->toBeTrue(); +}); + +it('never reloads plaintext password values for editing', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + 'field_name' => 'secret', + 'value_string' => Hash::make('api-key-123'), + ]); + + $loaded = app(CustomFieldsManager::class)->loadValues( + 'item', + $record, + collect([new FieldDefinition('secret', 'Secret', 'password')]), + ); + + expect($loaded['secret'])->toBe(StoredPassword::instance()); }); -it('does not load password values back into forms', function (): void { +it('treats legacy hashed password values as stored markers when loading', function (): void { $record = TestItem::query()->create(['title' => 'Demo']); FieldValue::query()->create([ @@ -164,13 +181,13 @@ 'value_string' => bcrypt('hidden'), ]); - $loaded = app(TypedValueDriver::class)->load( + $loaded = app(CustomFieldsManager::class)->loadValues( 'item', $record, collect([new FieldDefinition('secret', 'Secret', 'password')]), ); - expect($loaded)->toBe([]); + expect($loaded['secret'])->toBe(StoredPassword::instance()); }); it('purges values for a record via purger service', function (): void { diff --git a/packages/builder/tests/Unit/FieldValueValidatorTest.php b/packages/builder/tests/Unit/FieldValueValidatorTest.php new file mode 100644 index 0000000000..8fef1eaf01 --- /dev/null +++ b/packages/builder/tests/Unit/FieldValueValidatorTest.php @@ -0,0 +1,105 @@ + $validator->assertValid($field, [ + ['merkmal' => '', 'enthalten' => false], + ]))->toThrow(ValidationException::class); +}); + +it('accepts filled repeater rows', function (): void { + $field = new FieldDefinition( + name: 'ausstattung', + label: 'Ausstattung', + type: 'repeater', + children: collect([ + new FieldDefinition(name: 'merkmal', label: 'Merkmal', type: 'text'), + new FieldDefinition(name: 'enthalten', label: 'Enthalten', type: 'toggle'), + ]), + ); + + app(FieldValueValidator::class)->assertValid($field, [ + ['merkmal' => 'Sitzheizung', 'enthalten' => true], + ]); + + expect(true)->toBeTrue(); +}); + +it('validates required nested fields inside repeaters', function (): void { + $field = new FieldDefinition( + name: 'ausstattung', + label: 'Ausstattung', + type: 'repeater', + children: collect([ + new FieldDefinition( + name: 'merkmal', + label: 'Merkmal', + type: 'text', + validation: ['required' => true, 'rules' => []], + ), + ]), + ); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [ + ['merkmal' => ''], + ]))->toThrow(ValidationException::class); +}); + +it('builds nested validation rules for repeater children', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Vehicle data', + 'slug' => 'vehicle-data', + 'location_rules' => [], + 'active' => true, + ]); + + $repeater = $group->fields()->create([ + 'name' => 'ausstattung', + 'label' => 'Ausstattung', + 'type' => 'repeater', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + $repeater->children()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'merkmal', + 'label' => 'Merkmal', + 'type' => 'text', + 'sort' => 0, + 'validation' => ['required' => true, 'rules' => []], + ]); + + $group->load(['fields.children']); + $definition = FieldGroupDefinition::fromModel($group); + + $rules = app(SchemaCompiler::class)->rules(collect([$definition])); + + expect($rules)->toHaveKey('ausstattung.*.merkmal') + ->and($rules['ausstattung.*.merkmal'])->toContain('required'); +}); diff --git a/packages/builder/tests/Unit/FieldVisibilityTest.php b/packages/builder/tests/Unit/FieldVisibilityTest.php new file mode 100644 index 0000000000..035e1470e9 --- /dev/null +++ b/packages/builder/tests/Unit/FieldVisibilityTest.php @@ -0,0 +1,183 @@ + 'a', 'label' => 'A', 'type' => 'text']); + $group = FieldGroupDefinition::fromArray(['name' => 'G', 'slug' => 'g', 'placement' => 'default', 'fields' => []]); + + foreach (FieldVisibility::CONTEXTS as $context) { + expect($field->isVisibleIn($context))->toBeTrue() + ->and($group->isVisibleIn($context))->toBeTrue(); + } +}); + +it('removes groups hidden in the requested context but keeps them elsewhere', function (): void { + $groups = collect([ + FieldGroupDefinition::fromArray([ + 'name' => 'Visible', 'slug' => 'v', 'placement' => 'default', + 'fields' => [['name' => 'a', 'label' => 'A', 'type' => 'text']], + ]), + FieldGroupDefinition::fromArray([ + 'name' => 'Hidden', 'slug' => 'h', 'placement' => 'default', + 'settings' => ['visible_admin' => false], + 'fields' => [['name' => 'b', 'label' => 'B', 'type' => 'text']], + ]), + ]); + + expect(FieldVisibility::filterGroups($groups, FieldVisibility::ADMIN)->pluck('slug')->all())->toBe(['v']) + ->and(FieldVisibility::filterGroups($groups, FieldVisibility::API)->pluck('slug')->all())->toBe(['v', 'h']); +}); + +it('removes individual fields hidden in the requested context, cascading into children', function (): void { + $group = FieldGroupDefinition::fromArray([ + 'name' => 'G', 'slug' => 'g', 'placement' => 'default', + 'fields' => [ + ['name' => 'public', 'label' => 'Public', 'type' => 'text'], + ['name' => 'secret', 'label' => 'Secret', 'type' => 'text', 'settings' => ['visible_api' => false]], + ['name' => 'wrapper', 'label' => 'Wrapper', 'type' => 'repeater', 'children' => [ + ['name' => 'child-visible', 'label' => 'CV', 'type' => 'text'], + ['name' => 'child-hidden', 'label' => 'CH', 'type' => 'text', 'settings' => ['visible_api' => false]], + ]], + ], + ]); + + $filtered = FieldVisibility::filterFields($group->fields, FieldVisibility::API); + + expect($filtered->pluck('name')->all())->toBe(['public', 'wrapper']) + ->and($filtered->firstWhere('name', 'wrapper')->children->pluck('name')->all())->toBe(['child-visible']); +}); + +it('round trips per-context visibility through field group persistence', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Visibility', + 'slug' => 'visibility-roundtrip', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + $persistence = app(FieldGroupPersistence::class); + + $persistence->sync($group, [ + 'name' => 'Visibility', + 'slug' => 'visibility-roundtrip', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'secret', + 'label' => 'Secret', + 'type' => 'text', + 'required' => false, + 'settings' => [ + 'visible_admin' => true, + 'visible_frontend' => false, + 'visible_api' => false, + ], + ], + ], + ]); + + $stored = $group->fields()->where('name', 'secret')->first(); + + expect($stored->settings)->toMatchArray([ + 'visible_admin' => true, + 'visible_frontend' => false, + 'visible_api' => false, + ]); + + $rows = $persistence->fieldRowsForForm($group->fresh()); + + expect($rows[0]['settings'])->toMatchArray([ + 'visible_admin' => true, + 'visible_frontend' => false, + 'visible_api' => false, + ]); +}); + +it('filters entity fields per context via the custom fields manager', function (): void { + FieldGroup::query()->delete(); + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Public group', + 'slug' => 'public-group', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'public', + 'label' => 'Public', + 'type' => 'text', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'admin-only', + 'label' => 'Admin only', + 'type' => 'text', + 'sort' => 1, + 'settings' => ['visible_api' => false], + 'validation' => ['required' => false, 'rules' => []], + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'api-only', + 'label' => 'API only', + 'type' => 'text', + 'sort' => 2, + 'settings' => ['visible_admin' => false], + 'validation' => ['required' => false, 'rules' => []], + ]); + + $internal = FieldGroup::query()->create([ + 'name' => 'Internal group', + 'slug' => 'internal-group', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'settings' => ['visible_api' => false], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $internal->getKey(), + 'name' => 'note', + 'label' => 'Note', + 'type' => 'text', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $manager = app(CustomFieldsManager::class); + + $adminNames = $manager->visibleFieldsForEntity('item', FieldVisibility::ADMIN)->pluck('name')->all(); + $apiNames = $manager->visibleFieldsForEntity('item', FieldVisibility::API)->pluck('name')->all(); + + expect($adminNames)->toContain('public', 'admin-only', 'note') + ->and($adminNames)->not->toContain('api-only') + ->and($apiNames)->toContain('public', 'api-only') + ->and($apiNames)->not->toContain('admin-only') + ->and($apiNames)->not->toContain('note'); +}); diff --git a/packages/builder/tests/Unit/FileFieldTypeTest.php b/packages/builder/tests/Unit/FileFieldTypeTest.php new file mode 100644 index 0000000000..0b12c480f3 --- /dev/null +++ b/packages/builder/tests/Unit/FileFieldTypeTest.php @@ -0,0 +1,173 @@ +markTestSkipped('moox/media is not installed.'); + } + + $this->createItemsTable(); + MediaTestHelpers::ensureMediaTableExists(); +}); + +it('normalizes stored file snapshots', function (): void { + $fieldType = new FileFieldType; + + $snapshot = [ + 'id' => 42, + 'file_name' => 'brochure.pdf', + 'title' => 'Brochure', + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ]; + + expect($fieldType->castValue($snapshot))->toBe($snapshot) + ->and($fieldType->castValue(json_encode($snapshot)))->toBe($snapshot) + ->and($fieldType->castValue(null))->toBeNull(); +}); + +it('normalizes file values for the media picker form state', function (): void { + $fieldType = new FileFieldType; + + expect($fieldType->normalizeForForm([ + 'id' => 7, + 'file_name' => 'contract.pdf', + ]))->toBe([7]) + ->and($fieldType->normalizeForForm([]))->toBe([]); +}); + +it('persists form state as a media snapshot', function (): void { + MediaTestHelpers::seedMedia(99, 'existing.pdf', 'application/pdf'); + + $fieldType = new FileFieldType; + + $snapshot = [ + 'id' => 99, + 'file_name' => 'existing.pdf', + 'title' => null, + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ]; + + expect($fieldType->persistValue($snapshot))->toBe($snapshot) + ->and($fieldType->persistValue([99]))->toBe($snapshot) + ->and($fieldType->persistValue([]))->toBeNull(); +}); + +it('stores file values in value_json', function (): void { + expect(TypedValueColumns::columnForType('file'))->toBe('value_json'); +}); + +it('configures the file picker to exclude images', function (): void { + $field = new FieldDefinition('attachment', 'Attachment', 'file'); + $component = (new FileFieldType)->formComponent($field); + $config = $component->getUploadConfig(); + + expect($config['excluded_mime_prefixes'] ?? null)->toBe(['image/']) + ->and($config['accepted_file_types'] ?? [])->toContain('application/*'); +}); + +it('saves file values through the custom fields manager', function (): void { + MediaTestHelpers::seedMedia(3, 'download.pdf', 'application/pdf'); + + $item = TestItem::query()->create(['title' => 'Post']); + $field = new FieldDefinition('attachment', 'Attachment', 'file'); + + app(CustomFieldsManager::class)->saveValues('item', $item, [ + 'attachment' => [3], + ], collect([$field])); + + $stored = FieldValue::query() + ->forRecord('item', $item->getKey()) + ->where('field_name', 'attachment') + ->first(); + + expect($stored?->value_json)->toMatchArray([ + 'id' => 3, + 'file_name' => 'download.pdf', + ]); +}); + +it('rejects file values that reference missing media', function (): void { + $field = new FieldDefinition('attachment', 'Attachment', 'file'); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [404])) + ->toThrow(ValidationException::class); +}); + +it('rejects image media for file fields', function (): void { + MediaTestHelpers::seedMedia(50, 'photo.jpg', 'image/jpeg'); + + $field = new FieldDefinition('attachment', 'Attachment', 'file'); + $item = TestItem::query()->create(['title' => 'Post']); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [50], $item)) + ->toThrow(ValidationException::class); +}); + +it('accepts non-image media for file fields', function (): void { + MediaTestHelpers::seedMedia(51, 'notes.pdf', 'application/pdf'); + + $field = new FieldDefinition('attachment', 'Attachment', 'file'); + $item = TestItem::query()->create(['title' => 'Post']); + + app(FieldValueValidator::class)->assertValid($field, [51], $item); + + expect(true)->toBeTrue(); +}); + +it('rejects file values outside the record media scope', function (): void { + $item = TestItem::query()->create(['title' => 'Post']); + $item->setAttribute('scope', ScopeValue::forKeyString('item', ScopeValue::MODE_PRIVATE, 'item', '1')); + + $allowedScope = MediaFieldValueSupport::resolveExpectedMediaScope($item); + + MediaTestHelpers::seedMedia(60, 'allowed.pdf', 'application/pdf', $allowedScope); + MediaTestHelpers::seedMedia(61, 'foreign.pdf', 'application/pdf', 'media:other:99:private'); + + $field = new FieldDefinition('attachment', 'Attachment', 'file'); + + app(FieldValueValidator::class)->assertValid($field, [60], $item); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [61], $item)) + ->toThrow(ValidationException::class); +}); + +it('presents file values through the media item resource', function (): void { + $field = new FieldDefinition('attachment', 'Attachment', 'file'); + $snapshot = [ + 'id' => 12, + 'file_name' => 'manual.pdf', + 'title' => 'Manual', + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ]; + + expect(app(BuilderValuesResolver::class)->presentFieldValue($field, $snapshot)) + ->toBe(MediaFieldValueSupport::presentSingle($snapshot)); +}); diff --git a/packages/builder/tests/Unit/FlexibleContentFieldTypeTest.php b/packages/builder/tests/Unit/FlexibleContentFieldTypeTest.php new file mode 100644 index 0000000000..4e690f06ef --- /dev/null +++ b/packages/builder/tests/Unit/FlexibleContentFieldTypeTest.php @@ -0,0 +1,227 @@ +createItemsTable(); +}); + +it('syncs flexible content layouts and nested subfields', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Flexible', + 'slug' => 'flexible', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Flexible', + 'slug' => 'flexible', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'content', + 'label' => 'Content', + 'type' => 'flexible_content', + 'required' => false, + 'layouts' => [ + [ + 'name' => 'hero', + 'label' => 'Hero', + 'children' => [ + ['name' => 'title', 'label' => 'Title', 'type' => 'text', 'required' => true], + ], + ], + [ + 'name' => 'text', + 'label' => 'Text', + 'children' => [ + ['name' => 'body', 'label' => 'Body', 'type' => 'textarea'], + ], + ], + ], + ], + ], + ]); + + $group->load(['fields.children.children']); + + $content = $group->fields->firstWhere('name', 'content'); + + expect($content)->not->toBeNull() + ->and($content->children)->toHaveCount(2) + ->and($content->children->first()->type)->toBe('flexible_layout') + ->and($content->children->first()->children)->toHaveCount(1); +}); + +it('compiles flexible content as a filament builder with layout blocks', function (): void { + $field = new FieldDefinition( + name: 'content', + label: 'Content', + type: 'flexible_content', + children: collect([ + new FieldDefinition( + name: 'hero', + label: 'Hero', + type: 'flexible_layout', + children: collect([ + new FieldDefinition(name: 'title', label: 'Title', type: 'text'), + ]), + ), + new FieldDefinition( + name: 'text', + label: 'Text', + type: 'flexible_layout', + children: collect([ + new FieldDefinition(name: 'body', label: 'Body', type: 'textarea'), + ]), + ), + ]), + ); + + $component = app(FieldTypeRegistry::class) + ->get('flexible_content') + ->formComponent($field); + + expect($component)->toBeInstanceOf(Builder::class); +}); + +it('normalizes stored flexible content as a list for filament builder hydration', function (): void { + $fieldType = app(FieldTypeRegistry::class)->get('flexible_content'); + + $normalized = $fieldType->normalizeForForm([ + ['type' => 'hero', 'data' => ['title' => 'Hello']], + ['type' => 'text', 'data' => ['body' => 'World']], + ]); + + expect($normalized)->toHaveCount(2) + ->and(array_is_list($normalized))->toBeTrue() + ->and($normalized[0])->toMatchArray(['type' => 'hero', 'data' => ['title' => 'Hello']]) + ->and($normalized[1])->toMatchArray(['type' => 'text', 'data' => ['body' => 'World']]); +}); + +it('persists and loads flexible content values', function (): void { + $record = TestItem::query()->create(['title' => 'Flexible test']); + $manager = app(CustomFieldsManager::class); + + $field = new FieldDefinition( + name: 'content', + label: 'Content', + type: 'flexible_content', + children: collect([ + new FieldDefinition( + name: 'hero', + label: 'Hero', + type: 'flexible_layout', + children: collect([ + new FieldDefinition(name: 'title', label: 'Title', type: 'text'), + ]), + ), + ]), + ); + + $value = [ + ['type' => 'hero', 'data' => ['title' => 'Hello']], + ]; + + $manager->saveValues('item', $record, ['content' => $value], collect([$field])); + + $loaded = $manager->loadValues('item', $record, collect([$field])); + + expect($loaded['content'])->toBe([ + ['type' => 'hero', 'data' => ['title' => 'Hello']], + ]); +}); + +it('rejects empty flexible content blocks', function (): void { + $field = new FieldDefinition( + name: 'content', + label: 'Content', + type: 'flexible_content', + children: collect([ + new FieldDefinition( + name: 'hero', + label: 'Hero', + type: 'flexible_layout', + children: collect([ + new FieldDefinition( + name: 'title', + label: 'Title', + type: 'text', + validation: ['required' => true, 'rules' => []], + ), + ]), + ), + ]), + ); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [ + ['type' => 'hero', 'data' => ['title' => '']], + ]))->toThrow(ValidationException::class); +}); + +it('persists default values configured on flexible layout subfields', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Flexible defaults', + 'slug' => 'flexible-defaults', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Flexible defaults', + 'slug' => 'flexible-defaults', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'content', + 'label' => 'Content', + 'type' => 'flexible_content', + 'required' => false, + 'layouts' => [ + [ + 'name' => 'hero', + 'label' => 'Hero', + 'children' => [ + [ + 'name' => 'title', + 'label' => 'Title', + 'type' => 'text', + 'required' => false, + 'config' => ['default' => 'Hero default'], + ], + ], + ], + ], + ], + ], + ]); + + $group->load(['fields.children.children']); + + $title = $group->fields->firstWhere('name', 'content') + ?->children->firstWhere('name', 'hero') + ?->children->firstWhere('name', 'title'); + + expect($title)->not->toBeNull() + ->and($title->config['default'] ?? null)->toBe('Hero default'); +}); diff --git a/packages/builder/tests/Unit/GalleryFieldTypeTest.php b/packages/builder/tests/Unit/GalleryFieldTypeTest.php new file mode 100644 index 0000000000..c210e5b4ee --- /dev/null +++ b/packages/builder/tests/Unit/GalleryFieldTypeTest.php @@ -0,0 +1,242 @@ +markTestSkipped('moox/media is not installed.'); + } + + $this->createItemsTable(); + MediaTestHelpers::ensureMediaTableExists(); +}); + +it('normalizes gallery values as indexed snapshots', function (): void { + $fieldType = new GalleryFieldType; + + $gallery = [ + '1' => [ + 'id' => 1, + 'file_name' => 'a.jpg', + 'title' => 'A', + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ], + '2' => [ + 'id' => 2, + 'file_name' => 'b.jpg', + 'title' => 'B', + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ], + ]; + + expect($fieldType->castValue($gallery))->toBe($gallery) + ->and(MediaFieldValueSupport::isIndexedGallery($gallery))->toBeTrue(); +}); + +it('normalizes gallery values for the media picker form state', function (): void { + $fieldType = new GalleryFieldType; + + expect($fieldType->normalizeForForm([ + '1' => ['id' => 4, 'file_name' => 'a.jpg'], + '2' => ['id' => 8, 'file_name' => 'b.jpg'], + ]))->toBe([4, 8]) + ->and($fieldType->normalizeForForm([]))->toBe([]); +}); + +it('persists gallery form state as indexed snapshots', function (): void { + MediaTestHelpers::seedMedia(4, 'a.jpg'); + MediaTestHelpers::seedMedia(8, 'b.jpg'); + + $fieldType = new GalleryFieldType; + + expect($fieldType->persistValue([4, 8]))->toBe([ + '1' => [ + 'id' => 4, + 'file_name' => 'a.jpg', + 'title' => null, + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ], + '2' => [ + 'id' => 8, + 'file_name' => 'b.jpg', + 'title' => null, + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ], + ]); +}); + +it('stores gallery values in value_json', function (): void { + expect(TypedValueColumns::columnForType('gallery'))->toBe('value_json'); +}); + +it('configures the gallery picker to only show images', function (): void { + $field = new FieldDefinition('photos', 'Photos', 'gallery'); + $config = (new GalleryFieldType)->formComponent($field)->getUploadConfig(); + + expect($config['only_mime_prefixes'] ?? null)->toBe(['image/']) + ->and($config['accepted_file_types'] ?? [])->toContain('image/*'); +}); + +it('saves gallery values through the custom fields manager', function (): void { + MediaTestHelpers::seedMedia(5, 'one.jpg'); + MediaTestHelpers::seedMedia(6, 'two.jpg'); + + $item = TestItem::query()->create(['title' => 'Post']); + $field = new FieldDefinition('photos', 'Photos', 'gallery'); + + app(CustomFieldsManager::class)->saveValues('item', $item, [ + 'photos' => [5, 6], + ], collect([$field])); + + $stored = FieldValue::query() + ->forRecord('item', $item->getKey()) + ->where('field_name', 'photos') + ->first(); + + expect($stored?->value_json)->toBe([ + '1' => [ + 'id' => 5, + 'file_name' => 'one.jpg', + 'title' => null, + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ], + '2' => [ + 'id' => 6, + 'file_name' => 'two.jpg', + 'title' => null, + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ], + ]); +}); + +it('rejects gallery values with missing media', function (): void { + $field = new FieldDefinition('photos', 'Photos', 'gallery'); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [404])) + ->toThrow(ValidationException::class); +}); + +it('validates gallery min and max file limits', function (): void { + MediaTestHelpers::seedMedia(1, 'one.jpg'); + + $field = new FieldDefinition( + name: 'photos', + label: 'Photos', + type: 'gallery', + config: ['min_files' => 2, 'max_files' => 3], + ); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [1])) + ->toThrow(ValidationException::class); + + MediaTestHelpers::seedMedia(2, 'two.jpg'); + MediaTestHelpers::seedMedia(3, 'three.jpg'); + MediaTestHelpers::seedMedia(4, 'four.jpg'); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [1, 2, 3, 4])) + ->toThrow(ValidationException::class); + + expect(app(FieldValueValidator::class)->messagesFor( + $field, + [1, 2], + ))->toBe([]); +}); + +it('presents gallery values with indexed output', function (): void { + $field = new FieldDefinition('photos', 'Photos', 'gallery'); + $gallery = [ + '1' => [ + 'id' => 12, + 'file_name' => 'banner.webp', + 'title' => 'Banner', + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ], + ]; + + expect(app(BuilderValuesResolver::class)->presentFieldValue($field, $gallery)) + ->toBe(MediaFieldValueSupport::presentGallery($gallery)); +}); + +it('replaces snapshots inside gallery values during metadata sync', function (): void { + $fresh = [ + 'id' => 2, + 'file_name' => 'updated.jpg', + 'title' => 'Updated', + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ]; + + $stored = [ + '1' => ['id' => 1, 'file_name' => 'one.jpg', 'title' => null, 'alt' => null, 'description' => null, 'internal_note' => null], + '2' => ['id' => 2, 'file_name' => 'two.jpg', 'title' => 'Old', 'alt' => null, 'description' => null, 'internal_note' => null], + ]; + + expect(MediaFieldValueSupport::replaceSnapshotInStoredValue($stored, 2, $fresh)) + ->toBe([ + '1' => $stored['1'], + '2' => $fresh, + ]); +}); + +it('rejects gallery values that are not images', function (): void { + MediaTestHelpers::seedMedia(70, 'document.pdf', 'application/pdf'); + + $field = new FieldDefinition('photos', 'Photos', 'gallery'); + $item = TestItem::query()->create(['title' => 'Post']); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [70], $item)) + ->toThrow(ValidationException::class); +}); + +it('rejects gallery values outside the record media scope', function (): void { + $item = TestItem::query()->create(['title' => 'Post']); + $item->setAttribute('scope', ScopeValue::forKeyString('item', ScopeValue::MODE_PRIVATE, 'item', '1')); + + $allowedScope = MediaFieldValueSupport::resolveExpectedMediaScope($item); + + MediaTestHelpers::seedMedia(80, 'allowed.jpg', 'image/jpeg', $allowedScope); + MediaTestHelpers::seedMedia(81, 'foreign.jpg', 'image/jpeg', 'media:other:99:private'); + + $field = new FieldDefinition('photos', 'Photos', 'gallery'); + + app(FieldValueValidator::class)->assertValid($field, [80], $item); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [81], $item)) + ->toThrow(ValidationException::class); +}); diff --git a/packages/builder/tests/Unit/ImageFieldTypeTest.php b/packages/builder/tests/Unit/ImageFieldTypeTest.php new file mode 100644 index 0000000000..8fe767c189 --- /dev/null +++ b/packages/builder/tests/Unit/ImageFieldTypeTest.php @@ -0,0 +1,226 @@ +markTestSkipped('moox/media is not installed.'); + } + + $this->createItemsTable(); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Media', + 'slug' => 'media', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'hero_image', + 'label' => 'Hero image', + 'type' => 'image', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); +}); + +it('normalizes stored image snapshots', function (): void { + $fieldType = new ImageFieldType; + + $snapshot = [ + 'id' => 42, + 'file_name' => 'hero.jpg', + 'title' => 'Hero', + 'alt' => 'Hero alt', + 'description' => null, + 'internal_note' => null, + ]; + + expect($fieldType->castValue($snapshot))->toBe($snapshot) + ->and($fieldType->castValue(json_encode($snapshot)))->toBe($snapshot) + ->and($fieldType->castValue(null))->toBeNull(); +}); + +it('normalizes image values for the media picker form state', function (): void { + $fieldType = new ImageFieldType; + + expect($fieldType->normalizeForForm([ + 'id' => 7, + 'file_name' => 'cover.png', + ]))->toBe([7]) + ->and($fieldType->normalizeForForm([]))->toBe([]) + ->and($fieldType->normalizeForForm(null))->toBe([]); +}); + +it('persists form state as a media snapshot', function (): void { + $fieldType = new ImageFieldType; + + MediaTestHelpers::seedMedia(99, 'existing.jpg'); + + $snapshot = [ + 'id' => 99, + 'file_name' => 'existing.jpg', + 'title' => null, + 'alt' => null, + 'description' => null, + 'internal_note' => null, + ]; + + expect($fieldType->persistValue($snapshot))->toBe($snapshot) + ->and($fieldType->persistValue([99]))->toBe($snapshot) + ->and($fieldType->persistValue([]))->toBeNull(); +}); + +it('stores image values in value_json', function (): void { + expect(TypedValueColumns::columnForType('image'))->toBe('value_json'); +}); + +it('configures the image picker to only show images', function (): void { + $field = new FieldDefinition('hero_image', 'Hero image', 'image'); + $config = (new ImageFieldType)->formComponent($field)->getUploadConfig(); + + expect($config['only_mime_prefixes'] ?? null)->toBe(['image/']) + ->and($config['accepted_file_types'] ?? [])->toContain('image/*'); +}); + +it('loads and presents image custom field values', function (): void { + $item = TestItem::query()->create(['title' => 'Post']); + + $snapshot = [ + 'id' => 12, + 'file_name' => 'banner.webp', + 'title' => 'Banner', + 'alt' => 'Banner alt', + 'description' => null, + 'internal_note' => null, + ]; + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $item->getKey(), + 'field_name' => 'hero_image', + 'value_json' => $snapshot, + ]); + + $item->flushCustomFieldsCache(); + + expect($item->customField('hero_image'))->toBe($snapshot); + + $field = new FieldDefinition('hero_image', 'Hero image', 'image'); + + expect(app(BuilderValuesResolver::class)->presentFieldValue($field, $snapshot)) + ->toBe($snapshot); +}); + +it('extracts media ids from mixed state shapes', function (): void { + expect(MediaFieldValueSupport::extractIds([42]))->toBe([42]) + ->and(MediaFieldValueSupport::extractIds(['id' => 5, 'file_name' => 'a.jpg']))->toBe([5]) + ->and(MediaFieldValueSupport::extractIds(null))->toBe([]); +}); + +it('saves image values through the custom fields manager', function (): void { + MediaTestHelpers::seedMedia(3, 'thumb.jpg'); + + $item = TestItem::query()->create(['title' => 'Post']); + $field = new FieldDefinition('hero_image', 'Hero image', 'image'); + + app(CustomFieldsManager::class)->saveValues('item', $item, [ + 'hero_image' => [3], + ], collect([$field])); + + $item->flushCustomFieldsCache(); + + $stored = FieldValue::query() + ->forRecord('item', $item->getKey()) + ->where('field_name', 'hero_image') + ->first(); + + expect($stored)->not->toBeNull() + ->and($stored->value_json)->toMatchArray([ + 'id' => 3, + 'file_name' => 'thumb.jpg', + ]) + ->and($item->customField('hero_image'))->toBe($stored->value_json); +}); + +it('rejects image values that reference missing media', function (): void { + $field = new FieldDefinition('hero_image', 'Hero image', 'image'); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [404])) + ->toThrow(ValidationException::class); + + expect(fn () => app(CustomFieldsManager::class)->saveValues( + 'item', + TestItem::query()->create(['title' => 'Post']), + ['hero_image' => [404]], + collect([$field]), + ))->toThrow(ValidationException::class); +}); + +it('validates required image fields as empty when no media is selected', function (): void { + $field = new FieldDefinition( + name: 'hero_image', + label: 'Hero image', + type: 'image', + validation: ['required' => true, 'rules' => []], + ); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [])) + ->toThrow(ValidationException::class); +}); + +it('rejects image values that are not images', function (): void { + MediaTestHelpers::seedMedia(50, 'document.pdf', 'application/pdf'); + + $field = new FieldDefinition('hero_image', 'Hero image', 'image'); + $item = TestItem::query()->create(['title' => 'Post']); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [50], $item)) + ->toThrow(ValidationException::class); +}); + +it('rejects image values outside the record media scope', function (): void { + $item = TestItem::query()->create(['title' => 'Post']); + $item->setAttribute('scope', ScopeValue::forKeyString('item', ScopeValue::MODE_PRIVATE, 'item', '1')); + + $allowedScope = MediaFieldValueSupport::resolveExpectedMediaScope($item); + + MediaTestHelpers::seedMedia(60, 'allowed.jpg', 'image/jpeg', $allowedScope); + MediaTestHelpers::seedMedia(61, 'foreign.jpg', 'image/jpeg', 'media:other:99:private'); + + $field = new FieldDefinition('hero_image', 'Hero image', 'image'); + + app(FieldValueValidator::class)->assertValid($field, [60], $item); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, [61], $item)) + ->toThrow(ValidationException::class); +}); diff --git a/packages/builder/tests/Unit/InteractsWithCustomFieldsTest.php b/packages/builder/tests/Unit/InteractsWithCustomFieldsTest.php new file mode 100644 index 0000000000..695904aaaf --- /dev/null +++ b/packages/builder/tests/Unit/InteractsWithCustomFieldsTest.php @@ -0,0 +1,272 @@ +createItemsTable(); + + FieldGroup::query()->delete(); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $group = FieldGroup::query()->create([ + 'name' => 'Vehicle data', + 'slug' => 'vehicle-data', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'fahrzeugtyp-modell', + 'label' => 'Model', + 'type' => 'text', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'farbe', + 'label' => 'Farbe', + 'type' => 'text', + 'sort' => 1, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'unfallfrei', + 'label' => 'Unfallfrei', + 'type' => 'toggle', + 'sort' => 2, + 'config' => ['default' => true], + 'validation' => ['required' => false, 'rules' => []], + ]); +}); + +it('loads builder values through the model trait', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + 'field_name' => 'fahrzeugtyp-modell', + 'value_string' => 'Golf GTI', + ]); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + 'field_name' => 'unfallfrei', + 'value_boolean' => true, + ]); + + expect($record->customField('fahrzeugtyp-modell'))->toBe('Golf GTI') + ->and($record->customField('unfallfrei'))->toBeTrue() + ->and($record->hasCustomField('fahrzeugtyp-modell'))->toBeTrue() + ->and($record->hasCustomField('missing'))->toBeFalse() + ->and($record->customFields())->toMatchArray([ + 'fahrzeugtyp-modell' => 'Golf GTI', + 'unfallfrei' => true, + ]); +}); + +it('merges configured defaults when no stored value exists', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + expect($record->customField('unfallfrei'))->toBeTrue() + ->and($record->hasCustomField('unfallfrei'))->toBeTrue(); +}); + +it('exposes valid builder field names as native model attributes', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + $record->setCustomField('farbe', 'Blau'); + + expect($record->farbe)->toBe('Blau') + ->and($record->getAttribute('farbe'))->toBe('Blau') + ->and($record->title)->toBe('Demo'); +}); + +it('merges builder values into model arrays for api style serialization', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + $record->setCustomField('farbe', 'Rot'); + + expect($record->toArray())->toMatchArray([ + 'title' => 'Demo', + 'farbe' => 'Rot', + ]); +}); + +it('saves builder values through the model trait', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + $record->setCustomFields([ + 'fahrzeugtyp-modell' => 'Polo GTI', + 'unfallfrei' => false, + ]); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->get() + ->keyBy('field_name'); + + expect($stored['fahrzeugtyp-modell']->value_string)->toBe('Polo GTI') + ->and($stored['unfallfrei']->value_boolean)->toBeFalse() + ->and($record->fresh()->customField('fahrzeugtyp-modell'))->toBe('Polo GTI') + ->and($record->fresh()->customField('unfallfrei'))->toBeFalse(); +}); + +it('updates a single builder field through the model trait', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + $record->setCustomField('fahrzeugtyp-modell', 'Golf R'); + + expect($record->fresh()->customField('fahrzeugtyp-modell'))->toBe('Golf R'); +}); + +it('clears a builder field through the model trait', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + $record->setCustomField('farbe', 'Blau'); + + $record->clearCustomField('farbe'); + + expect(FieldValue::query()->forRecord('item', $record->getKey())->where('field_name', 'farbe')->exists())->toBeFalse() + ->and($record->fresh()->customField('farbe', 'missing'))->toBe('missing'); +}); + +it('rejects unknown builder fields when saving through the model trait', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + expect(fn () => $record->setCustomField('unknown-field', 'value')) + ->toThrow(InvalidArgumentException::class); +}); + +it('resolves the builder entity key from the model', function (): void { + expect(TestItem::resolveCustomFieldsEntity())->toBe('item'); +}); + +it('lists builder field definitions for the entity', function (): void { + expect(TestItem::customFieldNames())->toContain('farbe', 'unfallfrei', 'fahrzeugtyp-modell'); +}); + +it('queries records by builder field values using normal where syntax', function (): void { + $blue = TestItem::query()->create(['title' => 'Blue car']); + $red = TestItem::query()->create(['title' => 'Red car']); + + $blue->setCustomField('farbe', 'Blau'); + $red->setCustomField('farbe', 'Rot'); + + $results = TestItem::query() + ->where('farbe', 'Blau') + ->pluck('title') + ->all(); + + expect($results)->toBe(['Blue car']); +}); + +it('queries native database columns normally even when a builder field shares the name', function (): void { + $group = FieldGroup::query()->first(); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'title', + 'label' => 'Shadow title', + 'type' => 'text', + 'sort' => 3, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $record = TestItem::query()->create(['title' => 'Native title']); + $record->setCustomField('title', 'Builder title'); + + $results = TestItem::query() + ->where('title', 'Native title') + ->pluck('id') + ->all(); + + expect($results)->toBe([$record->getKey()]); +}); + +it('eager loads builder values for a collection with one query', function (): void { + $first = TestItem::query()->create(['title' => 'First']); + $second = TestItem::query()->create(['title' => 'Second']); + + $first->setCustomField('farbe', 'Blau'); + $second->setCustomField('farbe', 'Rot'); + + $records = TestItem::query()->withCustomFields()->orderBy('id')->get(); + + DB::enableQueryLog(); + + expect($records[0]->farbe)->toBe('Blau') + ->and($records[1]->farbe)->toBe('Rot'); + + $valueQueries = collect(DB::getQueryLog()) + ->filter(fn (array $query): bool => str_contains($query['query'], 'builder_field_values')); + + expect($valueQueries)->toHaveCount(0); +}); + +it('prefers native model attributes over builder fields with the same name', function (): void { + $group = FieldGroup::query()->first(); + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'title', + 'label' => 'Shadow title', + 'type' => 'text', + 'sort' => 3, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $record = TestItem::query()->create(['title' => 'Native title']); + $record->setCustomField('title', 'Builder title'); + + expect($record->title)->toBe('Native title'); +}); + +it('masks password values in debug output', function (): void { + $group = FieldGroup::query()->first(); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'secret', + 'label' => 'Secret', + 'type' => 'password', + 'sort' => 4, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $record = TestItem::query()->create(['title' => 'Demo']); + $record->setCustomField('secret', 'top-secret'); + + expect($record->__debugInfo()['secret'])->toBe('••••••••') + ->and($record->customField('secret'))->toBe(StoredPassword::instance()); +}); diff --git a/packages/builder/tests/Unit/LayoutFieldTypesTest.php b/packages/builder/tests/Unit/LayoutFieldTypesTest.php new file mode 100644 index 0000000000..0e7f14d2c8 --- /dev/null +++ b/packages/builder/tests/Unit/LayoutFieldTypesTest.php @@ -0,0 +1,265 @@ +createItemsTable(); +}); + +it('syncs nested subfields for group and repeater fields', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Nested', + 'slug' => 'nested', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Nested', + 'slug' => 'nested', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'address', + 'label' => 'Address', + 'type' => 'group', + 'required' => false, + 'children' => [ + ['name' => 'street', 'label' => 'Street', 'type' => 'text', 'required' => false], + ['name' => 'city', 'label' => 'City', 'type' => 'text', 'required' => false], + ], + ], + [ + 'name' => 'slides', + 'label' => 'Slides', + 'type' => 'repeater', + 'required' => false, + 'children' => [ + ['name' => 'title', 'label' => 'Title', 'type' => 'text', 'required' => false], + ], + ], + ], + ]); + + $group->load(['fields.children']); + + $address = $group->fields->firstWhere('name', 'address'); + $slides = $group->fields->firstWhere('name', 'slides'); + + expect($address)->not->toBeNull() + ->and($address->children)->toHaveCount(2) + ->and($slides)->not->toBeNull() + ->and($slides->children)->toHaveCount(1); +}); + +it('compiles group fields with nested subfield components', function (): void { + $field = new FieldDefinition( + name: 'address', + label: 'Address', + type: 'group', + children: collect([ + new FieldDefinition(name: 'street', label: 'Street', type: 'text'), + new FieldDefinition(name: 'city', label: 'City', type: 'text'), + ]), + ); + + $component = app(FieldTypeRegistry::class) + ->get('group') + ->formComponent($field); + + expect($component)->toBeInstanceOf(Fieldset::class) + ->and($component->getDefaultChildComponents())->toHaveCount(2); +}); + +it('compiles persisted group fields into runtime form components', function (): void { + require_once __DIR__.'/../Support/TestItemResource.php'; + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Grouped', + 'slug' => 'grouped', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Grouped', + 'slug' => 'grouped', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'gruop', + 'label' => 'Gruop', + 'type' => 'group', + 'required' => false, + 'children' => [ + ['name' => 'street', 'label' => 'Street', 'type' => 'text', 'required' => false], + ['name' => 'city', 'label' => 'City', 'type' => 'text', 'required' => false], + ], + ], + ], + ]); + + $sections = TestItemResource::customFieldComponents(); + + $fieldset = collect($sections[0]->getDefaultChildComponents()) + ->first(fn ($component) => $component instanceof Fieldset); + + expect($fieldset)->not->toBeNull() + ->and($fieldset->getDefaultChildComponents())->toHaveCount(2); +}); + +it('persists group and repeater values as json', function (): void { + $record = TestItem::query()->create(['title' => 'Layout test']); + $manager = app(CustomFieldsManager::class); + + $groupField = new FieldDefinition( + name: 'address', + label: 'Address', + type: 'group', + children: collect([ + new FieldDefinition(name: 'street', label: 'Street', type: 'text'), + new FieldDefinition(name: 'city', label: 'City', type: 'text'), + ]), + ); + + $repeaterField = new FieldDefinition( + name: 'slides', + label: 'Slides', + type: 'repeater', + children: collect([ + new FieldDefinition(name: 'title', label: 'Title', type: 'text'), + ]), + ); + + $manager->saveValues('item', $record, [ + 'address' => [['street' => 'Main St', 'city' => 'Berlin']], + 'slides' => [ + ['title' => 'Slide 1'], + ['title' => 'Slide 2'], + ], + ], collect([$groupField, $repeaterField])); + + $loaded = $manager->loadValues('item', $record, collect([$groupField, $repeaterField])); + + expect($loaded['address'])->toBe(['street' => 'Main St', 'city' => 'Berlin']) + ->and($loaded['slides'])->toBe([ + ['title' => 'Slide 1'], + ['title' => 'Slide 2'], + ]); +}); + +it('applies repeater min and max items from config', function (): void { + $field = new FieldDefinition( + name: 'slides', + label: 'Slides', + type: 'repeater', + config: ['min_items' => 1, 'max_items' => 5], + children: collect([ + new FieldDefinition(name: 'title', label: 'Title', type: 'text'), + ]), + ); + + $component = app(FieldTypeRegistry::class) + ->get('repeater') + ->formComponent($field); + + expect($component)->toBeInstanceOf(Repeater::class) + ->and($component->getMinItems())->toBe(1) + ->and($component->getMaxItems())->toBe(5); +}); + +it('does not apply item limits for optional repeaters without configured bounds', function (): void { + $field = new FieldDefinition( + name: 'slides', + label: 'Slides', + type: 'repeater', + config: ['min_items' => 0, 'max_items' => 0], + children: collect([ + new FieldDefinition(name: 'title', label: 'Title', type: 'text'), + ]), + ); + + $component = app(FieldTypeRegistry::class) + ->get('repeater') + ->formComponent($field); + + expect($component->getMinItems())->toBeNull() + ->and($component->getMaxItems())->toBeNull(); +}); + +it('requires at least one repeater item when the field is required', function (): void { + $field = new FieldDefinition( + name: 'slides', + label: 'Slides', + type: 'repeater', + validation: ['required' => true, 'rules' => []], + children: collect([ + new FieldDefinition(name: 'title', label: 'Title', type: 'text'), + ]), + ); + + $component = app(FieldTypeRegistry::class) + ->get('repeater') + ->formComponent($field); + + expect($component->getMinItems())->toBe(1); +}); + +it('builds field definitions with nested children from models', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Layout', + 'slug' => 'layout', + 'location_rules' => [], + 'active' => true, + ]); + + $parent = $group->fields()->create([ + 'name' => 'details', + 'label' => 'Details', + 'type' => 'group', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + $parent->children()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'note', + 'label' => 'Note', + 'type' => 'text', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + $group->load(['fields.children']); + + $definition = FieldGroupDefinition::fromModel($group); + + expect($definition->fields)->toHaveCount(1) + ->and($definition->fields->first()->children)->toHaveCount(1) + ->and($definition->fields->first()->children->first()->name)->toBe('note'); +}); diff --git a/packages/builder/tests/Unit/LayoutWidthAndSectionTest.php b/packages/builder/tests/Unit/LayoutWidthAndSectionTest.php new file mode 100644 index 0000000000..a1582cc9d1 --- /dev/null +++ b/packages/builder/tests/Unit/LayoutWidthAndSectionTest.php @@ -0,0 +1,274 @@ +createItemsTable(); + Cache::forget(DefinitionRegistry::CACHE_KEY); + FieldGroup::query()->delete(); +}); + +it('maps width fractions to column spans on a 12 grid', function (): void { + expect(FieldWidth::columnSpan('full'))->toBe(12) + ->and(FieldWidth::columnSpan('1/2'))->toBe(6) + ->and(FieldWidth::columnSpan('1/3'))->toBe(4) + ->and(FieldWidth::columnSpan('2/3'))->toBe(8) + ->and(FieldWidth::columnSpan('1/4'))->toBe(3) + ->and(FieldWidth::columnSpan('3/4'))->toBe(9) + ->and(FieldWidth::columnSpan(null))->toBe(12) + ->and(FieldWidth::columnSpan('bogus'))->toBe(12); +}); + +it('reads width and column span from the field definition settings', function (): void { + $default = FieldDefinition::fromArray(['name' => 'a', 'label' => 'A', 'type' => 'text']); + $half = FieldDefinition::fromArray(['name' => 'b', 'label' => 'B', 'type' => 'text', 'settings' => ['width' => '1/2']]); + + expect($default->width())->toBe('full') + ->and($default->columnSpan())->toBe(12) + ->and($half->width())->toBe('1/2') + ->and($half->columnSpan())->toBe(6); +}); + +it('applies the configured width as a column span on compiled components', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Layout', + 'slug' => 'layout-width', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Layout', + 'slug' => 'layout-width', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + ['name' => 'first-name', 'label' => 'First name', 'type' => 'text', 'required' => false, 'settings' => ['width' => '1/2']], + ['name' => 'last-name', 'label' => 'Last name', 'type' => 'text', 'required' => false, 'settings' => ['width' => '1/2']], + ], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $section = TestItemResource::customFieldComponents()[0]; + $first = collect($section->getDefaultChildComponents()) + ->first(fn ($component): bool => $component->getName() === 'first-name'); + + expect(normalizeSpan($section->getColumns()))->toBe(12) + ->and(normalizeSpan($first->getColumnSpan()))->toBe(6); +}); + +it('maps group column counts to default field spans', function (): void { + expect(FieldWidth::columnsToSpan(1))->toBe(12) + ->and(FieldWidth::columnsToSpan(2))->toBe(6) + ->and(FieldWidth::columnsToSpan(3))->toBe(4) + ->and(FieldWidth::columnsToSpan(4))->toBe(3) + ->and(FieldWidth::columnsToSpan(null))->toBe(12) + ->and(FieldWidth::columnsToSpan(7))->toBe(12); +}); + +it('reads columns and default span from the group definition', function (): void { + $single = FieldGroupDefinition::fromArray([ + 'name' => 'A', 'slug' => 'a', 'placement' => 'main', + ]); + $twoCol = FieldGroupDefinition::fromArray([ + 'name' => 'B', 'slug' => 'b', 'placement' => 'main', 'settings' => ['columns' => 2], + ]); + + expect($single->columns())->toBe(1) + ->and($single->defaultColumnSpan())->toBe(12) + ->and($twoCol->columns())->toBe(2) + ->and($twoCol->defaultColumnSpan())->toBe(6); +}); + +it('flows auto-width fields into the group column layout', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Grid', + 'slug' => 'grid-columns', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Grid', + 'slug' => 'grid-columns', + 'active' => true, + 'sort' => 0, + 'settings' => ['columns' => 2], + 'target_entities' => ['item'], + 'fields' => [ + ['name' => 'street', 'label' => 'Street', 'type' => 'text', 'required' => false, 'settings' => ['width' => 'auto']], + ['name' => 'city', 'label' => 'City', 'type' => 'text', 'required' => false], + ['name' => 'country', 'label' => 'Country', 'type' => 'text', 'required' => false, 'settings' => ['width' => '1/3']], + ], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $section = TestItemResource::customFieldComponents()[0]; + $children = collect($section->getDefaultChildComponents()); + $span = fn (string $name): int|string|null => normalizeSpan( + $children->first(fn ($component): bool => $component->getName() === $name)->getColumnSpan(), + ); + + expect($span('street'))->toBe(6) + ->and($span('city'))->toBe(6) + ->and($span('country'))->toBe(4); +}); + +it('round trips width through field group persistence', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Widths', + 'slug' => 'widths', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + $persistence = app(FieldGroupPersistence::class); + + $persistence->sync($group, [ + 'name' => 'Widths', + 'slug' => 'widths', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + ['name' => 'price', 'label' => 'Price', 'type' => 'number', 'required' => false, 'settings' => ['width' => '1/3']], + ], + ]); + + expect($group->fields()->where('name', 'price')->first()->settings['width'])->toBe('1/3') + ->and($persistence->fieldRowsForForm($group->fresh())[0]['settings']['width'])->toBe('1/3'); +}); + +it('compiles a section marker as a wrapping section with flat children', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Sectioned', + 'slug' => 'sectioned', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Sectioned', + 'slug' => 'sectioned', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'internal', + 'label' => 'Internal', + 'type' => 'section', + 'required' => false, + 'children' => [ + ['name' => 'note', 'label' => 'Note', 'type' => 'text', 'required' => false], + ], + ], + ], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $rootSection = TestItemResource::customFieldComponents()[0]; + $sectionMarker = collect($rootSection->getDefaultChildComponents()) + ->first(fn ($component): bool => $component instanceof Section); + + expect($sectionMarker)->not->toBeNull() + ->and($sectionMarker->getHeading())->toBe('Internal') + ->and(collect($sectionMarker->getDefaultChildComponents())->first()->getName())->toBe('note'); +}); + +it('flattens section children into storable fields and top-level rules', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Sectioned rules', + 'slug' => 'sectioned-rules', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Sectioned rules', + 'slug' => 'sectioned-rules', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'panel', + 'label' => 'Panel', + 'type' => 'section', + 'required' => false, + 'children' => [ + ['name' => 'headline', 'label' => 'Headline', 'type' => 'text', 'required' => true], + ], + ], + ], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $storableNames = app(CustomFieldsManager::class)->fieldsForEntity('item')->pluck('name')->all(); + $rules = TestItemResource::customFieldRules(); + + expect($storableNames)->toBe(['headline']) + ->and($rules)->toHaveKey('headline') + ->and($rules['headline'])->toContain('required'); +}); + +it('stores section child values flat at the top level', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + $manager = app(CustomFieldsManager::class); + + $sectionField = new FieldDefinition( + name: 'panel', + label: 'Panel', + type: 'section', + children: collect([ + new FieldDefinition(name: 'headline', label: 'Headline', type: 'text'), + ]), + ); + + $storable = app(StorableFieldCollector::class)->definitionsFromList(collect([$sectionField])); + + $manager->saveValues('item', $record, ['headline' => 'Hello'], $storable); + + expect($storable->pluck('name')->all())->toBe(['headline']) + ->and($manager->loadValues('item', $record, $storable))->toBe(['headline' => 'Hello']); +}); + +/** + * Filament stores spans per breakpoint (e.g. ['default' => 1, 'lg' => 6]); the + * configured value lives on the lg breakpoint. + * + * @param array|int|string|null $span + */ +function normalizeSpan(array|int|string|null $span): int|string|null +{ + if (! is_array($span)) { + return $span; + } + + return $span['lg'] ?? $span['default'] ?? (array_values($span)[0] ?? null); +} diff --git a/packages/builder/tests/Unit/LocationConstraintOptionsTest.php b/packages/builder/tests/Unit/LocationConstraintOptionsTest.php new file mode 100644 index 0000000000..f87f88a858 --- /dev/null +++ b/packages/builder/tests/Unit/LocationConstraintOptionsTest.php @@ -0,0 +1,353 @@ +mergeEntityRulesWithConstraints( + ['draft'], + [ + [ + 'param' => 'taxonomy', + 'taxonomy' => 'category', + 'operator' => '==', + 'value' => '12', + ], + ], + ); + + expect($rules[0][1])->toMatchArray([ + 'param' => 'taxonomy:category', + 'operator' => '==', + 'value' => 12, + ]); +}); + +it('casts multi select taxonomy values to integer arrays', function (): void { + $persistence = app(FieldGroupPersistence::class); + + $rules = $persistence->mergeEntityRulesWithConstraints( + ['draft'], + [ + [ + 'param' => 'taxonomy', + 'taxonomy' => 'tag', + 'operator' => 'in', + 'value' => ['12', '34'], + ], + ], + ); + + expect($rules[0][1]['value'])->toBe([12, 34]); +}); + +it('resolves translatable taxonomy term labels using locale fallback chain', function (): void { + $term = new class extends Model + { + public function translate(string $locale, bool $withFallback = true): ?object + { + return $this->getRelation('translations')->firstWhere('locale', $locale); + } + }; + + $term->setRawAttributes(['id' => 1]); + $term->setRelation('translations', collect([ + (object) ['locale' => 'en_US', 'title' => 'Plops'], + ])); + + $options = app(LocationConstraintOptions::class); + $method = new ReflectionMethod(LocationConstraintOptions::class, 'termLabel'); + $method->setAccessible(true); + + expect($method->invoke($options, $term))->toBe('Plops'); +}); + +it('falls back to any available translation when preferred locales are missing', function (): void { + $term = new class extends Model + { + public function translate(string $locale, bool $withFallback = true): ?object + { + return $this->getRelation('translations')->firstWhere('locale', $locale); + } + }; + + $term->setRawAttributes(['id' => 2]); + $term->setRelation('translations', collect([ + (object) ['locale' => 'de_DE', 'title' => 'Kategorie'], + ])); + + $resolver = app(BuilderLocaleResolver::class); + $options = new LocationConstraintOptions( + app(\Moox\Builder\Registry\EntityRegistry::class), + app(\Moox\Core\Services\TaxonomyService::class), + $resolver, + ); + + $method = new ReflectionMethod(LocationConstraintOptions::class, 'termLabel'); + $method->setAccessible(true); + + $label = $resolver->using('ja_JP', fn (): string => $method->invoke($options, $term)); + + expect($label)->toBe('Kategorie'); +}); + +it('collects record type options from selected entities', function (): void { + Schema::create('typed_items', function (Blueprint $table): void { + $table->id(); + $table->string('type')->nullable(); + $table->timestamps(); + }); + + $modelClass = new class extends Model + { + protected $table = 'typed_items'; + + protected $guarded = []; + }; + + $modelClass::query()->create(['type' => 'page']); + $modelClass::query()->create(['type' => 'article']); + $modelClass::query()->create(['type' => 'page']); + + $registry = new class($modelClass::class) extends EntityRegistry + { + public function __construct(protected string $modelClass) {} + + public function modelFor(string $entity): ?string + { + return $entity === 'typed-item' ? $this->modelClass : null; + } + }; + + $options = new LocationConstraintOptions( + $registry, + app(\Moox\Core\Services\TaxonomyService::class), + app(BuilderLocaleResolver::class), + ); + + expect($options->recordTypeOptionsForEntities(['typed-item']))->toBe([ + 'article' => 'Article', + 'page' => 'Page', + ]); +}); + +it('falls back to resource type select options when no records exist yet', function (): void { + $modelClass = new class extends Model + { + protected $table = 'resource_only_items'; + }; + + $resourceClass = new class + { + public static function getTypeSelect(): \Filament\Forms\Components\Select + { + return \Filament\Forms\Components\Select::make('type') + ->options([ + 'Post' => 'Post', + 'Page' => 'Page', + ]); + } + }; + + $registry = new class($modelClass::class, $resourceClass::class) extends EntityRegistry + { + public function __construct( + protected string $modelClass, + protected string $resourceClass, + ) {} + + public function modelFor(string $entity): ?string + { + return $entity === 'resource-only-item' ? $this->modelClass : null; + } + + public function resourceFor(string $entity): ?string + { + return $entity === 'resource-only-item' ? $this->resourceClass : null; + } + }; + + $options = new LocationConstraintOptions( + $registry, + app(\Moox\Core\Services\TaxonomyService::class), + app(BuilderLocaleResolver::class), + ); + + expect($options->recordTypeOptionsForEntities(['resource-only-item']))->toBe([ + 'Page' => 'Page', + 'Post' => 'Post', + ]); +}); + +it('hides user role param when auth user model has no roles support', function (): void { + config()->set('permission.table_names.roles', 'roles'); + config()->set('auth.providers.users.model', NoRolesUser::class); + + $options = app(LocationConstraintOptions::class); + + expect($options->supportsUserRoles())->toBeFalse() + ->and($options->availableParamOptions())->toHaveKey('user_role') + ->and($options->roleOptions())->toBe([]) + ->and($options->userRoleUnavailableReason())->toBe(__('builder::builder.field_group.location_value_role_unavailable_permissions')); +}); + +it('shows user role param only when roles exist and user model has hasroles', function (): void { + config()->set('permission.table_names.roles', 'roles'); + Schema::create('roles', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + }); + + config()->set('auth.providers.users.model', RolesUser::class); + + \Illuminate\Support\Facades\DB::table('roles')->insert([ + 'name' => 'editor', + 'guard_name' => 'web', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $options = app(LocationConstraintOptions::class); + + expect($options->supportsUserRoles())->toBeTrue() + ->and($options->availableParamOptions())->toHaveKey('user_role') + ->and($options->roleOptions())->toBe(['editor' => 'editor']) + ->and($options->userRoleUnavailableReason())->toBeNull(); +}); + +it('explains when no roles exist yet', function (): void { + config()->set('permission.table_names.roles', 'roles'); + config()->set('auth.providers.users.model', RolesUser::class); + + Schema::create('roles', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + }); + + $options = app(LocationConstraintOptions::class); + + expect($options->supportsUserRoles())->toBeFalse() + ->and($options->userRoleUnavailableReason())->toBe(__('builder::builder.field_group.location_value_role_unavailable_empty')); +}); + +it('returns entity-aware condition params', function (): void { + $modelClass = new class extends Model + { + protected $table = 'resource_only_items'; + }; + + $resourceClass = new class + { + public static function getTypeSelect(): \Filament\Forms\Components\Select + { + return \Filament\Forms\Components\Select::make('type') + ->options([ + 'Post' => 'Post', + ]); + } + }; + + $registry = new class($modelClass::class, $resourceClass::class) extends EntityRegistry + { + public function __construct( + protected string $modelClass, + protected string $resourceClass, + ) {} + + public function modelFor(string $entity): ?string + { + return $entity === 'resource-only-item' ? $this->modelClass : null; + } + + public function resourceFor(string $entity): ?string + { + return $entity === 'resource-only-item' ? $this->resourceClass : null; + } + }; + + $options = new LocationConstraintOptions( + $registry, + app(\Moox\Core\Services\TaxonomyService::class), + app(BuilderLocaleResolver::class), + ); + + expect($options->availableParamOptionsForEntities(['resource-only-item'])) + ->toHaveKey('record_type') + ->toHaveKey('user_role') + ->not->toHaveKey('taxonomy'); +}); + +it('memoizes record type options per model', function (): void { + Schema::create('typed_items', function (Blueprint $table): void { + $table->id(); + $table->string('type')->nullable(); + $table->timestamps(); + }); + + DB::table('typed_items')->insert([ + ['type' => 'post', 'created_at' => now(), 'updated_at' => now()], + ['type' => 'page', 'created_at' => now(), 'updated_at' => now()], + ]); + + $modelClass = new class extends Model + { + protected $table = 'typed_items'; + }; + + $registry = new class($modelClass::class) extends EntityRegistry + { + public function __construct(protected string $modelClass) {} + + public function modelFor(string $entity): ?string + { + return $entity === 'typed-item' ? $this->modelClass : null; + } + }; + + $options = new LocationConstraintOptions( + $registry, + app(\Moox\Core\Services\TaxonomyService::class), + app(BuilderLocaleResolver::class), + ); + + DB::flushQueryLog(); + DB::enableQueryLog(); + + $first = $options->recordTypeOptionsForEntities(['typed-item']); + $queriesAfterFirst = count(DB::getQueryLog()); + + $second = $options->recordTypeOptionsForEntities(['typed-item']); + + expect($first)->toHaveKeys(['post', 'page']) + ->and($second)->toBe($first) + ->and(count(DB::getQueryLog()))->toBe($queriesAfterFirst); +}); + +class NoRolesUser extends Model {} + +class RolesUser extends Model +{ + use HasRoles; +} diff --git a/packages/builder/tests/Unit/LocationMatcherTest.php b/packages/builder/tests/Unit/LocationMatcherTest.php index 2f9e8489d8..4346132af6 100644 --- a/packages/builder/tests/Unit/LocationMatcherTest.php +++ b/packages/builder/tests/Unit/LocationMatcherTest.php @@ -4,6 +4,7 @@ use Moox\Builder\Compiler\LocationMatcher; use Moox\Builder\Data\LocationContext; +use Moox\Builder\Services\FieldGroupPersistence; it('matches entity equals and not equals rules with OR groups', function (): void { $matcher = new LocationMatcher; @@ -35,7 +36,7 @@ $rules = [ [ - ['param' => 'taxonomy', 'operator' => '==', 'value' => 'news'], + ['param' => 'template', 'operator' => '==', 'value' => 'landing'], ], ]; @@ -55,3 +56,157 @@ expect($matcher->matches($rules, $context))->toBeFalse(); }); + +it('does not match any entity when location rules are empty', function (): void { + $matcher = new LocationMatcher; + + expect($matcher->matches([], new LocationContext('item')))->toBeFalse() + ->and($matcher->matches([], new LocationContext('record')))->toBeFalse(); +}); + +it('matches record type and user role params when present in the context', function (): void { + $matcher = new LocationMatcher; + + $rules = [[ + ['param' => 'entity', 'operator' => '==', 'value' => 'draft'], + ['param' => 'record_type', 'operator' => '==', 'value' => 'page'], + ['param' => 'user_role', 'operator' => 'in', 'value' => 'admin,editor'], + ]]; + + $context = new LocationContext('draft', [ + 'record_type' => 'page', + 'user_role' => ['editor'], + ]); + + expect($matcher->matches($rules, $context))->toBeTrue(); +}); + +it('matches taxonomy term ids using taxonomy param prefixes', function (): void { + $matcher = new LocationMatcher; + + $rules = [[ + ['param' => 'entity', 'operator' => '==', 'value' => 'draft'], + ['param' => 'taxonomy:tag', 'operator' => 'in', 'value' => '12,99'], + ]]; + + $context = new LocationContext('draft', [ + 'taxonomy:tag' => [12, 34], + ]); + + expect($matcher->matches($rules, $context))->toBeTrue(); +}); + +it('ignores record specific params on create forms without a record', function (): void { + $matcher = new LocationMatcher; + + $rules = [[ + ['param' => 'entity', 'operator' => '==', 'value' => 'draft'], + ['param' => 'record_type', 'operator' => '==', 'value' => 'page'], + ]]; + + expect($matcher->matches($rules, new LocationContext('draft')))->toBeTrue(); +}); + +it('ignores record specific params when the param is missing on an existing record', function (): void { + $matcher = new LocationMatcher; + + $rules = [[ + ['param' => 'entity', 'operator' => '==', 'value' => 'item'], + ['param' => 'record_type', 'operator' => '==', 'value' => 'page'], + ]]; + + $record = new class extends \Illuminate\Database\Eloquent\Model + { + }; + + expect($matcher->matches($rules, new LocationContext('item', [], $record))) + ->toBeTrue(); +}); + +it('does not ignore record specific params when the param is present and mismatches', function (): void { + $matcher = new LocationMatcher; + + $rules = [[ + ['param' => 'entity', 'operator' => '==', 'value' => 'item'], + ['param' => 'record_type', 'operator' => '==', 'value' => 'page'], + ]]; + + $record = new class extends \Illuminate\Database\Eloquent\Model + { + }; + + expect($matcher->matches($rules, new LocationContext('item', ['record_type' => 'post'], $record))) + ->toBeFalse(); +}); + +it('matches entity scope for cached definition loading', function (): void { + $matcher = new LocationMatcher; + + $rules = [[ + ['param' => 'entity', 'operator' => '==', 'value' => 'draft'], + ['param' => 'record_type', 'operator' => '==', 'value' => 'page'], + ]]; + + expect($matcher->matchesEntityScope($rules, 'draft'))->toBeTrue() + ->and($matcher->matchesEntityScope($rules, 'item'))->toBeFalse(); +}); + +it('merges location constraints into every selected entity rule group', function (): void { + $persistence = app(FieldGroupPersistence::class); + + $rules = $persistence->mergeEntityRulesWithConstraints( + ['item', 'draft'], + [ + [ + 'param' => 'record_type', + 'operator' => '==', + 'value' => 'page', + ], + [ + 'param' => 'taxonomy', + 'taxonomy' => 'tag', + 'operator' => 'in', + 'value' => '12, 34', + ], + ], + ); + + expect($rules)->toBe([ + [ + ['param' => 'entity', 'operator' => '==', 'value' => 'item'], + ['param' => 'record_type', 'operator' => '==', 'value' => 'page'], + ['param' => 'taxonomy:tag', 'operator' => 'in', 'value' => [12, 34]], + ], + [ + ['param' => 'entity', 'operator' => '==', 'value' => 'draft'], + ['param' => 'record_type', 'operator' => '==', 'value' => 'page'], + ['param' => 'taxonomy:tag', 'operator' => 'in', 'value' => [12, 34]], + ], + ]); +}); + +it('round trips location constraints through form extraction helpers', function (): void { + $persistence = app(FieldGroupPersistence::class); + + $rules = [ + [ + ['param' => 'entity', 'operator' => '==', 'value' => 'draft'], + ['param' => 'record_type', 'operator' => '==', 'value' => 'page'], + ['param' => 'taxonomy:tag', 'operator' => 'in', 'value' => [12, 34]], + ], + ]; + + expect($persistence->constraintsFromLocationRules($rules))->toBe([ + [ + 'param' => 'record_type', + 'operator' => '==', + 'value' => 'page', + ], + [ + 'param' => 'taxonomy', + 'taxonomy' => 'tag', + 'operator' => 'in', + 'value' => [12, 34], + ], + ]); +}); diff --git a/packages/builder/tests/Unit/MessageOembedFieldTypeTest.php b/packages/builder/tests/Unit/MessageOembedFieldTypeTest.php new file mode 100644 index 0000000000..14f98fe10b --- /dev/null +++ b/packages/builder/tests/Unit/MessageOembedFieldTypeTest.php @@ -0,0 +1,55 @@ +createItemsTable(); + + $record = TestItem::query()->create(['title' => 'Demo']); + $fields = collect([ + new FieldDefinition( + name: 'hint', + label: 'Hint', + type: 'message', + config: ['message' => 'Read-only hint'], + ), + ]); + + app(CustomFieldsManager::class)->saveValues('item', $record, [ + 'hint' => 'should-not-save', + ], $fields); + + expect(FieldValue::query()->count())->toBe(0) + ->and((new MessageFieldType)->storesValue())->toBeFalse(); +}); + +it('persists oembed urls as strings', function (): void { + $this->createItemsTable(); + + $record = TestItem::query()->create(['title' => 'Demo']); + $fields = collect([ + new FieldDefinition('video', 'Video', 'oembed'), + ]); + + app(CustomFieldsManager::class)->saveValues('item', $record, [ + 'video' => 'https://www.youtube.com/watch?v=demo', + ], $fields); + + $stored = FieldValue::query()->forRecord('item', $record->getKey())->first(); + + expect($stored?->value_string)->toBe('https://www.youtube.com/watch?v=demo') + ->and((new OembedFieldType)->storesValue())->toBeTrue(); +}); diff --git a/packages/builder/tests/Unit/OptionValueRulesTest.php b/packages/builder/tests/Unit/OptionValueRulesTest.php index 7abb182894..143417943d 100644 --- a/packages/builder/tests/Unit/OptionValueRulesTest.php +++ b/packages/builder/tests/Unit/OptionValueRulesTest.php @@ -4,12 +4,12 @@ require_once __DIR__.'/../TestCase.php'; -uses(TestCase::class); - use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Support\OptionValueRules; use Moox\Builder\Tests\TestCase; +uses(TestCase::class); + it('rejects invalid scalar option values', function (): void { $field = new FieldDefinition( name: 'status', diff --git a/packages/builder/tests/Unit/PasswordFieldTypeTest.php b/packages/builder/tests/Unit/PasswordFieldTypeTest.php new file mode 100644 index 0000000000..2c85e28561 --- /dev/null +++ b/packages/builder/tests/Unit/PasswordFieldTypeTest.php @@ -0,0 +1,171 @@ +createItemsTable(); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Secrets', + 'slug' => 'secrets', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'secret', + 'label' => 'Secret', + 'type' => 'password', + 'sort' => 0, + 'validation' => ['required' => true, 'rules' => []], + ]); +}); + +it('validates required passwords when empty', function (): void { + $field = new FieldDefinition( + name: 'secret', + label: 'Secret', + type: 'password', + validation: ['required' => true, 'rules' => []], + ); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, '')) + ->toThrow(ValidationException::class); +}); + +it('rejects missing required passwords when saving form data', function (): void { + $record = TestItem::query()->create(['title' => 'New']); + + expect(fn () => app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + ['secret' => ''], + ))->toThrow(ValidationException::class); +}); + +it('persists password values hashed and never reloads plaintext', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + $manager = app(CustomFieldsManager::class); + + $manager->saveFromFormData(TestItemResource::class, $record, [ + 'secret' => 'api-key-456', + ]); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'secret') + ->first(); + + expect(Hash::check('api-key-456', (string) $stored?->value_string))->toBeTrue() + ->and($manager->loadFormData(TestItemResource::class, $record))->toBe([ + 'secret' => StoredPassword::instance(), + ]); +}); + +it('clears password values when an empty string is saved', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Optional secrets', + 'slug' => 'optional-secrets', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'optional_secret', + 'label' => 'Optional Secret', + 'type' => 'password', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $record = TestItem::query()->create(['title' => 'Demo']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + 'field_name' => 'optional_secret', + 'value_string' => 'api-key-456', + ]); + + app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + ['optional_secret' => ''], + ); + + $stored = FieldValue::query()->forRecord('item', $record->getKey()) + ->where('field_name', 'optional_secret') + ->first(); + + expect($stored?->value_string)->toBeNull(); +}); + +it('hashes nested password values inside groups before storage', function (): void { + $field = new FieldDefinition( + name: 'contact', + label: 'Contact', + type: 'group', + children: collect([ + new FieldDefinition(name: 'pin', label: 'PIN', type: 'password'), + ]), + ); + + $record = TestItem::query()->create(['title' => 'Demo']); + + app(CustomFieldsManager::class)->saveValues('item', $record, [ + 'contact' => ['pin' => '1234'], + ], collect([$field])); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'contact') + ->first(); + + $pin = $stored?->value_json['pin'] ?? null; + + expect($pin)->not->toBe('1234') + ->and(Hash::check('1234', (string) $pin))->toBeTrue(); +}); + +it('builds revealable password inputs', function (): void { + $component = (new PasswordFieldType)->formComponent( + new FieldDefinition( + name: 'secret', + label: 'Secret', + type: 'password', + ), + ); + + expect($component)->toBeInstanceOf(TextInput::class) + ->and($component->isPassword())->toBeTrue() + ->and($component->isPasswordRevealable())->toBeTrue(); +}); diff --git a/packages/builder/tests/Unit/RadioFieldTypeTest.php b/packages/builder/tests/Unit/RadioFieldTypeTest.php new file mode 100644 index 0000000000..fca1c7c3c8 --- /dev/null +++ b/packages/builder/tests/Unit/RadioFieldTypeTest.php @@ -0,0 +1,179 @@ +createItemsTable(); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Vehicle options', + 'slug' => 'vehicle-options', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'kraftstoff', + 'label' => 'Kraftstoff', + 'type' => 'radio', + 'sort' => 0, + 'config' => ['default' => 'benzin'], + 'validation' => ['required' => false, 'rules' => []], + ])->options()->createMany([ + ['label' => 'Benzin', 'value' => 'benzin', 'sort' => 0], + ['label' => 'Diesel', 'value' => 'diesel', 'sort' => 1], + ]); +}); + +it('applies option defaults for radio fields at runtime', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'kraftstoff', + label: 'Kraftstoff', + type: 'radio', + config: ['default' => 'benzin'], + options: [ + ['label' => 'Benzin', 'value' => 'benzin'], + ['label' => 'Diesel', 'value' => 'diesel'], + ], + ); + + $component = (new RadioFieldType)->formComponent($field); + + expect($component->getDefaultState())->toBe('benzin') + ->and($capability->resolveForField($field))->toBe('benzin'); +}); + +it('ignores radio defaults that are not valid options', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'kraftstoff', + label: 'Kraftstoff', + type: 'radio', + config: ['default' => 'lpg'], + options: [ + ['label' => 'Benzin', 'value' => 'benzin'], + ['label' => 'Diesel', 'value' => 'diesel'], + ], + ); + + expect($capability->resolveForField($field))->toBeNull(); +}); + +it('persists configured radio defaults when the form value is empty on create', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + ['kraftstoff' => null], + ); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'kraftstoff') + ->first(); + + expect($stored?->value_string)->toBe('benzin'); +}); + +it('does not reapply radio defaults when an optional value was cleared on update', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + 'field_name' => 'kraftstoff', + 'value_string' => 'diesel', + ]); + + app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + ['kraftstoff' => null], + ); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'kraftstoff') + ->first(); + + expect($stored?->value_string)->toBeNull(); +}); + +it('syncs radio default values through field group persistence', function (): void { + FieldGroup::query()->where('slug', 'vehicle-options')->delete(); + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Sync radio', + 'slug' => 'sync-radio', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Sync radio', + 'slug' => 'sync-radio', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'kraftstoff', + 'label' => 'Kraftstoff', + 'type' => 'radio', + 'required' => false, + 'config' => ['default' => 'diesel'], + 'options' => [ + ['label' => 'Benzin', 'value' => 'benzin'], + ['label' => 'Diesel', 'value' => 'diesel'], + ], + ], + ], + ]); + + expect($group->fields()->where('name', 'kraftstoff')->value('config'))->toMatchArray([ + 'default' => 'diesel', + ]); +}); + +it('builds a select for radio default values in the admin', function (): void { + $fields = app(DefaultValue::class)->builderFieldsFor('radio'); + + expect($fields)->toHaveCount(1) + ->and($fields[0])->toBeInstanceOf(Select::class) + ->and($fields[0]->isLive())->toBeTrue(); +}); + +it('includes helper text capability on radio fields', function (): void { + expect((new RadioFieldType)->capabilities()) + ->toContain(HelperText::class); +}); diff --git a/packages/builder/tests/Unit/RangeFieldTypeTest.php b/packages/builder/tests/Unit/RangeFieldTypeTest.php new file mode 100644 index 0000000000..7aee88dc20 --- /dev/null +++ b/packages/builder/tests/Unit/RangeFieldTypeTest.php @@ -0,0 +1,254 @@ +createItemsTable(); +}); + +it('applies min and max bounds on range sliders', function (): void { + $field = new FieldDefinition( + name: 'power', + label: 'Power', + type: 'range', + config: ['min' => 50, 'max' => 500], + ); + + $component = (new RangeFieldType)->formComponent($field); + + expect($component->getMinValue())->toBe(50) + ->and($component->getMaxValue())->toBe(500); +}); + +it('applies aligned range defaults on runtime components', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'motorleistung', + label: 'Motorleistung', + type: 'range', + config: ['min' => 50, 'max' => 500, 'step' => 5, 'default' => 245], + ); + + $component = (new RangeFieldType)->formComponent($field); + + expect($component->getDefaultState())->toBe(245) + ->and($capability->resolveForField($field))->toBe(245); +}); + +it('falls back to the configured min when no range default is set', function (): void { + $field = new FieldDefinition( + name: 'motorleistung', + label: 'Motorleistung', + type: 'range', + config: ['min' => 50, 'max' => 500, 'step' => 5], + ); + + $component = (new RangeFieldType)->formComponent($field); + + expect($component->getDefaultState())->toBe(50); +}); + +it('compiles range fields with configured defaults from field groups', function (): void { + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Vehicle options', + 'slug' => 'vehicle-range-runtime-default', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'motorleistung', + 'label' => 'Motorleistung', + 'type' => 'range', + 'sort' => 0, + 'config' => ['min' => 50, 'max' => 500, 'step' => 5, 'default' => 245], + 'validation' => ['required' => false, 'rules' => []], + ]); + + $field = app(DefinitionRegistry::class) + ->fieldGroupsFor(TestItemResource::customFieldsLocationContext()) + ->first() + ?->fields + ->firstWhere('name', 'motorleistung'); + + expect($field)->not->toBeNull(); + + $component = (new RangeFieldType)->formComponent($field); + + expect($component->getDefaultState())->toBe(245); +}); + +it('ignores range defaults that do not align with the step', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'motorleistung', + label: 'Motorleistung', + type: 'range', + config: ['min' => 10, 'max' => 100, 'step' => 10, 'default' => 12], + ); + + expect($capability->resolveForField($field))->toBeNull() + ->and($capability->rangeDefaultIsValid(20, $field->config))->toBeTrue() + ->and($capability->rangeDefaultIsValid(12, $field->config))->toBeFalse(); +}); + +it('detects when a slider still shows the min fallback instead of the configured default', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'motorleistung', + label: 'Motorleistung', + type: 'range', + config: ['min' => 50, 'max' => 500, 'step' => 5, 'default' => 245], + ); + + expect($capability->shouldReplaceSliderFallbackState($field, 50))->toBeTrue() + ->and($capability->shouldReplaceSliderFallbackState($field, 245))->toBeFalse() + ->and($capability->shouldReplaceSliderFallbackState($field, 255))->toBeFalse(); +}); + +it('persists configured range defaults when the form value is empty on create', function (): void { + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Vehicle options', + 'slug' => 'vehicle-range-default', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'motorleistung', + 'label' => 'Motorleistung', + 'type' => 'range', + 'sort' => 0, + 'config' => ['min' => 50, 'max' => 500, 'step' => 5, 'default' => 245], + 'validation' => ['required' => false, 'rules' => []], + ]); + + $record = TestItem::query()->create(['title' => 'Demo']); + + app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + ['motorleistung' => null], + ); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'motorleistung') + ->first(); + + expect((float) $stored?->value_decimal)->toBe(245.0); +}); + +it('requires range max to be greater than min when saving field groups', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Vehicle', + 'slug' => 'vehicle-range', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + expect(fn () => app(FieldGroupValidator::class)->validate($group, [ + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'motorleistung', + 'label' => 'Motorleistung', + 'type' => 'range', + 'config' => ['min' => 500, 'max' => 50], + ], + ], + ]))->toThrow(ValidationException::class); +}); + +it('rejects equal min and max values for range fields', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Vehicle', + 'slug' => 'vehicle-range-equal', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + expect(fn () => app(FieldGroupValidator::class)->validate($group, [ + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'motorleistung', + 'label' => 'Motorleistung', + 'type' => 'range', + 'config' => ['min' => 100, 'max' => 100], + ], + ], + ]))->toThrow(ValidationException::class); +}); + +it('rejects misaligned range defaults when saving field groups', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Vehicle', + 'slug' => 'vehicle-range-default-invalid', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + expect(fn () => app(FieldGroupValidator::class)->validate($group, [ + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'motorleistung', + 'label' => 'Motorleistung', + 'type' => 'range', + 'config' => ['min' => 10, 'max' => 100, 'step' => 10, 'default' => 12], + ], + ], + ]))->toThrow(ValidationException::class); +}); + +it('builds cross validated min and max inputs for range fields in the admin', function (): void { + $minField = app(MinValue::class)->builderFieldsFor('range')[0]; + $maxField = app(MaxValue::class)->builderFieldsFor('range')[0]; + + expect($minField)->toBeInstanceOf(TextInput::class) + ->and($maxField)->toBeInstanceOf(TextInput::class) + ->and($minField->isLive())->toBeTrue() + ->and($maxField->isLive())->toBeTrue(); +}); + +it('builds a step aware default input for range fields in the admin', function (): void { + $fields = app(DefaultValue::class)->builderFieldsFor('range'); + + expect($fields)->toHaveCount(1) + ->and($fields[0])->toBeInstanceOf(TextInput::class) + ->and($fields[0]->isLive())->toBeTrue(); +}); diff --git a/packages/builder/tests/Unit/RelationFieldTypeTest.php b/packages/builder/tests/Unit/RelationFieldTypeTest.php new file mode 100644 index 0000000000..73b97dcda4 --- /dev/null +++ b/packages/builder/tests/Unit/RelationFieldTypeTest.php @@ -0,0 +1,317 @@ +createItemsTable(); + Cache::forget(DefinitionRegistry::CACHE_KEY); + bindRelationTestEntityRegistry(); +}); + +function bindRelationTestEntityRegistry(): void +{ + $registry = new class extends EntityRegistry + { + protected function panelResources(): array + { + return [TestItemResource::class]; + } + }; + + app()->instance(EntityRegistry::class, $registry); +} + +function relationFieldDefinition(bool $multiple = false): FieldDefinition +{ + return new FieldDefinition( + name: 'linked-item', + label: 'Linked item', + type: 'relation', + config: [ + 'related_entity' => 'item', + 'multiple' => $multiple, + ], + ); +} + +it('exposes relation settings for the relation field type', function (): void { + $fields = app(RelationSettings::class)->builderFieldsFor('relation'); + + $relatedEntity = collect($fields)->first(fn ($field) => $field->getName() === 'config.related_entity'); + + expect($fields)->not->toBeEmpty() + ->and($relatedEntity)->not->toBeNull() + ->and($relatedEntity)->toBeInstanceOf(Select::class) + ->and($relatedEntity->isSearchable())->toBeTrue() + // Searchable inside the reactive settings schema only persists when it + // commits immediately via live(); otherwise a rebuild drops the value. + ->and($relatedEntity->isLive())->toBeTrue(); +}); + +it('builds a searchable select that respects the multiple setting', function (): void { + $single = (new RelationFieldType)->formComponent(relationFieldDefinition()); + $multiple = (new RelationFieldType)->formComponent(relationFieldDefinition(multiple: true)); + + expect($single)->toBeInstanceOf(Select::class) + ->and($single->isMultiple())->toBeFalse() + ->and($single->isSearchable())->toBeTrue() + ->and($multiple->isMultiple())->toBeTrue(); +}); + +it('preloads initial relation suggestions without searching', function (): void { + $first = TestItem::query()->create(['title' => 'First option']); + $second = TestItem::query()->create(['title' => 'Second option']); + + $component = (new RelationFieldType)->formComponent(relationFieldDefinition()); + + expect($component->isPreloaded())->toBeTrue() + ->and($component->getSearchResults(''))->toBe([ + $first->getKey() => 'First option', + $second->getKey() => 'Second option', + ]); +}); + +it('wraps relation validation rules for Filament form evaluation', function (): void { + $rules = RelationValueRules::rules(relationFieldDefinition()); + + expect($rules)->toHaveCount(1) + ->and($rules[0])->toBeInstanceOf(Closure::class) + ->and($rules[0]())->toBeInstanceOf(Closure::class); +}); + +it('casts and persists relation ids for single and multiple fields', function (): void { + $type = new RelationFieldType; + $singleField = relationFieldDefinition(); + $multipleField = relationFieldDefinition(multiple: true); + + expect($type->castValue(null, $singleField))->toBeNull() + ->and($type->castValue(3, $singleField))->toBe(3) + ->and($type->castValue(null, $multipleField))->toBe([]) + ->and($type->castValue([1, 2], $multipleField))->toBe([1, 2]) + ->and($type->persistValue(4, $singleField))->toBe(4) + ->and($type->persistValue([5, 6], $multipleField))->toBe([5, 6]); +}); + +it('round trips single relation ids through field group persistence', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Relations', + 'slug' => 'relations', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Relations', + 'slug' => 'relations', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'linked-item', + 'label' => 'Linked item', + 'type' => 'relation', + 'required' => false, + 'config' => [ + 'related_entity' => 'item', + 'multiple' => false, + ], + ], + ], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $target = TestItem::query()->create(['title' => 'Related record']); + $record = TestItem::query()->create(['title' => 'Owner']); + $manager = app(CustomFieldsManager::class); + $fields = $manager->fieldsForEntity('item'); + + $manager->saveValues('item', $record, ['linked-item' => $target->getKey()], $fields); + + expect($manager->loadValues('item', $record, $fields))->toBe([ + 'linked-item' => $target->getKey(), + ]); +}); + +it('round trips multiple relation ids through field group persistence', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Relations multiple', + 'slug' => 'relations-multiple', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Relations multiple', + 'slug' => 'relations-multiple', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'linked-items', + 'label' => 'Linked items', + 'type' => 'relation', + 'required' => false, + 'config' => [ + 'related_entity' => 'item', + 'multiple' => true, + 'min' => 1, + 'max' => 3, + ], + ], + ], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $first = TestItem::query()->create(['title' => 'First']); + $second = TestItem::query()->create(['title' => 'Second']); + $record = TestItem::query()->create(['title' => 'Owner']); + $manager = app(CustomFieldsManager::class); + $fields = $manager->fieldsForEntity('item'); + + $manager->saveValues('item', $record, [ + 'linked-items' => [$first->getKey(), $second->getKey()], + ], $fields); + + expect($manager->loadValues('item', $record, $fields))->toBe([ + 'linked-items' => [$first->getKey(), $second->getKey()], + ]); +}); + +it('presents relation values as resolved id and label objects', function (): void { + $target = TestItem::query()->create(['title' => 'Resolved title']); + $field = relationFieldDefinition(); + $type = new RelationFieldType; + + expect($type->presentValue($target->getKey(), $field))->toBe([ + 'id' => $target->getKey(), + 'label' => 'Resolved title', + ]); +}); + +it('filters deleted relation targets from presented values', function (): void { + $target = TestItem::query()->create(['title' => 'Gone soon']); + $deletedId = $target->getKey(); + $target->delete(); + + $field = relationFieldDefinition(); + $type = new RelationFieldType; + + expect($type->presentValue($deletedId, $field))->toBeNull() + ->and($type->presentValue([$deletedId], relationFieldDefinition(multiple: true)))->toBe([]); +}); + +it('resolves searchable labels and batch output through the target resolver', function (): void { + $first = TestItem::query()->create(['title' => 'Alpha']); + $second = TestItem::query()->create(['title' => 'Beta']); + $resolver = app(RelationTargetResolver::class); + + expect($resolver->search('item', 'Al'))->toHaveKey($first->getKey()) + ->and($resolver->labelsFor('item', [$first->getKey(), $second->getKey()]))->toBe([ + $first->getKey() => 'Alpha', + $second->getKey() => 'Beta', + ]) + ->and($resolver->resolve('item', [$second->getKey(), $first->getKey()]))->toBe([ + ['id' => $second->getKey(), 'label' => 'Beta'], + ['id' => $first->getKey(), 'label' => 'Alpha'], + ]); +}); + +it('memoizes relation labels within a request to avoid repeated queries', function (): void { + $item = TestItem::query()->create(['title' => 'Cached']); + $resolver = app(RelationTargetResolver::class); + + DB::enableQueryLog(); + + $resolver->labelsFor('item', [$item->getKey()]); + $resolver->labelsFor('item', [$item->getKey()]); + + $itemQueries = collect(DB::getQueryLog()) + ->filter(fn (array $query): bool => str_contains($query['query'], '"items"') || str_contains($query['query'], '`items`')); + + DB::disableQueryLog(); + + expect($itemQueries)->toHaveCount(1); +}); + +it('uses record titles when the resource has no record title attribute', function (): void { + $first = TestItem::query()->create(['title' => 'English']); + $second = TestItem::query()->create(['title' => 'German']); + + $registry = new class extends EntityRegistry + { + protected function panelResources(): array + { + return [TestLocalizationLikeResource::class]; + } + }; + + app()->instance(EntityRegistry::class, $registry); + + $resolver = app(RelationTargetResolver::class); + + expect($resolver->search('item', ''))->toBe([ + $first->getKey() => 'English', + $second->getKey() => 'German', + ]); +}); + +it('presents relation values through the values resolver', function (): void { + $target = TestItem::query()->create(['title' => 'Via resolver']); + $field = relationFieldDefinition(); + + $presented = app(BuilderValuesResolver::class)->present( + collect([$field]), + ['linked-item' => $target->getKey()], + ); + + expect($presented)->toBe([ + 'linked-item' => [ + 'id' => $target->getKey(), + 'label' => 'Via resolver', + ], + ]); +}); + +it('rejects relation values that point to missing records', function (): void { + $field = relationFieldDefinition(); + + expect(fn () => RelationValueRules::assertValid($field, 999))->toThrow(ValidationException::class); +}); + +it('returns an empty search result when the target table is missing', function (): void { + Schema::dropIfExists('items'); + + expect(app(RelationTargetResolver::class)->search('item', 'ad'))->toBe([]); +}); diff --git a/packages/builder/tests/Unit/RepeaterFieldTypeTest.php b/packages/builder/tests/Unit/RepeaterFieldTypeTest.php new file mode 100644 index 0000000000..a92a43d617 --- /dev/null +++ b/packages/builder/tests/Unit/RepeaterFieldTypeTest.php @@ -0,0 +1,182 @@ + 'ff0000'], + ), + new FieldDefinition( + name: 'label', + label: 'Label', + type: 'text', + config: ['default' => 'Standard'], + ), + ]), + ); + + expect($capability->defaultDataForChildren($field->children))->toBe([ + 'lackfarbe' => '#ff0000', + 'label' => 'Standard', + ]); +}); + +it('merges missing color defaults into stored repeater rows', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'variants', + label: 'Variants', + type: 'repeater', + children: collect([ + new FieldDefinition( + name: 'lackfarbe', + label: 'Paint color', + type: 'color', + config: ['default' => '1a1a1a'], + ), + ]), + ); + + $merged = $capability->mergeCompoundDefaults($field, [ + ['lackfarbe' => ''], + ['label' => 'Custom'], + ]); + + expect($merged)->toBe([ + ['lackfarbe' => '#1a1a1a'], + ['label' => 'Custom', 'lackfarbe' => '#1a1a1a'], + ]); +}); + +it('applies color defaults on nested repeater color pickers', function (): void { + $field = new FieldDefinition( + name: 'variants', + label: 'Variants', + type: 'repeater', + children: collect([ + new FieldDefinition( + name: 'lackfarbe', + label: 'Paint color', + type: 'color', + config: ['default' => '336699'], + ), + ]), + ); + + $component = (new RepeaterFieldType)->formComponent($field); + $schema = $component->getDefaultChildComponents(); + $colorField = collect($schema)->first(fn ($item) => $item->getName() === 'lackfarbe'); + + expect($colorField)->not->toBeNull() + ->and($colorField->getDefaultState())->toBe('#336699'); +}); + +it('ignores legacy zero item limits for optional repeaters', function (): void { + $capability = app(RepeaterItems::class); + + $field = new FieldDefinition( + name: 'variants', + label: 'Variants', + type: 'repeater', + config: ['min_items' => 0, 'max_items' => 0], + ); + + $component = Repeater::make('variants'); + $result = $capability->apply($component, $field); + + expect($result->getMinItems())->toBeNull() + ->and($result->getMaxItems())->toBeNull(); +}); + +it('ignores stale range bounds stored in legacy config keys', function (): void { + $capability = app(RepeaterItems::class); + + $field = new FieldDefinition( + name: 'variants', + label: 'Variants', + type: 'repeater', + config: ['min' => 50, 'max' => 500, 'step' => 5], + ); + + $component = Repeater::make('variants'); + $result = $capability->apply($component, $field); + + expect($result->getMinItems())->toBeNull() + ->and($result->getMaxItems())->toBeNull(); +}); + +it('persists default values configured on repeater subfields', function (): void { + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Repeater defaults', + 'slug' => 'repeater-defaults', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Repeater defaults', + 'slug' => 'repeater-defaults', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'variants', + 'label' => 'Variants', + 'type' => 'repeater', + 'required' => false, + 'children' => [ + [ + 'name' => 'lackfarbe', + 'label' => 'Paint color', + 'type' => 'color', + 'required' => false, + 'config' => ['default' => 'ff5500'], + ], + ], + ], + ], + ]); + + $color = app(DefinitionRegistry::class) + ->fieldGroupsFor(TestItemResource::customFieldsLocationContext()) + ->first() + ?->fields + ->firstWhere('name', 'variants') + ?->children + ->firstWhere('name', 'lackfarbe'); + + expect($color)->not->toBeNull() + ->and($color->config['default'] ?? null)->toBe('ff5500') + ->and(app(DefaultValue::class)->resolveForField($color))->toBe('#ff5500'); +}); diff --git a/packages/builder/tests/Unit/ResolveBuilderAdminLocaleTest.php b/packages/builder/tests/Unit/ResolveBuilderAdminLocaleTest.php new file mode 100644 index 0000000000..448cc1790a --- /dev/null +++ b/packages/builder/tests/Unit/ResolveBuilderAdminLocaleTest.php @@ -0,0 +1,73 @@ + 'de_CH']); + + $middleware->handle($request, fn (Request $request): Response => response('ok')); + + expect(session(BuilderLocaleResolver::ADMIN_SESSION_KEY))->toBe('de_CH') + ->and($request->input('lang'))->toBe('de_CH') + ->and(app(BuilderLocaleResolver::class)->current())->toBe('de_CH'); +}); + +it('ignores locale switching on non-translatable custom field resources', function (): void { + $middleware = app(ResolveBuilderAdminLocale::class); + + $request = Request::create('/admin/items/1/edit', 'GET', ['lang' => 'de_CH']); + + $middleware->handle($request, fn (Request $request): Response => response('ok')); + + expect(session(BuilderLocaleResolver::ADMIN_SESSION_KEY))->toBeNull() + ->and($request->input('lang'))->toBe('de_CH'); +}); + +it('restores locale from session on livewire subrequests for field groups', function (): void { + session([BuilderLocaleResolver::ADMIN_SESSION_KEY => 'de_CH']); + + $middleware = app(ResolveBuilderAdminLocale::class); + + $request = Request::create('/livewire/update', 'POST'); + $request->headers->set('referer', url('/admin/field-groups/1/edit?lang=de_CH')); + + $middleware->handle($request, fn (Request $request): Response => response('ok')); + + expect($request->input('lang'))->toBe('de_CH') + ->and(app(BuilderLocaleResolver::class)->current())->toBe('de_CH'); +}); + +it('does not restore locale from session on livewire subrequests for non-translatable resources', function (): void { + session([BuilderLocaleResolver::ADMIN_SESSION_KEY => 'de_CH']); + + $middleware = app(ResolveBuilderAdminLocale::class); + + $request = Request::create('/livewire/update', 'POST'); + $request->headers->set('referer', url('/admin/items/1/edit')); + + $middleware->handle($request, fn (Request $request): Response => response('ok')); + + expect($request->input('lang'))->toBeNull(); +}); + +it('resolves locale from session when request has no lang parameter on field groups', function (): void { + session([BuilderLocaleResolver::ADMIN_SESSION_KEY => 'de_CH']); + + $request = Request::create('/admin/field-groups'); + + app(ResolveBuilderAdminLocale::class)->handle($request, fn (Request $request): Response => response('ok')); + + expect(app(BuilderLocaleResolver::class)->current())->toBe('de_CH'); +}); diff --git a/packages/builder/tests/Unit/RichTextFieldTypeTest.php b/packages/builder/tests/Unit/RichTextFieldTypeTest.php new file mode 100644 index 0000000000..9b7f136a62 --- /dev/null +++ b/packages/builder/tests/Unit/RichTextFieldTypeTest.php @@ -0,0 +1,213 @@ +createItemsTable(); +}); + +it('treats empty rich text html as empty', function (): void { + expect(RichTextValue::isEmpty(null))->toBeTrue() + ->and(RichTextValue::isEmpty(''))->toBeTrue() + ->and(RichTextValue::isEmpty('

'))->toBeTrue() + ->and(RichTextValue::isEmpty('


'))->toBeTrue() + ->and(RichTextValue::isEmpty('

 

'))->toBeTrue() + ->and(RichTextValue::isEmpty('

Hello

'))->toBeFalse(); +}); + +it('rejects required rich text fields that only contain empty editor html', function (): void { + $field = new FieldDefinition( + name: 'description', + label: 'Description', + type: 'rich_text', + validation: ['required' => true, 'rules' => []], + ); + + expect(fn () => app(FieldValueValidator::class)->assertValid($field, '

')) + ->toThrow(ValidationException::class); +}); + +it('accepts required rich text fields with visible content', function (): void { + $field = new FieldDefinition( + name: 'description', + label: 'Description', + type: 'rich_text', + validation: ['required' => true, 'rules' => []], + ); + + app(FieldValueValidator::class)->assertValid($field, '

Hello world

'); + + expect(true)->toBeTrue(); +}); + +it('adds a custom required rule to rich text components instead of filament required', function (): void { + $field = new FieldDefinition( + name: 'description', + label: 'Description', + type: 'rich_text', + validation: ['required' => true, 'rules' => []], + ); + + $component = (new RichTextFieldType)->formComponent($field); + + expect($component->isRequired())->toBeFalse(); +}); + +it('does not persist empty required rich text values on save', function (): void { + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Content', + 'slug' => 'content-rich-text', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'description', + 'label' => 'Description', + 'type' => 'rich_text', + 'sort' => 0, + 'validation' => ['required' => true, 'rules' => []], + ]); + + $record = TestItem::query()->create(['title' => 'Demo']); + + expect(fn () => app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + ['description' => '


'], + ))->toThrow(ValidationException::class); + + expect(FieldValue::query()->forRecord('item', $record->getKey())->count())->toBe(0); +}); + +it('strips executable xss vectors from rich text while keeping formatting', function (): void { + $dirty = '

Hello world

' + .'' + .'' + .'click' + .'safe'; + + $clean = RichTextValue::sanitizeForPersist($dirty); + + expect($clean) + ->toBeString() + ->toContain('world') + ->toContain('class="intro"') + ->toContain('href="https://example.com"') + ->not->toContain('not->toContain('onerror') + ->not->toContain('javascript:'); +}); + +it('sanitizes tip tap json documents when persisting through the field type', function (): void { + $document = [ + 'type' => 'doc', + 'content' => [ + [ + 'type' => 'paragraph', + 'content' => [ + [ + 'type' => 'text', + 'text' => 'click', + 'marks' => [ + [ + 'type' => 'link', + 'attrs' => ['href' => 'javascript:alert(1)', 'target' => null], + ], + ], + ], + ], + ], + [ + 'type' => 'paragraph', + 'content' => [ + [ + 'type' => 'text', + 'text' => 'safe', + 'marks' => [ + [ + 'type' => 'link', + 'attrs' => ['href' => 'https://example.com', 'target' => null], + ], + ], + ], + ], + ], + ], + ]; + + $persisted = (new RichTextFieldType)->persistValue($document); + + expect($persisted) + ->toBeString() + ->toContain('safe') + ->toContain('href="https://example.com"') + ->not->toContain('javascript:'); +}); + +it('sanitizes rich text html when persisting through the field type', function (): void { + $persisted = (new RichTextFieldType)->persistValue('

ok

'); + + expect($persisted) + ->toContain('

ok

') + ->not->toContain('create([ + 'name' => 'Content', + 'slug' => 'content-rich-text-valid', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'description', + 'label' => 'Description', + 'type' => 'rich_text', + 'sort' => 0, + 'validation' => ['required' => true, 'rules' => []], + ]); + + $record = TestItem::query()->create(['title' => 'Demo']); + + app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + ['description' => '

Published

'], + ); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'description') + ->first(); + + expect($stored?->value_text)->toBe('

Published

'); +}); diff --git a/packages/builder/tests/Unit/SchemaCompilerTest.php b/packages/builder/tests/Unit/SchemaCompilerTest.php index 6c32275405..826eac5362 100644 --- a/packages/builder/tests/Unit/SchemaCompilerTest.php +++ b/packages/builder/tests/Unit/SchemaCompilerTest.php @@ -4,14 +4,15 @@ require_once __DIR__.'/../TestCase.php'; -uses(TestCase::class); - use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs; use Moox\Builder\Compiler\SchemaCompiler; use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Tests\TestCase; +uses(TestCase::class); + it('compiles a section with sorted fields and merged rules', function (): void { $group = FieldGroup::query()->create([ 'name' => 'Vehicle data', @@ -61,3 +62,67 @@ expect($rules['first'])->toContain('required') ->and($rules['second'])->toContain('min:1'); }); + +it('compiles tabs spanning the full section width', function (): void { + $definition = FieldGroupDefinition::fromArray([ + 'name' => 'Tabbed', + 'slug' => 'tabbed', + 'placement' => 'default', + 'fields' => [ + [ + 'name' => 'first_tab', + 'label' => 'First tab', + 'type' => 'tab', + 'children' => [ + ['name' => 'inside', 'label' => 'Inside', 'type' => 'text'], + ], + ], + ], + ]); + + $compiler = app(SchemaCompiler::class); + $sections = $compiler->compile(collect([$definition])); + + $sectionReflection = new ReflectionProperty(Section::class, 'childComponents'); + $sectionReflection->setAccessible(true); + $children = collect($sectionReflection->getValue($sections[0]))->flatten(); + + $tabs = $children->first(fn (mixed $component): bool => $component instanceof Tabs); + + expect($tabs)->toBeInstanceOf(Tabs::class) + ->and($tabs->getColumnSpan())->toBe(['default' => 'full']); +}); + +it('collects conditional trigger field names for a field group', function (): void { + $definition = FieldGroupDefinition::fromArray([ + 'name' => 'Conditional', + 'slug' => 'conditional', + 'placement' => 'default', + 'fields' => [ + ['name' => 'customer_type', 'label' => 'Customer type', 'type' => 'text'], + [ + 'name' => 'company', + 'label' => 'Company', + 'type' => 'text', + 'settings' => [ + 'conditions' => [ + 'enabled' => true, + 'action' => 'show', + 'logic' => 'and', + 'rules' => [ + ['field' => 'customer_type', 'operator' => 'equals', 'value' => 'business'], + ], + ], + ], + ], + ], + ]); + + $compiler = app(SchemaCompiler::class); + $reflection = new ReflectionClass($compiler); + $method = $reflection->getMethod('conditionalTriggerNames'); + $method->setAccessible(true); + + expect($method->invoke($compiler, $definition->fields))->toBe(['customer_type']) + ->and($definition->fields->firstWhere('name', 'company')?->hasConditions())->toBeTrue(); +}); diff --git a/packages/builder/tests/Unit/SelectFieldTypeTest.php b/packages/builder/tests/Unit/SelectFieldTypeTest.php new file mode 100644 index 0000000000..5f931a8ace --- /dev/null +++ b/packages/builder/tests/Unit/SelectFieldTypeTest.php @@ -0,0 +1,203 @@ +createItemsTable(); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Vehicle options', + 'slug' => 'vehicle-options', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'karosserieform', + 'label' => 'Karosserieform', + 'type' => 'select', + 'sort' => 0, + 'config' => ['default' => 'limousine'], + 'validation' => ['required' => false, 'rules' => []], + ])->options()->createMany([ + ['label' => 'Limousine', 'value' => 'limousine', 'sort' => 0], + ['label' => 'Kombi', 'value' => 'kombi', 'sort' => 1], + ]); +}); + +it('applies option defaults for select fields at runtime', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'karosserieform', + label: 'Karosserieform', + type: 'select', + config: ['default' => 'limousine'], + options: [ + ['label' => 'Limousine', 'value' => 'limousine'], + ['label' => 'Kombi', 'value' => 'kombi'], + ], + ); + + $component = (new SelectFieldType)->formComponent($field); + + expect($component->getDefaultState())->toBe('limousine') + ->and($capability->resolveForField($field))->toBe('limousine') + ->and($component->isNative())->toBeFalse() + ->and($component->canSelectPlaceholder())->toBeFalse(); +}); + +it('keeps placeholder selection when no select default is configured', function (): void { + $field = new FieldDefinition( + name: 'karosserieform', + label: 'Karosserieform', + type: 'select', + options: [ + ['label' => 'Limousine', 'value' => 'limousine'], + ['label' => 'Kombi', 'value' => 'kombi'], + ], + ); + + $component = (new SelectFieldType)->formComponent($field); + + expect($component->getDefaultState())->toBeNull() + ->and($component->canSelectPlaceholder())->toBeTrue(); +}); + +it('ignores select defaults that are not valid options', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'karosserieform', + label: 'Karosserieform', + type: 'select', + config: ['default' => 'suv'], + options: [ + ['label' => 'Limousine', 'value' => 'limousine'], + ['label' => 'Kombi', 'value' => 'kombi'], + ], + ); + + expect($capability->resolveForField($field))->toBeNull(); +}); + +it('persists configured select defaults when the form value is empty on create', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + ['karosserieform' => null], + ); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'karosserieform') + ->first(); + + expect($stored?->value_string)->toBe('limousine'); +}); + +it('does not reapply select defaults when an optional value was cleared on update', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $record->getKey(), + 'field_name' => 'karosserieform', + 'value_string' => 'kombi', + ]); + + app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + ['karosserieform' => null], + ); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'karosserieform') + ->first(); + + expect($stored?->value_string)->toBeNull(); +}); + +it('syncs select default values through field group persistence', function (): void { + FieldGroup::query()->where('slug', 'vehicle-options')->delete(); + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Sync select', + 'slug' => 'sync-select', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Sync select', + 'slug' => 'sync-select', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'karosserieform', + 'label' => 'Karosserieform', + 'type' => 'select', + 'required' => false, + 'config' => ['default' => 'kombi'], + 'options' => [ + ['label' => 'Limousine', 'value' => 'limousine'], + ['label' => 'Kombi', 'value' => 'kombi'], + ], + ], + ], + ]); + + expect($group->fields()->where('name', 'karosserieform')->value('config'))->toMatchArray([ + 'default' => 'kombi', + ]); +}); + +it('resolves select defaults from cached field group definitions', function (): void { + $field = app(DefinitionRegistry::class) + ->fieldGroupsFor(TestItemResource::customFieldsLocationContext()) + ->first() + ?->fields + ->firstWhere('name', 'karosserieform'); + + expect($field)->not->toBeNull() + ->and(app(DefaultValue::class)->resolveForField($field))->toBe('limousine'); +}); + +it('builds a select for select default values in the admin', function (): void { + $fields = app(DefaultValue::class)->builderFieldsFor('select'); + + expect($fields)->toHaveCount(1) + ->and($fields[0])->toBeInstanceOf(Select::class) + ->and($fields[0]->isLive())->toBeTrue(); +}); diff --git a/packages/builder/tests/Unit/TabFieldDefaultsTest.php b/packages/builder/tests/Unit/TabFieldDefaultsTest.php new file mode 100644 index 0000000000..fa62aeef8d --- /dev/null +++ b/packages/builder/tests/Unit/TabFieldDefaultsTest.php @@ -0,0 +1,200 @@ +createItemsTable(); + Cache::forget(DefinitionRegistry::CACHE_KEY); +}); + +it('persists tab nested color defaults when the form value is empty on create', function (): void { + app(FieldGroupPersistence::class)->sync( + FieldGroup::query()->create([ + 'name' => 'Tabs', + 'slug' => 'tabs-color-default', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]), + [ + 'name' => 'Tabs', + 'slug' => 'tabs-color-default', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'tab-design', + 'label' => 'Design', + 'type' => 'tab', + 'children' => [ + [ + 'name' => 'lackfarbe', + 'label' => 'Lackfarbe', + 'type' => 'color', + 'required' => false, + 'config' => ['default' => 'ff0000'], + ], + ], + ], + ], + ], + ); + + $record = TestItem::query()->create(['title' => 'Demo']); + + app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + ['lackfarbe' => null], + ); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'lackfarbe') + ->first(); + + expect($stored?->value_string)->toBe('#ff0000'); +}); + +it('persists tab nested button group defaults when the form value is empty on create', function (): void { + app(FieldGroupPersistence::class)->sync( + FieldGroup::query()->create([ + 'name' => 'Tabs', + 'slug' => 'tabs-button-default', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]), + [ + 'name' => 'Tabs', + 'slug' => 'tabs-button-default', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'tab-drive', + 'label' => 'Antrieb', + 'type' => 'tab', + 'children' => [ + [ + 'name' => 'antrieb', + 'label' => 'Antrieb', + 'type' => 'button_group', + 'required' => false, + 'config' => ['default' => 'awd'], + 'options' => [ + ['label' => 'Front', 'value' => 'fwd'], + ['label' => 'Allrad', 'value' => 'awd'], + ], + ], + ], + ], + ], + ], + ); + + $record = TestItem::query()->create(['title' => 'Demo']); + + app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + ['antrieb' => null], + ); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'antrieb') + ->first(); + + expect($stored?->value_string)->toBe('awd'); +}); + +it('resolves tab nested defaults from cached definitions', function (): void { + app(FieldGroupPersistence::class)->sync( + FieldGroup::query()->create([ + 'name' => 'Tabs', + 'slug' => 'tabs-resolve-default', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]), + [ + 'name' => 'Tabs', + 'slug' => 'tabs-resolve-default', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'tab-design', + 'label' => 'Design', + 'type' => 'tab', + 'children' => [ + [ + 'name' => 'lackfarbe', + 'label' => 'Lackfarbe', + 'type' => 'color', + 'required' => false, + 'config' => ['default' => 'ff0000'], + ], + ], + ], + ], + ], + ); + + $field = app(DefinitionRegistry::class) + ->fieldGroupsFor(TestItemResource::customFieldsLocationContext()) + ->firstWhere('slug', 'tabs-resolve-default') + ?->fields + ->firstWhere('name', 'tab-design') + ?->children + ->firstWhere('name', 'lackfarbe'); + + expect($field)->not->toBeNull() + ->and(app(DefaultValue::class)->resolveForField($field))->toBe('#ff0000'); +}); + +it('applies defaults on tab nested color and button group components', function (): void { + $colorField = new FieldDefinition( + name: 'lackfarbe', + label: 'Lackfarbe', + type: 'color', + config: ['default' => 'ff0000'], + ); + + $buttonField = new FieldDefinition( + name: 'antrieb', + label: 'Antrieb', + type: 'button_group', + config: ['default' => 'awd'], + options: [ + ['label' => 'Front', 'value' => 'fwd'], + ['label' => 'Allrad', 'value' => 'awd'], + ], + ); + + expect((new ColorFieldType)->formComponent($colorField)->getDefaultState())->toBe('#ff0000') + ->and((new ButtonGroupFieldType)->formComponent($buttonField)->getDefaultState())->toBe('awd') + ->and(app(DefaultValue::class)->resolveForField($colorField))->toBe('#ff0000') + ->and(app(DefaultValue::class)->resolveForField($buttonField))->toBe('awd'); +}); diff --git a/packages/builder/tests/Unit/TabFieldTypeTest.php b/packages/builder/tests/Unit/TabFieldTypeTest.php new file mode 100644 index 0000000000..f8a414690d --- /dev/null +++ b/packages/builder/tests/Unit/TabFieldTypeTest.php @@ -0,0 +1,180 @@ + true], + ), + new FieldDefinition(name: 'preis', label: 'Preis', type: 'number', config: ['min' => 0]), + ]), + ), + new FieldDefinition( + name: 'tab-technik', + label: 'Technik', + type: 'tab', + children: collect([ + new FieldDefinition(name: 'ps', label: 'PS', type: 'number'), + ]), + ), + ]), + ); + + $compiler = app(SchemaCompiler::class); + $sections = $compiler->compile(collect([$group])); + + expect($sections)->toHaveCount(1) + ->and($compiler->rules(collect([$group])))->toHaveKeys(['modell', 'preis']); +}); + +it('syncs tab fields with nested children', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Tabs', + 'slug' => 'tabs', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Tabs', + 'slug' => 'tabs', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'tab-one', + 'label' => 'One', + 'type' => 'tab', + 'children' => [ + ['name' => 'title', 'label' => 'Title', 'type' => 'text', 'required' => false], + ], + ], + ], + ]); + + $group->load(['fields.children']); + + $tab = $group->fields->firstWhere('name', 'tab-one'); + + expect($tab)->not->toBeNull() + ->and($tab->children)->toHaveCount(1) + ->and($tab->children->first()->name)->toBe('title'); +}); + +it('loads flexible layout children when nested inside a tab', function (): void { + app(FieldGroupPersistence::class)->sync( + FieldGroup::query()->create([ + 'name' => 'Fahrzeugdaten', + 'slug' => 'fahrzeugdaten-nested-flex', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]), + [ + 'name' => 'Fahrzeugdaten', + 'slug' => 'fahrzeugdaten-nested-flex', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'tab-inserat', + 'label' => 'Inserat', + 'type' => 'tab', + 'children' => [ + [ + 'name' => 'inserat-inhalt', + 'label' => 'Inserat-Inhalt', + 'type' => 'flexible_content', + 'layouts' => [ + [ + 'name' => 'hero', + 'label' => 'Hero', + 'children' => [ + ['name' => 'titel', 'label' => 'Titel', 'type' => 'text', 'required' => false], + ], + ], + ], + ], + ], + ], + ], + ], + ); + + app(DefinitionRegistry::class)->forget(); + + $groups = app(DefinitionRegistry::class)->fieldGroupsFor( + new LocationContext('item'), + ); + + $flex = $groups + ->firstWhere('slug', 'fahrzeugdaten-nested-flex') + ?->fields + ->firstWhere('name', 'tab-inserat') + ?->children + ->firstWhere('name', 'inserat-inhalt'); + + expect($flex)->not->toBeNull() + ->and($flex->layouts()->first()?->children)->toHaveCount(1) + ->and($flex->layouts()->first()?->children->first()?->name)->toBe('titel'); +}); + +it('migrates legacy flat tab markers into tab children', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Legacy', + 'slug' => 'legacy', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + $tab = $group->fields()->create([ + 'name' => 'tab-old', + 'label' => 'Old tab', + 'type' => 'tab', + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + $group->fields()->create([ + 'name' => 'legacy-field', + 'label' => 'Legacy field', + 'type' => 'text', + 'sort' => 1, + 'validation' => ['required' => false, 'rules' => []], + ]); + + expect(app(TabStructureMigrator::class)->migrateGroup($group->fresh()))->toBeTrue(); + + $tab->refresh()->load('children'); + + expect($tab->children)->toHaveCount(1) + ->and($tab->children->first()->name)->toBe('legacy-field'); +}); diff --git a/packages/builder/tests/Unit/TableColumnCompilerTest.php b/packages/builder/tests/Unit/TableColumnCompilerTest.php new file mode 100644 index 0000000000..a72b8da994 --- /dev/null +++ b/packages/builder/tests/Unit/TableColumnCompilerTest.php @@ -0,0 +1,981 @@ +createItemsTable(); + + FieldGroup::query()->delete(); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); +}); + +function seedTableColumnFieldGroup(): FieldGroup +{ + $group = FieldGroup::query()->create([ + 'name' => 'List columns', + 'slug' => 'list-columns', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + foreach ([ + ['name' => 'color', 'label' => 'Color', 'type' => 'text', 'settings' => ['show_in_table' => true]], + ['name' => 'active', 'label' => 'Active', 'type' => 'toggle', 'settings' => ['show_in_table' => true]], + ['name' => 'gallery', 'label' => 'Gallery', 'type' => 'gallery', 'settings' => ['show_in_table' => true]], + ['name' => 'secret', 'label' => 'Secret', 'type' => 'password', 'settings' => ['show_in_table' => true]], + ['name' => 'hidden', 'label' => 'Hidden', 'type' => 'text', 'settings' => ['show_in_table' => false]], + ] as $index => $field) { + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => $field['name'], + 'label' => $field['label'], + 'type' => $field['type'], + 'settings' => $field['settings'], + 'sort' => $index, + 'validation' => ['required' => false, 'rules' => []], + ]); + } + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + return $group; +} + +it('builds table columns for scalar and image fields marked show in table', function (): void { + seedTableColumnFieldGroup(); + + $columns = TestItemResource::customFieldColumns(); + + expect($columns)->toHaveCount(3) + ->and(collect($columns)->map(fn ($column) => $column->getName())->all())->toBe(['color', 'active', 'gallery']) + ->and($columns[0])->toBeInstanceOf(TextColumn::class) + ->and($columns[1])->toBeInstanceOf(IconColumn::class) + ->and($columns[2])->toBeInstanceOf(ImageColumn::class); +}); + +it('builds color fields as dedicated color columns', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Color columns', + 'slug' => 'color-columns', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'brand_color', + 'label' => 'Brand color', + 'type' => 'color', + 'settings' => [ + 'show_in_table' => true, + 'badge' => true, + 'color' => 'success', + 'icon' => 'heroicon-o-star', + ], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $columns = TestItemResource::customFieldColumns(); + + expect($columns)->toHaveCount(1) + ->and($columns[0])->toBeInstanceOf(ColorColumn::class) + ->and($columns[0]->isSortable())->toBeTrue() + ->and($columns[0]->isSearchable())->toBeTrue(); +}); + +it('builds image fields as non sortable, non searchable image columns', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Media columns', + 'slug' => 'media-columns', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'hero', + 'label' => 'Hero', + 'type' => 'image', + 'settings' => ['show_in_table' => true, 'hidden_by_default' => false], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $columns = TestItemResource::customFieldColumns(); + + expect($columns)->toHaveCount(1) + ->and($columns[0])->toBeInstanceOf(ImageColumn::class) + ->and($columns[0]->isSortable())->toBeFalse() + ->and($columns[0]->isSearchable())->toBeFalse() + ->and($columns[0]->isToggledHiddenByDefault())->toBeFalse(); +}); + +it('applies display format and placeholder to date and number columns', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Formatted columns', + 'slug' => 'formatted-columns', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'starts_on', + 'label' => 'Starts on', + 'type' => 'date', + 'config' => ['displayFormat' => 'd.m.Y'], + 'settings' => ['show_in_table' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'price', + 'label' => 'Price', + 'type' => 'number', + 'config' => ['step' => 0.01], + 'settings' => ['show_in_table' => true], + 'sort' => 1, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'title', + 'label' => 'Title', + 'type' => 'text', + 'settings' => ['show_in_table' => true], + 'sort' => 2, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $columns = collect(TestItemResource::customFieldColumns())->keyBy(fn ($column) => $column->getName()); + + expect($columns['starts_on']->isDate())->toBeTrue() + ->and($columns['starts_on']->formatState('2026-06-16'))->toBe('16.06.2026') + ->and($columns['starts_on']->getPlaceholder())->toBe('—') + ->and($columns['price']->isNumeric())->toBeTrue() + ->and($columns['title']->getPlaceholder())->toBe('—'); +}); + +it('applies datetime display format from field config', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Datetime column', + 'slug' => 'datetime-column', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'starts_at', + 'label' => 'Starts at', + 'type' => 'datetime', + 'config' => ['displayFormat' => 'd.m.Y H:i'], + 'settings' => ['show_in_table' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $column = TestItemResource::customFieldColumns()[0]; + + expect($column->isDateTime())->toBeTrue() + ->and($column->formatState('2026-06-16T14:30:00+00:00'))->toBe('16.06.2026 14:30'); +}); + +it('renders boolean toggle columns with check and cross icons', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Flags', + 'slug' => 'flags', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'featured', + 'label' => 'Featured', + 'type' => 'toggle', + 'settings' => ['show_in_table' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $columns = TestItemResource::customFieldColumns(); + + expect($columns)->toHaveCount(1) + ->and($columns[0])->toBeInstanceOf(IconColumn::class) + ->and($columns[0]->isBoolean())->toBeTrue() + ->and($columns[0]->isSortable())->toBeTrue() + ->and($columns[0]->isSearchable())->toBeFalse() + ->and($columns[0]->getTrueIcon())->toBe(Heroicon::OutlinedCheckCircle) + ->and($columns[0]->getFalseIcon())->toBe(Heroicon::OutlinedXCircle) + ->and($columns[0]->getTrueColor())->toBe('success') + ->and($columns[0]->getFalseColor())->toBe('danger'); +}); + +it('defaults image column shape and size', function (): void { + $field = FieldDefinition::fromArray([ + 'name' => 'hero', + 'label' => 'Hero', + 'type' => 'image', + 'settings' => ['show_in_table' => true], + ]); + + expect($field->columnImageShape())->toBeNull() + ->and($field->columnImageSize())->toBe('md'); +}); + +it('reads image column shape and size from field definition', function (): void { + $field = FieldDefinition::fromArray([ + 'name' => 'hero', + 'label' => 'Hero', + 'type' => 'image', + 'settings' => [ + 'show_in_table' => true, + 'image_shape' => 'circular', + 'image_size' => 'lg', + ], + ]); + + expect($field->columnImageShape())->toBe('circular') + ->and($field->columnImageSize())->toBe('lg'); +}); + +it('applies circular shape and size to compiled image columns', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Media shape', + 'slug' => 'media-shape', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'avatar', + 'label' => 'Avatar', + 'type' => 'image', + 'settings' => [ + 'show_in_table' => true, + 'image_shape' => 'circular', + 'image_size' => 'lg', + ], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $columns = TestItemResource::customFieldColumns(); + + expect($columns)->toHaveCount(1) + ->and($columns[0])->toBeInstanceOf(ImageColumn::class) + ->and($columns[0]->isCircular())->toBeTrue() + ->and($columns[0]->getImageHeight())->toBe('56px'); +}); + +it('round trips image shape and size through field group persistence', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Sync', + 'slug' => 'sync-image-settings', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Sync', + 'slug' => 'sync-image-settings', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'hero', + 'label' => 'Hero', + 'type' => 'image', + 'required' => false, + 'settings' => [ + 'show_in_table' => true, + 'image_shape' => 'square', + 'image_size' => 'sm', + ], + ], + ], + ]); + + $rows = app(FieldGroupPersistence::class)->fieldRowsForForm($group->fresh()); + + expect($rows[0]['settings'])->toMatchArray([ + 'image_shape' => 'square', + 'image_size' => 'sm', + ]); +}); + +it('exposes field definition show in table helper', function (): void { + $field = FieldDefinition::fromArray([ + 'name' => 'color', + 'label' => 'Color', + 'type' => 'text', + 'settings' => ['show_in_table' => true], + ]); + + expect($field->showInTable())->toBeTrue(); +}); + +it('round trips show in table through field group persistence', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Sync', + 'slug' => 'sync-table-setting', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Sync', + 'slug' => 'sync-table-setting', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'color', + 'label' => 'Color', + 'type' => 'text', + 'required' => false, + 'settings' => ['show_in_table' => true], + ], + ], + ]); + + $field = $group->fields()->where('name', 'color')->first(); + + expect($field)->not->toBeNull() + ->and($field->settings['show_in_table'] ?? false)->toBeTrue(); + + $rows = app(FieldGroupPersistence::class)->fieldRowsForForm($group->fresh()); + + expect($rows[0]['settings']['show_in_table'] ?? false)->toBeTrue(); +}); + +it('sorts records by custom field column subquery', function (): void { + seedTableColumnFieldGroup(); + + $alpha = TestItem::query()->create(['title' => 'Alpha']); + $beta = TestItem::query()->create(['title' => 'Beta']); + + $locale = app(BuilderLocaleResolver::class)->defaultLocale(); + $fields = app(CustomFieldsManager::class)->fieldsForEntity('item'); + + app(CustomFieldsManager::class)->saveValues('item', $alpha, ['color' => 'Apple'], $fields, $locale); + app(CustomFieldsManager::class)->saveValues('item', $beta, ['color' => 'Banana'], $fields, $locale); + + $valuesTable = (new FieldValue)->getTable(); + $recordKey = (new TestItem)->getQualifiedKeyName(); + + $orderedIds = TestItem::query() + ->orderBy( + FieldValue::query() + ->select('value_string') + ->from($valuesTable) + ->whereColumn("{$valuesTable}.record_id", $recordKey) + ->where("{$valuesTable}.entity", 'item') + ->where("{$valuesTable}.field_name", 'color') + ->where("{$valuesTable}.locale", $locale) + ->limit(1), + 'asc', + ) + ->pluck('id') + ->all(); + + expect($orderedIds)->toBe([$alpha->getKey(), $beta->getKey()]); +}); + +it('searches records by custom field column through the eloquent builder', function (): void { + seedTableColumnFieldGroup(); + + $match = TestItem::query()->create(['title' => 'Match']); + $other = TestItem::query()->create(['title' => 'Other']); + + $locale = app(BuilderLocaleResolver::class)->defaultLocale(); + $fields = app(CustomFieldsManager::class)->fieldsForEntity('item'); + + app(CustomFieldsManager::class)->saveValues('item', $match, ['color' => 'Golf GTI'], $fields, $locale); + app(CustomFieldsManager::class)->saveValues('item', $other, ['color' => 'Polo'], $fields, $locale); + + $ids = TestItem::query() + ->where('color', 'like', '%Golf%') + ->pluck('id') + ->all(); + + expect($ids)->toBe([$match->getKey()]); +}); + +it('returns no columns when no field groups match the entity', function (): void { + FieldGroup::query()->delete(); + Cache::forget(DefinitionRegistry::CACHE_KEY); + + expect(app(TableColumnCompiler::class)->compile( + app(DefinitionRegistry::class)->fieldGroupsFor(new LocationContext('item')), + TestItemResource::class, + ))->toBe([]); +}); + +it('defaults column settings to sortable, searchable and hidden', function (): void { + $field = FieldDefinition::fromArray([ + 'name' => 'color', + 'label' => 'Color', + 'type' => 'text', + 'settings' => ['show_in_table' => true], + ]); + + expect($field->isColumnSortable())->toBeTrue() + ->and($field->isColumnSearchable())->toBeTrue() + ->and($field->isColumnHiddenByDefault())->toBeTrue(); +}); + +it('honours disabled column settings from field definition', function (): void { + $field = FieldDefinition::fromArray([ + 'name' => 'color', + 'label' => 'Color', + 'type' => 'text', + 'settings' => [ + 'show_in_table' => true, + 'sortable' => false, + 'searchable' => false, + 'hidden_by_default' => false, + ], + ]); + + expect($field->isColumnSortable())->toBeFalse() + ->and($field->isColumnSearchable())->toBeFalse() + ->and($field->isColumnHiddenByDefault())->toBeFalse(); +}); + +it('applies column settings to compiled columns', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Settings columns', + 'slug' => 'settings-columns', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'plain', + 'label' => 'Plain', + 'type' => 'text', + 'settings' => [ + 'show_in_table' => true, + 'sortable' => false, + 'searchable' => false, + 'hidden_by_default' => false, + ], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $columns = TestItemResource::customFieldColumns(); + + expect($columns)->toHaveCount(1) + ->and($columns[0]->isSortable())->toBeFalse() + ->and($columns[0]->isSearchable())->toBeFalse() + ->and($columns[0]->isToggledHiddenByDefault())->toBeFalse(); +}); + +it('defaults badge, color and icon column settings to disabled', function (): void { + $field = FieldDefinition::fromArray([ + 'name' => 'color', + 'label' => 'Color', + 'type' => 'text', + 'settings' => ['show_in_table' => true], + ]); + + expect($field->columnBadge())->toBeFalse() + ->and($field->columnColor())->toBeNull() + ->and($field->columnIcon())->toBeNull(); +}); + +it('reads badge, color and icon column settings from field definition', function (): void { + $field = FieldDefinition::fromArray([ + 'name' => 'status', + 'label' => 'Status', + 'type' => 'text', + 'settings' => [ + 'show_in_table' => true, + 'badge' => true, + 'color' => 'success', + 'icon' => 'heroicon-o-star', + ], + ]); + + expect($field->columnBadge())->toBeTrue() + ->and($field->columnColor())->toBe('success') + ->and($field->columnIcon())->toBe('heroicon-o-star'); +}); + +it('applies badge, color and icon to compiled text columns', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Presentation columns', + 'slug' => 'presentation-columns', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'status', + 'label' => 'Status', + 'type' => 'text', + 'settings' => [ + 'show_in_table' => true, + 'badge' => true, + 'color' => 'success', + 'icon' => 'heroicon-o-star', + ], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $columns = TestItemResource::customFieldColumns(); + + expect($columns)->toHaveCount(1) + ->and($columns[0])->toBeInstanceOf(TextColumn::class) + ->and($columns[0]->isBadge())->toBeTrue() + ->and($columns[0]->getColor(null))->toBe('success') + ->and($columns[0]->getIcon(null))->toBe('heroicon-o-star'); +}); + +it('applies badge, color and icon settings on numeric columns', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Numeric presentation columns', + 'slug' => 'numeric-presentation-columns', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'price', + 'label' => 'Price', + 'type' => 'number', + 'settings' => [ + 'show_in_table' => true, + 'badge' => true, + 'color' => 'success', + 'icon' => 'heroicon-o-star', + ], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $columns = TestItemResource::customFieldColumns(); + + expect($columns)->toHaveCount(1) + ->and($columns[0])->toBeInstanceOf(TextColumn::class) + ->and($columns[0]->isBadge())->toBeTrue() + ->and($columns[0]->getColor(null))->toBe('success') + ->and($columns[0]->getIcon(null))->toBe('heroicon-o-star'); +}); + +it('does not build rich text fields as table columns', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Rich text columns', + 'slug' => 'rich-text-columns', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'body', + 'label' => 'Body', + 'type' => 'rich_text', + 'settings' => ['show_in_table' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + expect(TestItemResource::customFieldColumns())->toBe([]); +}); + +it('round trips badge, color and icon through field group persistence', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Sync', + 'slug' => 'sync-presentation-settings', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Sync', + 'slug' => 'sync-presentation-settings', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'status', + 'label' => 'Status', + 'type' => 'text', + 'required' => false, + 'settings' => [ + 'show_in_table' => true, + 'badge' => true, + 'color' => 'success', + 'icon' => 'heroicon-o-star', + ], + ], + ], + ]); + + $rows = app(FieldGroupPersistence::class)->fieldRowsForForm($group->fresh()); + + expect($rows[0]['settings'])->toMatchArray([ + 'badge' => true, + 'color' => 'success', + 'icon' => 'heroicon-o-star', + ]); +}); + +it('round trips column settings through field group persistence', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Sync', + 'slug' => 'sync-column-settings', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Sync', + 'slug' => 'sync-column-settings', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'color', + 'label' => 'Color', + 'type' => 'text', + 'required' => false, + 'settings' => [ + 'show_in_table' => true, + 'sortable' => false, + 'searchable' => false, + 'hidden_by_default' => false, + ], + ], + ], + ]); + + $rows = app(FieldGroupPersistence::class)->fieldRowsForForm($group->fresh()); + + expect($rows[0]['settings'])->toMatchArray([ + 'show_in_table' => true, + 'sortable' => false, + 'searchable' => false, + 'hidden_by_default' => false, + ]); +}); + +function bindRelationColumnTestEntityRegistry(): void +{ + $registry = new class extends EntityRegistry + { + protected function panelResources(): array + { + return [TestItemResource::class]; + } + }; + + app()->instance(EntityRegistry::class, $registry); +} + +it('builds relation fields as sortable and searchable text columns by default', function (): void { + bindRelationColumnTestEntityRegistry(); + + $group = FieldGroup::query()->create([ + 'name' => 'Relation columns', + 'slug' => 'relation-columns', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'linked-item', + 'label' => 'Linked item', + 'type' => 'relation', + 'config' => ['related_entity' => 'item', 'multiple' => false], + 'settings' => ['show_in_table' => true, 'hidden_by_default' => false], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $columns = TestItemResource::customFieldColumns(); + + expect($columns)->toHaveCount(1) + ->and($columns[0])->toBeInstanceOf(TextColumn::class) + ->and($columns[0]->isSortable())->toBeTrue() + ->and($columns[0]->isSearchable())->toBeTrue() + ->and($columns[0]->getPlaceholder())->toBe('—'); +}); + +it('sorts records by relation column labels', function (): void { + bindRelationColumnTestEntityRegistry(); + + $group = FieldGroup::query()->create([ + 'name' => 'Relation sort', + 'slug' => 'relation-sort', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'linked-item', + 'label' => 'Linked item', + 'type' => 'relation', + 'config' => ['related_entity' => 'item', 'multiple' => false], + 'settings' => ['show_in_table' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $alpha = TestItem::query()->create(['title' => 'Alpha target']); + $beta = TestItem::query()->create(['title' => 'Beta target']); + $ownerAlpha = TestItem::query()->create(['title' => 'Owner A']); + $ownerBeta = TestItem::query()->create(['title' => 'Owner B']); + + $locale = app(BuilderLocaleResolver::class)->defaultLocale(); + $fields = app(CustomFieldsManager::class)->fieldsForEntity('item'); + $manager = app(CustomFieldsManager::class); + + $manager->saveValues('item', $ownerBeta, ['linked-item' => $beta->getKey()], $fields, $locale); + $manager->saveValues('item', $ownerAlpha, ['linked-item' => $alpha->getKey()], $fields, $locale); + + $orderedIds = TestItem::query() + ->whereKey([$ownerAlpha->getKey(), $ownerBeta->getKey()]) + ->tap(fn ($query) => app(RelationTableColumnQuery::class)->applySort( + $query, + $fields->firstWhere('name', 'linked-item'), + 'item', + $locale, + (new FieldValue)->getTable(), + 'asc', + )) + ->pluck('id') + ->all(); + + expect($orderedIds)->toBe([$ownerAlpha->getKey(), $ownerBeta->getKey()]); +}); + +it('searches records by relation column labels', function (): void { + bindRelationColumnTestEntityRegistry(); + + $group = FieldGroup::query()->create([ + 'name' => 'Relation search', + 'slug' => 'relation-search', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'linked-item', + 'label' => 'Linked item', + 'type' => 'relation', + 'config' => ['related_entity' => 'item', 'multiple' => false], + 'settings' => ['show_in_table' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $matchTarget = TestItem::query()->create(['title' => 'Golf target']); + $otherTarget = TestItem::query()->create(['title' => 'Polo target']); + $matchOwner = TestItem::query()->create(['title' => 'Match owner']); + $otherOwner = TestItem::query()->create(['title' => 'Other owner']); + + $locale = app(BuilderLocaleResolver::class)->defaultLocale(); + $fields = app(CustomFieldsManager::class)->fieldsForEntity('item'); + $manager = app(CustomFieldsManager::class); + + $manager->saveValues('item', $matchOwner, ['linked-item' => $matchTarget->getKey()], $fields, $locale); + $manager->saveValues('item', $otherOwner, ['linked-item' => $otherTarget->getKey()], $fields, $locale); + + $field = $fields->firstWhere('name', 'linked-item'); + + $ids = TestItem::query() + ->whereKey([$matchOwner->getKey(), $otherOwner->getKey()]) + ->tap(fn ($query) => app(RelationTableColumnQuery::class)->applySearch( + $query, + $field, + 'item', + $locale, + (new FieldValue)->getTable(), + 'Golf', + )) + ->pluck('id') + ->all(); + + expect($ids)->toBe([$matchOwner->getKey()]); +}); + +it('applies badge presentation to single relation columns when enabled', function (): void { + bindRelationColumnTestEntityRegistry(); + + $group = FieldGroup::query()->create([ + 'name' => 'Relation badge', + 'slug' => 'relation-badge', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'linked-item', + 'label' => 'Linked item', + 'type' => 'relation', + 'config' => ['related_entity' => 'item', 'multiple' => false], + 'settings' => ['show_in_table' => true, 'badge' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $columns = TestItemResource::customFieldColumns(); + + expect($columns)->toHaveCount(1) + ->and($columns[0])->toBeInstanceOf(TextColumn::class) + ->and($columns[0]->isBadge())->toBeTrue(); +}); + +it('formats relation column state as resolved labels', function (): void { + $compiler = app(TableColumnCompiler::class); + $reflection = new ReflectionClass($compiler); + $format = $reflection->getMethod('formatRelationColumnState'); + $format->setAccessible(true); + + expect($format->invoke($compiler, ['id' => 1, 'label' => 'Alpha']))->toBe('Alpha') + ->and($format->invoke($compiler, [ + ['id' => 1, 'label' => 'Alpha'], + ['id' => 2, 'label' => 'Beta'], + ]))->toBe('Alpha, Beta') + ->and($format->invoke($compiler, null))->toBeNull(); +}); + +it('resolves relation column labels from saved field values', function (): void { + bindRelationColumnTestEntityRegistry(); + + $group = FieldGroup::query()->create([ + 'name' => 'Relation values', + 'slug' => 'relation-values', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'linked-item', + 'label' => 'Linked item', + 'type' => 'relation', + 'config' => ['related_entity' => 'item', 'multiple' => false], + 'settings' => ['show_in_table' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $owner = TestItem::query()->create(['title' => 'Owner']); + $target = TestItem::query()->create(['title' => 'Linked Title']); + + $locale = app(BuilderLocaleResolver::class)->defaultLocale(); + $fields = app(CustomFieldsManager::class)->fieldsForEntity('item'); + + app(CustomFieldsManager::class)->saveValues('item', $owner, ['linked-item' => $target->getKey()], $fields, $locale); + + $field = $fields->firstWhere('name', 'linked-item'); + $compiler = app(TableColumnCompiler::class); + $reflection = new ReflectionClass($compiler); + $resolvePresented = $reflection->getMethod('resolvePresentedValue'); + $resolvePresented->setAccessible(true); + $format = $reflection->getMethod('formatRelationColumnState'); + $format->setAccessible(true); + + $presented = $resolvePresented->invoke($compiler, $field, $owner->fresh()); + + expect($format->invoke($compiler, $presented))->toBe('Linked Title'); +}); diff --git a/packages/builder/tests/Unit/TimeFieldTypeTest.php b/packages/builder/tests/Unit/TimeFieldTypeTest.php new file mode 100644 index 0000000000..fddad16f11 --- /dev/null +++ b/packages/builder/tests/Unit/TimeFieldTypeTest.php @@ -0,0 +1,208 @@ +createItemsTable(); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Vehicle schedule', + 'slug' => 'vehicle-schedule', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'besichtigungszeit', + 'label' => 'Bevorzugte Besichtigungszeit', + 'type' => 'time', + 'sort' => 0, + 'config' => ['default' => '14:00'], + 'validation' => ['required' => false, 'rules' => []], + ]); +}); + +it('applies time defaults on runtime components', function (): void { + $field = new FieldDefinition( + name: 'besichtigungszeit', + label: 'Bevorzugte Besichtigungszeit', + type: 'time', + config: ['default' => '14:00'], + ); + + $component = (new TimeFieldType)->formComponent($field); + + expect($component->getDefaultState())->toBeInstanceOf(Carbon::class) + ->and($component->getDefaultState()->format('H:i'))->toBe('14:00') + ->and($component->isNative())->toBeTrue(); +}); + +it('casts submitted time values using the configured display format', function (): void { + $fieldType = new TimeFieldType; + + $field = new FieldDefinition( + name: 'besichtigungszeit', + label: 'Bevorzugte Besichtigungszeit', + type: 'time', + config: ['displayFormat' => 'H:i:s'], + ); + + expect($fieldType->castValue('14:00', $field))->toBe('14:00:00') + ->and($fieldType->castValue(now()->setTime(9, 30), $field))->toBe('09:30:00') + ->and($fieldType->castValue('2026-06-16 14:00:00', $field))->toBe('14:00:00'); +}); + +it('resolves current time when default now is enabled', function (): void { + $capability = app(DefaultValue::class); + + $field = new FieldDefinition( + name: 'besichtigungszeit', + label: 'Bevorzugte Besichtigungszeit', + type: 'time', + config: ['defaultNow' => true], + ); + + $resolved = $capability->resolveForField($field); + + expect($resolved)->toBeInstanceOf(Carbon::class) + ->and($resolved->format('H:i'))->toBe(now()->format('H:i')); +}); + +it('casts submitted time values to H:i strings by default', function (): void { + $fieldType = new TimeFieldType; + + expect($fieldType->castValue('14:00'))->toBe('14:00') + ->and($fieldType->castValue(now()->setTime(9, 30)))->toBe('09:30') + ->and($fieldType->castValue('2026-06-16 14:00:00'))->toBe('14:00'); +}); + +it('persists configured time defaults when the form value is empty on create', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + + app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + ['besichtigungszeit' => null], + ); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'besichtigungszeit') + ->first(); + + expect($stored?->value_string)->toBe('14:00'); +}); + +it('persists current time when default now is enabled on create', function (): void { + FieldGroup::query()->where('slug', 'vehicle-schedule')->delete(); + Cache::forget(DefinitionRegistry::CACHE_KEY); + + FieldGroup::query()->create([ + 'name' => 'Vehicle schedule now', + 'slug' => 'vehicle-schedule-now', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ])->fields()->create([ + 'name' => 'besichtigungszeit', + 'label' => 'Bevorzugte Besichtigungszeit', + 'type' => 'time', + 'sort' => 0, + 'config' => ['defaultNow' => true], + 'validation' => ['required' => false, 'rules' => []], + ]); + + $record = TestItem::query()->create(['title' => 'Demo']); + + app(CustomFieldsManager::class)->saveFromFormData( + TestItemResource::class, + $record, + [], + ); + + $stored = FieldValue::query() + ->forRecord('item', $record->getKey()) + ->where('field_name', 'besichtigungszeit') + ->first(); + + expect($stored?->value_string)->toBe(now()->format('H:i')); +}); + +it('syncs time default values through field group persistence', function (): void { + FieldGroup::query()->where('slug', 'vehicle-schedule')->delete(); + Cache::forget(DefinitionRegistry::CACHE_KEY); + + $group = FieldGroup::query()->create([ + 'name' => 'Sync time', + 'slug' => 'sync-time', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Sync time', + 'slug' => 'sync-time', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => [ + [ + 'name' => 'besichtigungszeit', + 'label' => 'Bevorzugte Besichtigungszeit', + 'type' => 'time', + 'required' => false, + 'config' => ['default' => '09:30', 'defaultNow' => false], + ], + ], + ]); + + expect($group->fields()->where('name', 'besichtigungszeit')->value('config'))->toMatchArray([ + 'default' => '09:30', + 'defaultNow' => false, + 'displayFormat' => 'H:i', + ]); +}); + +it('builds temporal default controls for time fields in the admin', function (): void { + $fields = app(DefaultValue::class)->builderFieldsFor('time'); + + expect($fields)->toHaveCount(2) + ->and($fields[0])->toBeInstanceOf(Toggle::class) + ->and($fields[1])->toBeInstanceOf(TimePicker::class) + ->and($fields[1]->isNative())->toBeTrue(); +}); + +it('normalizes stored time strings for form hydration', function (): void { + $capability = app(DefaultValue::class); + + $normalized = $capability->normalizeTimeValue('14:00'); + + expect($normalized)->toBeInstanceOf(Carbon::class) + ->and($normalized->format('H:i'))->toBe('14:00') + ->and($capability->normalizeTimeValue('2026-06-16 14:00:00')?->format('H:i'))->toBe('14:00'); +}); diff --git a/packages/builder/tests/Unit/TypedValueDriverTest.php b/packages/builder/tests/Unit/TypedValueDriverTest.php deleted file mode 100644 index 9ad1260b02..0000000000 --- a/packages/builder/tests/Unit/TypedValueDriverTest.php +++ /dev/null @@ -1,74 +0,0 @@ -createItemsTable(); -}); - -it('roundtrips values through typed columns', function (): void { - $record = TestItem::query()->create(['title' => 'Demo']); - - $driver = app(TypedValueDriver::class); - $fields = collect([ - new FieldDefinition('fahrzeugtyp-modell', 'Model', 'text'), - new FieldDefinition('bruttolistenpreis', 'Price', 'number'), - new FieldDefinition('erstzulassung', 'Registered', 'date'), - ]); - - $driver->save('item', $record, [ - 'fahrzeugtyp-modell' => 'Golf GTI', - 'bruttolistenpreis' => '32990', - 'erstzulassung' => '2020-03-15', - ], $fields); - - expect(FieldValue::query()->forRecord('item', $record->getKey())->count())->toBe(3); - - $loaded = $driver->load('item', $record->fresh(), $fields); - - expect($loaded['fahrzeugtyp-modell'])->toBe('Golf GTI') - ->and($loaded['bruttolistenpreis'])->toEqual(32990) - ->and($loaded['erstzulassung']->format('Y-m-d'))->toBe('2020-03-15'); -}); - -it('clears stale typed columns when field type changes', function (): void { - FieldValue::query()->create([ - 'entity' => 'item', - 'record_id' => 1, - 'field_name' => 'price', - 'value_string' => 'old', - 'value_decimal' => null, - ]); - - $record = TestItem::query()->create(['title' => 'Demo']); - $driver = app(TypedValueDriver::class); - - $driver->save('item', $record, ['price' => 19999], collect([ - new FieldDefinition('price', 'Price', 'number'), - ])); - - $row = FieldValue::query()->forRecord('item', $record->getKey())->first(); - - expect($row?->value_string)->toBeNull() - ->and((float) $row?->value_decimal)->toBe(19999.0); -}); - -it('maps field types to typed columns', function (): void { - expect(TypedValueColumns::columnForType('number'))->toBe('value_decimal') - ->and(TypedValueColumns::columnForType('date'))->toBe('value_date') - ->and(TypedValueColumns::columnForType('multiselect'))->toBe('value_json') - ->and(TypedValueColumns::columnForType('text'))->toBe('value_string'); -}); diff --git a/packages/builder/tests/Unit/ValueStoreResolverTest.php b/packages/builder/tests/Unit/ValueStoreResolverTest.php deleted file mode 100644 index 904a43bad3..0000000000 --- a/packages/builder/tests/Unit/ValueStoreResolverTest.php +++ /dev/null @@ -1,19 +0,0 @@ -for())->toBeInstanceOf(TypedValueDriver::class) - ->and($resolver->for())->toBeInstanceOf(TypedValueDriver::class); -}); diff --git a/packages/builder/tests/bootstrap.php b/packages/builder/tests/bootstrap.php index 147c3fc39d..c12505d1d4 100644 --- a/packages/builder/tests/bootstrap.php +++ b/packages/builder/tests/bootstrap.php @@ -1,13 +1,8 @@ Date: Thu, 9 Jul 2026 09:23:24 +0000 Subject: [PATCH 093/105] Fix styling --- packages/builder/tests/TestCase.php | 2 +- .../tests/Unit/FieldGroupImportExportTest.php | 1 - .../tests/Unit/FieldValidationRulesTest.php | 5 ++-- .../Unit/LocationConstraintOptionsTest.php | 26 ++++++++++--------- .../tests/Unit/LocationMatcherTest.php | 9 +++---- 5 files changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/builder/tests/TestCase.php b/packages/builder/tests/TestCase.php index b8cfae48f5..b532aca171 100644 --- a/packages/builder/tests/TestCase.php +++ b/packages/builder/tests/TestCase.php @@ -7,10 +7,10 @@ use Astrotomic\Translatable\TranslatableServiceProvider; use Filament\Support\SupportServiceProvider; use Illuminate\Contracts\Config\Repository; -use Livewire\LivewireServiceProvider; use Illuminate\Database\Schema\Blueprint; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Schema; +use Livewire\LivewireServiceProvider; use Moox\Builder\BuilderServiceProvider; use Orchestra\Testbench\TestCase as Orchestra; diff --git a/packages/builder/tests/Unit/FieldGroupImportExportTest.php b/packages/builder/tests/Unit/FieldGroupImportExportTest.php index c962e14389..f105cefbc0 100644 --- a/packages/builder/tests/Unit/FieldGroupImportExportTest.php +++ b/packages/builder/tests/Unit/FieldGroupImportExportTest.php @@ -12,7 +12,6 @@ use Moox\Builder\Services\FieldGroupImporter; use Moox\Builder\Services\FieldGroupPersistence; use Moox\Builder\Support\FieldGroupExportSchema; -use Moox\Builder\Tests\Support\TestItemResource; use Moox\Builder\Tests\TestCase; uses(TestCase::class); diff --git a/packages/builder/tests/Unit/FieldValidationRulesTest.php b/packages/builder/tests/Unit/FieldValidationRulesTest.php index 197febd31f..e98db73fa2 100644 --- a/packages/builder/tests/Unit/FieldValidationRulesTest.php +++ b/packages/builder/tests/Unit/FieldValidationRulesTest.php @@ -6,6 +6,7 @@ use Illuminate\Validation\ValidationException; use Moox\Builder\Data\FieldDefinition; +use Moox\Builder\Services\FieldValueValidator; use Moox\Builder\Support\FieldValidationRules; use Moox\Builder\Tests\TestCase; @@ -76,7 +77,7 @@ validation: ['required' => false, 'rules' => ['min:3', 'alpha_dash']], ); - expect(fn () => app(\Moox\Builder\Services\FieldValueValidator::class)->assertValid($field, 'a!')) + expect(fn () => app(FieldValueValidator::class)->assertValid($field, 'a!')) ->toThrow(ValidationException::class); }); @@ -88,6 +89,6 @@ validation: ['required' => false, 'rules' => ['gte:10']], ); - expect(fn () => app(\Moox\Builder\Services\FieldValueValidator::class)->assertValid($field, 5)) + expect(fn () => app(FieldValueValidator::class)->assertValid($field, 5)) ->toThrow(ValidationException::class); }); diff --git a/packages/builder/tests/Unit/LocationConstraintOptionsTest.php b/packages/builder/tests/Unit/LocationConstraintOptionsTest.php index f87f88a858..b691c14661 100644 --- a/packages/builder/tests/Unit/LocationConstraintOptionsTest.php +++ b/packages/builder/tests/Unit/LocationConstraintOptionsTest.php @@ -6,16 +6,18 @@ require_once __DIR__.'/../Support/TestItem.php'; require_once __DIR__.'/../Support/TestItemResource.php'; +use Filament\Forms\Components\Select; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; -use Spatie\Permission\Traits\HasRoles; use Moox\Builder\Registry\EntityRegistry; use Moox\Builder\Services\FieldGroupPersistence; use Moox\Builder\Support\BuilderLocaleResolver; use Moox\Builder\Support\LocationConstraintOptions; use Moox\Builder\Tests\TestCase; +use Moox\Core\Services\TaxonomyService; +use Spatie\Permission\Traits\HasRoles; uses(TestCase::class); @@ -96,8 +98,8 @@ public function translate(string $locale, bool $withFallback = true): ?object $resolver = app(BuilderLocaleResolver::class); $options = new LocationConstraintOptions( - app(\Moox\Builder\Registry\EntityRegistry::class), - app(\Moox\Core\Services\TaxonomyService::class), + app(EntityRegistry::class), + app(TaxonomyService::class), $resolver, ); @@ -139,7 +141,7 @@ public function modelFor(string $entity): ?string $options = new LocationConstraintOptions( $registry, - app(\Moox\Core\Services\TaxonomyService::class), + app(TaxonomyService::class), app(BuilderLocaleResolver::class), ); @@ -157,9 +159,9 @@ public function modelFor(string $entity): ?string $resourceClass = new class { - public static function getTypeSelect(): \Filament\Forms\Components\Select + public static function getTypeSelect(): Select { - return \Filament\Forms\Components\Select::make('type') + return Select::make('type') ->options([ 'Post' => 'Post', 'Page' => 'Page', @@ -187,7 +189,7 @@ public function resourceFor(string $entity): ?string $options = new LocationConstraintOptions( $registry, - app(\Moox\Core\Services\TaxonomyService::class), + app(TaxonomyService::class), app(BuilderLocaleResolver::class), ); @@ -220,7 +222,7 @@ public function resourceFor(string $entity): ?string config()->set('auth.providers.users.model', RolesUser::class); - \Illuminate\Support\Facades\DB::table('roles')->insert([ + DB::table('roles')->insert([ 'name' => 'editor', 'guard_name' => 'web', 'created_at' => now(), @@ -260,9 +262,9 @@ public function resourceFor(string $entity): ?string $resourceClass = new class { - public static function getTypeSelect(): \Filament\Forms\Components\Select + public static function getTypeSelect(): Select { - return \Filament\Forms\Components\Select::make('type') + return Select::make('type') ->options([ 'Post' => 'Post', ]); @@ -289,7 +291,7 @@ public function resourceFor(string $entity): ?string $options = new LocationConstraintOptions( $registry, - app(\Moox\Core\Services\TaxonomyService::class), + app(TaxonomyService::class), app(BuilderLocaleResolver::class), ); @@ -328,7 +330,7 @@ public function modelFor(string $entity): ?string $options = new LocationConstraintOptions( $registry, - app(\Moox\Core\Services\TaxonomyService::class), + app(TaxonomyService::class), app(BuilderLocaleResolver::class), ); diff --git a/packages/builder/tests/Unit/LocationMatcherTest.php b/packages/builder/tests/Unit/LocationMatcherTest.php index 4346132af6..2e34f0f01e 100644 --- a/packages/builder/tests/Unit/LocationMatcherTest.php +++ b/packages/builder/tests/Unit/LocationMatcherTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Illuminate\Database\Eloquent\Model; use Moox\Builder\Compiler\LocationMatcher; use Moox\Builder\Data\LocationContext; use Moox\Builder\Services\FieldGroupPersistence; @@ -115,9 +116,7 @@ ['param' => 'record_type', 'operator' => '==', 'value' => 'page'], ]]; - $record = new class extends \Illuminate\Database\Eloquent\Model - { - }; + $record = new class extends Model {}; expect($matcher->matches($rules, new LocationContext('item', [], $record))) ->toBeTrue(); @@ -131,9 +130,7 @@ ['param' => 'record_type', 'operator' => '==', 'value' => 'page'], ]]; - $record = new class extends \Illuminate\Database\Eloquent\Model - { - }; + $record = new class extends Model {}; expect($matcher->matches($rules, new LocationContext('item', ['record_type' => 'post'], $record))) ->toBeFalse(); From 3170e904e0d2a957615bdcee5760e3f9d92bba72 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:19:19 +0200 Subject: [PATCH 094/105] Fix relation picker labels for translation-backed targets --- .../src/Support/RelationTargetResolver.php | 107 +++++++++++++++--- .../tests/Support/TestCategoryLike.php | 42 +++++++ .../Support/TestCategoryLikeResource.php | 19 ++++ .../Support/TestCategoryLikeTranslation.php | 16 +++ .../tests/Unit/RelationFieldTypeTest.php | 48 ++++++++ 5 files changed, 214 insertions(+), 18 deletions(-) create mode 100644 packages/builder/tests/Support/TestCategoryLike.php create mode 100644 packages/builder/tests/Support/TestCategoryLikeResource.php create mode 100644 packages/builder/tests/Support/TestCategoryLikeTranslation.php diff --git a/packages/builder/src/Support/RelationTargetResolver.php b/packages/builder/src/Support/RelationTargetResolver.php index f11149ea52..3b2340a1b5 100644 --- a/packages/builder/src/Support/RelationTargetResolver.php +++ b/packages/builder/src/Support/RelationTargetResolver.php @@ -55,17 +55,15 @@ public function search(string $entity, string $term, int $limit = 50): array return []; } - $titleAttribute = $this->titleAttributeFor($entity); - - if (! $this->modelHasColumn($modelClass, $titleAttribute)) { - return []; - } - try { $query = $this->scopedQuery($entity, $modelClass); if ($term !== '') { - $query->where($titleAttribute, 'like', "%{$term}%"); + $this->applySearchFilter($query, $modelClass, $term); + } + + if ($this->modelUsesTranslations($modelClass)) { + $query->with('translations'); } $results = []; @@ -111,7 +109,13 @@ public function labelsFor(string $entity, array $ids): array $model = new $modelClass; try { - foreach ($modelClass::query()->whereIn($model->getKeyName(), $missing)->get() as $record) { + $query = $modelClass::query()->whereIn($model->getKeyName(), $missing); + + if ($this->modelUsesTranslations($modelClass)) { + $query->with('translations'); + } + + foreach ($query->get() as $record) { $memo[$record->getKey()] = $this->titleFor($entity, $record); } @@ -275,7 +279,7 @@ protected function titleAttributeCandidates(string $entity): array } $modelClass = $this->modelClass($entity); - $candidates = ['display_name', 'title', 'name', 'label']; + $candidates = ['display_title', 'display_name', 'title', 'name', 'label']; if ($modelClass === null) { return $candidates; @@ -284,29 +288,96 @@ protected function titleAttributeCandidates(string $entity): array return array_values(array_filter( $candidates, fn (string $candidate): bool => $this->modelHasColumn($modelClass, $candidate) - || $this->modelHasAccessor($modelClass, $candidate), + || $this->modelHasAccessor($modelClass, $candidate) + || $this->modelHasTranslatableAttribute($modelClass, $candidate), )); } protected function titleAttributeFor(string $entity): string { - $candidates = $this->titleAttributeCandidates($entity); - - foreach ($candidates as $candidate) { - if ($candidate === 'display_name') { - continue; - } - - $modelClass = $this->modelClass($entity); + $modelClass = $this->modelClass($entity); + foreach (['title', 'name', 'label'] as $candidate) { if ($modelClass !== null && $this->modelHasColumn($modelClass, $candidate)) { return $candidate; } } + if ($modelClass !== null && $this->modelHasTranslatableAttribute($modelClass, 'title')) { + return 'title'; + } + + if ($modelClass !== null && $this->modelHasTranslatableAttribute($modelClass, 'name')) { + return 'name'; + } + return 'id'; } + /** + * @param Builder $query + * @param class-string $modelClass + */ + protected function applySearchFilter(Builder $query, string $modelClass, string $term): void + { + foreach (['title', 'name', 'label'] as $column) { + if ($this->modelHasColumn($modelClass, $column)) { + $query->where($column, 'like', "%{$term}%"); + + return; + } + } + + foreach (['title', 'name', 'label'] as $attribute) { + if (! $this->modelHasTranslatableAttribute($modelClass, $attribute)) { + continue; + } + + $query->whereHas('translations', function (Builder $translationQuery) use ($attribute, $term): void { + $translationQuery->where($attribute, 'like', "%{$term}%"); + }); + + return; + } + } + + /** + * @param class-string $modelClass + */ + protected function modelUsesTranslations(string $modelClass): bool + { + if (! is_subclass_of($modelClass, Model::class)) { + return false; + } + + $instance = new $modelClass; + + return method_exists($instance, 'translations') + && ( + $this->modelHasTranslatableAttribute($modelClass, 'title') + || $this->modelHasTranslatableAttribute($modelClass, 'name') + || $this->modelHasTranslatableAttribute($modelClass, 'label') + ); + } + + /** + * @param class-string $modelClass + */ + protected function modelHasTranslatableAttribute(string $modelClass, string $attribute): bool + { + if (! is_subclass_of($modelClass, Model::class)) { + return false; + } + + $instance = new $modelClass; + + if (! property_exists($instance, 'translatedAttributes') || ! is_array($instance->translatedAttributes)) { + return false; + } + + return in_array($attribute, $instance->translatedAttributes, true); + } + /** * @param class-string $modelClass */ diff --git a/packages/builder/tests/Support/TestCategoryLike.php b/packages/builder/tests/Support/TestCategoryLike.php new file mode 100644 index 0000000000..28e72bacc4 --- /dev/null +++ b/packages/builder/tests/Support/TestCategoryLike.php @@ -0,0 +1,42 @@ +hasMany(TestCategoryLikeTranslation::class, 'category_id'); + } + + public function getDisplayTitleAttribute(): string + { + $locale = request()->query('lang') ?? 'en_US'; + $translation = $this->relationLoaded('translations') + ? $this->translations->firstWhere('locale', $locale) + : $this->translations()->where('locale', $locale)->first(); + + if ($translation !== null && filled($translation->title)) { + return (string) $translation->title; + } + + return 'ID: '.$this->id; + } +} diff --git a/packages/builder/tests/Support/TestCategoryLikeResource.php b/packages/builder/tests/Support/TestCategoryLikeResource.php new file mode 100644 index 0000000000..d4bb196fc0 --- /dev/null +++ b/packages/builder/tests/Support/TestCategoryLikeResource.php @@ -0,0 +1,19 @@ +search('item', 'ad'))->toBe([]); }); + +it('resolves display titles for translation-backed relation targets', function (): void { + Schema::dropIfExists('category_translations'); + Schema::dropIfExists('categories'); + + Schema::create('categories', function (Blueprint $table): void { + $table->id(); + }); + + Schema::create('category_translations', function (Blueprint $table): void { + $table->id(); + $table->foreignId('category_id'); + $table->string('locale'); + $table->string('title')->nullable(); + }); + + $registry = new class extends EntityRegistry + { + protected function panelResources(): array + { + return [TestCategoryLikeResource::class]; + } + }; + + app()->instance(EntityRegistry::class, $registry); + + $category = TestCategoryLike::query()->create(); + TestCategoryLikeTranslation::query()->create([ + 'category_id' => $category->getKey(), + 'locale' => 'en_US', + 'title' => 'News', + ]); + + $resolver = app(RelationTargetResolver::class); + + expect($resolver->search('test_category_like', ''))->toBe([ + $category->getKey() => 'News', + ])->and($resolver->labelsFor('test_category_like', [$category->getKey()]))->toBe([ + $category->getKey() => 'News', + ])->and($resolver->search('test_category_like', 'New'))->toHaveKey($category->getKey()); +}); From b41948c1589e08c574fc87bd70129ddb8c2371eb Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:29:42 +0200 Subject: [PATCH 095/105] Document relation label resolution in builder README --- packages/builder/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/builder/README.md b/packages/builder/README.md index ca9df67b79..6819d12598 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -493,6 +493,18 @@ Type `relation` links a custom field to records of another Moox entity (any Fila **API:** `presentValue()` resolves `{id, label}` objects via `RelationTargetResolver`. +**Label resolution** (picker, API output, validation labels — all via `RelationTargetResolver`): + +1. Target resource `recordTitleAttribute` / `getRecordTitle()` when configured on the Filament resource +2. Otherwise the first filled value from: `display_title` → `display_name` → `title` → `name` → `label` +3. A candidate applies when it is a main-table column, an Eloquent accessor (e.g. `getDisplayTitleAttribute()`), or listed in the model's `translatedAttributes` (Astrotomic / Moox draft entities such as categories) +4. Search uses main-table `title` / `name` / `label` when present; otherwise it searches the `translations` relation for those attributes +5. If nothing resolves, the record ID is shown as a last resort + +For new relation targets, set `protected static ?string $recordTitleAttribute = 'title';` (or the correct attribute) on the target resource when the display name is not one of the defaults above — especially for translatable entities. + +Relation **table columns** (`show in table`) use the same resolver for display; SQL sort/search on relation columns may still fall back to IDs when the title exists only in translation tables. + Invalid or non-relatable `related_entity` values are stripped when field groups are saved. ### Conditional logic (v1) From df26eccc6bbb34fc8ca5b5922fbe3d4c2f8a1505 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:39:30 +0200 Subject: [PATCH 096/105] Fix relation column search for translation-backed targets --- .../src/Support/RelationTableColumnQuery.php | 192 ++++++++++++++++-- .../src/Support/RelationTargetResolver.php | 103 +++++++++- .../tests/Unit/RelationFieldTypeTest.php | 6 + .../tests/Unit/TableColumnCompilerTest.php | 99 +++++++++ 4 files changed, 373 insertions(+), 27 deletions(-) diff --git a/packages/builder/src/Support/RelationTableColumnQuery.php b/packages/builder/src/Support/RelationTableColumnQuery.php index 79f8f24672..9eda5dfd05 100644 --- a/packages/builder/src/Support/RelationTableColumnQuery.php +++ b/packages/builder/src/Support/RelationTableColumnQuery.php @@ -17,6 +17,8 @@ final class RelationTableColumnQuery { private const RELATED_ALIAS = 'relation_target'; + private const TRANSLATION_ALIAS = 'relation_target_translation'; + public function __construct( protected RelationTargetResolver $resolver, ) {} @@ -77,6 +79,7 @@ public function applySearch( $relatedKey = $target['key']; $titleColumn = $target['titleColumn']; $alias = self::RELATED_ALIAS; + $translation = $target['translation'] ?? null; return $query->whereExists(function ($subquery) use ( $field, @@ -89,6 +92,7 @@ public function applySearch( $relatedKey, $titleColumn, $alias, + $translation, $like, ): void { $subquery->from($valuesTable) @@ -99,24 +103,53 @@ public function applySearch( ->where("{$valuesTable}.locale", $locale); if ($target['multiple']) { + $bindings = $translation !== null ? [$locale, $like] : [$like]; $subquery->whereRaw( - $this->multipleRelationSearchSql($valuesTable, $relatedTable, $relatedKey, $titleColumn, $alias), - [$like], + $this->multipleRelationSearchSql( + $valuesTable, + $relatedTable, + $relatedKey, + $titleColumn, + $alias, + $translation, + ), + $bindings, ); return; } - $subquery - ->join("{$relatedTable} as {$alias}", function ($join) use ($valuesTable, $alias, $relatedKey): void { - $join->whereRaw($this->singleRelationJoinSql($valuesTable, $alias, $relatedKey)); - }) - ->where("{$alias}.{$titleColumn}", 'like', $like); + $subquery->join("{$relatedTable} as {$alias}", function ($join) use ($valuesTable, $alias, $relatedKey): void { + $join->whereRaw($this->singleRelationJoinSql($valuesTable, $alias, $relatedKey)); + }); + + if ($translation !== null) { + $this->applyTranslationJoin($subquery, $alias, $relatedKey, $translation, self::TRANSLATION_ALIAS, $locale); + $subquery->where(self::TRANSLATION_ALIAS.'.'.$titleColumn, 'like', $like); + + return; + } + + $subquery->where("{$alias}.{$titleColumn}", 'like', $like); }); } /** - * @param array{relatedEntity: string, modelClass: class-string, table: string, key: string, titleColumn: string, multiple: bool} $target + * @param array{ + * relatedEntity: string, + * modelClass: class-string, + * table: string, + * key: string, + * titleColumn: string, + * multiple: bool, + * translation?: array{ + * table: string, + * foreignKey: string, + * localeColumn: string, + * titleColumn: string, + * softDeletes: bool, + * }, + * } $target */ protected function titleSubquery( FieldDefinition $field, @@ -130,6 +163,7 @@ protected function titleSubquery( $relatedKey = $target['key']; $titleColumn = $target['titleColumn']; $alias = self::RELATED_ALIAS; + $translation = $target['translation'] ?? null; $query = DB::table($valuesTable) ->whereColumn("{$valuesTable}.record_id", $recordKey) @@ -138,17 +172,72 @@ protected function titleSubquery( ->where("{$valuesTable}.locale", $locale); if ($target['multiple']) { - return $this->multipleTitleSubquery($query, $valuesTable, $relatedTable, $relatedKey, $titleColumn, $alias); + return $this->multipleTitleSubquery( + $query, + $valuesTable, + $relatedTable, + $relatedKey, + $titleColumn, + $alias, + $translation, + $locale, + ); } - return $query - ->select("{$alias}.{$titleColumn}") + $query ->join("{$relatedTable} as {$alias}", function ($join) use ($valuesTable, $alias, $relatedKey): void { $join->whereRaw($this->singleRelationJoinSql($valuesTable, $alias, $relatedKey)); - }) + }); + + if ($translation !== null) { + $this->applyTranslationJoin($query, $alias, $relatedKey, $translation, self::TRANSLATION_ALIAS, $locale); + + return $query + ->select(self::TRANSLATION_ALIAS.'.'.$titleColumn) + ->limit(1); + } + + return $query + ->select("{$alias}.{$titleColumn}") ->limit(1); } + /** + * @param array{ + * table: string, + * foreignKey: string, + * localeColumn: string, + * titleColumn: string, + * softDeletes: bool, + * } $translation + */ + protected function applyTranslationJoin( + \Illuminate\Database\Query\Builder $query, + string $relatedAlias, + string $relatedKey, + array $translation, + string $translationAlias, + string $locale, + ): void { + $query->join("{$translation['table']} as {$translationAlias}", function ($join) use ( + $relatedAlias, + $relatedKey, + $translation, + $translationAlias, + $locale, + ): void { + $join->on( + "{$translationAlias}.{$translation['foreignKey']}", + '=', + "{$relatedAlias}.{$relatedKey}", + )->where("{$translationAlias}.{$translation['localeColumn']}", '=', $locale); + + if ($translation['softDeletes']) { + $join->whereNull("{$translationAlias}.deleted_at"); + } + }); + } + protected function singleRelationJoinSql(string $valuesTable, string $relatedAlias, string $relatedKey): string { $idExpression = $this->jsonScalarIdExpression("{$valuesTable}.value_json"); @@ -166,6 +255,13 @@ protected function jsonScalarIdExpression(string $column): string } /** + * @param array{ + * table: string, + * foreignKey: string, + * localeColumn: string, + * titleColumn: string, + * softDeletes: bool, + * }|null $translation * @return \Illuminate\Database\Query\Builder */ protected function multipleTitleSubquery( @@ -175,50 +271,102 @@ protected function multipleTitleSubquery( string $relatedKey, string $titleColumn, string $alias, + ?array $translation, + string $locale, ) { - return match (DB::connection()->getDriverName()) { + $titleExpression = $translation !== null + ? 'MIN('.self::TRANSLATION_ALIAS.'.'.$titleColumn.')' + : "MIN({$alias}.{$titleColumn})"; + + $baseQuery = match (DB::connection()->getDriverName()) { 'mysql' => $query - ->selectRaw("MIN({$alias}.{$titleColumn})") ->join(DB::raw( "JSON_TABLE({$valuesTable}.value_json, '\$[*]' COLUMNS (value BIGINT PATH '\$')) AS relation_value" ), DB::raw('1'), '=', DB::raw('1')) - ->join("{$relatedTable} as {$alias}", "{$alias}.{$relatedKey}", '=', 'relation_value.value') - ->limit(1), + ->join("{$relatedTable} as {$alias}", "{$alias}.{$relatedKey}", '=', 'relation_value.value'), default => $query - ->selectRaw("MIN({$alias}.{$titleColumn})") ->join(DB::raw("json_each({$valuesTable}.value_json) as relation_value"), DB::raw('1'), '=', DB::raw('1')) - ->join("{$relatedTable} as {$alias}", "{$alias}.{$relatedKey}", '=', 'relation_value.value') - ->limit(1), + ->join("{$relatedTable} as {$alias}", "{$alias}.{$relatedKey}", '=', 'relation_value.value'), }; + + if ($translation !== null) { + $this->applyTranslationJoin($baseQuery, $alias, $relatedKey, $translation, self::TRANSLATION_ALIAS, $locale); + } + + return $baseQuery + ->selectRaw($titleExpression) + ->limit(1); } + /** + * @param array{ + * table: string, + * foreignKey: string, + * localeColumn: string, + * titleColumn: string, + * softDeletes: bool, + * }|null $translation + */ protected function multipleRelationSearchSql( string $valuesTable, string $relatedTable, string $relatedKey, string $titleColumn, string $alias, + ?array $translation, ): string { + $translationAlias = self::TRANSLATION_ALIAS; + $titleRef = $translation !== null + ? "{$translationAlias}.{$titleColumn}" + : "{$alias}.{$titleColumn}"; + $translationJoin = ''; + + if ($translation !== null) { + $softDeleteClause = $translation['softDeletes'] + ? " AND {$translationAlias}.deleted_at IS NULL" + : ''; + $translationJoin = "INNER JOIN {$translation['table']} AS {$translationAlias} + ON {$translationAlias}.{$translation['foreignKey']} = {$alias}.{$relatedKey} + AND {$translationAlias}.{$translation['localeColumn']} = ?{$softDeleteClause} + "; + } + return match (DB::connection()->getDriverName()) { 'mysql' => "EXISTS ( SELECT 1 FROM JSON_TABLE({$valuesTable}.value_json, '\$[*]' COLUMNS (value BIGINT PATH '\$')) AS relation_value INNER JOIN {$relatedTable} AS {$alias} ON {$alias}.{$relatedKey} = relation_value.value - WHERE {$alias}.{$titleColumn} LIKE ? + {$translationJoin} + WHERE {$titleRef} LIKE ? )", default => "EXISTS ( SELECT 1 FROM json_each({$valuesTable}.value_json) AS relation_value INNER JOIN {$relatedTable} AS {$alias} ON {$alias}.{$relatedKey} = relation_value.value - WHERE {$alias}.{$titleColumn} LIKE ? + {$translationJoin} + WHERE {$titleRef} LIKE ? )", }; } /** - * @return array{relatedEntity: string, modelClass: class-string, table: string, key: string, titleColumn: string, multiple: bool}|null + * @return array{ + * relatedEntity: string, + * modelClass: class-string, + * table: string, + * key: string, + * titleColumn: string, + * multiple: bool, + * translation?: array{ + * table: string, + * foreignKey: string, + * localeColumn: string, + * titleColumn: string, + * softDeletes: bool, + * }, + * }|null */ protected function target(FieldDefinition $field): ?array { diff --git a/packages/builder/src/Support/RelationTargetResolver.php b/packages/builder/src/Support/RelationTargetResolver.php index 3b2340a1b5..e104ef5e66 100644 --- a/packages/builder/src/Support/RelationTargetResolver.php +++ b/packages/builder/src/Support/RelationTargetResolver.php @@ -187,7 +187,19 @@ public function recordExists(string $entity, int|string $id): bool } /** - * @return array{modelClass: class-string, table: string, key: string, titleColumn: string}|null + * @return array{ + * modelClass: class-string, + * table: string, + * key: string, + * titleColumn: string, + * translation?: array{ + * table: string, + * foreignKey: string, + * localeColumn: string, + * titleColumn: string, + * softDeletes: bool, + * }, + * }|null */ public function queryTarget(string $entity): ?array { @@ -198,18 +210,36 @@ public function queryTarget(string $entity): ?array } $model = new $modelClass; - $titleColumn = $this->titleAttributeFor($entity); + $translation = null; + $titleColumn = 'id'; - if ($titleColumn !== 'id' && ! $this->modelHasColumn($modelClass, $titleColumn)) { - $titleColumn = 'id'; + foreach (['title', 'name', 'label'] as $candidate) { + if ($this->modelHasColumn($modelClass, $candidate)) { + $titleColumn = $candidate; + break; + } } - return [ + if ($titleColumn === 'id') { + $translation = $this->translationTargetForModel($modelClass); + + if ($translation !== null) { + $titleColumn = $translation['titleColumn']; + } + } + + $target = [ 'modelClass' => $modelClass, 'table' => $model->getTable(), 'key' => $model->getKeyName(), 'titleColumn' => $titleColumn, ]; + + if ($translation !== null) { + $target['translation'] = $translation; + } + + return $target; } /** @@ -341,6 +371,69 @@ protected function applySearchFilter(Builder $query, string $modelClass, string } } + /** + * @param class-string $modelClass + * @return array{ + * table: string, + * foreignKey: string, + * localeColumn: string, + * titleColumn: string, + * softDeletes: bool, + * }|null + */ + protected function translationTargetForModel(string $modelClass): ?array + { + if (! $this->modelUsesTranslations($modelClass) || ! is_subclass_of($modelClass, Model::class)) { + return null; + } + + $instance = new $modelClass; + + if (! method_exists($instance, 'translations')) { + return null; + } + + $titleColumn = null; + + foreach (['title', 'name', 'label'] as $candidate) { + if ($this->modelHasTranslatableAttribute($modelClass, $candidate)) { + $titleColumn = $candidate; + break; + } + } + + if ($titleColumn === null) { + return null; + } + + $relation = $instance->translations(); + $translationModel = $relation->getRelated(); + + if (! $translationModel instanceof Model) { + return null; + } + + $translationModelClass = $translationModel::class; + $localeColumn = method_exists($instance, 'getLocaleKey') + ? $instance->getLocaleKey() + : 'locale'; + $foreignKey = method_exists($instance, 'getTranslationRelationKey') + ? $instance->getTranslationRelationKey() + : $relation->getForeignKeyName(); + + return [ + 'table' => $translationModel->getTable(), + 'foreignKey' => $foreignKey, + 'localeColumn' => $localeColumn, + 'titleColumn' => $titleColumn, + 'softDeletes' => in_array( + \Illuminate\Database\Eloquent\SoftDeletes::class, + class_uses_recursive($translationModelClass), + true, + ), + ]; + } + /** * @param class-string $modelClass */ diff --git a/packages/builder/tests/Unit/RelationFieldTypeTest.php b/packages/builder/tests/Unit/RelationFieldTypeTest.php index 2526229703..0c9192c7f5 100644 --- a/packages/builder/tests/Unit/RelationFieldTypeTest.php +++ b/packages/builder/tests/Unit/RelationFieldTypeTest.php @@ -362,4 +362,10 @@ protected function panelResources(): array ])->and($resolver->labelsFor('test_category_like', [$category->getKey()]))->toBe([ $category->getKey() => 'News', ])->and($resolver->search('test_category_like', 'New'))->toHaveKey($category->getKey()); + + $queryTarget = $resolver->queryTarget('test_category_like'); + + expect($queryTarget)->not->toBeNull() + ->and($queryTarget['titleColumn'])->toBe('title') + ->and($queryTarget['translation']['table'] ?? null)->toBe('category_translations'); }); diff --git a/packages/builder/tests/Unit/TableColumnCompilerTest.php b/packages/builder/tests/Unit/TableColumnCompilerTest.php index a72b8da994..ed603c2d15 100644 --- a/packages/builder/tests/Unit/TableColumnCompilerTest.php +++ b/packages/builder/tests/Unit/TableColumnCompilerTest.php @@ -5,13 +5,18 @@ require_once __DIR__.'/../TestCase.php'; require_once __DIR__.'/../Support/TestItem.php'; require_once __DIR__.'/../Support/TestItemResource.php'; +require_once __DIR__.'/../Support/TestCategoryLike.php'; +require_once __DIR__.'/../Support/TestCategoryLikeTranslation.php'; +require_once __DIR__.'/../Support/TestCategoryLikeResource.php'; use Filament\Support\Icons\Heroicon; use Filament\Tables\Columns\ColorColumn; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\ImageColumn; use Filament\Tables\Columns\TextColumn; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Schema; use Moox\Builder\Compiler\TableColumnCompiler; use Moox\Builder\Data\FieldDefinition; use Moox\Builder\Data\LocationContext; @@ -24,6 +29,9 @@ use Moox\Builder\Services\FieldGroupPersistence; use Moox\Builder\Support\BuilderLocaleResolver; use Moox\Builder\Support\RelationTableColumnQuery; +use Moox\Builder\Tests\Support\TestCategoryLike; +use Moox\Builder\Tests\Support\TestCategoryLikeResource; +use Moox\Builder\Tests\Support\TestCategoryLikeTranslation; use Moox\Builder\Tests\Support\TestItem; use Moox\Builder\Tests\Support\TestItemResource; use Moox\Builder\Tests\TestCase; @@ -890,6 +898,97 @@ protected function panelResources(): array expect($ids)->toBe([$matchOwner->getKey()]); }); +function bindTranslationBackedRelationColumnTestEntityRegistry(): void +{ + $registry = new class extends EntityRegistry + { + protected function panelResources(): array + { + return [TestItemResource::class, TestCategoryLikeResource::class]; + } + }; + + app()->instance(EntityRegistry::class, $registry); +} + +it('searches records by translation-backed relation column labels', function (): void { + Schema::dropIfExists('category_translations'); + Schema::dropIfExists('categories'); + + Schema::create('categories', function (Blueprint $table): void { + $table->id(); + }); + + Schema::create('category_translations', function (Blueprint $table): void { + $table->id(); + $table->foreignId('category_id'); + $table->string('locale'); + $table->string('title')->nullable(); + }); + + bindTranslationBackedRelationColumnTestEntityRegistry(); + + $group = FieldGroup::query()->create([ + 'name' => 'Translation relation search', + 'slug' => 'translation-relation-search', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'linked-category', + 'label' => 'Linked category', + 'type' => 'relation', + 'config' => ['related_entity' => 'test_category_like', 'multiple' => false], + 'settings' => ['show_in_table' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $matchTarget = TestCategoryLike::query()->create(); + TestCategoryLikeTranslation::query()->create([ + 'category_id' => $matchTarget->getKey(), + 'locale' => 'en_US', + 'title' => 'Golf category', + ]); + $otherTarget = TestCategoryLike::query()->create(); + TestCategoryLikeTranslation::query()->create([ + 'category_id' => $otherTarget->getKey(), + 'locale' => 'en_US', + 'title' => 'Polo category', + ]); + $matchOwner = TestItem::query()->create(['title' => 'Match owner']); + $otherOwner = TestItem::query()->create(['title' => 'Other owner']); + + $locale = app(BuilderLocaleResolver::class)->defaultLocale(); + $fields = app(CustomFieldsManager::class)->fieldsForEntity('item'); + $manager = app(CustomFieldsManager::class); + + $manager->saveValues('item', $matchOwner, ['linked-category' => $matchTarget->getKey()], $fields, $locale); + $manager->saveValues('item', $otherOwner, ['linked-category' => $otherTarget->getKey()], $fields, $locale); + + $field = $fields->firstWhere('name', 'linked-category'); + + $ids = TestItem::query() + ->whereKey([$matchOwner->getKey(), $otherOwner->getKey()]) + ->tap(fn ($query) => app(RelationTableColumnQuery::class)->applySearch( + $query, + $field, + 'item', + $locale, + (new FieldValue)->getTable(), + 'Golf', + )) + ->pluck('id') + ->all(); + + expect($ids)->toBe([$matchOwner->getKey()]); +}); + it('applies badge presentation to single relation columns when enabled', function (): void { bindRelationColumnTestEntityRegistry(); From 45ddf1ea05a53a959c0bf6c741a669ddf4899a1a Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:41:04 +0000 Subject: [PATCH 097/105] Fix styling --- packages/builder/src/Support/RelationTargetResolver.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/builder/src/Support/RelationTargetResolver.php b/packages/builder/src/Support/RelationTargetResolver.php index e104ef5e66..2f8378389e 100644 --- a/packages/builder/src/Support/RelationTargetResolver.php +++ b/packages/builder/src/Support/RelationTargetResolver.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\QueryException; use Moox\Builder\Registry\EntityRegistry; @@ -427,7 +428,7 @@ protected function translationTargetForModel(string $modelClass): ?array 'localeColumn' => $localeColumn, 'titleColumn' => $titleColumn, 'softDeletes' => in_array( - \Illuminate\Database\Eloquent\SoftDeletes::class, + SoftDeletes::class, class_uses_recursive($translationModelClass), true, ), From 272045a81bd3208c89e04860cf04e527d3c0028c Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:28:56 +0200 Subject: [PATCH 098/105] fix data import job --- packages/data/src/Jobs/ImportStaticDataJob.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/data/src/Jobs/ImportStaticDataJob.php b/packages/data/src/Jobs/ImportStaticDataJob.php index 294359187e..c4b4afa330 100644 --- a/packages/data/src/Jobs/ImportStaticDataJob.php +++ b/packages/data/src/Jobs/ImportStaticDataJob.php @@ -196,6 +196,8 @@ public function handle() 'gsw', // skip german swiss variant for example ]; + $separateLanguageCodes = config('rest-countries.separate_language_codes', ['gsw']); + try { Log::channel('daily')->info('Attempting to fetch data from REST Countries API...'); From 344e97abc81fc31dacf97bc7a60186f7c2cc3ac1 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:07:11 +0200 Subject: [PATCH 099/105] Add record_status location rule for custom field groups --- packages/builder/README.md | 5 +- .../builder/resources/lang/de/builder.php | 2 + .../builder/resources/lang/en/builder.php | 2 + .../builder/src/Compiler/LocationMatcher.php | 9 +- packages/builder/src/Data/LocationContext.php | 31 +++ .../src/Resources/FieldGroupResource.php | 34 +++- .../src/Services/FieldGroupValidator.php | 13 ++ .../src/Support/LocationConstraintOptions.php | 179 ++++++++++++++++-- .../Unit/LocationConstraintOptionsTest.php | 79 ++++++++ .../tests/Unit/LocationContextTest.php | 44 +++++ .../tests/Unit/LocationMatcherTest.php | 41 ++++ 11 files changed, 415 insertions(+), 24 deletions(-) create mode 100644 packages/builder/tests/Unit/LocationContextTest.php diff --git a/packages/builder/README.md b/packages/builder/README.md index 6819d12598..ee17316c61 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -183,6 +183,7 @@ Internally this becomes OR groups with AND rules: |-------|---------|----------------| | `entity` | `item`, `draft` | Resource / model entity key | | `record_type` | `page`, `article` | `$record->type` or `customFieldsLocationParams()` | +| `record_status` | `draft`, `published` | Draft entities: current locale `translation_status`; otherwise main-table `status` when present | | `user_role` | `admin`, `editor` | Authenticated user roles (`in` / `not in` supported when roles are available) | | `taxonomy:{key}` | term ID `12` or `12,34` | Taxonomy IDs on the record (`HasModelTaxonomy`) | @@ -965,7 +966,7 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc - **Relation fields** — link to Moox Filament entities, scoped search/validation, API `{id, label}` output - **Conditional logic (v1)** — show/hide on root-level sibling fields; save-side enforcement - **Per-context visibility** — `visible_admin`, `visible_api`, `visible_frontend` (admin + API wired) -- **Location rules** — entity, record type, user role, taxonomy term IDs (admin constraints + import/export) +- **Location rules** — entity, record type, record status, user role, taxonomy term IDs (admin constraints + import/export) - **Table columns** — opt-in list columns for scalar, media, and relation fields (`TableColumnCompiler`) - **Field width grid** — 12-column layout per field (`FieldWidth`) - **Sidebar placement** — `main` vs `sidebar` field group slots @@ -975,7 +976,7 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc **v1 limitations (known):** -- Location rules: no template/parent params yet; record/taxonomy rules need a saved record (ignored on create) +- Location rules: no template/parent params yet; record/taxonomy/status rules need a saved record (ignored on create) - Conditional logic: root-level siblings only — not inside repeaters/groups/flexible content - Custom `validation.rules`: supported in schema/DB, no admin UI (programmatic only) - Relation targets: Filament-registered Moox resources only (not arbitrary Eloquent models) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index a43abb2415..217a88b70d 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -38,6 +38,7 @@ 'location_param_helper_empty_entities' => 'Wähle zuerst eine oder mehrere Ressourcen, damit Builder passende Bedingungs-Parameter anbieten kann.', 'location_param_helper_roles_only' => 'Hier ist nur die Benutzerrolle verfügbar, weil die ausgewählten Ressourcen keine Record-Typen oder Taxonomies bereitstellen.', 'location_param_record_type' => 'Record-Typ', + 'location_param_record_status' => 'Record-Status', 'location_param_user_role' => 'Benutzerrolle', 'location_param_taxonomy' => 'Taxonomy-Term', 'location_taxonomy' => 'Taxonomy-Schlüssel', @@ -55,6 +56,7 @@ 'location_value_role_unavailable_empty' => 'Benutzerrollen sind verfügbar, aber es wurden noch keine Rollen angelegt.', 'location_value_role_unavailable_user_model' => 'Benutzerrollen sind nicht verfügbar, weil das konfigurierte Auth-User-Model HasRoles nicht verwendet.', 'location_value_record_type_helper' => 'Record-Typ, z. B. page oder article.', + 'location_value_record_status_helper' => 'Workflow-Status der gewählten Ressourcen. Bei Draft-Entities der Übersetzungsstatus (draft, published, scheduled, waiting, private).', 'location_constraint_incomplete_taxonomy' => 'Taxonomy wählen', 'location_constraint_incomplete_value' => 'Wert wählen', 'field_item' => 'Feld', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 40a225ddba..1608cff852 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -38,6 +38,7 @@ 'location_param_helper_empty_entities' => 'Choose one or more resources first so Builder can offer matching condition parameters.', 'location_param_helper_roles_only' => 'Only user role is available here because the selected resources do not expose record types or taxonomies.', 'location_param_record_type' => 'Record type', + 'location_param_record_status' => 'Record status', 'location_param_user_role' => 'User role', 'location_param_taxonomy' => 'Taxonomy term', 'location_taxonomy' => 'Taxonomy key', @@ -55,6 +56,7 @@ 'location_value_role_unavailable_empty' => 'User roles are available, but no roles exist yet.', 'location_value_role_unavailable_user_model' => 'User roles are not available because the configured auth user model does not use HasRoles.', 'location_value_record_type_helper' => 'Record type value, e.g. page or article.', + 'location_value_record_status_helper' => 'Workflow status for the selected resources. Draft entities use translation status (draft, published, scheduled, waiting, private).', 'location_constraint_incomplete_taxonomy' => 'choose taxonomy', 'location_constraint_incomplete_value' => 'choose value', 'field_item' => 'Field', diff --git a/packages/builder/src/Compiler/LocationMatcher.php b/packages/builder/src/Compiler/LocationMatcher.php index f9a4436643..e7194990b0 100644 --- a/packages/builder/src/Compiler/LocationMatcher.php +++ b/packages/builder/src/Compiler/LocationMatcher.php @@ -118,14 +118,15 @@ protected function matchesRule(array $rule, LocationContext $context): bool } return match ($param) { - 'record_type', 'user_role' => $this->compare($actual, $expected, $operator), + 'record_type', 'record_status', 'user_role' => $this->compare($actual, $expected, $operator), default => $this->matchesDynamicParam($param, $actual, $expected, $operator), }; } protected function requiresRecord(string $param): bool { - return $param === 'record_type' || str_starts_with($param, 'taxonomy:'); + return in_array($param, ['record_type', 'record_status'], true) + || str_starts_with($param, 'taxonomy:'); } protected function resolveActualValue(string $param, LocationContext $context): mixed @@ -146,6 +147,10 @@ protected function resolveActualValue(string $param, LocationContext $context): return $context->get('record_type'); } + if ($param === 'record_status') { + return $context->get('record_status'); + } + if ($param === 'user_role') { return $context->get('user_role'); } diff --git a/packages/builder/src/Data/LocationContext.php b/packages/builder/src/Data/LocationContext.php index 6929a0e010..68f621ee3c 100644 --- a/packages/builder/src/Data/LocationContext.php +++ b/packages/builder/src/Data/LocationContext.php @@ -85,6 +85,12 @@ protected static function paramsFromRecord(Model $record): array $params['record_type'] = (string) $record->getAttribute('type'); } + $recordStatus = self::resolveRecordStatus($record); + + if ($recordStatus !== null) { + $params['record_status'] = $recordStatus; + } + if (in_array(HasModelTaxonomy::class, class_uses_recursive($record), true)) { $resourceName = method_exists($record, 'getResourceName') ? $record::getResourceName() @@ -112,6 +118,31 @@ protected static function paramsFromRecord(Model $record): array return $params; } + protected static function resolveRecordStatus(Model $record): ?string + { + if (method_exists($record, 'translate')) { + $locale = request()->query('lang') ?? request()->input('lang'); + + if (! is_string($locale) || $locale === '') { + $locale = app()->getLocale(); + } + + $translation = $record->translate($locale); + + if ($translation !== null && filled($translation->translation_status ?? null)) { + $status = $translation->translation_status; + + return $status instanceof \BackedEnum ? $status->value : (string) $status; + } + } + + if ($record->offsetExists('status') && filled($record->getAttribute('status'))) { + return (string) $record->getAttribute('status'); + } + + return null; + } + /** * @return array */ diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index f6c734a14c..2740092c7e 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -1463,6 +1463,7 @@ public static function locationConstraintSchema(): array ->helperText(fn (Get $get): string => match ($get('param')) { 'taxonomy' => __('builder::builder.field_group.location_value_taxonomy_helper'), 'record_type' => __('builder::builder.field_group.location_value_record_type_helper'), + 'record_status' => __('builder::builder.field_group.location_value_record_status_helper'), 'user_role' => app(LocationConstraintOptions::class)->userRoleUnavailableReason() ?? __('builder::builder.field_group.location_value_role_helper'), default => __('builder::builder.field_group.location_value_helper'), @@ -1470,6 +1471,7 @@ public static function locationConstraintSchema(): array ->options(function (Get $get) use ($targetEntitiesPath): array { return match ($get('param')) { 'record_type' => app(LocationConstraintOptions::class)->recordTypeOptionsForEntities($get($targetEntitiesPath)), + 'record_status' => app(LocationConstraintOptions::class)->recordStatusOptionsForEntities($get($targetEntitiesPath)), 'user_role' => app(LocationConstraintOptions::class)->roleOptions(), default => [], }; @@ -1485,9 +1487,9 @@ public static function locationConstraintSchema(): array $search, ); }) - ->visible(fn (Get $get): bool => in_array($get('param'), ['taxonomy', 'record_type', 'user_role'], true) + ->visible(fn (Get $get): bool => in_array($get('param'), ['taxonomy', 'record_type', 'record_status', 'user_role'], true) && ($get('param') !== 'taxonomy' || filled($get('taxonomy')))) - ->required(fn (Get $get): bool => in_array($get('param'), ['taxonomy', 'record_type', 'user_role'], true) + ->required(fn (Get $get): bool => in_array($get('param'), ['taxonomy', 'record_type', 'record_status', 'user_role'], true) && ($get('param') !== 'user_role' || app(LocationConstraintOptions::class)->supportsUserRoles()) && ($get('param') !== 'taxonomy' || filled($get('taxonomy')))) ->disabled(fn (Get $get): bool => $get('param') === 'user_role' @@ -1504,6 +1506,10 @@ public static function locationConstraintSchema(): array $get($targetEntitiesPath), $value, ), + 'record_status' => app(LocationConstraintOptions::class)->recordStatusLabelForValue( + $get($targetEntitiesPath), + $value, + ), 'user_role' => filled($value) ? (string) $value : null, default => null, }; @@ -1519,6 +1525,10 @@ public static function locationConstraintSchema(): array $get($targetEntitiesPath), $values, ), + 'record_status' => app(LocationConstraintOptions::class)->recordStatusLabelsForValues( + $get($targetEntitiesPath), + $values, + ), 'user_role' => collect($values) ->filter(fn (mixed $value): bool => filled($value)) ->mapWithKeys(fn (mixed $value): array => [$value => (string) $value]) @@ -1526,7 +1536,7 @@ public static function locationConstraintSchema(): array default => [], }; }) - ->searchable(fn (Get $get): bool => in_array($get('param'), ['taxonomy', 'record_type', 'user_role'], true)) + ->searchable(fn (Get $get): bool => in_array($get('param'), ['taxonomy', 'record_type', 'record_status', 'user_role'], true)) ->preload(fn (Get $get): bool => $get('param') !== 'taxonomy') ->native(false) ->columnSpanFull(), @@ -1595,6 +1605,14 @@ protected static function locationConstraintValueLabel(array $state, mixed $targ return $options->recordTypeLabelForValue($targetEntities, $value) ?? (string) $value; } + if (($state['param'] ?? null) === 'record_status') { + if (is_array($value)) { + return implode(', ', $options->recordStatusLabelsForValues($targetEntities, $value)); + } + + return $options->recordStatusLabelForValue($targetEntities, $value) ?? (string) $value; + } + if (is_array($value)) { return implode(', ', array_map(static fn (mixed $item): string => (string) $item, $value)); } @@ -1650,9 +1668,10 @@ protected static function sanitizeLocationConstraintsForEntities(array $constrai $allowedParams = array_keys($options->availableParamOptionsForEntities($entities)); $allowedTaxonomies = $options->taxonomyKeysForEntities($entities); $recordTypeOptions = $options->recordTypeOptionsForEntities($entities); + $recordStatusOptions = $options->recordStatusOptionsForEntities($entities); return array_values(array_map( - static function (array $constraint) use ($allowedParams, $allowedTaxonomies, $recordTypeOptions): array { + static function (array $constraint) use ($allowedParams, $allowedTaxonomies, $recordTypeOptions, $recordStatusOptions): array { $param = (string) ($constraint['param'] ?? ''); if ($param === '' || ! in_array($param, $allowedParams, true)) { @@ -1684,6 +1703,13 @@ static function (array $constraint) use ($allowedParams, $allowedTaxonomies, $re ]; } + if ($param === 'record_status' && $recordStatusOptions === []) { + return [ + ...$constraint, + 'value' => null, + ]; + } + return $constraint; }, $constraints, diff --git a/packages/builder/src/Services/FieldGroupValidator.php b/packages/builder/src/Services/FieldGroupValidator.php index 11b0119fb0..2720e2e110 100644 --- a/packages/builder/src/Services/FieldGroupValidator.php +++ b/packages/builder/src/Services/FieldGroupValidator.php @@ -401,6 +401,19 @@ protected function locationConstraintMessages(array $rows, mixed $entities): arr continue; } + if ($param === 'record_status') { + $valueMessages = $this->invalidConstraintValueMessages( + Arr::wrap($row['value'] ?? null), + array_keys($this->locationConstraintOptions->recordStatusOptionsForEntities($entities)), + ); + + if ($valueMessages !== []) { + $messages["{$basePath}.value"] = $valueMessages; + } + + continue; + } + if ($param === 'user_role') { if (! $this->locationConstraintOptions->supportsUserRoles()) { $messages["{$basePath}.value"] = [ diff --git a/packages/builder/src/Support/LocationConstraintOptions.php b/packages/builder/src/Support/LocationConstraintOptions.php index ad70deca35..e909f77e5c 100644 --- a/packages/builder/src/Support/LocationConstraintOptions.php +++ b/packages/builder/src/Support/LocationConstraintOptions.php @@ -25,6 +25,11 @@ final class LocationConstraintOptions */ protected array $recordTypeOptionsCache = []; + /** + * @var array> + */ + protected array $recordStatusOptionsCache = []; + /** * @var array> */ @@ -51,6 +56,11 @@ final class LocationConstraintOptions */ protected array $recordTypeOptionsByModelCache = []; + /** + * @var array, array> + */ + protected array $recordStatusOptionsByModelCache = []; + public function __construct( protected EntityRegistry $entityRegistry, protected TaxonomyService $taxonomyService, @@ -195,6 +205,7 @@ public function availableParamOptions(): array { return [ 'record_type' => __('builder::builder.field_group.location_param_record_type'), + 'record_status' => __('builder::builder.field_group.location_param_record_status'), 'user_role' => __('builder::builder.field_group.location_param_user_role'), 'taxonomy' => __('builder::builder.field_group.location_param_taxonomy'), ]; @@ -222,6 +233,13 @@ public function availableParamOptionsForEntities(mixed $entities): array ]; } + if ($this->recordStatusOptionsForEntities($entities) !== []) { + $options = [ + 'record_status' => __('builder::builder.field_group.location_param_record_status'), + ...$options, + ]; + } + if ($this->taxonomyKeysForEntities($entities) !== []) { $options['taxonomy'] = __('builder::builder.field_group.location_param_taxonomy'); } @@ -330,22 +348,10 @@ protected function recordTypeOptionsForEntity(string $entity): array return []; } - $options = $resourceClass::getTypeSelect()->getOptions(); - - if ($options instanceof \Closure) { - $options = $options(); - } - - if (! is_array($options)) { - return []; - } - - return collect($options) - ->filter(fn (mixed $label, mixed $value): bool => filled($value)) - ->mapWithKeys(fn (mixed $label, mixed $value): array => [ - (string) $value => filled($label) ? (string) $label : $this->recordTypeLabel((string) $value), - ]) - ->all(); + return $this->normalizeSelectOptions( + $resourceClass::getTypeSelect()->getOptions(), + fn (string $value): string => $this->recordTypeLabel($value), + ); } public function termLabelForValue(string $taxonomy, mixed $entities, mixed $value): ?string @@ -453,6 +459,71 @@ public function recordTypeLabelsForValues(mixed $entities, array $values): array return $labels; } + /** + * @return array + */ + public function recordStatusOptionsForEntities(mixed $entities): array + { + $cacheKey = $this->entitiesCacheKey($entities); + + if (array_key_exists($cacheKey, $this->recordStatusOptionsCache)) { + return $this->recordStatusOptionsCache[$cacheKey]; + } + + $options = []; + + foreach ($this->normalizeEntities($entities) as $entity) { + $options = array_replace($options, $this->recordStatusOptionsForEntity($entity)); + + $modelClass = $this->entityRegistry->modelFor($entity); + + if (! is_string($modelClass) || ! class_exists($modelClass)) { + continue; + } + + $options = array_replace($options, $this->recordStatusOptionsForModel($modelClass)); + } + + asort($options); + + return $this->recordStatusOptionsCache[$cacheKey] = $options; + } + + public function recordStatusLabelForValue(mixed $entities, mixed $value): ?string + { + if (! filled($value)) { + return null; + } + + $options = $this->recordStatusOptionsForEntities($entities); + + return $options[$value] + ?? $options[(string) $value] + ?? $this->recordStatusLabel((string) $value); + } + + /** + * @param array $values + * @return array + */ + public function recordStatusLabelsForValues(mixed $entities, array $values): array + { + $options = $this->recordStatusOptionsForEntities($entities); + $labels = []; + + foreach ($values as $value) { + if (! filled($value)) { + continue; + } + + $labels[$value] = $options[$value] + ?? $options[(string) $value] + ?? $this->recordStatusLabel((string) $value); + } + + return $labels; + } + /** * @param class-string $modelClass * @return array @@ -505,6 +576,56 @@ protected function recordTypeOptionsForModel(string $modelClass): array ->all(); } + /** + * @return array + */ + protected function recordStatusOptionsForEntity(string $entity): array + { + $resourceClass = $this->entityRegistry->resourceFor($entity); + + if (! is_string($resourceClass) || ! class_exists($resourceClass)) { + return []; + } + + if (method_exists($resourceClass, 'getEditableTranslationStatusOptions')) { + return $this->normalizeSelectOptions( + $resourceClass::getEditableTranslationStatusOptions(), + fn (string $value): string => $this->recordStatusLabel($value), + ); + } + + return []; + } + + /** + * @param class-string $modelClass + * @return array + */ + protected function recordStatusOptionsForModel(string $modelClass): array + { + if (array_key_exists($modelClass, $this->recordStatusOptionsByModelCache)) { + return $this->recordStatusOptionsByModelCache[$modelClass]; + } + + $model = $modelClass::query()->getModel(); + $table = $model->getTable(); + + if (! $this->entityRegistry->databaseTableExists($table) || ! $this->entityRegistry->databaseTableHasColumn($table, 'status')) { + return $this->recordStatusOptionsByModelCache[$modelClass] = []; + } + + return $this->recordStatusOptionsByModelCache[$modelClass] = $modelClass::query() + ->whereNotNull('status') + ->where('status', '!=', '') + ->distinct() + ->orderBy('status') + ->pluck('status', 'status') + ->mapWithKeys(fn (mixed $status): array => [ + (string) $status => $this->recordStatusLabel((string) $status), + ]) + ->all(); + } + protected function termLabel(Model $term): string { $translatedTitle = $this->translatedTermAttribute($term, 'title'); @@ -543,6 +664,32 @@ protected function recordTypeLabel(string $value): string return Str::headline($value); } + protected function recordStatusLabel(string $value): string + { + return Str::headline($value); + } + + /** + * @return array + */ + protected function normalizeSelectOptions(mixed $options, callable $fallbackLabel): array + { + if ($options instanceof \Closure) { + $options = $options(); + } + + if (! is_array($options)) { + return []; + } + + return collect($options) + ->filter(fn (mixed $label, mixed $value): bool => filled($value)) + ->mapWithKeys(fn (mixed $label, mixed $value): array => [ + (string) $value => filled($label) ? (string) $label : $fallbackLabel((string) $value), + ]) + ->all(); + } + /** * @return class-string|null */ diff --git a/packages/builder/tests/Unit/LocationConstraintOptionsTest.php b/packages/builder/tests/Unit/LocationConstraintOptionsTest.php index b691c14661..a0ef423171 100644 --- a/packages/builder/tests/Unit/LocationConstraintOptionsTest.php +++ b/packages/builder/tests/Unit/LocationConstraintOptionsTest.php @@ -347,6 +347,85 @@ public function modelFor(string $entity): ?string ->and(count(DB::getQueryLog()))->toBe($queriesAfterFirst); }); +it('memoizes record status options per model', function (): void { + Schema::create('status_items', function (Blueprint $table): void { + $table->id(); + $table->string('status')->nullable(); + $table->timestamps(); + }); + + DB::table('status_items')->insert([ + ['status' => 'draft', 'created_at' => now(), 'updated_at' => now()], + ['status' => 'published', 'created_at' => now(), 'updated_at' => now()], + ]); + + $modelClass = new class extends Model + { + protected $table = 'status_items'; + }; + + $registry = new class($modelClass::class) extends EntityRegistry + { + public function __construct(protected string $modelClass) {} + + public function modelFor(string $entity): ?string + { + return $entity === 'status-item' ? $this->modelClass : null; + } + }; + + $options = new LocationConstraintOptions( + $registry, + app(TaxonomyService::class), + app(BuilderLocaleResolver::class), + ); + + expect($options->availableParamOptionsForEntities(['status-item'])) + ->toHaveKey('record_status') + ->and($options->recordStatusOptionsForEntities(['status-item'])) + ->toHaveKeys(['draft', 'published']); +}); + +it('exposes draft workflow statuses from resource helpers', function (): void { + $resourceClass = new class + { + public static function getEditableTranslationStatusOptions(): array + { + return [ + 'draft' => 'Draft', + 'waiting' => 'Waiting', + 'private' => 'Private', + 'scheduled' => 'Scheduled', + 'published' => 'Published', + ]; + } + }; + + $registry = new class($resourceClass::class) extends EntityRegistry + { + public function __construct(protected string $resourceClass) {} + + public function resourceFor(string $entity): ?string + { + return $entity === 'draft-page' ? $this->resourceClass : null; + } + }; + + $options = new LocationConstraintOptions( + $registry, + app(TaxonomyService::class), + app(BuilderLocaleResolver::class), + ); + + expect($options->recordStatusOptionsForEntities(['draft-page']))->toHaveKeys([ + 'draft', + 'waiting', + 'private', + 'scheduled', + 'published', + ]); +}); + class NoRolesUser extends Model {} class RolesUser extends Model diff --git a/packages/builder/tests/Unit/LocationContextTest.php b/packages/builder/tests/Unit/LocationContextTest.php new file mode 100644 index 0000000000..56360a8a45 --- /dev/null +++ b/packages/builder/tests/Unit/LocationContextTest.php @@ -0,0 +1,44 @@ + $locale === 'de_DE' + ? TranslationStatus::PUBLISHED + : TranslationStatus::DRAFT, + ]; + } + }; + + $record->setRawAttributes(['status' => 'draft']); + + app()->instance('request', \Illuminate\Http\Request::create('/', 'GET', ['lang' => 'de_DE'])); + + $context = LocationContext::forEntity('page', $record); + + expect($context->get('record_status'))->toBe('published'); +}); + +it('falls back to the main-table status when no translation status is available', function (): void { + $record = new class extends Model {}; + + $record->setRawAttributes(['status' => 'archived']); + + $context = LocationContext::forEntity('item', $record); + + expect($context->get('record_status'))->toBe('archived'); +}); diff --git a/packages/builder/tests/Unit/LocationMatcherTest.php b/packages/builder/tests/Unit/LocationMatcherTest.php index 2e34f0f01e..046107a750 100644 --- a/packages/builder/tests/Unit/LocationMatcherTest.php +++ b/packages/builder/tests/Unit/LocationMatcherTest.php @@ -82,6 +82,47 @@ expect($matcher->matches($rules, $context))->toBeTrue(); }); +it('matches record status when present in the context', function (): void { + $matcher = new LocationMatcher; + $record = new class extends Model {}; + + $rules = [[ + ['param' => 'entity', 'operator' => '==', 'value' => 'draft'], + ['param' => 'record_status', 'operator' => '==', 'value' => 'published'], + ]]; + + $context = new LocationContext('draft', [ + 'record_status' => 'published', + ], $record); + + expect($matcher->matches($rules, $context))->toBeTrue() + ->and($matcher->matches($rules, new LocationContext('draft', ['record_status' => 'draft'], $record)))->toBeFalse(); +}); + +it('ignores record status on create forms without a record', function (): void { + $matcher = new LocationMatcher; + + $rules = [[ + ['param' => 'entity', 'operator' => '==', 'value' => 'draft'], + ['param' => 'record_status', 'operator' => '==', 'value' => 'published'], + ]]; + + expect($matcher->matches($rules, new LocationContext('draft')))->toBeTrue(); +}); + +it('ignores record status when the param is missing on an existing record', function (): void { + $matcher = new LocationMatcher; + + $rules = [[ + ['param' => 'entity', 'operator' => '==', 'value' => 'item'], + ['param' => 'record_status', 'operator' => '==', 'value' => 'published'], + ]]; + + $record = new class extends Model {}; + + expect($matcher->matches($rules, new LocationContext('item', [], $record)))->toBeTrue(); +}); + it('matches taxonomy term ids using taxonomy param prefixes', function (): void { $matcher = new LocationMatcher; From 6318ad0a3ad299a62dcb45e8ce56afd14de722e2 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:08:22 +0000 Subject: [PATCH 100/105] Fix styling --- packages/builder/tests/Unit/LocationContextTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/builder/tests/Unit/LocationContextTest.php b/packages/builder/tests/Unit/LocationContextTest.php index 56360a8a45..4d4ae1a148 100644 --- a/packages/builder/tests/Unit/LocationContextTest.php +++ b/packages/builder/tests/Unit/LocationContextTest.php @@ -5,6 +5,7 @@ require_once __DIR__.'/../TestCase.php'; use Illuminate\Database\Eloquent\Model; +use Illuminate\Http\Request; use Moox\Builder\Data\LocationContext; use Moox\Builder\Tests\TestCase; use Moox\Core\Enums\TranslationStatus; @@ -26,7 +27,7 @@ public function translate(?string $locale = null, bool $withFallback = false): ? $record->setRawAttributes(['status' => 'draft']); - app()->instance('request', \Illuminate\Http\Request::create('/', 'GET', ['lang' => 'de_DE'])); + app()->instance('request', Request::create('/', 'GET', ['lang' => 'de_DE'])); $context = LocationContext::forEntity('page', $record); From ff66403db0fa4d849f80311d6c91cf8770a5f00e Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:52:17 +0200 Subject: [PATCH 101/105] Fix empty taxonomy term options in location rule value select --- .../src/Resources/FieldGroupResource.php | 9 ++- .../src/Support/LocationConstraintOptions.php | 38 +++++++------ .../Unit/LocationConstraintOptionsTest.php | 57 +++++++++++++++++++ 3 files changed, 85 insertions(+), 19 deletions(-) diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 2740092c7e..9a7afbf31b 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -1470,6 +1470,13 @@ public static function locationConstraintSchema(): array }) ->options(function (Get $get) use ($targetEntitiesPath): array { return match ($get('param')) { + 'taxonomy' => filled($get('taxonomy')) + ? app(LocationConstraintOptions::class)->searchTermOptionsForTaxonomy( + (string) $get('taxonomy'), + $get($targetEntitiesPath), + '', + ) + : [], 'record_type' => app(LocationConstraintOptions::class)->recordTypeOptionsForEntities($get($targetEntitiesPath)), 'record_status' => app(LocationConstraintOptions::class)->recordStatusOptionsForEntities($get($targetEntitiesPath)), 'user_role' => app(LocationConstraintOptions::class)->roleOptions(), @@ -1537,7 +1544,7 @@ public static function locationConstraintSchema(): array }; }) ->searchable(fn (Get $get): bool => in_array($get('param'), ['taxonomy', 'record_type', 'record_status', 'user_role'], true)) - ->preload(fn (Get $get): bool => $get('param') !== 'taxonomy') + ->preload() ->native(false) ->columnSpanFull(), ]; diff --git a/packages/builder/src/Support/LocationConstraintOptions.php b/packages/builder/src/Support/LocationConstraintOptions.php index e909f77e5c..21f0f21429 100644 --- a/packages/builder/src/Support/LocationConstraintOptions.php +++ b/packages/builder/src/Support/LocationConstraintOptions.php @@ -126,11 +126,11 @@ public function termOptionsForTaxonomy(string $taxonomy, mixed $entities): array /** * @return array */ - public function searchTermOptionsForTaxonomy(string $taxonomy, mixed $entities, string $search): array + public function searchTermOptionsForTaxonomy(string $taxonomy, mixed $entities, string $search, int $limit = 50): array { $search = trim($search); - if ($taxonomy === '' || $search === '') { + if ($taxonomy === '') { return []; } @@ -142,31 +142,33 @@ public function searchTermOptionsForTaxonomy(string $taxonomy, mixed $entities, $model = $modelClass::query()->getModel(); $query = $modelClass::query() - ->limit(50) + ->limit($limit) ->orderBy($model->getKeyName()); if (method_exists($model, 'translations')) { $query->with('translations'); } - $like = '%'.addcslashes($search, '%_\\').'%'; + if ($search !== '') { + $like = '%'.addcslashes($search, '%_\\').'%'; - $query->where(function ($builder) use ($model, $like): void { - foreach (['title', 'name', 'label', 'slug'] as $column) { - if ($this->entityRegistry->databaseTableHasColumn($model->getTable(), $column)) { - $builder->orWhere($column, 'like', $like); + $query->where(function ($builder) use ($model, $like): void { + foreach (['title', 'name', 'label', 'slug'] as $column) { + if ($this->entityRegistry->databaseTableHasColumn($model->getTable(), $column)) { + $builder->orWhere($column, 'like', $like); + } } - } - if (method_exists($model, 'translations')) { - $builder->orWhereHas('translations', function ($translationQuery) use ($like): void { - $translationQuery - ->where('title', 'like', $like) - ->orWhere('name', 'like', $like) - ->orWhere('label', 'like', $like); - }); - } - }); + if (method_exists($model, 'translations')) { + $builder->orWhereHas('translations', function ($translationQuery) use ($like): void { + $translationQuery + ->where('title', 'like', $like) + ->orWhere('name', 'like', $like) + ->orWhere('label', 'like', $like); + }); + } + }); + } return $query ->get() diff --git a/packages/builder/tests/Unit/LocationConstraintOptionsTest.php b/packages/builder/tests/Unit/LocationConstraintOptionsTest.php index a0ef423171..f213183175 100644 --- a/packages/builder/tests/Unit/LocationConstraintOptionsTest.php +++ b/packages/builder/tests/Unit/LocationConstraintOptionsTest.php @@ -426,6 +426,63 @@ public function resourceFor(string $entity): ?string ]); }); +it('returns initial taxonomy term options when search is empty', function (): void { + Schema::create('taxonomy_terms', function (Blueprint $table): void { + $table->id(); + $table->string('title')->nullable(); + $table->timestamps(); + }); + + DB::table('taxonomy_terms')->insert([ + ['title' => 'News', 'created_at' => now(), 'updated_at' => now()], + ['title' => 'Sports', 'created_at' => now(), 'updated_at' => now()], + ]); + + config([ + 'page.taxonomies' => [ + 'category' => [ + 'label' => 'Category', + 'model' => TaxonomyTermTestModel::class, + ], + ], + ]); + + $registry = new class extends EntityRegistry + { + public function modelFor(string $entity): ?string + { + return $entity === 'page' ? PageTaxonomyTestModel::class : null; + } + }; + + $options = new LocationConstraintOptions( + $registry, + app(TaxonomyService::class), + app(BuilderLocaleResolver::class), + ); + + expect($options->searchTermOptionsForTaxonomy('category', ['page'], '')) + ->toMatchArray([ + '1' => 'News', + '2' => 'Sports', + ]) + ->and($options->searchTermOptionsForTaxonomy('category', ['page'], 'new')) + ->toBe(['1' => 'News']); +}); + +class PageTaxonomyTestModel extends Model +{ + public static function getResourceName(): string + { + return 'page'; + } +} + +class TaxonomyTermTestModel extends Model +{ + protected $table = 'taxonomy_terms'; +} + class NoRolesUser extends Model {} class RolesUser extends Model From 6cd0e1872705d599452d880ad7efc31fed95aeed Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:46:26 +0200 Subject: [PATCH 102/105] Add opt-in table filters for custom field list views Co-authored-by: Cursor --- packages/builder/README.md | 35 +- .../builder/resources/lang/de/builder.php | 7 + .../builder/resources/lang/en/builder.php | 7 + .../src/Compiler/TableFilterCompiler.php | 163 ++++++++ .../builder/src/Concerns/HasCustomFields.php | 15 + packages/builder/src/Data/FieldDefinition.php | 5 + .../src/Resources/FieldGroupResource.php | 76 ++++ .../src/Services/FieldGroupPersistence.php | 2 + .../Support/CustomFieldTableFilterQuery.php | 87 +++++ .../src/Support/FilterableFieldTypes.php | 21 ++ .../tests/Feature/HasCustomFieldsTest.php | 21 ++ .../tests/Unit/FilterableFieldTypesTest.php | 83 +++++ .../tests/Unit/TableFilterCompilerTest.php | 349 ++++++++++++++++++ packages/core/src/Entities/BaseResource.php | 11 + .../draft/src/Resources/DraftResource.php | 1 + packages/item/src/Resources/ItemResource.php | 6 +- .../record/src/Resources/RecordResource.php | 1 + 17 files changed, 888 insertions(+), 2 deletions(-) create mode 100644 packages/builder/src/Compiler/TableFilterCompiler.php create mode 100644 packages/builder/src/Support/CustomFieldTableFilterQuery.php create mode 100644 packages/builder/src/Support/FilterableFieldTypes.php create mode 100644 packages/builder/tests/Unit/FilterableFieldTypesTest.php create mode 100644 packages/builder/tests/Unit/TableFilterCompilerTest.php diff --git a/packages/builder/README.md b/packages/builder/README.md index ee17316c61..564a136423 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -339,6 +339,7 @@ Open the resource create/edit form — custom field sections should appear. |------|-------------| | Read/write in PHP, queries, Tinker | `InteractsWithCustomFields` on the model — [Model API](#model-api) | | List table columns | `...static::customFieldColumns()` in `table()` (enable **Show in table** on the field) | +| List table filters | `...static::customFieldFilters()` in `table()` (enable **Show in table filter** on the field) | | REST / JSON API | `MergesCustomFields` on your `JsonResource` — [API Serialization](#api-serialization) | | Per-locale values | Translatable model, or `customFieldsAreTranslatable(): true` — [Translations](#translations) | | Show only on some records | Location rules in the field group — [Location Rules](#location-rules) | @@ -357,10 +358,39 @@ Open the resource create/edit form — custom field sections should appear. | No sections on the form | Inactive group, wrong entity, or location rules | Check group is active, **Show on**, and [location rules](#location-rules) | | Sidebar empty | Group placement is `sidebar` but form has no sidebar call | Add `...static::customFieldComponents('sidebar')` in the sidebar column | | `$item->feld` does not work | Model missing trait | Add `InteractsWithCustomFields` to the model | -| Image/gallery/file types missing | `moox/media` not installed | Install media package (optional integration) | +| No filter on list | Toggle not saved, wrong list resource, or unsupported field config | Enable **Show in table filter** under **List filter**, save the group, open Item/Record/Draft list, click the funnel icon | +| Filter missing for relation | Multiple relation or no related entity | Use single relation and pick a related entity first | Details and overrides: [Connect a Resource](#connect-a-resource). Manual verification: [Testing → Checklist](#checklist). +### Table filters + +Opt-in Filament list filters for **select**, **radio**, **button_group**, **toggle**, and **single relation** fields. + +1. In the field group, open **List filter** on the field and enable **Show in table filter** +2. Save the field group +3. On the resource list page, use the funnel icon in the table toolbar + +Wire filters on custom resources: + +```php +public static function table(Table $table): Table +{ + return $table + ->columns([ + // ... + ...static::customFieldColumns(), + ]) + ->filters([ + // your own filters … + ...static::customFieldFilters(), + ]) + ->deferFilters(false); +} +``` + +Built-in on **Item**, **Record**, and **Draft** in this monorepo. Choice fields need at least one option; relation filters require a related entity and `multiple` off. + --- ## Connect a Resource @@ -433,6 +463,7 @@ Navigation: **Fields → Field Groups** | **Visibility** | Per context: admin, frontend, API (`visible_*` toggles) | | **Conditional logic** | Show/hide rules based on sibling field values (root-level v1) | | **Table column** | Optional list-column settings for scalar, media, and relation fields | +| **Table filter** | Optional list-filter for select, toggle, and single relation fields | Repeater rows are collapsed by default and show type, key, and required flag in the label. @@ -943,6 +974,7 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc | Flexible content sortable | no errors after save/load | | Image/gallery/file (with `moox/media`) | library filtered, save/load works, `media_usables` updated | | Relation field on a group | searchable select, save/load IDs, API shows labels | +| Table filter on select/toggle/relation | filter chip on list pages narrows matching records | | Conditional logic (show/hide) | field visibility updates live; hidden required fields do not block save | | `?lang=` on translatable resource | values stored per `locale` column | @@ -968,6 +1000,7 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc - **Per-context visibility** — `visible_admin`, `visible_api`, `visible_frontend` (admin + API wired) - **Location rules** — entity, record type, record status, user role, taxonomy term IDs (admin constraints + import/export) - **Table columns** — opt-in list columns for scalar, media, and relation fields (`TableColumnCompiler`) +- **Table filters** — opt-in list filters for select, toggle, and single relation fields (`TableFilterCompiler`) - **Field width grid** — 12-column layout per field (`FieldWidth`) - **Sidebar placement** — `main` vs `sidebar` field group slots - **Translations** — definition + value locales (Astrotomic + `builder_field_values.locale`) diff --git a/packages/builder/resources/lang/de/builder.php b/packages/builder/resources/lang/de/builder.php index 217a88b70d..a0c731f40b 100644 --- a/packages/builder/resources/lang/de/builder.php +++ b/packages/builder/resources/lang/de/builder.php @@ -132,6 +132,13 @@ 'table_column_helper' => 'Dieses Feld als Spalte auf den Listen-Seiten anzeigen.', 'show_in_table' => 'In Tabelle anzeigen', 'show_in_table_helper' => 'Fügt dieses Feld als Spalte auf den Listen-Seiten der Entity hinzu. Standardmäßig ausgeblendet; über den Spalten-Umschalter aktivierbar.', + 'show_in_filter' => 'Als Tabellenfilter', + 'show_in_filter_helper' => 'Fügt einen Listenfilter für Select-, Toggle- und einfache Relation-Felder hinzu. Standardmäßig aus.', + 'show_in_filter_relation_multiple_helper' => 'Listenfilter gibt es nur für einfache Relation-Felder (ohne „Mehrfach“).', + 'show_in_filter_relation_entity_helper' => 'Zuerst eine verknüpfte Entity wählen, dann den Listenfilter aktivieren.', + 'show_in_filter_choice_options_helper' => 'Mindestens eine Option hinzufügen, bevor der Listenfilter aktiviert werden kann.', + 'list_filter' => 'Listenfilter', + 'list_filter_helper' => 'Optionaler Filter auf Listen-Seiten (Item, Record, Draft). Feldgruppe nach dem Aktivieren speichern.', 'column_sortable' => 'Sortierbar', 'column_searchable' => 'Durchsuchbar', 'column_hidden_by_default' => 'Standardmäßig ausgeblendet', diff --git a/packages/builder/resources/lang/en/builder.php b/packages/builder/resources/lang/en/builder.php index 1608cff852..ed0f33b6af 100644 --- a/packages/builder/resources/lang/en/builder.php +++ b/packages/builder/resources/lang/en/builder.php @@ -132,6 +132,13 @@ 'table_column_helper' => 'Show this field as a column on list pages.', 'show_in_table' => 'Show in table', 'show_in_table_helper' => 'Adds this field as a column on entity list pages. Hidden by default; enable via the column toggle.', + 'show_in_filter' => 'Show in table filter', + 'show_in_filter_helper' => 'Adds a list filter for select, toggle, and single relation fields. Off by default.', + 'show_in_filter_relation_multiple_helper' => 'List filters are only available for single relation fields. Disable “Multiple” first.', + 'show_in_filter_relation_entity_helper' => 'Choose a related entity before enabling the list filter.', + 'show_in_filter_choice_options_helper' => 'Add at least one option before enabling the list filter.', + 'list_filter' => 'List filter', + 'list_filter_helper' => 'Optional filter on entity list pages (Item, Record, Draft). Save the field group after enabling.', 'column_sortable' => 'Sortable', 'column_searchable' => 'Searchable', 'column_hidden_by_default' => 'Hidden by default', diff --git a/packages/builder/src/Compiler/TableFilterCompiler.php b/packages/builder/src/Compiler/TableFilterCompiler.php new file mode 100644 index 0000000000..c1bb23626d --- /dev/null +++ b/packages/builder/src/Compiler/TableFilterCompiler.php @@ -0,0 +1,163 @@ + $fieldGroups + * @param class-string|null $resourceClass + * @return list + */ + public function compile(Collection $fieldGroups, ?string $resourceClass = null): array + { + if ($fieldGroups->isEmpty()) { + return []; + } + + $entity = $resourceClass !== null + ? $this->customFieldsManager->locationContextForResource($resourceClass)->entity + : null; + + if ($entity === null) { + return []; + } + + /** @var class-string|null $modelClass */ + $modelClass = $resourceClass !== null ? $resourceClass::getModel() : null; + + if ($modelClass === null || ! is_subclass_of($modelClass, Model::class)) { + return []; + } + + $fields = $this->storableFieldCollector + ->definitionsFromList($fieldGroups->flatMap(fn (FieldGroupDefinition $group): Collection => $group->fields)) + ->filter(fn (FieldDefinition $field): bool => $field->showInFilter() + && FilterableFieldTypes::supports($field)) + ->values(); + + $filters = []; + + foreach ($fields as $field) { + $filter = $this->compileFilter($field, $entity, $modelClass); + + if ($filter !== null) { + $filters[] = $filter; + } + } + + return $filters; + } + + /** + * @param class-string $modelClass + */ + protected function compileFilter( + FieldDefinition $field, + string $entity, + string $modelClass, + ): ?BaseFilter { + return match ($field->type) { + 'toggle' => $this->compileToggleFilter($field, $entity, $modelClass), + 'select', 'radio', 'button_group' => $this->compileChoiceFilter($field, $entity, $modelClass), + 'relation' => $this->compileRelationFilter($field, $entity, $modelClass), + default => null, + }; + } + + /** + * @param class-string $modelClass + */ + protected function compileToggleFilter( + FieldDefinition $field, + string $entity, + string $modelClass, + ): TernaryFilter { + return TernaryFilter::make($field->name) + ->label($field->label) + ->queries( + true: fn (Builder $query): Builder => $this->filterQuery->applyEquals($query, $field, $entity, $modelClass, true), + false: fn (Builder $query): Builder => $this->filterQuery->applyEquals($query, $field, $entity, $modelClass, false), + ); + } + + /** + * @param class-string $modelClass + */ + protected function compileChoiceFilter( + FieldDefinition $field, + string $entity, + string $modelClass, + ): SelectFilter { + $options = collect($field->options) + ->mapWithKeys(fn (array $option): array => [ + (string) $option['value'] => (string) $option['label'], + ]) + ->all(); + + return SelectFilter::make($field->name) + ->label($field->label) + ->options($options) + ->query(fn (Builder $query, array $data): Builder => filled($data['value'] ?? null) + ? $this->filterQuery->applyEquals($query, $field, $entity, $modelClass, $data['value']) + : $query); + } + + /** + * @param class-string $modelClass + */ + protected function compileRelationFilter( + FieldDefinition $field, + string $entity, + string $modelClass, + ): SelectFilter { + $relatedEntity = (string) $field->config['related_entity']; + + return SelectFilter::make($field->name) + ->label($field->label) + ->native(false) + ->searchable() + ->preload() + ->options(fn (): array => $this->relationTargetResolver->search($relatedEntity, '')) + ->getSearchResultsUsing(fn (string $search): array => $this->relationTargetResolver->search($relatedEntity, $search)) + ->getOptionLabelUsing(function (mixed $value) use ($relatedEntity): string { + if (! filled($value)) { + return ''; + } + + $labels = $this->relationTargetResolver->labelsFor($relatedEntity, [$value]); + + return $labels[$value] + ?? $labels[(int) $value] + ?? $labels[(string) $value] + ?? (string) $value; + }) + ->query(fn (Builder $query, array $data): Builder => filled($data['value'] ?? null) + ? $this->filterQuery->applyEquals($query, $field, $entity, $modelClass, $data['value']) + : $query); + } +} diff --git a/packages/builder/src/Concerns/HasCustomFields.php b/packages/builder/src/Concerns/HasCustomFields.php index 7034cfd5c8..836e5f5d54 100644 --- a/packages/builder/src/Concerns/HasCustomFields.php +++ b/packages/builder/src/Concerns/HasCustomFields.php @@ -13,6 +13,7 @@ use Illuminate\Support\Str; use Moox\Builder\Compiler\SchemaCompiler; use Moox\Builder\Compiler\TableColumnCompiler; +use Moox\Builder\Compiler\TableFilterCompiler; use Moox\Builder\Data\FieldGroupDefinition; use Moox\Builder\Data\LocationContext; use Moox\Builder\Registry\DefinitionRegistry; @@ -92,6 +93,20 @@ public static function customFieldColumns(): array return app(TableColumnCompiler::class)->compile($groups, static::class); } + /** + * @return list<\Filament\Tables\Filters\BaseFilter> + */ + public static function customFieldFilters(): array + { + $groups = static::visibleCustomFieldGroups(); + + if ($groups->isEmpty()) { + return []; + } + + return app(TableFilterCompiler::class)->compile($groups, static::class); + } + /** * Eager-loads custom field values on list/table queries (via BaseResource *ModifyTableQuery convention). * diff --git a/packages/builder/src/Data/FieldDefinition.php b/packages/builder/src/Data/FieldDefinition.php index 2406a59553..018e9e9bce 100644 --- a/packages/builder/src/Data/FieldDefinition.php +++ b/packages/builder/src/Data/FieldDefinition.php @@ -40,6 +40,11 @@ public function showInTable(): bool return (bool) ($this->settings['show_in_table'] ?? false); } + public function showInFilter(): bool + { + return (bool) ($this->settings['show_in_filter'] ?? false); + } + public function isColumnSortable(): bool { return (bool) ($this->settings['sortable'] ?? true); diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 9a7afbf31b..2c788433d1 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -229,6 +229,7 @@ public static function form(Schema $schema): Schema static::requirementAndWidthRow(), ...static::validationSettingsSchema(), ...static::columnSettingsSchema(), + ...static::filterSettingsSchema(), ...static::visibilitySettingsSchema(), ...static::conditionalLogicSchema(), ...static::optionFieldSections($registry), @@ -484,6 +485,7 @@ protected static function tabChildFieldSchema(FieldTypeRegistry $registry): arra ->live(onBlur: true), static::requirementAndWidthRow(), ...static::columnSettingsSchema(), + ...static::filterSettingsSchema(), ...static::visibilitySettingsSchema(), ...static::optionFieldSections($registry), Section::make(__('builder::builder.field.subfields')) @@ -652,6 +654,80 @@ protected static function fieldTypeSupportsAnyColumn(?string $type): bool || static::fieldTypeSupportsRelationColumn($type); } + protected static function fieldTypeSupportsFilter(?string $type): bool + { + if (blank($type)) { + return false; + } + + return in_array($type, ['select', 'radio', 'button_group', 'toggle', 'relation'], true); + } + + protected static function fieldHasFilterableChoiceOptions(callable $get): bool + { + if (! in_array($get('type'), ['select', 'radio', 'button_group'], true)) { + return true; + } + + $options = $get('options') ?? []; + + if (! is_array($options) || $options === []) { + return false; + } + + foreach ($options as $option) { + if (is_array($option) && filled($option['value'] ?? null)) { + return true; + } + } + + return false; + } + + protected static function fieldFilterCanBeEnabled(callable $get): bool + { + if (! static::fieldTypeSupportsFilter($get('type'))) { + return false; + } + + if ($get('type') === 'relation') { + return ! (bool) ($get('config.multiple') ?? false) + && filled($get('config.related_entity')); + } + + return static::fieldHasFilterableChoiceOptions($get); + } + + /** + * @return list
+ */ + protected static function filterSettingsSchema(): array + { + return [ + Section::make(__('builder::builder.field.list_filter')) + ->description(__('builder::builder.field.list_filter_helper')) + ->icon(Heroicon::OutlinedFunnel) + ->collapsible() + ->visible(fn (callable $get): bool => static::fieldTypeSupportsFilter($get('type'))) + ->schema([ + Toggle::make('settings.show_in_filter') + ->label(__('builder::builder.field.show_in_filter')) + ->helperText(fn (callable $get): string => match (true) { + $get('type') === 'relation' && (bool) ($get('config.multiple') ?? false) + => __('builder::builder.field.show_in_filter_relation_multiple_helper'), + $get('type') === 'relation' && blank($get('config.related_entity')) + => __('builder::builder.field.show_in_filter_relation_entity_helper'), + in_array($get('type'), ['select', 'radio', 'button_group'], true) + && ! static::fieldHasFilterableChoiceOptions($get) + => __('builder::builder.field.show_in_filter_choice_options_helper'), + default => __('builder::builder.field.show_in_filter_helper'), + }) + ->inline(false) + ->disabled(fn (callable $get): bool => ! static::fieldFilterCanBeEnabled($get)), + ]), + ]; + } + protected static function fieldTypeSupportsSortableColumn(?string $type): bool { return static::fieldTypeSupportsColumn($type) diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php index 00507cf0ed..e1512cabd1 100644 --- a/packages/builder/src/Services/FieldGroupPersistence.php +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -399,6 +399,7 @@ protected function mapFieldRows(Collection $fields, ?string $locale = null): arr 'config' => $this->definitionTranslator->translatedFieldConfig($field, $locale), 'settings' => [ 'show_in_table' => (bool) ($field->settings['show_in_table'] ?? false), + 'show_in_filter' => (bool) ($field->settings['show_in_filter'] ?? false), 'sortable' => (bool) ($field->settings['sortable'] ?? true), 'searchable' => (bool) ($field->settings['searchable'] ?? true), 'hidden_by_default' => (bool) ($field->settings['hidden_by_default'] ?? true), @@ -509,6 +510,7 @@ protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldI ], (string) $row['type']), 'settings' => array_merge($field->settings ?? [], [ 'show_in_table' => (bool) ($row['settings']['show_in_table'] ?? false), + 'show_in_filter' => (bool) ($row['settings']['show_in_filter'] ?? false), 'sortable' => (bool) ($row['settings']['sortable'] ?? true), 'searchable' => (bool) ($row['settings']['searchable'] ?? true), 'hidden_by_default' => (bool) ($row['settings']['hidden_by_default'] ?? true), diff --git a/packages/builder/src/Support/CustomFieldTableFilterQuery.php b/packages/builder/src/Support/CustomFieldTableFilterQuery.php new file mode 100644 index 0000000000..261e4e5ace --- /dev/null +++ b/packages/builder/src/Support/CustomFieldTableFilterQuery.php @@ -0,0 +1,87 @@ + $modelClass + */ + public function applyEquals( + Builder $query, + FieldDefinition $field, + string $entity, + string $modelClass, + mixed $value, + ): Builder { + if ($field->type === 'relation') { + return $this->applyRelationEquals($query, $field, $entity, $modelClass, $value); + } + + return $query->where($field->name, $this->normalizeValue($field, $value)); + } + + /** + * @param class-string $modelClass + */ + protected function applyRelationEquals( + Builder $query, + FieldDefinition $field, + string $entity, + string $modelClass, + mixed $value, + ): Builder { + if (! filled($value)) { + return $query; + } + + $valuesTable = (new FieldValue)->getTable(); + $recordKey = $query->getModel()->getQualifiedKeyName(); + $locale = $this->localeResolver->valuesLocaleForEntity($entity, null, $modelClass); + $relatedId = is_numeric($value) ? (int) $value : $value; + + return $query->whereExists(function ($subQuery) use ( + $entity, + $field, + $valuesTable, + $recordKey, + $locale, + $relatedId, + ): void { + $subQuery->selectRaw('1') + ->from($valuesTable) + ->whereColumn("{$valuesTable}.record_id", $recordKey) + ->where("{$valuesTable}.entity", $entity) + ->where("{$valuesTable}.field_name", $field->name) + ->where("{$valuesTable}.locale", $locale) + ->where(function ($builder) use ($relatedId): void { + $builder->where('value_json', $relatedId) + ->orWhere('value_json', json_encode($relatedId)); + }); + }); + } + + protected function normalizeValue(FieldDefinition $field, mixed $value): mixed + { + if ($field->type === 'toggle') { + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + return $value; + } +} diff --git a/packages/builder/src/Support/FilterableFieldTypes.php b/packages/builder/src/Support/FilterableFieldTypes.php new file mode 100644 index 0000000000..9c1430c6ce --- /dev/null +++ b/packages/builder/src/Support/FilterableFieldTypes.php @@ -0,0 +1,21 @@ +type) { + 'select', 'radio', 'button_group' => $field->options !== [], + 'toggle' => true, + 'relation' => ! RelationValueRules::isMultiple($field) + && filled($field->config['related_entity'] ?? null), + default => false, + }; + } +} diff --git a/packages/builder/tests/Feature/HasCustomFieldsTest.php b/packages/builder/tests/Feature/HasCustomFieldsTest.php index bd7d35164d..b5cc99678e 100644 --- a/packages/builder/tests/Feature/HasCustomFieldsTest.php +++ b/packages/builder/tests/Feature/HasCustomFieldsTest.php @@ -57,6 +57,27 @@ ->and($components[0])->toBeInstanceOf(Section::class); }); +it('exposes compiled custom field filters for matching resources', function (): void { + Field::query()->create([ + 'field_group_id' => FieldGroup::query()->first()->getKey(), + 'name' => 'fuel', + 'label' => 'Fuel', + 'type' => 'select', + 'settings' => ['show_in_filter' => true], + 'sort' => 2, + 'validation' => ['required' => false, 'rules' => []], + ])->options()->create([ + 'label' => 'Petrol', + 'value' => 'petrol', + 'sort' => 0, + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + expect(TestItemResource::customFieldFilters())->toHaveCount(1); +}); + it('returns no components when no field groups match the entity', function (): void { FieldGroup::query()->delete(); diff --git a/packages/builder/tests/Unit/FilterableFieldTypesTest.php b/packages/builder/tests/Unit/FilterableFieldTypesTest.php new file mode 100644 index 0000000000..6d24981d4b --- /dev/null +++ b/packages/builder/tests/Unit/FilterableFieldTypesTest.php @@ -0,0 +1,83 @@ + 'fuel', + 'label' => 'Fuel', + 'type' => 'select', + 'options' => [ + ['label' => 'Petrol', 'value' => 'petrol'], + ], + ]); + + expect(FilterableFieldTypes::supports($field))->toBeTrue(); +}); + +it('does not support choice fields without options', function (): void { + $field = FieldDefinition::fromArray([ + 'name' => 'fuel', + 'label' => 'Fuel', + 'type' => 'radio', + 'options' => [], + ]); + + expect(FilterableFieldTypes::supports($field))->toBeFalse(); +}); + +it('supports toggle fields', function (): void { + $field = FieldDefinition::fromArray([ + 'name' => 'active', + 'label' => 'Active', + 'type' => 'toggle', + ]); + + expect(FilterableFieldTypes::supports($field))->toBeTrue(); +}); + +it('supports single relation fields with a related entity', function (): void { + $field = FieldDefinition::fromArray([ + 'name' => 'linked_item', + 'label' => 'Linked item', + 'type' => 'relation', + 'config' => ['related_entity' => 'item', 'multiple' => false], + ]); + + expect(FilterableFieldTypes::supports($field))->toBeTrue(); +}); + +it('does not support multiple relation fields', function (): void { + $field = FieldDefinition::fromArray([ + 'name' => 'linked_items', + 'label' => 'Linked items', + 'type' => 'relation', + 'config' => ['related_entity' => 'item', 'multiple' => true], + ]); + + expect(FilterableFieldTypes::supports($field))->toBeFalse(); +}); + +it('does not support relation fields without a related entity', function (): void { + $field = FieldDefinition::fromArray([ + 'name' => 'linked_item', + 'label' => 'Linked item', + 'type' => 'relation', + 'config' => ['multiple' => false], + ]); + + expect(FilterableFieldTypes::supports($field))->toBeFalse(); +}); + +it('does not support non-filterable field types', function (): void { + $field = FieldDefinition::fromArray([ + 'name' => 'notes', + 'label' => 'Notes', + 'type' => 'text', + ]); + + expect(FilterableFieldTypes::supports($field))->toBeFalse(); +}); diff --git a/packages/builder/tests/Unit/TableFilterCompilerTest.php b/packages/builder/tests/Unit/TableFilterCompilerTest.php new file mode 100644 index 0000000000..903ba6138c --- /dev/null +++ b/packages/builder/tests/Unit/TableFilterCompilerTest.php @@ -0,0 +1,349 @@ +createItemsTable(); + + FieldGroup::query()->delete(); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); +}); + +it('builds table filters for choice and toggle fields marked show in filter', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'List filters', + 'slug' => 'list-filters', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'fuel', + 'label' => 'Fuel', + 'type' => 'select', + 'settings' => ['show_in_filter' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ])->options()->createMany([ + ['label' => 'Petrol', 'value' => 'petrol', 'sort' => 0], + ['label' => 'Diesel', 'value' => 'diesel', 'sort' => 1], + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'accident_free', + 'label' => 'Accident free', + 'type' => 'toggle', + 'settings' => ['show_in_filter' => true], + 'sort' => 1, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'notes', + 'label' => 'Notes', + 'type' => 'text', + 'settings' => ['show_in_filter' => true], + 'sort' => 2, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $filters = TestItemResource::customFieldFilters(); + + expect($filters)->toHaveCount(2) + ->and(collect($filters)->map(fn ($filter) => $filter->getName())->all())->toBe(['fuel', 'accident_free']) + ->and($filters[0])->toBeInstanceOf(SelectFilter::class) + ->and($filters[1])->toBeInstanceOf(TernaryFilter::class); +}); + +it('filters list queries by custom field values', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Fuel filter', + 'slug' => 'fuel-filter', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + $field = Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'fuel', + 'label' => 'Fuel', + 'type' => 'select', + 'settings' => ['show_in_filter' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + $field->options()->createMany([ + ['label' => 'Petrol', 'value' => 'petrol', 'sort' => 0], + ['label' => 'Diesel', 'value' => 'diesel', 'sort' => 1], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $petrolItem = TestItem::query()->create(['title' => 'Petrol car']); + $dieselItem = TestItem::query()->create(['title' => 'Diesel car']); + + app(CustomFieldsManager::class)->saveFromFormData(TestItemResource::class, $petrolItem, [ + 'fuel' => 'petrol', + ]); + app(CustomFieldsManager::class)->saveFromFormData(TestItemResource::class, $dieselItem, [ + 'fuel' => 'diesel', + ]); + + $definition = app(DefinitionRegistry::class) + ->fieldGroupsFor(TestItemResource::customFieldsLocationContext()) + ->flatMap(fn ($group) => $group->fields) + ->firstWhere('name', 'fuel'); + + expect($definition)->not->toBeNull(); + + $filtered = app(CustomFieldTableFilterQuery::class)->applyEquals( + TestItem::query(), + $definition, + 'item', + TestItem::class, + 'petrol', + )->pluck('id')->all(); + + expect($filtered)->toBe([$petrolItem->getKey()]); +}); + +it('filters list queries by single relation custom fields', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Relation filter', + 'slug' => 'relation-filter', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'linked_item', + 'label' => 'Linked item', + 'type' => 'relation', + 'config' => ['related_entity' => 'item', 'multiple' => false], + 'settings' => ['show_in_filter' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $filters = TestItemResource::customFieldFilters(); + + expect($filters)->toHaveCount(1) + ->and($filters[0])->toBeInstanceOf(SelectFilter::class) + ->and($filters[0]->getName())->toBe('linked_item'); + + $target = TestItem::query()->create(['title' => 'Target']); + $linked = TestItem::query()->create(['title' => 'Linked']); + $other = TestItem::query()->create(['title' => 'Other']); + + FieldValue::query()->create([ + 'entity' => 'item', + 'record_id' => $linked->getKey(), + 'field_name' => 'linked_item', + 'locale' => 'en_US', + 'value_json' => $target->getKey(), + ]); + + $definition = app(DefinitionRegistry::class) + ->fieldGroupsFor(TestItemResource::customFieldsLocationContext()) + ->flatMap(fn ($group) => $group->fields) + ->firstWhere('name', 'linked_item'); + + expect($definition)->not->toBeNull(); + + $filtered = app(CustomFieldTableFilterQuery::class)->applyEquals( + TestItem::query(), + $definition, + 'item', + TestItem::class, + $target->getKey(), + )->pluck('id')->all(); + + expect($filtered)->toBe([$linked->getKey()]) + ->and($filtered)->not->toContain($other->getKey()); +}); + +it('does not build filters when show in filter is disabled', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Hidden filter', + 'slug' => 'hidden-filter', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'fuel', + 'label' => 'Fuel', + 'type' => 'select', + 'settings' => ['show_in_filter' => false], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + expect(TestItemResource::customFieldFilters())->toBe([]); +}); + +it('does not build filters for select fields without options', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Empty options filter', + 'slug' => 'empty-options-filter', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'fuel', + 'label' => 'Fuel', + 'type' => 'select', + 'settings' => ['show_in_filter' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + expect(TestItemResource::customFieldFilters())->toBe([]); +}); + +it('does not build filters for multiple relation fields', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Multiple relation filter', + 'slug' => 'multiple-relation-filter', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'linked_items', + 'label' => 'Linked items', + 'type' => 'relation', + 'config' => ['related_entity' => 'item', 'multiple' => true], + 'settings' => ['show_in_filter' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + expect(TestItemResource::customFieldFilters())->toBe([]); +}); + +it('filters list queries by toggle custom fields', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Toggle filter', + 'slug' => 'toggle-filter', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + Field::query()->create([ + 'field_group_id' => $group->getKey(), + 'name' => 'accident_free', + 'label' => 'Accident free', + 'type' => 'toggle', + 'settings' => ['show_in_filter' => true], + 'sort' => 0, + 'validation' => ['required' => false, 'rules' => []], + ]); + + Cache::forget(DefinitionRegistry::CACHE_KEY); + TestItem::flushCustomFieldDefinitionCache(); + + $safeItem = TestItem::query()->create(['title' => 'Safe car']); + $damagedItem = TestItem::query()->create(['title' => 'Damaged car']); + + app(CustomFieldsManager::class)->saveFromFormData(TestItemResource::class, $safeItem, [ + 'accident_free' => true, + ]); + app(CustomFieldsManager::class)->saveFromFormData(TestItemResource::class, $damagedItem, [ + 'accident_free' => false, + ]); + + $definition = app(DefinitionRegistry::class) + ->fieldGroupsFor(TestItemResource::customFieldsLocationContext()) + ->flatMap(fn ($group) => $group->fields) + ->firstWhere('name', 'accident_free'); + + expect($definition)->not->toBeNull(); + + $filtered = app(CustomFieldTableFilterQuery::class)->applyEquals( + TestItem::query(), + $definition, + 'item', + TestItem::class, + true, + )->pluck('id')->all(); + + expect($filtered)->toBe([$safeItem->getKey()]) + ->and($filtered)->not->toContain($damagedItem->getKey()); +}); + +it('syncs show in filter through field group persistence', function (): void { + $group = FieldGroup::query()->create([ + 'name' => 'Persist filter', + 'slug' => 'persist-filter', + 'location_rules' => [[['param' => 'entity', 'operator' => '==', 'value' => 'item']]], + 'active' => true, + ]); + + app(\Moox\Builder\Services\FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Persist filter', + 'slug' => 'persist-filter', + 'target_entities' => ['item'], + 'fields' => [[ + 'name' => 'fuel', + 'label' => 'Fuel', + 'type' => 'select', + 'settings' => ['show_in_filter' => true], + 'options' => [ + ['label' => 'Petrol', 'value' => 'petrol'], + ], + ]], + ]); + + $field = Field::query()->where('name', 'fuel')->first(); + + expect($field)->not->toBeNull() + ->and($field->settings['show_in_filter'] ?? false)->toBeTrue(); +}); diff --git a/packages/core/src/Entities/BaseResource.php b/packages/core/src/Entities/BaseResource.php index e1131a6031..c6b2cc65a5 100644 --- a/packages/core/src/Entities/BaseResource.php +++ b/packages/core/src/Entities/BaseResource.php @@ -52,6 +52,17 @@ public static function customFieldColumns(): array return []; } + /** + * Custom field table filters. Overridden by the moox/builder HasCustomFields + * trait when the package is installed; returns no filters otherwise. + * + * @return array + */ + public static function customFieldFilters(): array + { + return []; + } + public static function make(string $key = 'default'): ResourceConfiguration { return ScopedResourceConfiguration::make(static::class, $key); diff --git a/packages/draft/src/Resources/DraftResource.php b/packages/draft/src/Resources/DraftResource.php index f0cfe32020..f9a3163dc5 100644 --- a/packages/draft/src/Resources/DraftResource.php +++ b/packages/draft/src/Resources/DraftResource.php @@ -206,6 +206,7 @@ public static function table(Table $table): Table ->options(['Post' => 'Post', 'Page' => 'Page']), ...static::getTaxonomyFilters(), static::getLocaleFilter(), + ...static::customFieldFilters(), ])->deferFilters(false) ->persistFiltersInSession(); } diff --git a/packages/item/src/Resources/ItemResource.php b/packages/item/src/Resources/ItemResource.php index 273297c4ca..5130b92e97 100644 --- a/packages/item/src/Resources/ItemResource.php +++ b/packages/item/src/Resources/ItemResource.php @@ -112,7 +112,11 @@ public static function table(Table $table): Table ->defaultSort('title', 'desc') ->recordActions([...static::getTableActions()]) ->toolbarActions([...static::getBulkActions()]) - ->filters([]); + ->filters([ + ...static::customFieldFilters(), + ]) + ->deferFilters(false) + ->persistFiltersInSession(); } public static function getPages(): array diff --git a/packages/record/src/Resources/RecordResource.php b/packages/record/src/Resources/RecordResource.php index 6ff935c05c..44a4b302e7 100644 --- a/packages/record/src/Resources/RecordResource.php +++ b/packages/record/src/Resources/RecordResource.php @@ -158,6 +158,7 @@ public static function table(Table $table): Table ->toolbarActions([...static::getBulkActions()]) ->filters([ ...static::getTaxonomyFilters(), + ...static::customFieldFilters(), ])->deferFilters(false) ->persistFiltersInSession(); } From c13936585069d7814de8efb9b7174be31ec876c9 Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:27:54 +0000 Subject: [PATCH 103/105] Fix styling --- packages/builder/src/Concerns/HasCustomFields.php | 3 ++- packages/builder/src/Resources/FieldGroupResource.php | 9 +++------ packages/builder/tests/Unit/TableFilterCompilerTest.php | 4 ++-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/builder/src/Concerns/HasCustomFields.php b/packages/builder/src/Concerns/HasCustomFields.php index 836e5f5d54..e852afbb90 100644 --- a/packages/builder/src/Concerns/HasCustomFields.php +++ b/packages/builder/src/Concerns/HasCustomFields.php @@ -7,6 +7,7 @@ use Astrotomic\Translatable\Contracts\Translatable; use Filament\Schemas\Components\Section; use Filament\Tables\Columns\Column; +use Filament\Tables\Filters\BaseFilter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; @@ -94,7 +95,7 @@ public static function customFieldColumns(): array } /** - * @return list<\Filament\Tables\Filters\BaseFilter> + * @return list */ public static function customFieldFilters(): array { diff --git a/packages/builder/src/Resources/FieldGroupResource.php b/packages/builder/src/Resources/FieldGroupResource.php index 2c788433d1..2165827571 100644 --- a/packages/builder/src/Resources/FieldGroupResource.php +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -713,13 +713,10 @@ protected static function filterSettingsSchema(): array Toggle::make('settings.show_in_filter') ->label(__('builder::builder.field.show_in_filter')) ->helperText(fn (callable $get): string => match (true) { - $get('type') === 'relation' && (bool) ($get('config.multiple') ?? false) - => __('builder::builder.field.show_in_filter_relation_multiple_helper'), - $get('type') === 'relation' && blank($get('config.related_entity')) - => __('builder::builder.field.show_in_filter_relation_entity_helper'), + $get('type') === 'relation' && (bool) ($get('config.multiple') ?? false) => __('builder::builder.field.show_in_filter_relation_multiple_helper'), + $get('type') === 'relation' && blank($get('config.related_entity')) => __('builder::builder.field.show_in_filter_relation_entity_helper'), in_array($get('type'), ['select', 'radio', 'button_group'], true) - && ! static::fieldHasFilterableChoiceOptions($get) - => __('builder::builder.field.show_in_filter_choice_options_helper'), + && ! static::fieldHasFilterableChoiceOptions($get) => __('builder::builder.field.show_in_filter_choice_options_helper'), default => __('builder::builder.field.show_in_filter_helper'), }) ->inline(false) diff --git a/packages/builder/tests/Unit/TableFilterCompilerTest.php b/packages/builder/tests/Unit/TableFilterCompilerTest.php index 903ba6138c..12aadc25ed 100644 --- a/packages/builder/tests/Unit/TableFilterCompilerTest.php +++ b/packages/builder/tests/Unit/TableFilterCompilerTest.php @@ -9,12 +9,12 @@ use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\TernaryFilter; use Illuminate\Support\Facades\Cache; -use Moox\Builder\Compiler\TableFilterCompiler; use Moox\Builder\Models\Field; use Moox\Builder\Models\FieldGroup; use Moox\Builder\Models\FieldValue; use Moox\Builder\Registry\DefinitionRegistry; use Moox\Builder\Services\CustomFieldsManager; +use Moox\Builder\Services\FieldGroupPersistence; use Moox\Builder\Support\CustomFieldTableFilterQuery; use Moox\Builder\Tests\Support\TestItem; use Moox\Builder\Tests\Support\TestItemResource; @@ -327,7 +327,7 @@ 'active' => true, ]); - app(\Moox\Builder\Services\FieldGroupPersistence::class)->sync($group, [ + app(FieldGroupPersistence::class)->sync($group, [ 'name' => 'Persist filter', 'slug' => 'persist-filter', 'target_entities' => ['item'], From bb4f2bc0d35eaae3dd5b335c8d1ead882c1a2500 Mon Sep 17 00:00:00 2001 From: Aziz Gasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:50:54 +0200 Subject: [PATCH 104/105] update readme and use more name field options --- packages/builder/README.md | 237 ++++++++++++++---- .../src/Support/RelationTargetResolver.php | 31 ++- .../tests/Support/TestStaticUnitLike.php | 16 ++ .../Support/TestStaticUnitLikeResource.php | 12 + .../tests/Unit/RelationFieldTypeTest.php | 40 ++- .../Filament/Resources/StaticUnitResource.php | 35 +++ 6 files changed, 317 insertions(+), 54 deletions(-) create mode 100644 packages/builder/tests/Support/TestStaticUnitLike.php create mode 100644 packages/builder/tests/Support/TestStaticUnitLikeResource.php diff --git a/packages/builder/README.md b/packages/builder/README.md index 564a136423..250f2a92dd 100644 --- a/packages/builder/README.md +++ b/packages/builder/README.md @@ -16,15 +16,17 @@ Admins define fields in the panel. Values are stored in typed `builder_field_val 6. [Quick Start](#quick-start) 7. [Connect a Resource](#connect-a-resource) 8. [Field Groups in Admin](#field-groups-in-admin) -9. [Field Types & Capabilities](#field-types--capabilities) -10. [Translations](#translations) -11. [Configuration](#configuration) -12. [Extension](#extension) -13. [Model API](#model-api) -14. [API Serialization](#api-serialization) -15. [Package Structure](#package-structure) -16. [Testing](#testing) -17. [Limits & Roadmap](#limits--roadmap) +9. [Table columns](#table-columns) +10. [Table filters](#table-filters) +11. [Field Types & Capabilities](#field-types--capabilities) +12. [Translations](#translations) +13. [Configuration](#configuration) +14. [Extension](#extension) +15. [Model API](#model-api) +16. [API Serialization](#api-serialization) +17. [Package Structure](#package-structure) +18. [Testing](#testing) +19. [Limits & Roadmap](#limits--roadmap) --- @@ -346,9 +348,23 @@ Open the resource create/edit form — custom field sections should appear. ### Live examples in this monorepo -- Resource: `packages/item/src/Resources/ItemResource.php` -- Model: `packages/item/src/Models/Item.php` -- Tests: `packages/builder/tests/Support/TestItemResource.php` +Branch: **`feature/custom-fields`** (includes list table filters). + +```bash +git fetch origin +git checkout feature/custom-fields +composer install +php artisan migrate # if builder tables are not present yet +``` + +| Piece | Path | +|-------|------| +| Resource (filters wired) | `packages/item/src/Resources/ItemResource.php` | +| Model | `packages/item/src/Models/Item.php` | +| Same pattern | `packages/record`, `packages/draft` | +| Tests | `packages/builder/tests/Support/TestItemResource.php` | + +Built-in list resources already call `...static::customFieldFilters()` with `deferFilters(false)` and `persistFiltersInSession()` — see [Table filters](#table-filters). ### Troubleshooting @@ -358,38 +374,12 @@ Open the resource create/edit form — custom field sections should appear. | No sections on the form | Inactive group, wrong entity, or location rules | Check group is active, **Show on**, and [location rules](#location-rules) | | Sidebar empty | Group placement is `sidebar` but form has no sidebar call | Add `...static::customFieldComponents('sidebar')` in the sidebar column | | `$item->feld` does not work | Model missing trait | Add `InteractsWithCustomFields` to the model | -| No filter on list | Toggle not saved, wrong list resource, or unsupported field config | Enable **Show in table filter** under **List filter**, save the group, open Item/Record/Draft list, click the funnel icon | +| No filter on list | Toggle not saved, wrong list resource, or unsupported field config | Enable **Show in table filter** under **List filter**, save the group, open Item/Record/Draft list, click the funnel icon — see [Table filters](#table-filters) | | Filter missing for relation | Multiple relation or no related entity | Use single relation and pick a related entity first | +| Filter toggle disabled in admin | Choice field has no options yet | Add at least one option, then enable the list filter | +| Filter chip visible but no results | Values stored under another locale | Switch `?lang=` on the list page to match stored `builder_field_values.locale` | -Details and overrides: [Connect a Resource](#connect-a-resource). Manual verification: [Testing → Checklist](#checklist). - -### Table filters - -Opt-in Filament list filters for **select**, **radio**, **button_group**, **toggle**, and **single relation** fields. - -1. In the field group, open **List filter** on the field and enable **Show in table filter** -2. Save the field group -3. On the resource list page, use the funnel icon in the table toolbar - -Wire filters on custom resources: - -```php -public static function table(Table $table): Table -{ - return $table - ->columns([ - // ... - ...static::customFieldColumns(), - ]) - ->filters([ - // your own filters … - ...static::customFieldFilters(), - ]) - ->deferFilters(false); -} -``` - -Built-in on **Item**, **Record**, and **Draft** in this monorepo. Choice fields need at least one option; relation filters require a related entity and `multiple` off. +Details and overrides: [Connect a Resource](#connect-a-resource). List views: [Table columns](#table-columns), [Table filters](#table-filters). Manual verification: [Testing → Checklist](#checklist). --- @@ -438,6 +428,15 @@ protected static function customFieldsEntity(): ?string - Define fields - Keep active +### Step 4: List views (optional) + +| Goal | Resource `table()` | Field group admin | +|------|-------------------|-------------------| +| Columns | `...static::customFieldColumns()` | **Table column** → **Show in table** | +| Filters | `...static::customFieldFilters()` + `deferFilters(false)` | **List filter** → **Show in table filter** | + +See [Table columns](#table-columns) and [Table filters](#table-filters). Item, Record, and Draft in this monorepo already include both. + **Not required:** - Trait or column on the Eloquent model @@ -462,8 +461,8 @@ Navigation: **Fields → Field Groups** | **Layouts** | For flexible content (layout key + subfields) | | **Visibility** | Per context: admin, frontend, API (`visible_*` toggles) | | **Conditional logic** | Show/hide rules based on sibling field values (root-level v1) | -| **Table column** | Optional list-column settings for scalar, media, and relation fields | -| **Table filter** | Optional list-filter for select, toggle, and single relation fields | +| **Table column** | Optional list-column settings for scalar, media, and relation fields — see [Table columns](#table-columns) | +| **Table filter** | Optional list-filter for select, radio, button_group, toggle, and single relation fields — see [Table filters](#table-filters) | Repeater rows are collapsed by default and show type, key, and required flag in the label. @@ -473,6 +472,144 @@ Package UI strings: `resources/lang/de/builder.php`, `en/builder.php`. --- +## Table columns + +Opt-in Filament list columns for custom fields. Values are read from `builder_field_values` (with locale resolution) — no extra column on the consumer model. + +### Supported field types + +| Category | Types | Filament column | +|----------|-------|-----------------| +| **Scalar** | `text`, `textarea`, `email`, `url`, `number`, `range`, `select`, `radio`, `button_group`, `multiselect`, `checkbox_list`, `date`, `datetime`, `time`, `color`, `link`, `oembed` | `TextColumn`, `ColorColumn`, or formatted text | +| **Toggle** | `toggle` | `IconColumn` (boolean) | +| **Media** *(requires `moox/media`)* | `image` | `ImageColumn` | +| **Relation** | `relation` (single or multiple) | `TextColumn` with resolved labels | + +**Not supported as columns:** `password`, `rich_text`, layout/compound types (`group`, `repeater`, `flexible_content`, `tab`, `section`, `message`), `gallery`, `file`. + +### Admin setup + +1. **Fields → Field Groups** → open a field +2. Section **Table column** → enable **Show in table** +3. Optional: sortable, searchable, hidden by default, badge/color/icon presentation (text-based types) +4. Save the field group + +### Wire on a resource + +```php +public static function table(Table $table): Table +{ + return $table + ->columns([ + // your own columns … + ...static::customFieldColumns(), + ]); +} +``` + +`HasCustomFields` also registers `customFieldsModifyTableQuery()` (via Moox `BaseResource` conventions) to eager-load values when columns are present — avoids N+1 on list pages. + +### Built-in in this monorepo + +**Item**, **Record**, and **Draft** already spread `...static::customFieldColumns()` in their `table()` definitions. + +### Notes + +- Relation columns resolve labels through `RelationTargetResolver` (same rules as relation fields). +- Sort/search on relation columns may fall back to stored IDs when titles exist only in translation tables. +- Columns respect the active list locale (`?lang=` / session) via `BuilderLocaleResolver`. + +--- + +## Table filters + +Opt-in Filament list filters for custom fields. Filtering runs as subqueries on `builder_field_values` — the consumer model needs no filter scopes. + +### Supported field types + +| Field type | Filter UI | Requirements | +|------------|-----------|--------------| +| `select`, `radio`, `button_group` | `SelectFilter` (options from field definition) | At least one option | +| `toggle` | `TernaryFilter` (yes / no / any) | — | +| `relation` | `SelectFilter` (searchable, preloaded) | `related_entity` set, `multiple` off | + +**Not filterable:** all other types (text, number, media, compound fields, multiselect, etc.). + +### Admin setup + +1. **Fields → Field Groups** → open a filterable field +2. Section **List filter** → enable **Show in table filter** +3. Save the field group +4. Open the resource list (e.g. Items) → funnel icon in the table toolbar → pick a value + +The toggle is disabled in admin when prerequisites are missing (e.g. relation without target entity, choice field without options, multiple relation). + +### Wire on a resource + +```php +public static function table(Table $table): Table +{ + return $table + ->columns([ + // ... + ...static::customFieldColumns(), + ]) + ->filters([ + // your own filters … + ...static::customFieldFilters(), + ]) + ->deferFilters(false) // recommended: filters visible without "Apply" + ->persistFiltersInSession(); // optional: remember filter state per session +} +``` + +| Method | Role | +|--------|------| +| `customFieldFilters()` | Compiles `TableFilterCompiler` output for visible field groups | +| `deferFilters(false)` | Shows filter controls immediately (Filament default defers them) | +| `persistFiltersInSession()` | Keeps selected filters across list navigation | + +### Built-in in this monorepo + +| Resource | Filters wired | +|----------|---------------| +| `packages/item/src/Resources/ItemResource.php` | `customFieldFilters()` + `deferFilters(false)` + `persistFiltersInSession()` | +| `packages/record/src/Resources/RecordResource.php` | same | +| `packages/draft/src/Resources/DraftResource.php` | same | + +Other resources with `HasCustomFields` do **not** get filters automatically — add the snippet above to `table()`. + +### How it works + +``` +FieldGroup (show_in_filter = true) + → DefinitionRegistry + → TableFilterCompiler + → SelectFilter / TernaryFilter + → CustomFieldTableFilterQuery (subquery on builder_field_values) +``` + +Only fields marked `settings.show_in_filter` in active, location-matched groups are compiled. Filter names use the field key (`fuel`, `accident_free`, …). + +### Troubleshooting + +| Symptom | Fix | +|---------|-----| +| No funnel / no custom filters | Resource missing `...static::customFieldFilters()` in `table()` | +| Filter not in admin toggle list | Field type not filterable, or choice/relation preconditions not met | +| Relation filter empty | Set `related_entity`, disable **Multiple**, save, reload field group editor | +| Filter has no effect | Save field group after enabling toggle; ensure records have values for the active locale | +| Filters hidden behind "Apply" | Add `->deferFilters(false)` on the table | + +### Tests + +```bash +php artisan test --compact packages/builder/tests/Unit/TableFilterCompilerTest.php +php artisan test --compact packages/builder/tests/Unit/FilterableFieldTypesTest.php +``` + +--- + ## Field Types & Capabilities ### Built-in field types (29 with `moox/media`, otherwise 26) @@ -877,7 +1014,8 @@ packages/builder/ ├── Compiler/ │ ├── LocationMatcher.php │ ├── SchemaCompiler.php - │ └── TableColumnCompiler.php + │ ├── TableColumnCompiler.php + │ └── TableFilterCompiler.php ├── Data/ │ ├── FieldDefinition.php │ ├── FieldGroupDefinition.php @@ -916,6 +1054,8 @@ packages/builder/ │ └── FieldValueValidator.php └── Support/ ├── BuilderLocaleResolver.php + ├── CustomFieldTableFilterQuery.php + ├── FilterableFieldTypes.php ├── ConditionalLogic.php ├── CustomFieldsFilamentHooks.php ├── CustomFieldsTranslatability.php @@ -974,7 +1114,8 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc | Flexible content sortable | no errors after save/load | | Image/gallery/file (with `moox/media`) | library filtered, save/load works, `media_usables` updated | | Relation field on a group | searchable select, save/load IDs, API shows labels | -| Table filter on select/toggle/relation | filter chip on list pages narrows matching records | +| Table column on scalar/toggle/media/relation field | column appears (toggle via column picker if hidden by default) | +| Table filter on select/radio/button_group/toggle/relation | filter chip on list pages narrows matching records | | Conditional logic (show/hide) | field visibility updates live; hidden required fields do not block save | | `?lang=` on translatable resource | values stored per `locale` column | @@ -1000,7 +1141,7 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc - **Per-context visibility** — `visible_admin`, `visible_api`, `visible_frontend` (admin + API wired) - **Location rules** — entity, record type, record status, user role, taxonomy term IDs (admin constraints + import/export) - **Table columns** — opt-in list columns for scalar, media, and relation fields (`TableColumnCompiler`) -- **Table filters** — opt-in list filters for select, toggle, and single relation fields (`TableFilterCompiler`) +- **Table filters** — opt-in list filters for select, radio, button_group, toggle, and single relation fields (`TableFilterCompiler`, `CustomFieldTableFilterQuery`) - **Field width grid** — 12-column layout per field (`FieldWidth`) - **Sidebar placement** — `main` vs `sidebar` field group slots - **Translations** — definition + value locales (Astrotomic + `builder_field_values.locale`) @@ -1011,12 +1152,14 @@ php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --forc - Location rules: no template/parent params yet; record/taxonomy/status rules need a saved record (ignored on create) - Conditional logic: root-level siblings only — not inside repeaters/groups/flexible content +- Table filters: no centralized filter builder / preset groups yet (per-field opt-in only) - Custom `validation.rules`: supported in schema/DB, no admin UI (programmatic only) - Relation targets: Filament-registered Moox resources only (not arbitrary Eloquent models) - No clone field type (ACF-style reusable field group in a field) **Not implemented yet:** +- Centralized filter groups / filter presets (per-field list filters only in v1) - Clone field type (ACF) - Location params beyond entity/record type/taxonomy/user role (e.g. template, parent) - Nested conditional logic (inside compound fields) diff --git a/packages/builder/src/Support/RelationTargetResolver.php b/packages/builder/src/Support/RelationTargetResolver.php index 2f8378389e..d9b2ef9ddf 100644 --- a/packages/builder/src/Support/RelationTargetResolver.php +++ b/packages/builder/src/Support/RelationTargetResolver.php @@ -214,7 +214,7 @@ public function queryTarget(string $entity): ?array $translation = null; $titleColumn = 'id'; - foreach (['title', 'name', 'label'] as $candidate) { + foreach (['title', 'name', 'label', 'common_name', 'symbol', 'code'] as $candidate) { if ($this->modelHasColumn($modelClass, $candidate)) { $titleColumn = $candidate; break; @@ -310,7 +310,16 @@ protected function titleAttributeCandidates(string $entity): array } $modelClass = $this->modelClass($entity); - $candidates = ['display_title', 'display_name', 'title', 'name', 'label']; + $candidates = [ + 'display_title', + 'display_name', + 'title', + 'name', + 'label', + 'common_name', + 'symbol', + 'code', + ]; if ($modelClass === null) { return $candidates; @@ -351,14 +360,24 @@ protected function titleAttributeFor(string $entity): string */ protected function applySearchFilter(Builder $query, string $modelClass, string $term): void { - foreach (['title', 'name', 'label'] as $column) { - if ($this->modelHasColumn($modelClass, $column)) { - $query->where($column, 'like', "%{$term}%"); + $columns = []; - return; + foreach (['title', 'name', 'label', 'common_name', 'symbol', 'code'] as $column) { + if ($this->modelHasColumn($modelClass, $column)) { + $columns[] = $column; } } + if ($columns !== []) { + $query->where(function (Builder $builder) use ($columns, $term): void { + foreach ($columns as $column) { + $builder->orWhere($column, 'like', "%{$term}%"); + } + }); + + return; + } + foreach (['title', 'name', 'label'] as $attribute) { if (! $this->modelHasTranslatableAttribute($modelClass, $attribute)) { continue; diff --git a/packages/builder/tests/Support/TestStaticUnitLike.php b/packages/builder/tests/Support/TestStaticUnitLike.php new file mode 100644 index 0000000000..0cabd66a56 --- /dev/null +++ b/packages/builder/tests/Support/TestStaticUnitLike.php @@ -0,0 +1,16 @@ +getKey() => 'News', ])->and($resolver->labelsFor('test_category_like', [$category->getKey()]))->toBe([ $category->getKey() => 'News', - ])->and($resolver->search('test_category_like', 'New'))->toHaveKey($category->getKey()); + ]) ->and($resolver->search('test_category_like', 'New'))->toHaveKey($category->getKey()); $queryTarget = $resolver->queryTarget('test_category_like'); @@ -369,3 +371,39 @@ protected function panelResources(): array ->and($queryTarget['titleColumn'])->toBe('title') ->and($queryTarget['translation']['table'] ?? null)->toBe('category_translations'); }); + +it('resolves labels for models that use common_name instead of title', function (): void { + require_once __DIR__.'/../Support/TestStaticUnitLike.php'; + require_once __DIR__.'/../Support/TestStaticUnitLikeResource.php'; + + Schema::dropIfExists('static_units'); + + Schema::create('static_units', function (Blueprint $table): void { + $table->id(); + $table->string('code', 10); + $table->string('common_name'); + $table->string('symbol', 20)->nullable(); + }); + + $registry = new class extends EntityRegistry + { + protected function panelResources(): array + { + return [TestStaticUnitLikeResource::class]; + } + }; + + app()->instance(EntityRegistry::class, $registry); + + $unit = TestStaticUnitLike::query()->create([ + 'code' => 'KGM', + 'common_name' => 'Kilogram', + 'symbol' => 'kg', + ]); + + $resolver = app(RelationTargetResolver::class); + + expect($resolver->search('test_static_unit_like', ''))->toBe([ + $unit->getKey() => 'Kilogram', + ])->and($resolver->search('test_static_unit_like', 'kg'))->toHaveKey($unit->getKey()); +}); diff --git a/packages/data/src/Filament/Resources/StaticUnitResource.php b/packages/data/src/Filament/Resources/StaticUnitResource.php index 398d34f6b7..814a95ba0d 100644 --- a/packages/data/src/Filament/Resources/StaticUnitResource.php +++ b/packages/data/src/Filament/Resources/StaticUnitResource.php @@ -13,6 +13,7 @@ use Filament\Tables\Filters\Filter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Moox\Core\Entities\Items\Record\BaseRecordResource; use Moox\Data\Filament\Resources\StaticUnitResource\Pages\CreateStaticUnit; use Moox\Data\Filament\Resources\StaticUnitResource\Pages\EditStaticUnit; @@ -212,4 +213,38 @@ public static function getNavigationBadge(): ?string { return (string) static::getModel()::count(); } + + public static function hasRecordTitle(): bool + { + return true; + } + + public static function getRecordTitle(?Model $record): string + { + if (! $record instanceof StaticUnit) { + return ''; + } + + /** @var StaticUnit $record */ + $symbol = filled($record->symbol) ? (string) $record->symbol : null; + $name = filled($record->common_name) ? (string) $record->common_name : null; + + if ($symbol !== null && $name !== null) { + return "{$symbol} — {$name}"; + } + + if ($symbol !== null) { + return $symbol; + } + + if ($name !== null) { + return $name; + } + + if (filled($record->code)) { + return (string) $record->code; + } + + return (string) $record->getKey(); + } } From 16c607fdf9e0dcd6905ef90984626c36153af6fb Mon Sep 17 00:00:00 2001 From: AzGasim <104441723+AzGasim@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:52:13 +0000 Subject: [PATCH 105/105] Fix styling --- packages/builder/tests/Unit/RelationFieldTypeTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/builder/tests/Unit/RelationFieldTypeTest.php b/packages/builder/tests/Unit/RelationFieldTypeTest.php index eb11fca083..ecbeaa3dcb 100644 --- a/packages/builder/tests/Unit/RelationFieldTypeTest.php +++ b/packages/builder/tests/Unit/RelationFieldTypeTest.php @@ -28,13 +28,13 @@ use Moox\Builder\Support\RelationTargetResolver; use Moox\Builder\Support\RelationValueRules; use Moox\Builder\Tests\Support\TestCategoryLike; -use Moox\Builder\Tests\Support\TestStaticUnitLike; -use Moox\Builder\Tests\Support\TestStaticUnitLikeResource; use Moox\Builder\Tests\Support\TestCategoryLikeResource; use Moox\Builder\Tests\Support\TestCategoryLikeTranslation; use Moox\Builder\Tests\Support\TestItem; use Moox\Builder\Tests\Support\TestItemResource; use Moox\Builder\Tests\Support\TestLocalizationLikeResource; +use Moox\Builder\Tests\Support\TestStaticUnitLike; +use Moox\Builder\Tests\Support\TestStaticUnitLikeResource; use Moox\Builder\Tests\TestCase; uses(TestCase::class); @@ -363,7 +363,7 @@ protected function panelResources(): array $category->getKey() => 'News', ])->and($resolver->labelsFor('test_category_like', [$category->getKey()]))->toBe([ $category->getKey() => 'News', - ]) ->and($resolver->search('test_category_like', 'New'))->toHaveKey($category->getKey()); + ])->and($resolver->search('test_category_like', 'New'))->toHaveKey($category->getKey()); $queryTarget = $resolver->queryTarget('test_category_like');