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..250f2a92dd --- /dev/null +++ b/packages/builder/README.md @@ -0,0 +1,1180 @@ +# Moox Builder + +Runtime field groups for Filament resources — ACF-like, but pure Laravel/Filament. + +Admins define fields in the panel. Values are stored in typed `builder_field_values` rows (no JSON blob on the model, no WordPress/postmeta). + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [The Two Layers](#the-two-layers) +3. [Database](#database) +4. [Runtime Flow](#runtime-flow) +5. [Installation](#installation) +6. [Quick Start](#quick-start) +7. [Connect a Resource](#connect-a-resource) +8. [Field Groups in Admin](#field-groups-in-admin) +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) + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ ADMIN (Definition) │ +│ Filament → Fields → Field Groups (FieldGroupResource) │ +│ ↓ │ +│ builder_field_groups / builder_fields / builder_field_options │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + DefinitionRegistry (Cache) + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ RUNTIME (Consumer Resources) │ +│ ItemResource + HasCustomFields │ +│ ↓ │ +│ EntityRegistry → LocationMatcher → SchemaCompiler → Filament Sections │ +│ ↓ │ +│ PersistCustomFields (RecordSaved) → CustomFieldsManager │ +│ ↓ │ +│ builder_field_values (TypedValueColumns) │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +**Core principle:** Definition and storage are strictly separated. + +| Layer | Question | Where | +|-------|----------|-------| +| **Definition** | Which fields exist? | `builder_field_*` tables + admin UI | +| **Storage** | Where are values stored? | `builder_field_values` + `TypedValueColumns` | + +--- + +## The Two Layers + +### 1. Definition Layer + +Responsible for **what** is shown and **where** (location). + +| 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 | + +Definitions are transported as **DTOs** (`FieldGroupDefinition`, `FieldDefinition`) — not as loose Eloquent models in the runtime path. + +### 2. Storage Layer + +Responsible for **values** per record. + +| 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 | + +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. + +--- + +## Database + +### `builder_field_groups` + +| Column | Meaning | +|--------|---------| +| `name` | Display name (= section title in the form) | +| `slug` | Technical group key | +| `location_rules` | JSON: where the group appears (see below) | +| `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 | + +### `builder_fields` + +Fields of a group: `name`, `label`, `type`, `config`, `validation`, `sort`. + +**`parent_field_id`** links subfields to layout fields (group, repeater, flexible content layouts). The tree is synced recursively in `FieldGroupPersistence`. + +### `builder_field_options` + +Options for `select`, `radio`, `multiselect`, `checkbox_list`, `button_group`. + +### `builder_field_values` + +One row per value: + +| 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 | +| `value_date` | date | +| `value_datetime` | datetime | +| `value_boolean` | toggle | +| `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)`. + +### 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 + +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 +[ + [ + { "param": "entity", "operator": "==", "value": "draft" }, + { "param": "record_type", "operator": "==", "value": "page" } + ] +] +``` + +| Param | Example | Context source | +|-------|---------|----------------| +| `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`) | + +- 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: + +```php +protected static function customFieldsLocationParams(?Model $record): array +{ + return ['record_type' => $record?->type]; +} +``` + +--- + +## Runtime Flow + +### Open form (create/edit) + +``` +1. Resource::form() includes ...static::customFieldComponents() + +2. HasCustomFields → DefinitionRegistry::fieldGroupsFor(LocationContext) + → loads cached groups + → LocationMatcher filters by entity + +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 +``` + +### Save + +``` +1. Filament saves the model (title, description, …) + +2. RecordSaved event fires + +3. PersistCustomFields::handle($record, $data, $page) + → checks: does the resource use HasCustomFields? + +4. CustomFieldsManager::saveFromFormData() + → 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 +``` + +**Important:** No page hooks (`afterCreate`, `mutateFormDataBeforeSave`) needed. + +### Cache + +`DefinitionRegistry` caches under `builder.definitions` as **PHP arrays**. + +Invalidation is automatic via `InvalidateDefinitionCacheObserver` when groups/fields/options change. + +Manual: `php artisan cache:forget builder.definitions` + +--- + +## Installation + +### Via Moox Installer + +```bash +composer require moox/builder +php artisan moox:install +``` + +Select migrations, config, seeder, and `BuilderPlugin`. + +### Manual + +```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" +``` + +Register the plugin in your panel: + +```php +use Moox\Builder\Plugins\BuilderPlugin; + +$panel->plugins([ + BuilderPlugin::make(), +]); +``` + +--- + +## 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) | +| 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) | + +### Live examples in this monorepo + +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 + +| 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 | +| 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). List views: [Table columns](#table-columns), [Table filters](#table-filters). Manual verification: [Testing → Checklist](#checklist). + +--- + +## Connect a Resource + +### Step 1: Trait on the Filament resource + +```php +use Moox\Core\Traits\HasCustomFields; // Moox monorepo — or Moox\Builder\Concerns\HasCustomFields + +class ItemResource extends Resource +{ + use HasCustomFields; + + public static function form(Schema $schema): Schema + { + return $schema->components([ + // your own fields … + ...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 +protected static function customFieldsEntity(): ?string +{ + return 'item'; +} +``` + +### Step 2: Entity discovery + +`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. + +### Step 3: Field group in admin + +**Fields → Field Groups → Create** + +- **Show on:** e.g. `Items`, `Records` +- 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 +- Custom listeners or page hooks +- Migration for JSON on the model +- Manual entity config + +--- + +## Field Groups in Admin + +Navigation: **Fields → Field Groups** + +| Section | Content | +|---------|---------| +| **General** | Name, technical key, active, sort order | +| **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 | +| **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 — 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. + +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`. + +--- + +## 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) + +| Category | Keys | +|----------|------| +| **Text** | `text`, `textarea`, `email`, `url`, `password`, `rich_text` | +| **Number** | `number`, `range` | +| **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` | + +Internal only (DB, not selectable): `flexible_layout` — defines a layout inside flexible content. + +### Media fields (`moox/media` optional) + +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. + +| 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 | + +**Architecture:** + +- 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 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`. + +**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) + +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 | +|------|-------------------|---------| +| `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 | +|------------|--------| +| `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) | +| `RelationSettings` | Target entity, multiple, min/max (relation) | + +Each field type implements `FieldType`: `key()`, `formComponent()`, `capabilities()`, optionally `castValue()`, `hasSubFields()`. + +### Validation + +- **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 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`. + +--- + +## 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 + +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) + +```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`: + +| 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`, `BUILDER_DEFAULT_LOCALE`. + +--- + +## Extension + +### Register a custom field type + +```php +use Moox\Builder\FieldTypes\FieldType; +use Moox\Builder\Registry\FieldTypeRegistry; + +$this->app->afterResolving(FieldTypeRegistry::class, function (FieldTypeRegistry $registry) { + $registry->register(new MyCustomFieldType); +}); +``` + +Translation under `builder::builder.field_types.{key}` in `resources/lang`. + +### Get validation rules + +```php +ItemResource::customFieldRules(); +// ['field-name' => ['required', 'max:255'], 'repeater.*.subfield' => ['required'], ...] +``` + +### Load values for Filament forms + +```php +use Moox\Builder\Services\CustomFieldsManager; + +$values = app(CustomFieldsManager::class)->loadFormData( + ItemResource::class, + $item, +); +``` + +This is for the Filament form context, not for API output. + +--- + +## Model API + +Add `InteractsWithCustomFields` to the consumer model (e.g. `Item`). Entity key resolution: `getResourceName()` → `customFieldsEntity()` → Filament resource → model basename. + +```php +use Moox\Builder\Concerns\InteractsWithCustomFields; + +// 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(); // 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: custom entity key (same hook as on the Filament resource) +protected static function customFieldsEntity(): ?string +{ + return 'my-entity'; +} +``` + +**Notes:** + +- 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 Serialization + +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 ItemApiResource extends JsonResource +{ + use MergesCustomFields; + + public function toArray($request): array + { + return $this->mergeCustomFields([ + 'id' => $this->id, + 'title' => $this->title, + ]); + } +} +``` + +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": {...}}]` | +| `relation` (single) | `{"id": 1, "label": "Record title"}` | +| `relation` (multiple) | `[{"id": 1, "label": "..."}, ...]` | + +### 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(), +); +``` + +--- + +## Package Structure + +``` +packages/builder/ +├── config/builder.php +├── database/ +│ ├── migrations/ # 7 tables + translation tables +│ └── seeders/BuilderSeeder.php +├── resources/lang/{de,en}/builder.php +└── src/ + ├── BuilderServiceProvider.php + ├── Concerns/ + │ ├── HasCustomFields.php # Filament resource + │ └── InteractsWithCustomFields.php # Consumer model + ├── Filament/Resources/Pages/Concerns/ + │ └── InteractsWithBuilderLocale.php + ├── Compiler/ + │ ├── LocationMatcher.php + │ ├── SchemaCompiler.php + │ ├── TableColumnCompiler.php + │ └── TableFilterCompiler.php + ├── Data/ + │ ├── FieldDefinition.php + │ ├── FieldGroupDefinition.php + │ └── LocationContext.php + ├── FieldTypes/ + │ ├── FieldType.php + │ ├── Capabilities/ + │ └── Types/ # 29 field types + ├── Forms/Components/BuilderMediaPicker.php + ├── Http/ + │ ├── Middleware/ResolveBuilderAdminLocale.php + │ ├── Livewire/BuilderMediaPickerModal.php + │ └── Resources/Concerns/MergesCustomFields.php + ├── Listeners/PersistCustomFields.php + ├── Models/ + │ ├── Concerns/HasBuilderTranslatableAttributes.php + │ ├── FieldGroup.php, Field.php, FieldOption.php, FieldValue.php + │ └── *Translation.php # Astrotomic translation models + ├── Observers/ + │ ├── InvalidateDefinitionCacheObserver.php + │ └── PurgeFieldValuesObserver.php + ├── Plugins/BuilderPlugin.php + ├── Registry/ + │ ├── DefinitionRegistry.php + │ ├── EntityRegistry.php + │ └── FieldTypeRegistry.php + ├── Resources/FieldGroupResource.php # Admin UI + ├── Services/ + │ ├── CustomFieldsManager.php + │ ├── BuilderValuesResolver.php + │ ├── BuilderMediaUsageSync.php + │ ├── BuilderFieldValueMediaMetadataSync.php + │ ├── FieldGroupPersistence.php + │ ├── FieldGroupValidator.php + │ ├── FieldValuePurger.php + │ └── FieldValueValidator.php + └── Support/ + ├── BuilderLocaleResolver.php + ├── CustomFieldTableFilterQuery.php + ├── FilterableFieldTypes.php + ├── ConditionalLogic.php + ├── CustomFieldsFilamentHooks.php + ├── CustomFieldsTranslatability.php + ├── DefinitionTranslator.php + ├── EntityModelDeletionRegistrar.php + ├── FieldVisibility.php + ├── FieldWidth.php + ├── MediaFieldValueSupport.php + ├── MediaIntegration.php + ├── OptionValueRules.php + ├── RelationTargetResolver.php + ├── RelationValueRules.php + ├── RichTextValue.php + └── TypedValueColumns.php +``` + +--- + +## Testing + +### Package tests + +```bash +cd packages/builder && composer test +``` + +### Manual in the panel + +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, locale, value_string, value_decimal, value_json +FROM builder_field_values +WHERE entity = 'item'; +``` + +4. Re-open the item — values must be loaded. + +### Seeder + +```bash +php artisan db:seed --class="Moox\Builder\Database\Seeders\BuilderSeeder" --force +``` + +### Checklist + +| 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 | +| Relation field on a group | searchable select, save/load IDs, API shows labels | +| 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 | + +--- + +## Limits & Roadmap + +**Implemented:** + +- Nested fields via `parent_field_id` (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 (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) +- **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, 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`) +- **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):** + +- 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) +- 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) + +--- + +## License + +MIT diff --git a/packages/builder/composer.json b/packages/builder/composer.json new file mode 100644 index 0000000000..f1436df92a --- /dev/null +++ b/packages/builder/composer.json @@ -0,0 +1,84 @@ +{ + "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\\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..407e52ca95 --- /dev/null +++ b/packages/builder/config/builder.php @@ -0,0 +1,8 @@ + env('BUILDER_NAVIGATION_GROUP', 'Felder'), + 'default_locale' => env('BUILDER_DEFAULT_LOCALE', 'en_US'), +]; 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_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_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_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_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 new file mode 100644 index 0000000000..17d86cfc07 --- /dev/null +++ b/packages/builder/database/migrations/create_builder_field_values_table.php.stub @@ -0,0 +1,41 @@ +id(); + $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(); + $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', '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']); + $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..6ce2401ba0 --- /dev/null +++ b/packages/builder/database/migrations/create_builder_fields_table.php.stub @@ -0,0 +1,34 @@ +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->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..81ade445f9 --- /dev/null +++ b/packages/builder/database/seeders/BuilderSeeder.php @@ -0,0 +1,433 @@ +removeObsoleteGroups(); + $this->migrateLegacyTabStructures(); + + $this->seedFahrzeugdatenGroup(); + $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', 'layout-showcase']) + ->each(fn (FieldGroup $group) => $group->delete()); + } + + protected function seedFahrzeugdatenGroup(): void + { + $group = $this->upsertGroup( + slug: 'fahrzeugdaten', + name: 'Fahrzeugdaten', + sort: 0, + entities: ['item'], + ); + + $this->resetGroupFields($group); + + app(FieldGroupPersistence::class)->sync($group, [ + 'name' => 'Fahrzeugdaten', + 'slug' => 'fahrzeugdaten', + 'active' => true, + 'sort' => 0, + 'target_entities' => ['item'], + 'fields' => $this->fahrzeugdatenFields(), + ]); + } + + /** + * Alle wählbaren Feldtypen am Beispiel eines Fahrzeuginserats. + * + * @return list> + */ + protected function fahrzeugdatenFields(): array + { + 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“.', + ], + ], + $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, 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, config: [ + 'default' => ['climate', 'cruise', 'led'], + ], options: [ + ['label' => 'Klimaautomatik', 'value' => 'climate'], + ['label' => 'Tempomat', 'value' => 'cruise'], + ['label' => 'Einparkhilfe', 'value' => 'parking'], + ['label' => 'LED-Scheinwerfer', 'value' => 'led'], + ]), + [ + 'name' => 'ausstattung', + 'label' => 'Zusatzausstattung', + 'type' => 'repeater', + 'sort' => 4, + 'config' => ['min' => 0, 'max' => 20], + 'children' => [ + $this->field('merkmal', 'Merkmal', 'text', 0), + $this->field('enthalten', 'Enthalten', 'toggle', 1, config: ['default' => true]), + ], + ], + [ + 'name' => 'wartungshistorie', + 'label' => 'Wartungshistorie', + 'type' => 'repeater', + 'sort' => 5, + 'children' => [ + $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' => 'inserat-inhalt', + 'label' => 'Inserat-Inhalt (Flexible Inhalte)', + 'type' => 'flexible_content', + 'sort' => 3, + 'config' => ['min' => 0, 'max' => 10], + 'layouts' => [ + [ + 'name' => 'hero', + 'label' => 'Hero-Bereich', + 'sort' => 0, + 'children' => [ + $this->field('titel', 'Titel', 'text', 0, required: true), + $this->field('text', 'Text', 'textarea', 1), + ], + ], + [ + 'name' => 'technik-block', + 'label' => 'Technik-Block', + 'sort' => 1, + 'children' => [ + $this->field('ueberschrift', 'Überschrift', 'text', 0), + $this->field('details', 'Details', 'rich_text', 1), + ], + ], + ], + ], + ]), + ]; + } + + /** + * @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; + } + + /** + * Demo-Werte für das erste Item — nur wenn items-Tabelle und ein Datensatz existieren. + */ + protected function seedDemoValuesForFirstItem(): void + { + $recordId = $this->resolveDemoItemId(); + + if ($recordId === null) { + return; + } + + FieldValue::query() + ->where('entity', 'item') + ->where('record_id', $recordId) + ->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], + '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'], + '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'], + ], + 'serienausstattung' => [ + 'type' => 'checkbox_list', + 'value' => ['climate', 'cruise', 'led'], + ], + 'standort' => [ + 'type' => 'group', + 'value' => [ + 'stadt' => 'Berlin', + '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 vorn', 'enthalten' => true], + ['merkmal' => 'Navigationssystem Discover Pro', 'enthalten' => true], + ['merkmal' => 'Schiebedach', 'enthalten' => false], + ['merkmal' => 'Sportsitze', 'enthalten' => true], + ], + ], + 'wartungshistorie' => [ + 'type' => 'repeater', + 'value' => [ + ['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'], + ], + ], + 'fahrzeugbeschreibung' => [ + 'type' => 'rich_text', + 'value' => '

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

' + .'

Scheckheftgepflegt, Nichtraucherfahrzeug, sofort verfügbar.

', + ], + 'inserat-url' => [ + 'type' => 'link', + 'value' => [ + 'url' => 'https://moox.org/items/golf-gti', + 'label' => 'Inserat online ansehen', + 'target' => '_blank', + ], + ], + 'fahrzeugvideo' => [ + 'type' => 'oembed', + 'value' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ], + 'inserat-inhalt' => [ + 'type' => 'flexible_content', + 'value' => [ + [ + 'type' => 'hero', + 'data' => [ + 'titel' => 'Golf GTI — sofort verfügbar', + 'text' => '245 PS, Automatik, unfallfrei. Jetzt Probefahrt vereinbaren.', + ], + ], + [ + 'type' => 'technik-block', + 'data' => [ + 'ueberschrift' => 'Technische Daten', + 'details' => '
  • 2,0 l TSI, 245 PS
  • 7-Gang DSG
  • Verbrauch komb.: 6,9 l/100 km
', + ], + ], + ], + ], + ]; + + foreach ($demos as $fieldName => $demo) { + $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 + */ + 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' => $locationRules, + 'placement' => 'default', + 'sort' => $sort, + 'active' => true, + ], + ); + } + + protected function resetGroupFields(FieldGroup $group): void + { + $group->fields()->each(fn (Field $field) => $field->delete()); + } + + 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/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 new file mode 100644 index 0000000000..a0c731f40b --- /dev/null +++ b/packages/builder/resources/lang/de/builder.php @@ -0,0 +1,355 @@ + '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.', + 'placement' => 'Platzierung', + '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…', + '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_record_status' => 'Record-Status', + '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.', + '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', + 'fields_count' => 'Felder', + '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' => [ + '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', + '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_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', + '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)', + '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)', + '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', + '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', + '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_helper' => 'Badge-Farbe, wenn die Badge-Anzeige aktiv ist.', + '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ß', + '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.', + '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.', + 'layout_item' => 'Layout', + 'layouts_count' => '{1} :count Layout|[2,*] :count Layouts', + ], + + 'field_types' => [ + 'text' => 'Text (kurz)', + 'textarea' => 'Text (mehrzeilig)', + 'number' => 'Zahl', + 'range' => 'Bereich', + 'email' => 'E-Mail', + 'url' => 'URL', + 'password' => 'Passwort', + 'select' => 'Auswahl (Dropdown)', + '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', + 'relation' => 'Verknüpfung', + 'image' => 'Bild', + 'gallery' => 'Galerie', + 'file' => 'Datei', + 'rich_text' => 'Rich Text', + 'message' => 'Hinweis', + 'oembed' => 'oEmbed', + 'tab' => 'Tab', + 'section' => 'Section', + 'group' => 'Gruppe', + 'repeater' => 'Repeater', + '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', + ], + + 'message' => [ + 'body' => 'Hinweistext', + 'body_helper' => 'Wird nur im Formular angezeigt — kein speicherbarer Wert pro Datensatz.', + ], + + 'oembed' => [ + 'helper' => 'Video- oder Embed-URL einfügen (YouTube, Vimeo, …).', + ], + + 'link' => [ + 'url' => 'URL', + 'label' => 'Bezeichnung', + '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', + '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_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.', + '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.', + 'default_value_now_active_helper' => 'Deaktiviert, solange die aktuelle Uhrzeit aktiv ist.', + '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', + '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', + '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', + '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', + 'display_format_hi' => '14:30', + 'display_format_his' => '14:30:00', + 'display_format_gi_a' => '2:30 PM', + ], + + 'repeater' => [ + 'collapse_all' => 'Alle einklappen', + 'expand_all' => 'Alle ausklappen', + ], + + '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.', + '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.', + '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.', + '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.', + '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.', + '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 new file mode 100644 index 0000000000..ed0f33b6af --- /dev/null +++ b/packages/builder/resources/lang/en/builder.php @@ -0,0 +1,355 @@ + '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.', + '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', + '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…', + '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_record_status' => 'Record status', + '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.', + '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', + '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.', + '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' => [ + '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', + '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_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', + '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)', + '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)', + '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', + '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', + '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_helper' => 'Badge color when badge display is enabled.', + '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', + '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.', + '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.', + 'layout_item' => 'Layout', + 'layouts_count' => '{1} :count layout|[2,*] :count layouts', + ], + + 'field_types' => [ + 'text' => 'Text (short)', + 'textarea' => 'Text (multiline)', + 'number' => 'Number', + 'range' => 'Range', + 'email' => 'Email', + 'url' => 'URL', + 'password' => 'Password', + 'select' => 'Select (dropdown)', + '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', + 'relation' => 'Relation', + 'image' => 'Image', + 'gallery' => 'Gallery', + 'file' => 'File', + 'rich_text' => 'Rich text', + 'message' => 'Message', + 'oembed' => 'oEmbed', + 'tab' => 'Tab', + 'section' => 'Section', + 'group' => 'Group', + 'repeater' => 'Repeater', + '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', + ], + + 'message' => [ + 'body' => 'Message', + 'body_helper' => 'Shown in the form only — no per-record stored value.', + ], + + 'oembed' => [ + 'helper' => 'Paste a video or embed URL (YouTube, Vimeo, etc.).', + ], + + 'link' => [ + 'url' => 'URL', + 'label' => 'Label', + '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', + '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_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.', + '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.', + 'default_value_now_active_helper' => 'Disabled while current time is active.', + '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', + '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', + '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', + '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', + 'display_format_hi' => '14:30', + 'display_format_his' => '14:30:00', + 'display_format_gi_a' => '2:30 PM', + ], + + 'repeater' => [ + 'collapse_all' => 'Collapse all', + 'expand_all' => 'Expand all', + ], + + '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.', + '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.', + '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.', + '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.', + '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.', + '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 new file mode 100644 index 0000000000..1579eb7437 --- /dev/null +++ b/packages/builder/resources/views/forms/components/builder-media-picker.blade.php @@ -0,0 +1,121 @@ + + @php + $mimeTypeLabels = \Moox\Media\Helpers\MediaIconHelper::getIconMapWithLabels(); + $statePath = $getStatePath(); + $modalId = 'builder-media-picker-' . md5($statePath); + @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/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/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..79e865fb9a --- /dev/null +++ b/packages/builder/resources/views/livewire/builder-media-picker-modal.blade.php @@ -0,0 +1,234 @@ + +
+ {{ $this->form }} +
+ +
+
+ +
+ + + + + + + + @if (($onlyMimePrefixes ?? []) === ['image/']) + + @else + @unless(in_array('image/', $excludedMimePrefixes ?? [], true)) + + @endunless + + + + @endif + + + + + + + + + + + + + + + + + @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 new file mode 100644 index 0000000000..860ddf95d6 --- /dev/null +++ b/packages/builder/src/BuilderServiceProvider.php @@ -0,0 +1,221 @@ +name('builder') + ->hasConfigFile() + ->hasViews() + ->hasTranslations() + ->hasMigrations([ + 'create_builder_field_groups_table', + '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', + ]); + } + + 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(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 + { + FieldGroup::observe(InvalidateDefinitionCacheObserver::class); + FieldGroup::observe(PurgeFieldValuesObserver::class); + 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); + + FilamentAsset::register([ + Css::make('moox-builder', __DIR__.'/../resources/css/builder.css'), + ], 'moox/builder'); + + CustomFieldsFilamentHooks::register(); + + $this->app['router']->pushMiddlewareToGroup('web', ResolveBuilderAdminLocale::class); + + $this->registerMediaMetadataSyncListeners(); + + $this->app->booted(function (): void { + app(EntityModelDeletionRegistrar::class)->register(); + $this->registerMediaPickerModal(); + }); + } + + /** + * @return list + */ + protected function defaultFieldTypes(): array + { + $types = [ + 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, + new RangeFieldType, + new ButtonGroupFieldType, + new LinkFieldType, + new RelationFieldType, + ]; + + if (MediaIntegration::isAvailable()) { + $types[] = new FieldTypes\Types\ImageFieldType; + $types[] = new FieldTypes\Types\GalleryFieldType; + $types[] = new FieldTypes\Types\FileFieldType; + } + + return array_merge($types, [ + new RichTextFieldType, + new MessageFieldType, + new OembedFieldType, + new TabFieldType, + new SectionFieldType, + new GroupFieldType, + new RepeaterFieldType, + new FlexibleContentFieldType, + new FlexibleLayoutFieldType, + ]); + } + + 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()) { + 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/Compiler/LocationMatcher.php b/packages/builder/src/Compiler/LocationMatcher.php new file mode 100644 index 0000000000..e7194990b0 --- /dev/null +++ b/packages/builder/src/Compiler/LocationMatcher.php @@ -0,0 +1,259 @@ +> $locationRules + */ + public function matches(array $locationRules, LocationContext $context): bool + { + if ($locationRules === []) { + return false; + } + + foreach ($locationRules as $andGroup) { + if ($this->matchesAndGroup($andGroup, $context)) { + return true; + } + } + + 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 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; + } + + /** + * @param array{param: string, operator: string, value: mixed} $rule + */ + protected function matchesRule(array $rule, LocationContext $context): bool + { + $param = (string) ($rule['param'] ?? ''); + $operator = (string) ($rule['operator'] ?? '=='); + $expected = $rule['value'] ?? null; + + if ($param === 'entity') { + return $this->compare((string) $context->entity, (string) $expected, $operator); + } + + if ($this->requiresRecord($param) && ! $context->hasRecord()) { + return true; + } + + $actual = $this->resolveActualValue($param, $context); + + if ($actual === null && $this->requiresRecord($param)) { + // 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) { + '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 in_array($param, ['record_type', 'record_status'], true) + || 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 === 'record_status') { + return $context->get('record_status'); + } + + 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) $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 $list + */ + 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 new file mode 100644 index 0000000000..aab8f96b47 --- /dev/null +++ b/packages/builder/src/Compiler/SchemaCompiler.php @@ -0,0 +1,597 @@ + $fieldGroups + * @param class-string|null $resourceClass + * @return list
+ */ + 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) { + $sortedFields = $group->fields->sortBy(fn (FieldDefinition $field): int => $field->sort)->values(); + $conditionalTriggers = $this->conditionalTriggerNames($sortedFields); + + $components = $this->compileRootFields( + $sortedFields, + $entity, + $storableFields, + $group->defaultColumnSpan(), + $conditionalTriggers, + ); + + if ($components === []) { + continue; + } + + $groupScalarFields = $this->storableFieldCollector->definitionsFromList($group->fields); + + $section = Section::make($group->name) + ->columns(FieldWidth::GRID_COLUMNS) + ->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); + }, + ); + } + + 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; + } + + return $sections; + } + + /** + * @param Collection $fields + * @return list + */ + 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, $conditionalTriggers)) + ->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(); + + $component = Fieldset::make($field->label) + ->schema($this->compileSubFields($field->children, $entity, $storableFields)) + ->statePath($field->name) + ->columns(FieldWidth::GRID_COLUMNS) + ->columnSpan($field->columnSpan()); + + 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 { + $hasStoredValue = false; + $storedValue = null; + + if ($record?->exists) { + $values = $this->customFieldsManager->loadCachedValues( + $entity, + $record, + $storableFields, + ); + + if (array_key_exists($field->name, $values)) { + $hasStoredValue = true; + $storedValue = (new GroupFieldType)->normalizeForForm($values[$field->name]); + } + } + + $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]; + } + + $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 + * @return list + */ + 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(); + $index = 0; + + while ($index < $sorted->count()) { + $field = $sorted[$index]; + + if ($field->type === 'tab') { + $tabPanels = []; + + while ($index < $sorted->count() && $sorted[$index]->type === 'tab') { + $tabField = $sorted[$index]; + + if ($tabField->children->isNotEmpty()) { + $tabPanels[] = [ + 'label' => $tabField->label, + 'fields' => $tabField->children, + ]; + } + + $index++; + } + + if ($tabPanels !== []) { + $tabScalarFields = collect($tabPanels) + ->flatMap(fn (array $panel): Collection => $this->storableFieldCollector->definitionsFromList($panel['fields'])) + ->values(); + + $tabs = Tabs::make('custom_fields_tabs') + ->columnSpanFull() + ->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, conditionalTriggers: $conditionalTriggers)), + )->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; + } + + $components[] = $this->compileField($field, $entity, $storableFields, defaultSpan: $defaultSpan, conditionalTriggers: $conditionalTriggers); + $index++; + } + + return $components; + } + + /** + * @param Collection $fieldGroups + * @return array> + */ + public function rules(Collection $fieldGroups): array + { + $rules = []; + + foreach ($fieldGroups as $group) { + foreach ($group->fields as $field) { + $rules = array_merge($rules, $this->rulesForFieldTree($field)); + } + } + + return $rules; + } + + /** + * @return array> + */ + protected function rulesForFieldTree(FieldDefinition $field, string $prefix = ''): array + { + $rules = []; + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if (in_array($field->type, ['tab', 'section'], true)) { + $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; + } + + $childPrefix = $prefix === '' ? $field->name : "{$prefix}.{$field->name}"; + + if ($field->type === 'repeater') { + $childPrefix .= '.*'; + } + + foreach ($field->children as $child) { + $rules = array_merge($rules, $this->rulesForFieldTree($child, $childPrefix)); + } + + return $rules; + } + + $fieldRules = $this->rulesForField($field); + + if ($fieldRules !== []) { + $key = $prefix === '' ? $field->name : "{$prefix}.{$field->name}"; + $rules[$key] = $fieldRules; + } + + return $rules; + } + + /** + * @return list + */ + protected function rulesForField(FieldDefinition $field): array + { + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if (! $fieldType->storesValue()) { + return []; + } + + $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 (in_array($field->type, ['url', 'oembed'], true)) { + $rules[] = 'url'; + } + + if ($fieldType->hasOptions()) { + $rules = array_merge($rules, OptionValueRules::forField($field)); + } + + return array_values(array_unique($rules, SORT_REGULAR)); + } + + /** + * @param Collection $storableFields + */ + protected function compileField(FieldDefinition $field, ?string $entity = null, ?Collection $storableFields = null, bool $insideTabs = false, ?int $defaultSpan = null, array $conditionalTriggers = []): Component + { + $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($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); + } + + if (! $fieldType->storesValue() || $entity === null) { + return $component; + } + + $defaultValue = app(DefaultValue::class); + + return $component + ->afterStateHydrated(function (Component $component, mixed $state, ?Model $record) use ($field, $entity, $fieldType, $storableFields, $defaultValue): void { + $hasStoredValue = false; + $storedValue = null; + + if ($record?->exists) { + $values = $this->customFieldsManager->loadCachedValues( + $entity, + $record, + $storableFields, + ); + + if (array_key_exists($field->name, $values)) { + $hasStoredValue = true; + $storedValue = $values[$field->name]; + + if (method_exists($fieldType, 'normalizeForForm')) { + $storedValue = $fieldType->normalizeForForm($storedValue); + } + } + } + + if ($fieldType->hasSubFields()) { + $this->hydrateCompoundFieldState( + $component, + $field, + $defaultValue, + $hasStoredValue, + $storedValue, + $state, + ); + + return; + } + + if ($hasStoredValue) { + $component->state(match ($field->type) { + 'color' => $defaultValue->normalizeColorValue($storedValue) ?? $storedValue, + 'time' => $defaultValue->normalizeTimeValue($storedValue) ?? $storedValue, + default => $storedValue, + }); + + return; + } + + if ($defaultValue->hasConfiguredDefault($field)) { + $component->state($defaultValue->resolveForField($field)); + } + }) + ->afterStateUpdated(function (Component $component) use ($field, $fieldType, $defaultValue): void { + if (! $fieldType->hasSubFields()) { + return; + } + + $state = $component->getState(); + + if (! is_array($state)) { + return; + } + + if ($state === []) { + return; + } + + $this->applyCompoundState( + $component, + $field, + $defaultValue, + $defaultValue->normalizeCompoundState($state), + ); + }); + } + + /** + * @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 + */ + 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 instanceof Repeater) { + $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; + } + + if (! $defaultValue->hasConfiguredDefault($field)) { + continue; + } + + $resolved = $defaultValue->resolveForField($field); + + if ($field->type === 'time') { + $resolved = $defaultValue->normalizeTimeValue($resolved) ?? $resolved; + } + + $fieldComponent = $root->getComponent($field->name); + + if ($fieldComponent === null) { + continue; + } + + if ($field->type !== 'toggle' && ! $defaultValue->shouldApplyDefault($fieldComponent->getState(), $field->type)) { + continue; + } + + $fieldComponent->state($resolved); + + if (method_exists($fieldComponent, 'partiallyRender')) { + $fieldComponent->partiallyRender(); + } + } + } + + /** + * @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/Compiler/TableColumnCompiler.php b/packages/builder/src/Compiler/TableColumnCompiler.php new file mode 100644 index 0000000000..688a08527a --- /dev/null +++ b/packages/builder/src/Compiler/TableColumnCompiler.php @@ -0,0 +1,379 @@ + $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) || $this->isRelationColumn($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) && $field->type !== 'rich_text'; + } + + 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, + string $modelClass, + string $locale, + string $valuesTable, + ): Column { + if ($this->isImageColumn($field)) { + return $this->compileImageColumn($field); + } + + if ($this->isRelationColumn($field)) { + return $this->compileRelationColumn($field, $entity, $locale, $valuesTable); + } + + $valueColumn = TypedValueColumns::columnForType($field->type); + $name = $field->name; + + 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() + ->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 ($this->supportsTextColumnPresentation($field) && $field->columnBadge()) { + $column->badge(); + } + + if ($this->supportsTextColumnPresentation($field) && ($color = $field->columnColor()) !== null) { + $column->color($color); + } + + if ($this->supportsTextColumnPresentation($field) && ($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() && $field->type !== 'toggle') { + $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 supportsTextColumnPresentation(FieldDefinition $field): bool + { + return $field->type !== 'rich_text'; + } + + 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; + } + + 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 { + $presented = $this->resolvePresentedValue($field, $record); + + 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 + { + 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 + */ + 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/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 new file mode 100644 index 0000000000..e852afbb90 --- /dev/null +++ b/packages/builder/src/Concerns/HasCustomFields.php @@ -0,0 +1,199 @@ + + */ + public static function customFieldComponents(string $placement = FieldGroupPlacement::MAIN): array + { + $groups = static::visibleCustomFieldGroups() + ->filter(fn (FieldGroupDefinition $group): bool => $group->hasPlacement($placement)) + ->values(); + + if ($groups->isEmpty()) { + return []; + } + + return app(SchemaCompiler::class)->compile($groups, static::class); + } + + /** + * @return list + */ + public static function customFieldColumns(): array + { + $groups = static::visibleCustomFieldGroups(); + + if ($groups->isEmpty()) { + return []; + } + + return app(TableColumnCompiler::class)->compile($groups, static::class); + } + + /** + * @return list + */ + 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). + * + * @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 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 + { + $entity = static::customFieldsEntity(); + + if ($entity !== null) { + return $entity; + } + + $model = static::getModel(); + + return Str::kebab(class_basename($model)); + } + + /** + * 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 null; + } + + /** + * @return array> + */ + public static function customFieldRules(): array + { + return app(SchemaCompiler::class)->rules(static::visibleCustomFieldGroups()); + } + + /** + * 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/Concerns/InteractsWithCustomFields.php b/packages/builder/src/Concerns/InteractsWithCustomFields.php new file mode 100644 index 0000000000..d4f78318bd --- /dev/null +++ b/packages/builder/src/Concerns/InteractsWithCustomFields.php @@ -0,0 +1,424 @@ +|null + */ + protected ?array $customFieldsCache = null; + + protected ?string $customFieldsCacheLocale = 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, ?string $locale = null): array + { + $locale = app(BuilderLocaleResolver::class)->valuesLocaleForEntity( + static::resolveCustomFieldsEntity(), + $locale, + static::class, + ); + + if (! $fresh + && $this->customFieldsCache !== null + && $this->customFieldsCacheLocale === $locale) { + return $this->customFieldsCache; + } + + /** @var CustomFieldsManager $manager */ + $manager = app(CustomFieldsManager::class); + + $entity = static::resolveCustomFieldsEntity(); + $fields = $manager->fieldsForEntity($entity); + + if ($fields->isEmpty()) { + $this->customFieldsCache = []; + $this->customFieldsCacheLocale = $locale; + + return []; + } + + $values = $fresh + ? $manager->loadValuesWithDefaults($entity, $this, $fields, $locale) + : $manager->loadCachedValuesWithDefaults($entity, $this, $fields, $locale); + + $this->customFieldsCache = $values; + $this->customFieldsCacheLocale = $locale; + + 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(), + app(BuilderLocaleResolver::class)->valuesLocaleForEntity($entity, null, static::class), + ); + + $this->flushCustomFieldsCache(); + + 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()) + ->forLocale(app(BuilderLocaleResolver::class)->valuesLocaleForEntity( + static::resolveCustomFieldsEntity(), + null, + static::class, + )) + ->where('field_name', $name) + ->delete(); + + $this->flushCustomFieldsCache(); + + 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, ?string $locale = null): void + { + $this->customFieldsCache = $values; + $this->customFieldsCacheLocale = app(BuilderLocaleResolver::class)->valuesLocaleForEntity( + static::resolveCustomFieldsEntity(), + $locale, + static::class, + ); + } + + public static function flushCustomFieldDefinitionCache(): void + { + static::$customFieldNamesCache = []; + static::$passwordCustomFieldNamesCache = []; + } + + public function flushCustomFieldsCache(): void + { + $this->customFieldsCache = null; + $this->customFieldsCacheLocale = 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); + } + + /** + * @return array + */ + public static function customFieldsLocationParams(?Model $record): array + { + return []; + } + + 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/Data/FieldDefinition.php b/packages/builder/src/Data/FieldDefinition.php new file mode 100644 index 0000000000..018e9e9bce --- /dev/null +++ b/packages/builder/src/Data/FieldDefinition.php @@ -0,0 +1,320 @@ + $config + * @param array $validation + * @param array $settings + * @param list}> $options + * @param Collection $children + * @param array}> $translations + */ + public function __construct( + public string $name, + public string $label, + public string $type, + 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 showInFilter(): bool + { + return (bool) ($this->settings['show_in_filter'] ?? 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'; + } + + /** + * 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); + } + + /** + * @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. + */ + public function width(): string + { + return FieldWidth::normalize(is_string($this->settings['width'] ?? null) ? $this->settings['width'] : null); + } + + /** + * 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 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()); + } + + /** + * @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') + ? $field->options->map(fn ($option): array => [ + 'label' => $option->label, + 'value' => $option->value, + 'translations' => self::mapOptionTranslations($option), + ])->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, + type: $field->type, + sort: (int) $field->sort, + config: $field->config ?? [], + validation: $field->validation ?? [], + settings: $field->settings ?? [], + 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 ?? [], + ]; + } + + $defaultLocale = app(BuilderLocaleResolver::class)->defaultLocale(); + + $translations[$defaultLocale] = [ + 'label' => $field->label, + 'config' => app(DefinitionTranslator::class) + ->extractTranslatableConfig($field->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, + ]; + } + + $defaultLocale = app(BuilderLocaleResolver::class)->defaultLocale(); + + if (filled($option->label)) { + $translations[$defaultLocale] = [ + 'label' => $option->label, + ]; + } + + return $translations; + } + + /** + * @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>} + */ + public function toArray(): array + { + return [ + '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' => $this->children + ->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, settings?: array, options?: list}>, children?: list>, translations?: array}>} $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'], + type: $data['type'], + 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/Data/FieldGroupDefinition.php b/packages/builder/src/Data/FieldGroupDefinition.php new file mode 100644 index 0000000000..4446aa9ca8 --- /dev/null +++ b/packages/builder/src/Data/FieldGroupDefinition.php @@ -0,0 +1,172 @@ + $fields + * @param list> $locationRules + * @param array $settings + * @param array $translations + */ + public function __construct( + public string $name, + public string $slug, + public string $placement, + public Collection $fields, + public array $locationRules = [], + public array $settings = [], + 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); + } + + /** + * 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); + } + + /** + * 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 + */ + 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') + ? $group->fields + ->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(); + + return new self( + name: $group->name, + slug: $group->slug, + placement: $group->placement, + 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, + ]; + } + + $defaultLocale = app(BuilderLocaleResolver::class)->defaultLocale(); + + if (filled($group->name)) { + $translations[$defaultLocale] = [ + 'name' => $group->name, + ]; + } + + return $translations; + } + + /** + * @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, + 'translations' => $this->translations, + ]; + } + + /** + * @param array{name: string, slug: string, placement: string, fields?: list>, locationRules?: list>, settings?: array, translations?: 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'] ?? [], + translations: $data['translations'] ?? [], + ); + } +} diff --git a/packages/builder/src/Data/LocationContext.php b/packages/builder/src/Data/LocationContext.php new file mode 100644 index 0000000000..68f621ee3c --- /dev/null +++ b/packages/builder/src/Data/LocationContext.php @@ -0,0 +1,169 @@ + $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'); + } + + $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() + : 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; + } + + 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 + */ + 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/Database/Eloquent/CustomFieldsBuilder.php b/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php new file mode 100644 index 0000000000..f9d6815b8c --- /dev/null +++ b/packages/builder/src/Database/Eloquent/CustomFieldsBuilder.php @@ -0,0 +1,176 @@ + + */ +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|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|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(); + $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') + ->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); + } + + /** + * @param array|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(); + $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') + ->from($valuesTable) + ->whereColumn("{$valuesTable}.record_id", $recordKey) + ->where("{$valuesTable}.entity", $entity) + ->where("{$valuesTable}.field_name", $field) + ->where("{$valuesTable}.locale", $locale); + + if ($not) { + $subQuery->whereNotIn("{$valuesTable}.{$valueColumn}", $values); + } else { + $subQuery->whereIn("{$valuesTable}.{$valueColumn}", $values); + } + }, $boolean); + } +} diff --git a/packages/builder/src/Exceptions/UnknownFieldTypeException.php b/packages/builder/src/Exceptions/UnknownFieldTypeException.php new file mode 100644 index 0000000000..f67caf4a1e --- /dev/null +++ b/packages/builder/src/Exceptions/UnknownFieldTypeException.php @@ -0,0 +1,15 @@ + + */ + abstract public function builderFields(): array; + + /** + * @return list + */ + public function builderFieldsFor(string $fieldType): array + { + return $this->builderFields(); + } + + /** + * @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..ef3e250e6a --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/DefaultValue.php @@ -0,0 +1,969 @@ +label(__('builder::builder.capabilities.default_value')), + ]; + } + + 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' => [ + $this->colorDefaultField(), + ], + 'date' => $this->temporalDefaultFields( + DatePicker::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->native(false), + 'date', + ), + 'datetime' => $this->temporalDefaultFields( + DateTimePicker::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->native(false), + 'datetime', + ), + 'time' => $this->temporalDefaultFields( + TimePicker::make('config.default') + ->label(__('builder::builder.capabilities.default_value')) + ->native(true), + 'time', + ), + '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) + ->live(), + ], + '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' => [ + $this->numericDefaultField(), + ], + 'range' => [ + $this->rangeDefaultField(), + ], + 'text', 'password' => [ + $this->textDefaultField(), + ], + 'textarea', 'rich_text' => [ + $this->textareaDefaultField(), + ], + 'email' => [ + $this->emailDefaultField(), + ], + 'url', 'oembed' => [ + $this->urlDefaultField(), + ], + default => [ + TextInput::make('config.default') + ->label(__('builder::builder.capabilities.default_value')), + ], + }; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + $default = $this->resolveForField($field); + + if ($default === null) { + return $component; + } + + $component->default($default); + + return $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; + } + + $default = $field->config['default']; + + if ($default === null || $default === '') { + return null; + } + + if ($field->type === 'toggle') { + return $this->resolveBooleanDefault($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); + } + + if (in_array($field->type, ['select', 'radio', 'button_group'], true)) { + return $this->resolveOptionDefault($field, (string) $default); + } + + if ($field->type === 'color') { + return $this->normalizeColorDefault($default); + } + + if (in_array($field->type, ['url', 'oembed'], true)) { + return $this->resolveUrlDefault($default); + } + + if (in_array($field->type, ['text', 'textarea', 'rich_text', 'email', 'password'], true) + && ! $this->defaultWithinMaxLength($default, $field)) { + return null; + } + + 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 ($default === null) { + return null; + } + + if (! is_string($default)) { + if (is_scalar($default)) { + $default = (string) $default; + } else { + return null; + } + } + + $color = trim($default); + + if ($color === '' || $color === '#') { + return null; + } + + if (! str_starts_with($color, '#')) { + $color = '#'.$color; + } + + return $color; + } + + 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') + ->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 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') { + 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; + } + + 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 === []; + } + + if ($type === 'rich_text') { + return RichTextValue::isEmpty($state); + } + + 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)) { + 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 + */ + 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 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)) { + return $default; + } + + return filter_var($default, FILTER_VALIDATE_BOOLEAN); + } + + /** + * @return list + */ + protected function temporalDefaultFields(Component $picker, string $type): array + { + if ($picker instanceof DateTimePicker && in_array($type, ['date', 'datetime', 'time'], true)) { + $picker = $this->configureTemporalAdminPicker($picker, $type); + } + + 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) + ->default(false) + ->live(), + $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)) + ->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')) + ? (string) $get('config.displayFormat') + : DisplayFormat::defaultFor($type); + } + + 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' + : 'Y-m-d H:i'; + } + + return 'Y-m-d'; + } + + 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(), + 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 ($type === 'time') { + return $this->normalizeTimeValue($default); + } + + if ($default instanceof CarbonInterface) { + return $default; + } + + if (! is_string($default)) { + return null; + } + + try { + return Carbon::parse($default); + } catch (\Throwable) { + return null; + } + } + + 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 !== '')); + } + + 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') + ->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') + ->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); + } + + 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 + */ + 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; + } + + 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/DisplayFormat.php b/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php new file mode 100644 index 0000000000..4ca93df697 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/DisplayFormat.php @@ -0,0 +1,147 @@ +builderFieldsFor('date'); + } + + public function builderFieldsFor(string $fieldType): array + { + $defaultFormat = self::defaultFor($fieldType); + + return [ + Select::make('config.displayFormat') + ->label(__('builder::builder.capabilities.display_format')) + ->helperText(match ($fieldType) { + 'time' => __('builder::builder.capabilities.display_format_time_helper'), + default => __('builder::builder.capabilities.display_format_helper'), + }) + ->options($this->formatOptionsFor($fieldType)) + ->default($defaultFormat) + ->afterStateHydrated(function (Select $component, mixed $state) use ($defaultFormat): void { + if (blank($state)) { + $component->state($defaultFormat); + } + }) + ->live() + ->native(false), + ]; + } + + 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') { + $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'); + } + } + + return $component; + } + + 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)) { + return (string) $field->config['displayFormat']; + } + + return self::defaultFor($field->type); + } + + /** + * @return array + */ + 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 + */ + 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/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/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..14e196cfae --- /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 || method_exists($component, 'maxLength')) { + $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..b04df8aeed --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/MaxValue.php @@ -0,0 +1,80 @@ +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'])) { + return []; + } + + return ['max:'.$field->config['max']]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if (isset($field->config['max']) && ($component instanceof TextInput || $component instanceof Slider)) { + $component->maxValue($field->config['max']); + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/Capabilities/MessageBody.php b/packages/builder/src/FieldTypes/Capabilities/MessageBody.php new file mode 100644 index 0000000000..ad6e00d127 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/MessageBody.php @@ -0,0 +1,28 @@ +label(__('builder::builder.message.body')) + ->helperText(__('builder::builder.message.body_helper')) + ->rows(4) + ->required(), + ]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + 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..79faf7c9e3 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/MinValue.php @@ -0,0 +1,80 @@ +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'])) { + return []; + } + + return ['min:'.$field->config['min']]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if (isset($field->config['min']) && ($component instanceof TextInput || $component instanceof Slider)) { + $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..b426a20681 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/PrefixSuffix.php @@ -0,0 +1,41 @@ +label(__('builder::builder.capabilities.prefix')) + ->helperText(__('builder::builder.capabilities.prefix_helper')), + TextInput::make('config.suffix') + ->label(__('builder::builder.capabilities.suffix')) + ->helperText(__('builder::builder.capabilities.suffix_helper')), + ]; + } + + 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/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/Capabilities/RepeaterItems.php b/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php new file mode 100644 index 0000000000..5d691c5aa3 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/RepeaterItems.php @@ -0,0 +1,78 @@ +label(__('builder::builder.capabilities.min_items')) + ->helperText(__('builder::builder.capabilities.min_items_helper')) + ->numeric() + ->minValue(1), + TextInput::make('config.max_items') + ->label(__('builder::builder.capabilities.max_items')) + ->helperText(__('builder::builder.capabilities.max_items_helper')) + ->numeric() + ->minValue(1), + ]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if (! $component instanceof Repeater && ! $component instanceof Builder) { + return $component; + } + + $min = $this->resolveMinItems($field); + + if ($min !== null) { + $component->minItems($min); + } elseif (($field->validation['required'] ?? false) === true) { + $component->minItems(1); + } + + $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/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..9f6184f383 --- /dev/null +++ b/packages/builder/src/FieldTypes/Capabilities/Step.php @@ -0,0 +1,31 @@ +label(__('builder::builder.capabilities.step')) + ->numeric(), + ]; + } + + public function apply(Component $component, FieldDefinition $field): Component + { + if (isset($field->config['step']) && ($component instanceof TextInput || $component instanceof Slider)) { + $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..fb5559f0b9 --- /dev/null +++ b/packages/builder/src/FieldTypes/Concerns/BuildsOptionComponents.php @@ -0,0 +1,47 @@ + + */ + 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 instanceof ToggleButtons) { + $component->options($options); + + if ($options !== []) { + $component->in(array_map( + fn (mixed $value): string => (string) $value, + array_column($field->options, 'value'), + )); + } + } + + return $component; + } +} diff --git a/packages/builder/src/FieldTypes/FieldType.php b/packages/builder/src/FieldTypes/FieldType.php new file mode 100644 index 0000000000..3768ebc4b5 --- /dev/null +++ b/packages/builder/src/FieldTypes/FieldType.php @@ -0,0 +1,198 @@ +> + */ + public function capabilities(): array + { + return []; + } + + 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; + } + + public function storesValue(): bool + { + return true; + } + + public function hasSubFields(): bool + { + return false; + } + + public function isInternal(): 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())); + } + + /** + * 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', + 'relation' => 'heroicon-o-arrows-right-left', + '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); + + $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; + } + + 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 + */ + protected function additionalRules(FieldDefinition $field): array + { + return $field->validation['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..3c01904ac9 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/ButtonGroupFieldType.php @@ -0,0 +1,59 @@ +name) + ->label($field->label) + ->inline(); + + $component = $this->applyOptions($component, $field); + $component = $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/CheckboxListFieldType.php b/packages/builder/src/FieldTypes/Types/CheckboxListFieldType.php new file mode 100644 index 0000000000..7264ad2ce6 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/CheckboxListFieldType.php @@ -0,0 +1,55 @@ +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..9e723fc20c --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/ColorFieldType.php @@ -0,0 +1,57 @@ +name) + ->label($field->label) + ->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 $component->default(static function () use ($field, $defaultValue): ?string { + return $defaultValue->resolveForField($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..1d88683026 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/DateFieldType.php @@ -0,0 +1,44 @@ +name) + ->label($field->label) + ->native(false); + + 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 new file mode 100644 index 0000000000..73cfddcf64 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/DatetimeFieldType.php @@ -0,0 +1,44 @@ +name) + ->label($field->label) + ->native(false); + + 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/EmailFieldType.php b/packages/builder/src/FieldTypes/Types/EmailFieldType.php new file mode 100644 index 0000000000..cac0f6bb3a --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/EmailFieldType.php @@ -0,0 +1,46 @@ +name) + ->label($field->label) + ->email(); + + return $this->applyCapabilitiesAndValidation($component, $field); + } +} 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/FlexibleContentFieldType.php b/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php new file mode 100644 index 0000000000..3d1206fff7 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/FlexibleContentFieldType.php @@ -0,0 +1,175 @@ +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() + ->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), + $field, + ); + } + + /** + * @param array> $layoutDefaults + */ + protected function configureAddAction(Action $action, FieldDefinition $field): Action + { + 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, FieldDefinition $field): Action + { + return $action->action(function (array $arguments, Builder $component, array $data = []) use ($field): void { + $this->insertBuilderBlock( + $component, + $field, + (string) $arguments['block'], + $data, + afterItem: $arguments['afterItem'] ?? null, + ); + }); + } + + protected function insertBuilderBlock( + Builder $component, + FieldDefinition $field, + string $block, + array $data, + ?string $afterItem, + ): void { + $layout = $field->layouts()->firstWhere('name', $block); + + if ($layout !== null) { + $data = app(DefaultValue::class)->mergeIntoData($layout->children, $data); + } + + $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(filled($data) ? $data : null); + $component->hydrateItems(); + + $component->collapsed(false, shouldMakeComponentCollapsible: false); + $component->callAfterStateUpdated(); + } + + public function castValue(mixed $raw, ?FieldDefinition $field = null): 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 @@ +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/GroupFieldType.php b/packages/builder/src/FieldTypes/Types/GroupFieldType.php new file mode 100644 index 0000000000..67e0466b10 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/GroupFieldType.php @@ -0,0 +1,54 @@ +buildGroupComponent($field); + } + + public function castValue(mixed $raw, ?FieldDefinition $field = null): 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) && isset($stored[0]) && is_array($stored[0])) { + return $stored[0]; + } + + return $stored; + } +} diff --git a/packages/builder/src/FieldTypes/Types/ImageFieldType.php b/packages/builder/src/FieldTypes/Types/ImageFieldType.php new file mode 100644 index 0000000000..aeae039a30 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/ImageFieldType.php @@ -0,0 +1,62 @@ +name) + ->label($field->label) + ->acceptedFileTypes(['image/*']) + ->onlyMimePrefixes(['image/']) + ->maxFiles(1) + ->multiple(false); + + return Capability::applyAll($this->capabilities(), $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..d43dd5aa57 --- /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 + { + $urlRules = ($field->validation['required'] ?? false) === true + ? ['required', 'url'] + : ['nullable', 'url']; + + $url = TextInput::make('url') + ->label(__('builder::builder.link.url')) + ->rules($urlRules); + + $url = Capability::applyAll($this->capabilities(), $url, $field); + + $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/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/MultiselectFieldType.php b/packages/builder/src/FieldTypes/Types/MultiselectFieldType.php new file mode 100644 index 0000000000..4d8c8643e6 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/MultiselectFieldType.php @@ -0,0 +1,56 @@ +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..26e98a7549 --- /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/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/FieldTypes/Types/PasswordFieldType.php b/packages/builder/src/FieldTypes/Types/PasswordFieldType.php new file mode 100644 index 0000000000..de53176b78 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/PasswordFieldType.php @@ -0,0 +1,92 @@ +name) + ->label($field->label) + ->password() + ->revealable(); + + $component = $this->applyCapabilitiesAndValidation($component, $field); + + if (! filled($field->config['helperText'] ?? null)) { + $component->helperText(__('builder::builder.password.helper')); + } + + return $component; + } + + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed + { + if ($raw === null || $raw === '') { + return null; + } + + 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 ['has_value' => true]; + } +} diff --git a/packages/builder/src/FieldTypes/Types/RadioFieldType.php b/packages/builder/src/FieldTypes/Types/RadioFieldType.php new file mode 100644 index 0000000000..24ac589396 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/RadioFieldType.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/RangeFieldType.php b/packages/builder/src/FieldTypes/Types/RangeFieldType.php new file mode 100644 index 0000000000..ff56eb5bf9 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/RangeFieldType.php @@ -0,0 +1,97 @@ +name) + ->label($field->label) + ->tooltips(true) + ->required(false) + ->decimalPlaces($this->decimalPlacesForStep($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 + { + $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/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/FieldTypes/Types/RepeaterFieldType.php b/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php new file mode 100644 index 0000000000..93259d15ae --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/RepeaterFieldType.php @@ -0,0 +1,110 @@ +name) + ->label($field->label) + ->schema($compiler->compileSubFields($field->children, null)) + ->columns(FieldWidth::GRID_COLUMNS) + ->collapsible() + ->defaultItems(0) + ->reorderable() + ->addAction(fn (Action $action): Action => $action->action( + function (Repeater $component) use ($defaultValue, $field): void { + $data = $defaultValue->mergeIntoData($field->children, []); + + $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->hydrateItems(); + $component->collapsed(false, shouldMakeComponentCollapsible: false); + $component->callAfterStateUpdated(); + }, + )) + ->addBetweenAction(fn (Action $action): Action => $action->action( + function (array $arguments, Repeater $component) use ($defaultValue, $field): void { + $data = $defaultValue->mergeIntoData($field->children, []); + $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->hydrateItems(); + $component->collapsed(false, shouldMakeComponentCollapsible: false); + $component->callAfterStateUpdated(); + }, + )); + + return $this->applyNestedValueValidation( + $this->applyCapabilitiesAndValidation($component, $field), + $field, + ); + } + + public function castValue(mixed $raw, ?FieldDefinition $field = null): mixed + { + if (! is_array($raw)) { + return []; + } + + return array_values($raw); + } +} diff --git a/packages/builder/src/FieldTypes/Types/RichTextFieldType.php b/packages/builder/src/FieldTypes/Types/RichTextFieldType.php new file mode 100644 index 0000000000..c977990ac8 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/RichTextFieldType.php @@ -0,0 +1,75 @@ +name) + ->label($field->label); + + 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/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 @@ +name) + ->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 $component; + } +} diff --git a/packages/builder/src/FieldTypes/Types/TabFieldType.php b/packages/builder/src/FieldTypes/Types/TabFieldType.php new file mode 100644 index 0000000000..9f8ab47bdd --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/TabFieldType.php @@ -0,0 +1,32 @@ +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..6f8538cc87 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/TimeFieldType.php @@ -0,0 +1,87 @@ +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) + ->native(true); + + $component = $this->applyCapabilitiesAndValidation($component, $field); + + return $component->default(static function () use ($field, $defaultValue): ?CarbonInterface { + 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/Types/ToggleFieldType.php b/packages/builder/src/FieldTypes/Types/ToggleFieldType.php new file mode 100644 index 0000000000..bc23114c41 --- /dev/null +++ b/packages/builder/src/FieldTypes/Types/ToggleFieldType.php @@ -0,0 +1,51 @@ +name) + ->label($field->label); + + $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/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/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 @@ +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/Filament/Resources/Pages/Concerns/InteractsWithBuilderLocale.php b/packages/builder/src/Filament/Resources/Pages/Concerns/InteractsWithBuilderLocale.php new file mode 100644 index 0000000000..ed2a445e74 --- /dev/null +++ b/packages/builder/src/Filament/Resources/Pages/Concerns/InteractsWithBuilderLocale.php @@ -0,0 +1,83 @@ +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 + { + $catalog = app(BuilderAdminLocalizationCatalog::class); + + if ($this->lang === '' || ! $catalog->isAvailable()) { + return; + } + + if ($catalog->isAllowedAdminLocale($this->lang)) { + 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'); + } + + 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/Forms/Components/BuilderMediaPicker.php b/packages/builder/src/Forms/Components/BuilderMediaPicker.php new file mode 100644 index 0000000000..14e9098096 --- /dev/null +++ b/packages/builder/src/Forms/Components/BuilderMediaPicker.php @@ -0,0 +1,90 @@ +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); + } + + /** + * @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 + */ + 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/Http/Livewire/BuilderMediaPickerModal.php b/packages/builder/src/Http/Livewire/BuilderMediaPickerModal.php new file mode 100644 index 0000000000..988d29ab63 --- /dev/null +++ b/packages/builder/src/Http/Livewire/BuilderMediaPickerModal.php @@ -0,0 +1,182 @@ +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 */ + $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, + '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/Http/Middleware/ResolveBuilderAdminLocale.php b/packages/builder/src/Http/Middleware/ResolveBuilderAdminLocale.php new file mode 100644 index 0000000000..ba999cdaef --- /dev/null +++ b/packages/builder/src/Http/Middleware/ResolveBuilderAdminLocale.php @@ -0,0 +1,164 @@ +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 + { + return $this->localizationCatalog->isAllowedAdminLocale($locale); + } + + protected function shouldResolveLocale(Request $request): bool + { + if ($request->query('lang') !== null || $request->input('lang') !== null) { + return $this->targetsBuilderLocaleContext($request); + } + + 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 $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/Http/Resources/Concerns/MergesCustomFields.php b/packages/builder/src/Http/Resources/Concerns/MergesCustomFields.php new file mode 100644 index 0000000000..0c0d9f3047 --- /dev/null +++ b/packages/builder/src/Http/Resources/Concerns/MergesCustomFields.php @@ -0,0 +1,37 @@ + $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->visibleFieldsForEntity($entity, FieldVisibility::API), + $model->customFields(), + ); + + return array_merge($payload, $presented); + } +} 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/Concerns/HasBuilderTranslatableAttributes.php b/packages/builder/src/Models/Concerns/HasBuilderTranslatableAttributes.php new file mode 100644 index 0000000000..f8f46c8a18 --- /dev/null +++ b/packages/builder/src/Models/Concerns/HasBuilderTranslatableAttributes.php @@ -0,0 +1,110 @@ +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 + { + return app(BuilderLocaleResolver::class)->defaultLocale(); + } + + 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 new file mode 100644 index 0000000000..fcdee40f05 --- /dev/null +++ b/packages/builder/src/Models/Field.php @@ -0,0 +1,89 @@ + */ + public array $translatedAttributes = ['label']; + + protected $fillable = [ + 'ulid', + 'field_group_id', + 'parent_field_id', + 'name', + 'label', + 'type', + 'config', + 'validation', + 'conditional_logic', + 'settings', + 'sort', + ]; + + protected $casts = [ + 'config' => '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(); + } + }); + + static::deleting(function (Field $field): void { + $field->children()->each(fn (Field $child) => $child->delete()); + }); + } + + /** + * @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 children(): HasMany + { + return $this->hasMany(self::class, 'parent_field_id')->orderBy('sort'); + } + + /** + * @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..b4086f9148 --- /dev/null +++ b/packages/builder/src/Models/FieldGroup.php @@ -0,0 +1,72 @@ + */ + public array $translatedAttributes = ['name']; + + protected $attributes = [ + 'placement' => '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/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', + 'label', + 'value', + 'sort', + ]; + + protected $casts = [ + 'sort' => '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/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 new file mode 100644 index 0000000000..604c2de9a0 --- /dev/null +++ b/packages/builder/src/Models/FieldValue.php @@ -0,0 +1,66 @@ + 'integer', + 'value_decimal' => 'decimal:6', + 'value_date' => 'date', + 'value_datetime' => 'datetime', + 'value_boolean' => 'boolean', + '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 + */ + public function scopeForRecord(Builder $query, string $entity, int|string $recordId): Builder + { + return $query + ->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/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/Observers/PurgeFieldValuesObserver.php b/packages/builder/src/Observers/PurgeFieldValuesObserver.php new file mode 100644 index 0000000000..144620f376 --- /dev/null +++ b/packages/builder/src/Observers/PurgeFieldValuesObserver.php @@ -0,0 +1,65 @@ +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) { + return; + } + + $entities = $this->fieldGroupPersistence->entitiesFromLocationRules( + $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/Plugins/BuilderPlugin.php b/packages/builder/src/Plugins/BuilderPlugin.php new file mode 100644 index 0000000000..23760efe3e --- /dev/null +++ b/packages/builder/src/Plugins/BuilderPlugin.php @@ -0,0 +1,42 @@ +resources([ + FieldGroupResource::class, + ]); + + $panel->middleware([ + ResolveBuilderAdminLocale::class, + ], isPersistent: true); + } + + 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..1dc7eb4f9d --- /dev/null +++ b/packages/builder/src/Registry/DefinitionRegistry.php @@ -0,0 +1,89 @@ + + */ + public function fieldGroupsFor(LocationContext $context, ?string $locale = null): Collection + { + $locale = $this->localeResolver->current($locale); + + return $this->allActiveGroups() + ->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(); + } + + /** + * @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(FieldRelationTree::eagerLoadForDefinition()) + ->with('translations') + ->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..7b3e0cd45f --- /dev/null +++ b/packages/builder/src/Registry/EntityRegistry.php @@ -0,0 +1,518 @@ +|null + */ + protected ?array $allCache = null; + + /** + * @var array|null + */ + protected ?array $relatableCache = null; + + /** + * @var array, bool> + */ + protected array $queryableModelCache = []; + + /** + * @var array + */ + protected array $tableExistsCache = []; + + /** + * @var array> + */ + protected array $tableColumnsListingCache = []; + + /** + * @return array + */ + public function all(): array + { + if ($this->allCache !== null) { + return $this->allCache; + } + + $entities = []; + + foreach ($this->traitResources() as $resourceClass) { + $entity = $this->resolveEntityKey($resourceClass); + + if ($entity === null) { + continue; + } + + $entities[$entity] = [ + 'resource' => $resourceClass, + 'label' => $this->labelForResource($resourceClass), + ]; + } + + return $this->allCache = $entities; + } + + /** + * @param class-string $resourceClass + */ + public function resolveForResource(string $resourceClass): ?string + { + if (! $this->usesCustomFields($resourceClass)) { + return null; + } + + return $this->resolveEntityKey($resourceClass); + } + + /** + * @return class-string|null + */ + public function resourceFor(string $entity): ?string + { + foreach ($this->all() as $key => $definition) { + if ($key === $entity) { + $resource = $definition['resource'] ?? null; + + return is_string($resource) ? $resource : null; + } + } + + return 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 $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] ?? []; + + 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 ($this->all() as $entity => $definition) { + $options[$entity] = $definition['label'] ?? $this->labelFor($entity); + } + + 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 + { + if ($this->relatableCache !== null) { + return $this->relatableCache; + } + + $resources = []; + $seenModels = []; + + $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; + } + + $model = $resourceClass::getModel(); + + if (! is_string($model) || $model === '' || isset($candidateModelTables[$model])) { + continue; + } + + if (! is_subclass_of($model, Model::class)) { + continue; + } + + 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; + } + + $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 $this->relatableCache = $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 (array_key_exists($modelClass, $this->queryableModelCache)) { + return $this->queryableModelCache[$modelClass]; + } + + if (! is_subclass_of($modelClass, Model::class)) { + return $this->queryableModelCache[$modelClass] = false; + } + + try { + $table = (new $modelClass)->getTable(); + + 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]); + } + + /** + * @var array + */ + protected static array $usesCustomFieldsCache = []; + + /** + * @param class-string $resourceClass + */ + public function usesCustomFields(string $resourceClass): bool + { + return self::$usesCustomFieldsCache[$resourceClass] ??= 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 + { + 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 []; + } + + try { + $panels = Filament::getPanels(); + } catch (\Throwable) { + return []; + } + + $resources = []; + + foreach ($panels as $panel) { + foreach ($panel->getResources() as $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 + */ + 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/Registry/FieldTypeRegistry.php b/packages/builder/src/Registry/FieldTypeRegistry.php new file mode 100644 index 0000000000..2a476d0433 --- /dev/null +++ b/packages/builder/src/Registry/FieldTypeRegistry.php @@ -0,0 +1,100 @@ + + */ + 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) { + if ($type->isInternal()) { + continue; + } + + $options[$key] = $type->label(); + } + + asort($options); + + 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 + */ + public function optionsForSubFields(): array + { + $options = []; + + foreach ($this->types as $key => $type) { + if ($type->hasSubFields() || $type->isInternal()) { + 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 new file mode 100644 index 0000000000..2165827571 --- /dev/null +++ b/packages/builder/src/Resources/FieldGroupResource.php @@ -0,0 +1,1874 @@ + + */ + 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'; + + protected static ?int $navigationSort = 1; + + public static function getNavigationGroup(): ?string + { + $group = config('builder.navigation_group'); + + return filled($group) ? (string) $group : __('builder::builder.navigation_group'); + } + + public static function getModelLabel(): string + { + return __('builder::builder.field_group.single'); + } + + public static function getPluralModelLabel(): string + { + return __('builder::builder.field_group.plural'); + } + + public static function form(Schema $schema): Schema + { + $registry = app(FieldTypeRegistry::class); + $entityOptions = app(EntityRegistry::class)->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), + 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), + 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([ + 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')) + ->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')) + ->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'))) + ->schema(static::locationConstraintSchema()) + ->columns(1) + ->collapsible() + ->collapsed(), + ]), + Section::make(__('builder::builder.field_group.visibility')) + ->description(__('builder::builder.field_group.visibility_helper')) + ->collapsible() + ->collapsed() + ->schema(static::visibilityToggles()), + ]) + ->columnSpan(1) + ->columns(1), + Section::make(__('builder::builder.field_group.fields')) + ->columnSpan(2) + ->headerActions(static::fieldRepeaterHeaderActions()) + ->schema([ + Repeater::make('fields') + ->hiddenLabel() + ->extraAttributes(['class' => 'moox-builder-fields']) + ->orderColumn('sort') + ->reorderable() + ->collapsible() + ->collapsed() + ->cloneable() + ->collapseAllAction(fn (Action $action): Action => $action->hidden()) + ->expandAllAction(fn (Action $action): Action => $action->hidden()) + ->itemLabel(fn (array $state): HtmlString => static::fieldRepeaterItemLabel($registry, $state)) + ->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() + ->afterStateUpdated(fn ($state, callable $set, callable $get): mixed => static::seedConfigForFieldType((string) $state, $set, $get)) + ->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), + static::requirementAndWidthRow(), + ...static::validationSettingsSchema(), + ...static::columnSettingsSchema(), + ...static::filterSettingsSchema(), + ...static::visibilitySettingsSchema(), + ...static::conditionalLogicSchema(), + ...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' + ? __('builder::builder.field.tab_content_helper') + : __('builder::builder.field.subfields_helper')) + ->icon(fn (callable $get): Heroicon => $get('type') === 'tab' + ? Heroicon::OutlinedFolder + : Heroicon::OutlinedSquares2x2) + ->collapsed() + ->schema([ + Repeater::make('children') + ->hiddenLabel() + ->orderColumn('sort') + ->reorderable() + ->collapsible() + ->collapsed() + ->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') + ->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'), + ]), + ]), + ]) + ->columns(3) + ->columnSpanFull(), + ]); + } + + /** + * @param array $state + */ + protected static function fieldRepeaterItemLabel(FieldTypeRegistry $registry, array $state): HtmlString + { + $type = filled($state['type'] ?? null) ? (string) $state['type'] : null; + + $title = filled($state['label'] ?? null) + ? (string) $state['label'] + : __('builder::builder.field_group.field_item'); + + $meta = []; + + if ($type !== null) { + $meta[] = static::fieldTypeLabel($registry, $type); + } + + if (filled($state['name'] ?? null)) { + $meta[] = (string) $state['name']; + } + + if (($state['required'] ?? false) === true) { + $meta[] = __('builder::builder.field.required_badge'); + } + + $childrenCount = count($state['children'] ?? []); + if ($childrenCount > 0) { + $meta[] = trans_choice('builder::builder.field.subfields_count', $childrenCount, [ + 'count' => $childrenCount, + ]); + } + + $layoutsCount = count($state['layouts'] ?? []); + if ($layoutsCount > 0) { + $meta[] = trans_choice('builder::builder.field.layouts_count', $layoutsCount, [ + 'count' => $layoutsCount, + ]); + } + + 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 + { + $iconSvg = static::fieldTypeIconSvg($registry, $type); + + $metaHtml = $meta === [] + ? '' + : ''.e(implode(' · ', $meta)).''; + + return new HtmlString( + '' + .''.$iconSvg.'' + .''.e($title).'' + .$metaHtml + .'' + ); + } + + 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)) { + return 'heroicon-o-cube'; + } + + try { + return $registry->get($type)->icon(); + } catch (UnknownFieldTypeException) { + return 'heroicon-o-cube'; + } + } + + /** + * @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); + } + + protected static function fieldTypeLabel(FieldTypeRegistry $registry, string $type): string + { + try { + return $registry->get($type)->label(); + } catch (UnknownFieldTypeException) { + return $type; + } + } + + /** + * @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), + ...static::validationSettingsSchema(), + Repeater::make('children') + ->label(__('builder::builder.field.subfields')) + ->orderColumn('sort') + ->reorderable() + ->collapsible() + ->collapsed() + ->itemLabel(fn (array $state): HtmlString => static::fieldRepeaterItemLabel($registry, $state)) + ->schema(static::subFieldSchema($registry)) + ->defaultItems(0), + ]; + } + + /** + * @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() + ->afterStateUpdated(fn ($state, callable $set, callable $get): mixed => static::seedConfigForFieldType((string) $state, $set, $get)) + ->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), + static::requirementAndWidthRow(), + ...static::columnSettingsSchema(), + ...static::filterSettingsSchema(), + ...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') + ->hiddenLabel() + ->orderColumn('sort') + ->reorderable() + ->collapsible() + ->collapsed() + ->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') + ->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 + */ + 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() + ->afterStateUpdated(fn ($state, callable $set, callable $get): mixed => static::seedConfigForFieldType((string) $state, $set, $get)) + ->native(false), + ]), + TextInput::make('name') + ->label(__('builder::builder.field.name')) + ->required() + ->maxLength(255) + ->regex('/^[a-z0-9]+(?:-[a-z0-9]+)*$/') + ->live(onBlur: true), + static::requirementAndWidthRow(), + ...static::validationSettingsSchema(), + ...static::optionFieldSections($registry), + ]; + } + + protected static function typeHasSettings(?string $type): bool + { + if (blank($type)) { + return false; + } + + try { + return app(FieldTypeRegistry::class)->get($type)->capabilities() !== []; + } catch (UnknownFieldTypeException) { + return false; + } + } + + protected static function fieldTypeSupportsRequired(?string $type): bool + { + if (blank($type)) { + return false; + } + + try { + return app(FieldTypeRegistry::class)->get($type)->storesValue(); + } catch (UnknownFieldTypeException) { + return false; + } + } + + protected static function fieldTypeSupportsColumn(?string $type): bool + { + if (blank($type) || in_array($type, ['password', 'rich_text'], true)) { + 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 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) + || 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) + || 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. + */ + protected static function fieldTypeUsesTextColumn(?string $type): bool + { + return static::fieldTypeSupportsColumn($type) && ! in_array($type, ['toggle', 'color'], true); + } + + 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')) && $get('type') !== 'rich_text' + || static::fieldTypeSupportsRelationBadge($get); + } + + /** + * @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'), + ]; + } + + /** + * @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; + * 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')) + ->icon(Heroicon::OutlinedTableCells) + ->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) + ->visible(fn (callable $get): bool => static::fieldTypeSupportsSortableColumn($get('type'))), + Toggle::make('settings.searchable') + ->label(__('builder::builder.field.column_searchable')) + ->inline(false) + ->default(true) + ->visible(fn (callable $get): bool => static::fieldTypeSupportsSearchableColumn($get('type'))), + ]) + ->visible(fn (callable $get): bool => $enabled($get) + && (static::fieldTypeSupportsSortableColumn($get('type')) + || static::fieldTypeSupportsSearchableColumn($get('type')))), + 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([ + static::configureFieldHint( + Toggle::make('settings.badge'), + __('builder::builder.field.column_badge'), + __('builder::builder.field.column_badge_helper'), + ) + ->inline(false) + ->default(false), + 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'))), + 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'))), + ]) + ->visible(fn (callable $get): bool => $enabled($get) + && static::fieldTypeSupportsTextColumnPresentation($get)), + 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'))), + ]), + ]; + } + + /** + * 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 + * walk up the state path and return the first ancestor `fields` collection. + * + * @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 + { + for ($depth = 1; $depth <= 10; $depth++) { + $candidate = $get(str_repeat('../', $depth).'fields'); + + if (static::looksLikeFieldRows($candidate)) { + /** @var array> $candidate */ + return array_values($candidate); + } + } + + 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. + * + * @return list
+ */ + 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()), + ]; + } + + /** + * @return list + */ + protected static function visibilityToggles(): array + { + return [ + Grid::make(3) + ->schema([ + 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') + ->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 + */ + protected static function placementOptions(): array + { + return [ + FieldGroupPlacement::MAIN => __('builder::builder.field_group.placement_main'), + FieldGroupPlacement::SIDEBAR => __('builder::builder.field_group.placement_sidebar'), + ]; + } + + /** + * "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(), + ]); + } + + /** + * 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 + { + return Select::make('settings.width') + ->label(__('builder::builder.field.width')) + ->helperText(__('builder::builder.field.width_helper')) + ->options(static::widthOptions()) + ->default(FieldWidth::AUTO) + ->selectablePlaceholder(false) + ->native(false) + ->visible(fn (callable $get): bool => $get('type') !== 'tab'); + } + + /** + * @return array + */ + 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'), + '2/3' => __('builder::builder.field.width_two_thirds'), + '1/4' => __('builder::builder.field.width_quarter'), + '3/4' => __('builder::builder.field.width_three_quarters'), + ]; + } + + /** + * @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). + * + * @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 + */ + 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 + */ + protected static function optionFieldSections(FieldTypeRegistry $registry): array + { + 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') + ->label(__('builder::builder.field.options')) + ->orderColumn('sort') + ->reorderable() + ->live() + ->schema([ + Hidden::make('id'), + TextInput::make('label') + ->label(__('builder::builder.field.option_label')) + ->required() + ->live(onBlur: true), + 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()), + ]; + } + + /** + * @return list + */ + protected static function reactiveTypeSettingsSchema(callable $get): array + { + $type = $get('type'); + + if (in_array($type, ['date', 'datetime', 'time'], true)) { + $get('config.displayFormat'); + $get('config.default'); + $get('config.defaultNow'); + } + + if (in_array($type, ['select', 'radio', 'button_group', 'multiselect', 'checkbox_list'], true)) { + $get('options'); + $get('config.default'); + } + + if ($type === 'range') { + $get('config.min'); + $get('config.max'); + $get('config.step'); + $get('config.default'); + } + + if ($type === 'color') { + $get('config.default'); + } + + if (in_array($type, ['text', 'textarea', 'rich_text', 'email', 'password'], true)) { + $get('config.maxLength'); + $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 + */ + protected static function typeSettingsSchema(?string $type): array + { + if (blank($type)) { + return []; + } + + if (! array_key_exists($type, static::$typeSettingsCapabilityCache)) { + try { + static::$typeSettingsCapabilityCache[$type] = app(FieldTypeRegistry::class) + ->get($type) + ->capabilities(); + } catch (UnknownFieldTypeException) { + static::$typeSettingsCapabilityCache[$type] = []; + } + } + + $components = []; + + foreach (static::$typeSettingsCapabilityCache[$type] as $capabilityClass) { + $components = array_merge($components, app($capabilityClass)->builderFieldsFor($type)); + } + + 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')"), + ]; + } + + /** + * @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(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 { + $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'), + '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'), + }) + ->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(), + 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', 'record_status', 'user_role'], true) + && ($get('param') !== 'taxonomy' || filled($get('taxonomy')))) + ->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' + && ! 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, + ), + 'record_status' => app(LocationConstraintOptions::class)->recordStatusLabelForValue( + $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, + ), + '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]) + ->all(), + default => [], + }; + }) + ->searchable(fn (Get $get): bool => in_array($get('param'), ['taxonomy', 'record_type', 'record_status', 'user_role'], true)) + ->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']); + } + + $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; + + 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 (($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)); + } + + return (string) $value; + } + + /** + * @return array + */ + 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> + */ + 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); + $recordStatusOptions = $options->recordStatusOptionsForEntities($entities); + + return array_values(array_map( + static function (array $constraint) use ($allowedParams, $allowedTaxonomies, $recordTypeOptions, $recordStatusOptions): 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, + ]; + } + + if ($param === 'record_status' && $recordStatusOptions === []) { + return [ + ...$constraint, + 'value' => null, + ]; + } + + return $constraint; + }, + $constraints, + )); + } + + /** + * @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); + $entityRegistry = app(EntityRegistry::class); + $localeResolver = app(BuilderLocaleResolver::class); + + return $table + ->columns([ + TextColumn::make('name') + ->label(__('builder::builder.field_group.name')) + ->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') + ->label(__('builder::builder.field_group.assigned_to')) + ->getStateUsing(function (FieldGroup $record) use ($entityRegistry, $persistence): string { + return $entityRegistry->labelsFor( + $persistence->entitiesFromLocationRules($record->location_rules ?? []), + ); + }) + ->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') + ->recordActions([ + EditAction::make(), + FieldGroupDefinitionActions::export(), + ]); + } + + 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/Concerns/InteractsWithFieldGroupLocale.php b/packages/builder/src/Resources/FieldGroupResource/Pages/Concerns/InteractsWithFieldGroupLocale.php new file mode 100644 index 0000000000..016a3da76a --- /dev/null +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/Concerns/InteractsWithFieldGroupLocale.php @@ -0,0 +1,40 @@ +hydrateInteractsWithBuilderLocale(); + } + + public function mountInteractsWithFieldGroupLocale(): void + { + $this->mountInteractsWithBuilderLocale(); + } + + protected function applyFieldGroupDefaultLocale(object $record): void + { + if (method_exists($record, 'setDefaultLocale') && $this->lang !== '') { + $record->setDefaultLocale($this->lang); + } + } + + protected function guardFieldGroupAdminLocale(): void + { + $this->guardBuilderAdminLocale(); + } + + protected function getFieldGroupLanguageSelectorAction(): Action + { + return $this->getBuilderLanguageSelectorAction(); + } +} 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 new file mode 100644 index 0000000000..ba2fed2b75 --- /dev/null +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/CreateFieldGroup.php @@ -0,0 +1,50 @@ +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); + + 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 new file mode 100644 index 0000000000..5ee7cea145 --- /dev/null +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/EditFieldGroup.php @@ -0,0 +1,85 @@ +mountInteractsWithFieldGroupLocale(); + + parent::mount($record); + + $this->guardFieldGroupAdminLocale(); + } + + public function hydrate(): void + { + $this->hydrateInteractsWithFieldGroupLocale(); + } + + protected function getHeaderActions(): array + { + return [ + $this->getFieldGroupLanguageSelectorAction(), + FieldGroupDefinitionActions::export($this->getRecord()), + DeleteAction::make(), + ]; + } + + /** + * @return array + */ + protected function mutateFormDataBeforeFill(array $data): array + { + $this->syncLangToRequest(); + + /** @var FieldGroup $record */ + $record = $this->getRecord(); + + $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 ?? [], + ); + + $data['target_entities'] = $persistence->entitiesFromLocationRules( + $record->location_rules ?? [], + ); + + $data['location_constraints'] = $persistence->constraintsFromLocationRules( + $record->location_rules ?? [], + ); + + $data['fields'] = $persistence->fieldRowsForForm($record, $this->lang); + + return $data; + } + + protected function handleRecordUpdate($record, array $data): FieldGroup + { + $this->syncLangToRequest(); + $this->applyFieldGroupDefaultLocale($record); + + 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 new file mode 100644 index 0000000000..7c47d65fc5 --- /dev/null +++ b/packages/builder/src/Resources/FieldGroupResource/Pages/ListFieldGroups.php @@ -0,0 +1,60 @@ +> */ + 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 [ + $this->getFieldGroupLanguageSelectorAction(), + FieldGroupDefinitionActions::import($this, $this->lang !== '' ? $this->lang : null), + CreateAction::make() + ->url(fn (): string => FieldGroupResource::getUrl('create', [ + 'lang' => $this->lang !== '' + ? $this->lang + : app(BuilderLocaleResolver::class)->adminDefaultLocale(), + ])), + ]; + } +} diff --git a/packages/builder/src/Services/BuilderFieldValueMediaMetadataSync.php b/packages/builder/src/Services/BuilderFieldValueMediaMetadataSync.php new file mode 100644 index 0000000000..26fd5d778a --- /dev/null +++ b/packages/builder/src/Services/BuilderFieldValueMediaMetadataSync.php @@ -0,0 +1,74 @@ +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; + } + + $updated = MediaFieldValueSupport::replaceSnapshotInStoredValue( + $row->value_json, + $mediaId, + $fresh, + ); + + if ($updated === $row->value_json) { + return; + } + + $row->update(['value_json' => $updated]); + + $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..669844eb42 --- /dev/null +++ b/packages/builder/src/Services/BuilderMediaUsageSync.php @@ -0,0 +1,99 @@ + + */ + private const MEDIA_FIELD_TYPES = ['image', 'gallery', 'file']; + + /** + * @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/BuilderValuesResolver.php b/packages/builder/src/Services/BuilderValuesResolver.php new file mode 100644 index 0000000000..8310b1a9be --- /dev/null +++ b/packages/builder/src/Services/BuilderValuesResolver.php @@ -0,0 +1,318 @@ + $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, ?string $locale = null): 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(); + $localeChain = $this->localeResolver->valuesFallbackChainForEntity($entity, $locale, $model::class); + + $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())->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, $resolvedRows, mergeDefaults: true), + $this->localeResolver->valuesLocaleForEntity($entity, $locale, $record::class), + ); + } + } + + 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/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 new file mode 100644 index 0000000000..fe7b0daf9b --- /dev/null +++ b/packages/builder/src/Services/CustomFieldsManager.php @@ -0,0 +1,376 @@ +> + */ + protected array $valuesCache = []; + + public function __construct( + protected DefinitionRegistry $definitionRegistry, + protected FieldTypeRegistry $fieldTypeRegistry, + protected FieldValueValidator $fieldValueValidator, + protected StorableFieldCollector $storableFieldCollector, + protected BuilderLocaleResolver $localeResolver, + protected EntityRegistry $entityRegistry, + ) {} + + /** + * @param class-string $resourceClass + */ + public function locationContextForResource(string $resourceClass): LocationContext + { + return LocationContext::forResource($resourceClass); + } + + /** + * @param class-string $resourceClass + * @return Collection + */ + public function fieldsForResource(string $resourceClass): Collection + { + $groups = $this->definitionRegistry->fieldGroupsFor( + $this->locationContextForResource($resourceClass), + ); + + 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(); + } + + /** + * 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(); + } + + /** + * @param class-string $resourceClass + * @return array + */ + public function loadFormData(string $resourceClass, Model $record): array + { + $fields = $this->fieldsForResource($resourceClass); + + if ($fields->isEmpty()) { + return []; + } + + return $this->loadValues( + $this->locationContextForResource($resourceClass)->entity, + $record, + $fields, + ); + } + + /** + * @param Collection $fields + * @return array + */ + public function loadValues(string $entity, Model $record, Collection $fields, ?string $locale = null): array + { + if ($fields->isEmpty()) { + return []; + } + + $localeChain = $this->localeResolver->valuesFallbackChainForEntity($entity, $locale, $record::class); + + $rows = FieldValue::query() + ->forRecord($entity, $record->getKey()) + ->whereIn('field_name', $fields->pluck('name')) + ->whereIn('locale', $localeChain) + ->get() + ->groupBy('field_name'); + + $values = []; + + foreach ($fields as $field) { + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if (! $fieldType->storesValue()) { + continue; + } + + $row = $this->resolveRowForLocale($rows->get($field->name, collect()), $localeChain); + + if ($row === null) { + continue; + } + + $raw = TypedValueColumns::read($row, $field->type); + $values[$field->name] = $fieldType->castValue($raw, $field); + } + + return $values; + } + + /** + * @param Collection $fields + * @return array + */ + public function loadCachedValues(string $entity, Model $record, Collection $fields, ?string $locale = null): array + { + if ($fields->isEmpty()) { + return []; + } + + $cacheKey = $this->valuesCacheKey($entity, $record->getKey(), $locale); + + if (! array_key_exists($cacheKey, $this->valuesCache)) { + $this->valuesCache[$cacheKey] = $this->loadValues($entity, $record, $fields, $locale); + } + + return $this->valuesCache[$cacheKey]; + } + + /** + * @param Collection $fields + * @return array + */ + public function loadValuesWithDefaults(string $entity, Model $record, Collection $fields, ?string $locale = null): array + { + if ($fields->isEmpty()) { + return []; + } + + $values = $this->loadValues($entity, $record, $fields, $locale); + + return app(BuilderValuesResolver::class)->mergeDefaults($fields, $values); + } + + /** + * @param Collection $fields + * @return array + */ + public function loadCachedValuesWithDefaults(string $entity, Model $record, Collection $fields, ?string $locale = null): array + { + if ($fields->isEmpty()) { + return []; + } + + $cacheKey = $this->valuesCacheKey($entity, $record->getKey(), $locale); + + if (! array_key_exists($cacheKey, $this->valuesCache)) { + $this->valuesCache[$cacheKey] = $this->loadValuesWithDefaults($entity, $record, $fields, $locale); + } + + return $this->valuesCache[$cacheKey]; + } + + /** + * @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 = []; + $defaultValue = app(DefaultValue::class); + $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); + + if (! $fieldType->storesValue()) { + continue; + } + + if (! array_key_exists($field->name, $data)) { + if ($defaultValue->hasConfiguredDefault($field)) { + $values[$field->name] = $defaultValue->resolveForField($field); + } + + continue; + } + + $value = $data[$field->name]; + + $hasStoredValue = isset($storedFieldNames[$field->name]); + + if (! $hasStoredValue && $defaultValue->hasConfiguredDefault($field)) { + if ($field->type === 'toggle' || $defaultValue->shouldApplyDefault($value, $field->type)) { + $resolved = $defaultValue->resolveForField($field); + + if ($field->type === 'toggle' || $resolved !== null) { + $value = $resolved; + } + } + } + + $values[$field->name] = $value; + } + + if ($values === []) { + return; + } + + $this->saveValues( + $this->locationContextForResource($resourceClass)->entity, + $record, + $values, + $fields, + $locale, + ); + } + + /** + * @param array $values + * @param Collection $fields + */ + public function saveValues(string $entity, Model $record, array $values, Collection $fields, ?string $locale = null): void + { + $locale = $this->localeResolver->valuesLocaleForEntity($entity, $locale, $record::class); + + foreach ($fields as $field) { + if (! array_key_exists($field->name, $values)) { + continue; + } + + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if (! $fieldType->storesValue()) { + continue; + } + + $value = $values[$field->name]; + + if (ConditionalLogic::isVisibleForValues($field, $values)) { + $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); + $columns = TypedValueColumns::attributesFor($field->type, $persisted); + + FieldValue::query()->updateOrCreate( + [ + 'entity' => $entity, + 'record_id' => $record->getKey(), + 'field_name' => $field->name, + 'locale' => $locale, + ], + $columns, + ); + } + + if (MediaIntegration::isAvailable()) { + app(BuilderMediaUsageSync::class)->syncForRecord($entity, $record, $fields); + } + + $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; + } + + protected function valuesCacheKey(string $entity, int|string $recordId, ?string $locale = null): string + { + return "{$entity}:{$recordId}:".$this->localeResolver->valuesLocaleForEntity($entity, $locale); + } + + /** + * @param class-string $resourceClass + */ + public function usesCustomFields(string $resourceClass): bool + { + return $this->entityRegistry->usesCustomFields($resourceClass); + } +} diff --git a/packages/builder/src/Services/FieldGroupExporter.php b/packages/builder/src/Services/FieldGroupExporter.php new file mode 100644 index 0000000000..1cbe917880 --- /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..45edea554a --- /dev/null +++ b/packages/builder/src/Services/FieldGroupImporter.php @@ -0,0 +1,363 @@ + $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'] = []; + } + } + + $groupData = $this->canonicalizeGroupData($groupData); + + $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) + && ! 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; + } + + /** + * @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 + */ + protected function validationError(string $message): void + { + throw ValidationException::withMessages([ + 'file' => [$message], + ]); + } +} diff --git a/packages/builder/src/Services/FieldGroupPersistence.php b/packages/builder/src/Services/FieldGroupPersistence.php new file mode 100644 index 0000000000..e1512cabd1 --- /dev/null +++ b/packages/builder/src/Services/FieldGroupPersistence.php @@ -0,0 +1,785 @@ + $data + */ + 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, [ + 'slug', + 'active', + 'sort', + 'placement', + '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> + */ + 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->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> + */ + 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; + } + + /** + * @return list> + */ + public function fieldRowsForForm(FieldGroup $group, ?string $locale = null): array + { + $group->loadMissing(FieldRelationTree::eagerLoadForDefinition()); + + 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, ?string $locale = null): array + { + $locale = $this->localeResolver->current($locale); + + return $fields->map(function (Field $field) use ($locale): array { + $row = [ + 'id' => $field->getKey(), + 'label' => $this->definitionTranslator->translatedFieldLabel($field, $locale), + '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), + '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), + '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, + '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, + 'conditions' => ConditionalLogic::normalizeSettings($field->settings['conditions'] ?? []), + ], + 'sort' => $field->sort, + 'options' => $field->options->map(fn (FieldOption $option): array => [ + 'id' => $option->getKey(), + '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, $locale); + } else { + $row['children'] = $this->mapFieldRows($field->children, $locale); + } + + return $row; + })->values()->all(); + } + + /** + * @param Collection $fields + * @return list> + */ + 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' => $this->definitionTranslator->translatedFieldLabel($layout, $locale), + 'name' => $layout->name, + 'sort' => $layout->sort, + 'children' => $this->mapFieldRows($layout->children, $locale), + ]) + ->values() + ->all(); + } + + /** + * @param list> $rows + */ + protected function syncFields(FieldGroup $group, array $rows, ?int $parentFieldId = null): void + { + $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) { + if (blank($row['name'] ?? null) || blank($row['type'] ?? null)) { + continue; + } + + $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' => $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( + array_intersect_key($field->config ?? [], array_flip(BuilderLocaleResolver::TRANSLATABLE_CONFIG_KEYS)), + $structuralConfig, + ), + 'validation' => $this->fieldValidationRules->compileValidation([ + ...((is_array($row['validation'] ?? null) ? $row['validation'] : [])), + 'required' => (bool) ($row['required'] ?? false), + ], (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), + '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, + '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, + 'conditions' => ConditionalLogic::normalizeSettings($row['settings']['conditions'] ?? []), + ]), + 'sort' => $index, + ]); + + $field->save(); + + $this->syncFieldTranslation($field, $label, $config); + + $existing->put($field->getKey(), $field); + + if ($previousName !== null && $previousName !== $field->name) { + $entities = $this->entitiesFromLocationRules($group->location_rules ?? []); + + if ($field->parent_field_id !== null) { + app(CompoundFieldValueMigrator::class)->renameNestedSubfield($field, $previousName, $entities); + } else { + $this->fieldValuePurger->purgeForFieldName($previousName, $entities); + } + } + + $retainedIds[] = $field->getKey(); + + $this->syncOptions($field, $row['options'] ?? []); + + 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()); + } + } + + $deleteQuery = $group->fields(); + + if ($parentFieldId === null) { + $deleteQuery->whereNull('parent_field_id'); + } else { + $deleteQuery->where('parent_field_id', $parentFieldId); + } + + $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 + */ + 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; + } + + $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' => $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(); + } + + $field->options() + ->whereNotIn('id', $retainedIds) + ->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]); + } + + /** + * @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; + } + + /** + * @param array $config + * @return array + */ + protected function filterConfigForType(string $type, array $config): array + { + if ($config === []) { + return []; + } + + $allowed = $this->allowedConfigKeysForType($type); + + if ($allowed === []) { + return []; + } + + $filtered = Arr::only($config, $allowed); + + if (in_array($type, ['date', 'datetime', 'time'], true) && ! array_key_exists('displayFormat', $filtered)) { + $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 + */ + 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)); + } +} diff --git a/packages/builder/src/Services/FieldGroupValidator.php b/packages/builder/src/Services/FieldGroupValidator.php new file mode 100644 index 0000000000..2720e2e110 --- /dev/null +++ b/packages/builder/src/Services/FieldGroupValidator.php @@ -0,0 +1,467 @@ + $data + */ + public function validate(FieldGroup $group, array $data): void + { + $locationRules = $this->fieldGroupPersistence->resolveLocationRules($data); + $entities = $this->fieldGroupPersistence->entitiesFromLocationRules($locationRules); + $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'] ?? [], + ), + ); + + if ($entities === [] || $fieldRows === []) { + if ($messages !== []) { + throw ValidationException::withMessages($messages); + } + + return; + } + + $entries = $this->storableFieldCollector->pathsFromRows($fieldRows); + + $internalMessages = $this->internalDuplicateMessages($entries); + $externalMessages = $entities === [] ? [] : $this->externalConflictMessages($group, $entities, $entries); + + $messages = array_merge( + $messages, + $internalMessages, + $externalMessages, + ); + + if ($externalMessages !== []) { + $summary = collect($externalMessages)->flatten()->first(); + + if (is_string($summary)) { + $messages['target_entities'] = [$summary]; + } + } + + if ($messages !== []) { + throw ValidationException::withMessages($messages); + } + } + + /** + * @param list $entries + * @return array> + */ + protected function internalDuplicateMessages(array $entries): array + { + $seen = []; + $messages = []; + + foreach ($entries as $entry) { + if (isset($seen[$entry['name']])) { + $messages[$entry['path']] = [ + __('builder::builder.validation.duplicate_field_name_internal', ['name' => $entry['name']]), + ]; + + continue; + } + + $seen[$entry['name']] = $entry['path']; + } + + return $messages; + } + + /** + * @param list $entities + * @param list $entries + * @return array> + */ + protected function externalConflictMessages(FieldGroup $group, array $entities, array $entries): array + { + if ($entries === []) { + return []; + } + + $currentNames = []; + + foreach ($entries as $entry) { + $currentNames[$entry['name']] = $entry['path']; + } + + $otherGroups = FieldGroup::query() + ->active() + ->when($group->exists, fn ($query) => $query->whereKeyNot($group->getKey())) + ->get(); + + $messages = []; + + foreach ($otherGroups as $otherGroup) { + $otherEntities = $this->fieldGroupPersistence->entitiesFromLocationRules($otherGroup->location_rules ?? []); + + if (array_intersect($entities, $otherEntities) === []) { + continue; + } + + $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, + ]), + ]; + } + } + + 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; + } + + /** + * @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')]; + } + } + + 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, + $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> + */ + protected function locationConstraintMessages(array $rows, mixed $entities): array + { + $messages = []; + $availableParams = array_keys($this->locationConstraintOptions->availableParamOptionsForEntities($entities)); + $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 === '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"] = [ + $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/Services/FieldValuePurger.php b/packages/builder/src/Services/FieldValuePurger.php new file mode 100644 index 0000000000..e3b20a7eb8 --- /dev/null +++ b/packages/builder/src/Services/FieldValuePurger.php @@ -0,0 +1,65 @@ +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(); + } + } + + /** + * @param list $entities + */ + public function purgeForFieldName(string $fieldName, array $entities): void + { + $this->purgeForFieldNames([$fieldName], $entities); + } + + /** + * @param list $fieldNames + * @param list $entities + */ + public function purgeForFieldNames(array $fieldNames, array $entities): void + { + if ($fieldNames === [] || $entities === []) { + return; + } + + FieldValue::query() + ->whereIn('field_name', $fieldNames) + ->whereIn('entity', $entities) + ->delete(); + } +} diff --git a/packages/builder/src/Services/FieldValueValidator.php b/packages/builder/src/Services/FieldValueValidator.php new file mode 100644 index 0000000000..8a8a7cc75d --- /dev/null +++ b/packages/builder/src/Services/FieldValueValidator.php @@ -0,0 +1,493 @@ +> + */ + public function messagesFor(FieldDefinition $field, mixed $value, ?string $path = null, ?Model $record = null): array + { + $path ??= $field->name; + + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if ($field->type === 'tab') { + return []; + } + + if ($fieldType->hasSubFields()) { + return $this->messagesForCompound($field, $value, $path, $record); + } + + return $this->messagesForLeaf($field, $value, $path, $record); + } + + public function assertValid(FieldDefinition $field, mixed $value, ?Model $record = null): void + { + $messages = $this->messagesFor($field, $value, record: $record); + + if ($messages !== []) { + throw ValidationException::withMessages($messages); + } + } + + /** + * @return 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, $record); + } + + if ($field->type === 'flexible_content') { + return $this->messagesForFlexibleContent($field, $value, $path, $record); + } + + 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, $record)); + } + + return $messages; + } + + /** + * @return array> + */ + protected function messagesForFlexibleContent(FieldDefinition $field, mixed $value, string $path, ?Model $record = null): 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", $record)); + } + + return $messages; + } + + /** + * @return array> + */ + protected function messagesForRow(FieldDefinition $parent, array $row, string $path, ?Model $record = null): array + { + $messages = []; + + foreach ($parent->children as $child) { + $childPath = "{$path}.{$child->name}"; + $messages = array_merge( + $messages, + $this->messagesFor($child, $row[$child->name] ?? null, $childPath, $record), + ); + } + + return $messages; + } + + /** + * @return array> + */ + protected function messagesForLeaf(FieldDefinition $field, mixed $value, string $path, ?Model $record = null): array + { + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if (! $fieldType->storesValue()) { + return []; + } + + if ($field->type === 'link') { + return $this->messagesForLink($field, $value, $path); + } + + if ($field->type === 'image') { + return $this->messagesForImage($field, $value, $path, $record); + } + + if ($field->type === 'gallery') { + return $this->messagesForGallery($field, $value, $path, $record); + } + + if ($field->type === 'file') { + 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)) { + $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; + } + } + + $messages = array_merge($messages, $this->messagesForCustomRules($field, $value, $path)); + + 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; + } + + /** + * @return 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 messagesForFile(FieldDefinition $field, mixed $value, string $path, ?Model $record = null): array + { + return $this->messagesForMediaField($field, $value, $path, 'file', $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 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> + */ + protected function messagesForMediaField(FieldDefinition $field, mixed $value, string $path, string $type, ?Model $record = null): array + { + $messages = []; + + if (($field->validation['required'] ?? false) === true && $this->isEmptyValue($type, $value)) { + $messages[$path] = [ + __('validation.required', ['attribute' => $field->label]), + ]; + } + + if ($this->isEmptyValue($type, $value)) { + return $messages; + } + + $ids = MediaFieldValueSupport::extractIds($value); + + if ($ids === []) { + $messages[$path] = [ + __('builder::builder.validation.invalid_media'), + ]; + + return $messages; + } + + foreach ($ids as $mediaId) { + $result = MediaFieldValueSupport::mediaValidationResult($mediaId, $type, $record); + + if ($result['valid']) { + continue; + } + + $messages[$path] = match ($result['reason']) { + '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]), + ], + 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 + */ + 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 $required = false): bool + { + if ($type === 'rich_text') { + return RichTextValue::isEmpty($value); + } + + 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 (in_array($type, ['image', 'gallery', 'file'], true)) { + return MediaFieldValueSupport::extractIds($value) === []; + } + + 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; + } +} 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/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 new file mode 100644 index 0000000000..0d78954047 --- /dev/null +++ b/packages/builder/src/Support/BuilderLocaleResolver.php @@ -0,0 +1,208 @@ +> */ + private array $resolvedFallbackChains = []; + + /** + * @return list + */ + public const TRANSLATABLE_CONFIG_KEYS = [ + 'helperText', + 'placeholder', + 'prefix', + 'suffix', + 'message', + ]; + + public function current(?string $locale = null): string + { + if ($this->overrideLocale !== null) { + return $this->overrideLocale; + } + + if (is_string($locale) && $locale !== '') { + return $locale; + } + + $requestLocale = request()->query('lang') ?? request()->input('lang'); + + if (is_string($requestLocale) && $requestLocale !== '') { + return $requestLocale; + } + + $sessionLocale = session(self::ADMIN_SESSION_KEY); + + if (is_string($sessionLocale) && $sessionLocale !== '') { + return $sessionLocale; + } + + return $this->defaultLocale(); + } + + public function defaultLocale(): string + { + 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 $this->resolvedDefaultLocale = (string) $localization->getAttribute('locale_variant'); + } + } + + return $this->resolvedDefaultLocale = (string) config('builder.default_locale', config('app.locale', 'en_US')); + } + + public function adminDefaultLocale(): string + { + if ($this->resolvedAdminDefaultLocale !== null) { + return $this->resolvedAdminDefaultLocale; + } + + if ($this->localizationsTableExists()) { + $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 $this->resolvedAdminDefaultLocale = (string) $variant; + } + } + + $firstActiveLocale = Localization::query() + ->where('is_active_admin', true) + ->first(); + + if ($firstActiveLocale !== null) { + $variant = $firstActiveLocale->getAttribute('locale_variant'); + + if (filled($variant)) { + return $this->resolvedAdminDefaultLocale = (string) $variant; + } + } + } + + return $this->resolvedAdminDefaultLocale = $this->defaultLocale(); + } + + /** + * @return list + */ + public function fallbackChain(?string $locale = null): array + { + $cacheKey = $this->current($locale); + + if (array_key_exists($cacheKey, $this->resolvedFallbackChains)) { + return $this->resolvedFallbackChains[$cacheKey]; + } + + $chain = [ + $cacheKey, + $this->defaultLocale(), + 'en_US', + ]; + + 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 + * + * @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)) { + 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/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); + } +} 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/CustomFieldsFilamentHooks.php b/packages/builder/src/Support/CustomFieldsFilamentHooks.php new file mode 100644 index 0000000000..bc871d74be --- /dev/null +++ b/packages/builder/src/Support/CustomFieldsFilamentHooks.php @@ -0,0 +1,139 @@ +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('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, + ); + } + } + + /** + * @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; + } + + if (! app(CustomFieldsTranslatability::class)->forResource($resourceClass)) { + continue; + } + + if (! $resourceClass::customFieldsRenderLocaleSwitcher()) { + continue; + } + + foreach ($pageKeys as $pageKey) { + $registration = $resourceClass::getPages()[$pageKey] ?? null; + + if ($registration === null) { + continue; + } + + $pageClasses[] = $registration->getPage(); + } + } + + return array_values(array_unique($pageClasses)); + } +} 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/builder/src/Support/DefinitionTranslator.php b/packages/builder/src/Support/DefinitionTranslator.php new file mode 100644 index 0000000000..3ea3456d5b --- /dev/null +++ b/packages/builder/src/Support/DefinitionTranslator.php @@ -0,0 +1,233 @@ +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, + settings: $field->settings, + 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 = $this->translationRow($field, $candidate); + $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 = $this->translationAttribute($model, $candidate, $attribute); + + if (is_string($value) && $value !== '') { + return $value; + } + } + + 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 + */ + 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/EntityModelDeletionRegistrar.php b/packages/builder/src/Support/EntityModelDeletionRegistrar.php new file mode 100644 index 0000000000..d9871c491d --- /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(), $record); + }); + + self::$registeredModels[] = $modelClass; + } + } +} diff --git a/packages/builder/src/Support/FieldGroupDefinitionMapper.php b/packages/builder/src/Support/FieldGroupDefinitionMapper.php new file mode 100644 index 0000000000..630685d7bc --- /dev/null +++ b/packages/builder/src/Support/FieldGroupDefinitionMapper.php @@ -0,0 +1,154 @@ + + */ + public function toPersistenceData( + FieldGroupDefinition $definition, + bool $active, + int $sort, + ): array { + $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, + 'fields' => $this->mapFieldsToRows($definition->fields), + ]; + + if ($constraints === []) { + $payload['target_entities'] = $entities; + } else { + $payload['target_entities'] = $entities; + $payload['location_constraints'] = $constraints; + } + + return $payload; + } + + /** + * @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 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 @@ + */ + 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/FieldGroupSaveNotifier.php b/packages/builder/src/Support/FieldGroupSaveNotifier.php new file mode 100644 index 0000000000..e15e8c92a2 --- /dev/null +++ b/packages/builder/src/Support/FieldGroupSaveNotifier.php @@ -0,0 +1,27 @@ +errors())->flatten()->first(); + + if (! is_string($message) || $message === '') { + return; + } + + Notification::make() + ->title(__('builder::builder.field_group.save_failed')) + ->body($message) + ->danger() + ->persistent() + ->send(); + } +} diff --git a/packages/builder/src/Support/FieldRelationTree.php b/packages/builder/src/Support/FieldRelationTree.php new file mode 100644 index 0000000000..bf5e5f9604 --- /dev/null +++ b/packages/builder/src/Support/FieldRelationTree.php @@ -0,0 +1,36 @@ + + */ + 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'; + + 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; + } +} diff --git a/packages/builder/src/Support/FieldValidationRules.php b/packages/builder/src/Support/FieldValidationRules.php new file mode 100644 index 0000000000..dd50052dfb --- /dev/null +++ b/packages/builder/src/Support/FieldValidationRules.php @@ -0,0 +1,310 @@ + + */ + 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'), + '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'), + ], + '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->allowedRawRulesFromText($validation['raw_rules'] ?? null, $supportedRules, $type) 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 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; + } + + /** + * @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); + } + + 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 + */ + 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 !== '', + )); + } + + /** + * @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, + ]; + } + + protected function categoryForType(?string $type): ?string + { + return match ($type) { + 'text', 'textarea', 'email', 'password', 'url', 'oembed', 'rich_text' => 'text', + 'number', 'range' => 'numeric', + default => null, + }; + } +} diff --git a/packages/builder/src/Support/FieldVisibility.php b/packages/builder/src/Support/FieldVisibility.php new file mode 100644 index 0000000000..e2de45e289 --- /dev/null +++ b/packages/builder/src/Support/FieldVisibility.php @@ -0,0 +1,59 @@ + */ + public const CONTEXTS = [self::ADMIN, self::FRONTEND, self::API]; + + /** + * 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(); + } +} diff --git a/packages/builder/src/Support/FieldWidth.php b/packages/builder/src/Support/FieldWidth.php new file mode 100644 index 0000000000..ca0b4fdf5c --- /dev/null +++ b/packages/builder/src/Support/FieldWidth.php @@ -0,0 +1,79 @@ + */ + public const SPANS = [ + self::FULL => 12, + '1/2' => 6, + '1/3' => 4, + '2/3' => 8, + '1/4' => 3, + '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; + } + + 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)); + } +} 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/src/Support/LocationConstraintOptions.php b/packages/builder/src/Support/LocationConstraintOptions.php new file mode 100644 index 0000000000..21f0f21429 --- /dev/null +++ b/packages/builder/src/Support/LocationConstraintOptions.php @@ -0,0 +1,855 @@ +> + */ + protected array $taxonomyKeysCache = []; + + /** + * @var array> + */ + protected array $recordTypeOptionsCache = []; + + /** + * @var array> + */ + protected array $recordStatusOptionsCache = []; + + /** + * @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 = []; + + /** + * @var array, array> + */ + protected array $recordStatusOptionsByModelCache = []; + + public function __construct( + protected EntityRegistry $entityRegistry, + protected TaxonomyService $taxonomyService, + protected BuilderLocaleResolver $localeResolver, + ) {} + + /** + * @return array + */ + 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) { + $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 $this->taxonomyKeysCache[$cacheKey] = $options; + } + + /** + * @return array + */ + public function termOptionsForTaxonomy(string $taxonomy, mixed $entities): array + { + if ($taxonomy === '') { + return []; + } + + $cacheKey = $this->entitiesCacheKey($entities).'|'.$taxonomy; + + if (array_key_exists($cacheKey, $this->termOptionsCache)) { + return $this->termOptionsCache[$cacheKey]; + } + + $modelClass = $this->taxonomyModelFor($taxonomy, $entities); + + if ($modelClass === null) { + return $this->termOptionsCache[$cacheKey] = []; + } + + return $this->termOptionsCache[$cacheKey] = $this->termOptionsForModel($modelClass); + } + + /** + * @return array + */ + public function searchTermOptionsForTaxonomy(string $taxonomy, mixed $entities, string $search, int $limit = 50): array + { + $search = trim($search); + + if ($taxonomy === '') { + return []; + } + + $modelClass = $this->taxonomyModelFor($taxonomy, $entities); + + if ($modelClass === null) { + return []; + } + + $model = $modelClass::query()->getModel(); + $query = $modelClass::query() + ->limit($limit) + ->orderBy($model->getKeyName()); + + if (method_exists($model, 'translations')) { + $query->with('translations'); + } + + 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); + } + } + + 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(); + } + + /** + * @return array + */ + public function roleOptions(): array + { + if ($this->roleOptionsCache !== null) { + return $this->roleOptionsCache; + } + + if (! $this->supportsUserRoles()) { + return $this->roleOptionsCache = []; + } + + $rolesTable = (string) config('permission.table_names.roles'); + + return $this->roleOptionsCache = 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'), + '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'), + ]; + } + + /** + * @return 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'), + ]; + + if ($this->recordTypeOptionsForEntities($entities) !== []) { + $options = [ + 'record_type' => __('builder::builder.field_group.location_param_record_type'), + ...$options, + ]; + } + + 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'); + } + + 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 $this->supportsUserRolesCache = false; + } + + $rolesTable = config('permission.table_names.roles'); + + if (! is_string($rolesTable) || $rolesTable === '') { + return $this->supportsUserRolesCache = false; + } + + if (! $this->entityRegistry->databaseTableExists($rolesTable) || ! DB::table($rolesTable)->exists()) { + return $this->supportsUserRolesCache = false; + } + + $userModel = $this->authUserModel(); + + if ($userModel === null || ! class_exists($userModel)) { + return $this->supportsUserRolesCache = false; + } + + 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 $this->userRoleUnavailableReasonCache = __('builder::builder.field_group.location_value_role_unavailable_permissions'); + } + + $rolesTable = config('permission.table_names.roles'); + + 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 $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 $this->userRoleUnavailableReasonCache = __('builder::builder.field_group.location_value_role_unavailable_user_model'); + } + + return $this->userRoleUnavailableReasonCache = null; + } + + /** + * @return array + */ + 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) { + $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 $this->recordTypeOptionsCache[$cacheKey] = $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 []; + } + + return $this->normalizeSelectOptions( + $resourceClass::getTypeSelect()->getOptions(), + fn (string $value): string => $this->recordTypeLabel($value), + ); + } + + public function termLabelForValue(string $taxonomy, mixed $entities, mixed $value): ?string + { + if (! filled($value)) { + return null; + } + + $modelClass = $this->taxonomyModelFor($taxonomy, $entities); + + 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 + { + 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 + { + $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 ($normalizedValues as $value) { + $term = $termsById[(string) $value] ?? null; + + if ($term instanceof Model) { + $labels[$value] = $this->termLabel($term); + } + } + + 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; + } + + /** + * @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 + */ + 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 + { + if (array_key_exists($modelClass, $this->recordTypeOptionsByModelCache)) { + return $this->recordTypeOptionsByModelCache[$modelClass]; + } + + $model = $modelClass::query()->getModel(); + $table = $model->getTable(); + + if (! $this->entityRegistry->databaseTableExists($table) || ! $this->entityRegistry->databaseTableHasColumn($table, 'type')) { + return $this->recordTypeOptionsByModelCache[$modelClass] = []; + } + + return $this->recordTypeOptionsByModelCache[$modelClass] = $modelClass::query() + ->whereNotNull('type') + ->where('type', '!=', '') + ->distinct() + ->orderBy('type') + ->pluck('type', 'type') + ->mapWithKeys(fn (mixed $type): array => [ + (string) $type => $this->recordTypeLabel((string) $type), + ]) + ->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'); + + 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); + } + + 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 + */ + 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 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 + */ + 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 !== '', + )); + } +} diff --git a/packages/builder/src/Support/MediaFieldValueSupport.php b/packages/builder/src/Support/MediaFieldValueSupport.php new file mode 100644 index 0000000000..29eb9e5ce5 --- /dev/null +++ b/packages/builder/src/Support/MediaFieldValueSupport.php @@ -0,0 +1,511 @@ + 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)); + } + + 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 === []) { + 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 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(ScopeValue::class)) { + return null; + } + + return 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']; + } + + if ($fieldType === 'file' && self::mediaRowIsImage($row)) { + return ['valid' => false, 'reason' => 'invalid_file_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); + + if ($snapshot === null) { + return null; + } + + if (! self::canQueryMedia()) { + return $snapshot; + } + + $media = Media::query()->find($snapshot['id']); + + if (! $media instanceof Media) { + 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(); + } + + /** + * @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 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(ScopeValue::class)) { + return trim($scope); + } + + return ScopeValue::toStringOrNull($scope); + } + + 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 @@ + + */ + public static function forField(FieldDefinition $field): array + { + if (! in_array($field->type, ['select', 'radio', 'button_group', '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/src/Support/RelationTableColumnQuery.php b/packages/builder/src/Support/RelationTableColumnQuery.php new file mode 100644 index 0000000000..9eda5dfd05 --- /dev/null +++ b/packages/builder/src/Support/RelationTableColumnQuery.php @@ -0,0 +1,391 @@ +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; + $translation = $target['translation'] ?? null; + + return $query->whereExists(function ($subquery) use ( + $field, + $entity, + $locale, + $valuesTable, + $recordKey, + $target, + $relatedTable, + $relatedKey, + $titleColumn, + $alias, + $translation, + $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']) { + $bindings = $translation !== null ? [$locale, $like] : [$like]; + $subquery->whereRaw( + $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)); + }); + + 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, + * translation?: array{ + * table: string, + * foreignKey: string, + * localeColumn: string, + * titleColumn: string, + * softDeletes: 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; + $translation = $target['translation'] ?? null; + + $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, + $translation, + $locale, + ); + } + + $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"); + + 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)", + }; + } + + /** + * @param array{ + * table: string, + * foreignKey: string, + * localeColumn: string, + * titleColumn: string, + * softDeletes: bool, + * }|null $translation + * @return \Illuminate\Database\Query\Builder + */ + protected function multipleTitleSubquery( + \Illuminate\Database\Query\Builder $query, + string $valuesTable, + string $relatedTable, + string $relatedKey, + string $titleColumn, + string $alias, + ?array $translation, + string $locale, + ) { + $titleExpression = $translation !== null + ? 'MIN('.self::TRANSLATION_ALIAS.'.'.$titleColumn.')' + : "MIN({$alias}.{$titleColumn})"; + + $baseQuery = match (DB::connection()->getDriverName()) { + 'mysql' => $query + ->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'), + default => $query + ->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'), + }; + + 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 + {$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 + {$translationJoin} + WHERE {$titleRef} LIKE ? + )", + }; + } + + /** + * @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 + { + $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 new file mode 100644 index 0000000000..d9b2ef9ddf --- /dev/null +++ b/packages/builder/src/Support/RelationTargetResolver.php @@ -0,0 +1,554 @@ + [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, + ) {} + + /** + * @return class-string|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 []; + } + + try { + $query = $this->scopedQuery($entity, $modelClass); + + if ($term !== '') { + $this->applySearchFilter($query, $modelClass, $term); + } + + if ($this->modelUsesTranslations($modelClass)) { + $query->with('translations'); + } + + $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 []; + } + + $memo = $this->labelMemo[$entity] ?? []; + $missing = array_values(array_filter($ids, fn (mixed $id): bool => ! array_key_exists($id, $memo))); + + if ($missing !== []) { + $modelClass = $this->modelClass($entity); + + if ($modelClass === null || ! $this->entityRegistry->modelIsQueryable($modelClass)) { + return $this->pickLabels($memo, $ids); + } + + $model = new $modelClass; + + try { + $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); + } + + $this->labelMemo[$entity] = $memo; + } catch (QueryException) { + return $this->pickLabels($memo, $ids); + } + } + + return $this->pickLabels($memo, $ids); + } + + /** + * @param array $memo + * @param list $ids + * @return array + */ + protected function pickLabels(array $memo, array $ids): array + { + $labels = []; + + foreach ($ids as $id) { + if (array_key_exists($id, $memo)) { + $labels[$id] = $memo[$id]; + } + } + + 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 $this->scopedQuery($entity, $modelClass)->whereKey($id)->exists(); + } catch (QueryException) { + return false; + } + } + + /** + * @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 + { + $modelClass = $this->modelClass($entity); + + if ($modelClass === null || ! $this->entityRegistry->modelIsQueryable($modelClass)) { + return null; + } + + $model = new $modelClass; + $translation = null; + $titleColumn = 'id'; + + foreach (['title', 'name', 'label', 'common_name', 'symbol', 'code'] as $candidate) { + if ($this->modelHasColumn($modelClass, $candidate)) { + $titleColumn = $candidate; + break; + } + } + + 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; + } + + /** + * 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); + + 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_title', + 'display_name', + 'title', + 'name', + 'label', + 'common_name', + 'symbol', + 'code', + ]; + + if ($modelClass === null) { + return $candidates; + } + + return array_values(array_filter( + $candidates, + fn (string $candidate): bool => $this->modelHasColumn($modelClass, $candidate) + || $this->modelHasAccessor($modelClass, $candidate) + || $this->modelHasTranslatableAttribute($modelClass, $candidate), + )); + } + + protected function titleAttributeFor(string $entity): string + { + $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 + { + $columns = []; + + 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; + } + + $query->whereHas('translations', function (Builder $translationQuery) use ($attribute, $term): void { + $translationQuery->where($attribute, 'like', "%{$term}%"); + }); + + return; + } + } + + /** + * @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( + SoftDeletes::class, + class_uses_recursive($translationModelClass), + true, + ), + ]; + } + + /** + * @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 + */ + 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) && $this->entityRegistry->databaseTableHasColumn($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/RichTextValue.php b/packages/builder/src/Support/RichTextValue.php new file mode 100644 index 0000000000..b97bce2031 --- /dev/null +++ b/packages/builder/src/Support/RichTextValue.php @@ -0,0 +1,135 @@ +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 === '') { + return true; + } + + if (is_array($value)) { + return self::isEmptyDocument($value); + } + + if (! is_string($value)) { + return blank($value); + } + + return self::isEmptyHtml($value); + } + + /** + * @param array $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) === ''; + } +} diff --git a/packages/builder/src/Support/StorableFieldCollector.php b/packages/builder/src/Support/StorableFieldCollector.php new file mode 100644 index 0000000000..4193b8ec82 --- /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 (in_array($field->type, ['tab', 'section'], true)) { + return $this->definitionsFromList($field->children); + } + + $fieldType = $this->fieldTypeRegistry->get($field->type); + + if (! $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 (in_array($type, ['tab', 'section'], true)) { + $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); + } +} diff --git a/packages/builder/src/Support/TypedValueColumns.php b/packages/builder/src/Support/TypedValueColumns.php new file mode 100644 index 0000000000..96dd05ca6c --- /dev/null +++ b/packages/builder/src/Support/TypedValueColumns.php @@ -0,0 +1,86 @@ + + */ + 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', 'rich_text' => 'value_text', + 'number', 'range' => 'value_decimal', + 'date' => 'value_date', + 'datetime' => 'value_datetime', + 'toggle' => 'value_boolean', + 'multiselect', 'checkbox_list', 'link', 'relation', 'image', 'gallery', 'file', 'group', 'repeater', 'flexible_content' => 'value_json', + 'button_group' => 'value_string', + default => 'value_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); + } + + public static function isRelationColumnType(string $type): bool + { + return $type === 'relation'; + } + + /** + * @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..b5cc99678e --- /dev/null +++ b/packages/builder/tests/Feature/HasCustomFieldsTest.php @@ -0,0 +1,141 @@ +createItemsTable(); + + 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('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(); + + 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']); + + session([BuilderLocaleResolver::ADMIN_SESSION_KEY => 'de_CH']); + request()->merge(['lang' => 'de_CH']); + + $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) + ->and($values['fahrzeugtyp-modell']->locale)->toBe(app(BuilderLocaleResolver::class)->defaultLocale()); +}); diff --git a/packages/builder/tests/Pest.php b/packages/builder/tests/Pest.php new file mode 100644 index 0000000000..367baefc51 --- /dev/null +++ b/packages/builder/tests/Pest.php @@ -0,0 +1,5 @@ +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/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 @@ +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'); + $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))); + $this->applyTranslatableConfig($app['config']); + } + + 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', + '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"; + + 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/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 new file mode 100644 index 0000000000..38c53e4790 --- /dev/null +++ b/packages/builder/tests/Unit/CapabilitiesTest.php @@ -0,0 +1,154 @@ + 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'); +}); + +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 new file mode 100644 index 0000000000..21efe3644b --- /dev/null +++ b/packages/builder/tests/Unit/DefinitionRegistryTest.php @@ -0,0 +1,123 @@ +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']); +}); + +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 new file mode 100644 index 0000000000..aa9017bcb8 --- /dev/null +++ b/packages/builder/tests/Unit/EntityRegistryTest.php @@ -0,0 +1,188 @@ +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->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('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); + + 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..f105cefbc0 --- /dev/null +++ b/packages/builder/tests/Unit/FieldGroupImportExportTest.php @@ -0,0 +1,353 @@ +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 new file mode 100644 index 0000000000..07fca77c4e --- /dev/null +++ b/packages/builder/tests/Unit/FieldGroupPersistenceTest.php @@ -0,0 +1,253 @@ +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'); +}); + +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 new file mode 100644 index 0000000000..0cd43efd77 --- /dev/null +++ b/packages/builder/tests/Unit/FieldTypeRegistryTest.php @@ -0,0 +1,74 @@ +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 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..e98db73fa2 --- /dev/null +++ b/packages/builder/tests/Unit/FieldValidationRulesTest.php @@ -0,0 +1,94 @@ +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(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(FieldValueValidator::class)->assertValid($field, 5)) + ->toThrow(ValidationException::class); +}); diff --git a/packages/builder/tests/Unit/FieldValueIntegrityTest.php b/packages/builder/tests/Unit/FieldValueIntegrityTest.php new file mode 100644 index 0000000000..7603d65edd --- /dev/null +++ b/packages/builder/tests/Unit/FieldValueIntegrityTest.php @@ -0,0 +1,204 @@ +createItemsTable(); +}); + +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', + ]); + + TestItem::deleted(function (TestItem $deleted): void { + app(FieldValuePurger::class)->purgeForRecord('item', $deleted->getKey()); + }); + + $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('stores password values hashed', function (): void { + $record = TestItem::query()->create(['title' => 'Demo']); + $fields = collect([ + new FieldDefinition('secret', 'Secret', 'password'), + ]); + + app(CustomFieldsManager::class)->saveValues('item', $record, [ + 'secret' => 'plain-text', + ], $fields); + + $stored = FieldValue::query()->forRecord('item', $record->getKey())->first(); + + expect($stored?->value_string)->not->toBe('plain-text') + ->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('treats legacy hashed password values as stored markers when loading', 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(CustomFieldsManager::class)->loadValues( + 'item', + $record, + collect([new FieldDefinition('secret', 'Secret', 'password')]), + ); + + expect($loaded['secret'])->toBe(StoredPassword::instance()); +}); + +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/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/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/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/LinkFieldTypeTest.php b/packages/builder/tests/Unit/LinkFieldTypeTest.php new file mode 100644 index 0000000000..6f4a74dbe3 --- /dev/null +++ b/packages/builder/tests/Unit/LinkFieldTypeTest.php @@ -0,0 +1,106 @@ +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(TextInput::class); +}); diff --git a/packages/builder/tests/Unit/LocationConstraintOptionsTest.php b/packages/builder/tests/Unit/LocationConstraintOptionsTest.php new file mode 100644 index 0000000000..f213183175 --- /dev/null +++ b/packages/builder/tests/Unit/LocationConstraintOptionsTest.php @@ -0,0 +1,491 @@ +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(EntityRegistry::class), + app(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(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(): Select + { + return 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(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); + + 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(): Select + { + return 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(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(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); +}); + +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', + ]); +}); + +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 +{ + use HasRoles; +} diff --git a/packages/builder/tests/Unit/LocationContextTest.php b/packages/builder/tests/Unit/LocationContextTest.php new file mode 100644 index 0000000000..4d4ae1a148 --- /dev/null +++ b/packages/builder/tests/Unit/LocationContextTest.php @@ -0,0 +1,45 @@ + $locale === 'de_DE' + ? TranslationStatus::PUBLISHED + : TranslationStatus::DRAFT, + ]; + } + }; + + $record->setRawAttributes(['status' => 'draft']); + + app()->instance('request', 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 new file mode 100644 index 0000000000..046107a750 --- /dev/null +++ b/packages/builder/tests/Unit/LocationMatcherTest.php @@ -0,0 +1,250 @@ + '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' => 'template', 'operator' => '==', 'value' => 'landing'], + ], + ]; + + 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(); +}); + +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 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; + + $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 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 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 new file mode 100644 index 0000000000..143417943d --- /dev/null +++ b/packages/builder/tests/Unit/OptionValueRulesTest.php @@ -0,0 +1,58 @@ + '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); +}); 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..ecbeaa3dcb --- /dev/null +++ b/packages/builder/tests/Unit/RelationFieldTypeTest.php @@ -0,0 +1,409 @@ +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([]); +}); + +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()); + + $queryTarget = $resolver->queryTarget('test_category_like'); + + expect($queryTarget)->not->toBeNull() + ->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/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 new file mode 100644 index 0000000000..826eac5362 --- /dev/null +++ b/packages/builder/tests/Unit/SchemaCompilerTest.php @@ -0,0 +1,128 @@ +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'); +}); + +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..ed603c2d15 --- /dev/null +++ b/packages/builder/tests/Unit/TableColumnCompilerTest.php @@ -0,0 +1,1080 @@ +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()]); +}); + +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(); + + $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/TableFilterCompilerTest.php b/packages/builder/tests/Unit/TableFilterCompilerTest.php new file mode 100644 index 0000000000..12aadc25ed --- /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(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/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/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(); +}); diff --git a/packages/builder/tests/bootstrap.php b/packages/builder/tests/bootstrap.php new file mode 100644 index 0000000000..c12505d1d4 --- /dev/null +++ b/packages/builder/tests/bootstrap.php @@ -0,0 +1,19 @@ + + */ + public static function customFieldComponents(string $placement = 'main'): 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 []; + } + + /** + * 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/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 @@ +getProperty('loaded'); - $property->setAccessible(true); $property->setValue($translator, []); } } diff --git a/packages/core/src/Traits/InteractsWithCustomFields.php b/packages/core/src/Traits/InteractsWithCustomFields.php new file mode 100644 index 0000000000..bdbe0b5e7c --- /dev/null +++ b/packages/core/src/Traits/InteractsWithCustomFields.php @@ -0,0 +1,16 @@ +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(); + } } diff --git a/packages/data/src/Jobs/ImportStaticDataJob.php b/packages/data/src/Jobs/ImportStaticDataJob.php index c02c3270f5..c4b4afa330 100644 --- a/packages/data/src/Jobs/ImportStaticDataJob.php +++ b/packages/data/src/Jobs/ImportStaticDataJob.php @@ -190,10 +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 = []; + // Variant language codes to skip during import + // These are regional variants/creoles that aren't needed + $skipVariantCodes = [ + 'gsw', // skip german swiss variant for example + ]; $separateLanguageCodes = config('rest-countries.separate_language_codes', ['gsw']); diff --git a/packages/devlink/config/devlink.php b/packages/devlink/config/devlink.php index c2a907b9ee..88d9cdfd81 100644 --- a/packages/devlink/config/devlink.php +++ b/packages/devlink/config/devlink.php @@ -151,6 +151,11 @@ 'path' => $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/draft/src/Models/Draft.php b/packages/draft/src/Models/Draft.php index 3d0b06052d..a48dfc307b 100644 --- a/packages/draft/src/Models/Draft.php +++ b/packages/draft/src/Models/Draft.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Moox\Core\Entities\Items\Draft\BaseDraftModel; +use Moox\Core\Traits\InteractsWithCustomFields; use Moox\Core\Traits\Taxonomy\HasModelTaxonomy; use Moox\Draft\Database\Factories\DraftFactory; use Moox\Media\Traits\HasMediaUsable; @@ -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..f9a3163dc5 100644 --- a/packages/draft/src/Resources/DraftResource.php +++ b/packages/draft/src/Resources/DraftResource.php @@ -18,6 +18,7 @@ use Filament\Tables\Table; use Illuminate\Validation\Rules\Unique; use Moox\Core\Entities\Items\Draft\BaseDraftResource; +use Moox\Core\Traits\HasCustomFields; use Moox\Core\Traits\Tabs\HasResourceTabs; use Moox\Core\Traits\Taxonomy\HasResourceTaxonomy; use Moox\Draft\Models\Draft; @@ -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), @@ -134,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(), @@ -166,6 +170,7 @@ public static function table(Table $table): Table ->boolean() ->label('Active') ->sortable(), + ...static::customFieldColumns(), TextColumn::make('description') ->limit(50) ->toggleable(isToggledHiddenByDefault: true), @@ -201,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/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..72418296eb 100644 --- a/packages/item/composer.json +++ b/packages/item/composer.json @@ -18,7 +18,7 @@ } ], "require": { - "moox/core": "self.version" + "moox/core": "dev-main" }, "autoload": { "psr-4": { 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); } diff --git a/packages/item/src/Models/Item.php b/packages/item/src/Models/Item.php index 4262423ca4..2610ab5797 100644 --- a/packages/item/src/Models/Item.php +++ b/packages/item/src/Models/Item.php @@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Moox\Core\Entities\Items\Item\BaseItemModel; +use Moox\Core\Traits\InteractsWithCustomFields; use Moox\Item\Database\Factories\ItemFactory; class Item extends BaseItemModel { - use HasFactory; + use HasFactory, InteractsWithCustomFields; protected $fillable = [ 'title', diff --git a/packages/item/src/Resources/ItemResource.php b/packages/item/src/Resources/ItemResource.php index 47cb2101fa..5130b92e97 100644 --- a/packages/item/src/Resources/ItemResource.php +++ b/packages/item/src/Resources/ItemResource.php @@ -12,6 +12,7 @@ use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Moox\Core\Entities\Items\Item\BaseItemResource; +use Moox\Core\Traits\HasCustomFields; use Moox\Item\Models\Item; use Moox\Item\Resources\ItemResource\Pages\CreateItem; use Moox\Item\Resources\ItemResource\Pages\EditItem; @@ -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), @@ -72,6 +76,7 @@ public static function form(Schema $form): Schema ->schema([ static::getFormActions(), ]), + ...static::customFieldComponents('sidebar'), Section::make('') ->schema([ ...static::getStandardTimestampFields(), @@ -97,6 +102,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') @@ -106,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/Models/Record.php b/packages/record/src/Models/Record.php index ea133780de..eede5431e8 100644 --- a/packages/record/src/Models/Record.php +++ b/packages/record/src/Models/Record.php @@ -4,12 +4,13 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; use Moox\Core\Entities\Items\Record\BaseRecordModel; +use Moox\Core\Traits\InteractsWithCustomFields; use Moox\Core\Traits\Taxonomy\HasModelTaxonomy; use Moox\Record\Enums\RecordStatus; class Record extends BaseRecordModel { - use HasModelTaxonomy; + use HasModelTaxonomy, InteractsWithCustomFields; protected $fillable = [ 'title', diff --git a/packages/record/src/Resources/RecordResource.php b/packages/record/src/Resources/RecordResource.php index c19b847285..44a4b302e7 100644 --- a/packages/record/src/Resources/RecordResource.php +++ b/packages/record/src/Resources/RecordResource.php @@ -10,6 +10,7 @@ use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Moox\Core\Entities\Items\Record\BaseRecordResource; +use Moox\Core\Traits\HasCustomFields; use Moox\Core\Traits\Tabs\HasResourceTabs; use Moox\Core\Traits\Taxonomy\HasResourceTaxonomy; use Moox\Record\Enums\RecordStatus; @@ -22,6 +23,7 @@ class RecordResource extends BaseRecordResource { + use HasCustomFields; use HasResourceTabs; use HasResourceTaxonomy; @@ -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), @@ -103,6 +106,7 @@ public static function form(Schema $form): Schema ->schema([ static::getAuthorSelect(), ]), + ...static::customFieldComponents('sidebar'), Section::make('') ->schema([ ...static::getStandardCopyableFields(), @@ -140,6 +144,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') @@ -153,6 +158,7 @@ public static function table(Table $table): Table ->toolbarActions([...static::getBulkActions()]) ->filters([ ...static::getTaxonomyFilters(), + ...static::customFieldFilters(), ])->deferFilters(false) ->persistFiltersInSession(); } 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);