# Dynamic Fields (Developer Guide)

This document explains how **Dynamic Fields** work in Upimo/LncShared: how they are defined (`dynamic_fields` table), how they are stored on `DynamicModel` instances (inside a JSON attribute like `data`), how they validate/cast, and how the front-end expects them to look.

## Concepts

- **DynamicField** (`LncShared\App\Models\DynamicField`): the schema/config for one dynamic field (name, type, where it is stored, and UI/validation metadata).
- **DynamicModel** (`LncShared\App\Models\DynamicModel`): an Eloquent base model that makes dynamic fields behave like normal attributes (read, write, validation rules, fillable, serialization).
- **Target JSON** (`target_json`): the JSON attribute on the model where dynamic values are stored (default is `data`).
- **Field meta** (`meta` JSON): UI-oriented properties stored on the `DynamicField` record (order, input type, select values, etc.).
- **DynamicFieldBank**: request-level cache so each model’s dynamic field definitions are only queried once per request.

## Database schema (`dynamic_fields`)

Migration: `lncshared/database/migrations/2018_11_08_140359_create_dynamic_fields_table.php`

- **id**: UUID primary key
- **model**: fully qualified PHP class name of the model, e.g. `App\Models\Party`
- **unique_target**: nullable string (optional scoping / legacy usage)
- **field_name**: the attribute name exposed on the API/model (snake_case recommended)
- **target_json**: the JSON attribute that stores values (e.g. `data`)
- **field_type**: one of: `string`, `long`, `double`, `date`, `boolean`, `image`
- **meta**: jsonb, defaults to `{}`
- **timestamps**

## Defining dynamic fields

### Core attributes (columns)

When creating/updating a `DynamicField`, the important columns are:

- **model** _(required)_: must be an existing class (`class_exists()` is enforced).
- **field_type** _(required)_: must be one of `DynamicField::$field_types`.
- **display_name** _(required)_: used for UI labels (and can derive `field_name`).
- **field_name** _(optional)_:
  - If empty on create/update, it is derived from `display_name` to a snake_case-like format.
  - Uniqueness is enforced per `(model, field_name)` (duplicate throws `DuplicateDynamicFieldException`).
- **target_json**:
  - If null on create, defaults to the model’s `$defaultJsonTarget` (usually `data`).

### Meta properties (stored in `meta` JSON)

`DynamicField::$meta_fields` are treated as first-class properties on `DynamicField` even though they are stored inside `meta`.

Supported meta fields:

- **order**: integer sort key (lower first). `DynamicFieldBank` sorts by `meta.order` (default 100000).
- **description**: string (tooltip/help text)
- **user_description**: array
- **display_name**: string (required by rules)
- **input_type**: one of `DynamicField::$input_types` (`text`, `currency`, `country`, `textarea`, `image`, `select`, `dropdown`, `separator`)
- **validation**: string (Laravel rule string, pipe-separated). If set, it **overrides** the default type rule.
- **placeholder**: string
- **image_dimension**: string/int, format:
  - `200` (square) or `"200"`
  - `"200*300"` (width\*height)
  - Minimum value is 100 for each dimension
- **is_currency**: boolean-like (used by front-end formatting)
- **xl_format**: (export-related)
- **no_edit**: boolean (UI can disable editing)
- **number_format**:
  - for **numbers** (`long`, `double`): numeral.js format string (e.g. `(0,0.00)`, `0,0`, `0.00%`, etc.)
  - for **dates** (`date`): optional **moment.js output format** (e.g. `DD/MM/YY`, `DD/MM/YYYY`). Used by the `dynamic-field` Vue filter when no explicit format argument is provided.
- **select_values**: list of allowed values (normalized to **newline-separated** values when saved)
- **dropdown_value**: string

Notes:

- Setting `select_values` requires at least **2** values.
- Changing `field_type` clears `input_type` and `number_format` on update.

### `input_type = separator` (profile-only helper)

`separator` is a **display helper** meant for read-only profile screens (the `fields-list` component). It renders as a **full-width horizontal separator** (`<hr>`) and may show an optional title.

- **Where it renders**: in `fields-list` (profile display)
- **Value**: ignored (the separator is shown even when the value is null and `hideNull=true`)
- **Title**: use `display_name` as the separator label (optional)
- **Placement**: use `order` to position it among other fields

Example meta:

```json
{
  "display_name": "Facturation",
  "input_type": "separator",
  "order": 150
}
```

### Foreign autocomplete fields (`input_type = foreign_autocomplete`)

For **foreign-key** style fields (store a UUID/id, display a human label), the UI supports a dedicated input type: `foreign_autocomplete`.

#### How it works (high level)

- **Stored value**: the field `value` remains the **id/uuid** (string)
- **Displayed value**:
  - In **forms**, the component fetches suggestions remotely via an API `lookup` endpoint.
  - In **profile/read-only views**, the backend can provide `display_value` to render a friendly label without extra requests.

#### Required field metadata

Set these on the formatted field payload (resource) or dynamic field meta:

- `**input_type: "foreign_autocomplete"`\*\*
- `**fk_preset: "<Model>.<field>"`\*\* (example: `"Lease.client_id"`)

`fk_preset` is a key into the front-end preset registry (see `resources/assets/js/data/foreign_key_properties.json`). A preset defines:

- `**resource**`: API resource name used to build the lookup route `api.<resource>.lookup`
- `**display_template**`: how an option is labeled (supports `{field}` placeholders and optional blocks `{field?}...{/field?}`)
- `**min_chars**`: minimum characters before querying
- `**limit**`: max results returned

Example preset (frontend):

```json
{
  "Lease": {
    "client_id": {
      "resource": "parties",
      "display_template": "{name}{full_name?} — {full_name}{/full_name?}",
      "min_chars": 2,
      "limit": 20
    }
  }
}
```

#### Backend requirements

1. **Lookup endpoint**: the resource must expose `GET /api/<resource>/lookup` named `api.<resource>.lookup` that supports:

- `?q=<string>&limit=<n>` for search
- `?id=<uuid>` for prefill by id

1. **Profile display (optional but recommended)**: include a `display_value` in the formatted field object so the `dynamic-field` filter can show the label.

Minimal field shape example returned by a resource:

```json
{
  "name": "client_id",
  "display_name": "Locataire",
  "value": "57e269a0-4d27-11f0-b045-c53381c8acda",
  "type": "string",
  "input_type": "foreign_autocomplete",
  "fk_preset": "Lease.client_id",
  "display_value": "ACME"
}
```

## How values are stored on models

Dynamic values live inside the model attribute named by `target_json` (usually `data`), as a map keyed by `field_name`.

Example storage shape:

```json
{
  "data": {
    "custom_field_1": "hello",
    "has_kyc": true,
    "inception_date": "2024-01-31",
    "logo": "https://cdn.example.com/media/abc.png"
  }
}
```

### Reading values

On a `DynamicModel`, accessing `$model->custom_field_1`:

- Looks up the `DynamicField` definition for that `field_name`.
- Reads the raw value from `$model->{$target_json}[$field_name]` if present.
- Special case: `date` values are formatted using the model’s `getDateFormat()` (invalid dates return null).

### Writing values

Setting `$model->custom_field_1 = $value`:

- Writes into `$model->{$target_json}[$field_name]`.
- Special cases:
  - **Reset**: the string `'n.a.'` is treated as `null` (removes key from JSON).
  - **null**: removes the key from the JSON.
  - **boolean**: accepts extended boolean inputs (e.g. yes/no) via `ExtendedBool::getBool()`, otherwise throws.
  - **date**: parsed using `asDateTime()`, stored formatted using `getDateFormat()`, otherwise throws `InvalidDateException`.
  - **image**: stored via `Image::save($value, '', $dynamicField->image_dimension)` and the returned URL is saved.
  - **long**: cast to integer via `settype($value, 'integer')`.

Important: `DynamicModel` expects the target JSON attribute (e.g. `data`) to be cast to **array**. If it is a string, it attempts to `json_decode()` it; if it’s not an array afterwards, execution stops (`dd()`).

## Validation rules

### Field definition validation (`DynamicField::rules()`)

When validating a `DynamicField` definition itself:

- `model`: required string
- `field_type`: required, must be in allowed list
- `display_name`: required string
- `meta`: json
- plus other optional columns (`field_name`, `target_json`, etc.)

### Model payload validation (dynamic field values)

`DynamicModel::dynamicRules()` produces a rules array mapping:

- `field_name` → `$dynamicField->validation_rule`

`DynamicField::validation_rule` default behavior:

- **string**:
  - if `input_type == select`: `nullable|string|in:<values>` (values are taken from `select_values`)
  - else: `nullable|string`
- **long/double**: `nullable|numeric`
- **date**: `nullable|MultiDate`
- **boolean**: `nullable|ExtendedBool`
- **image**: `nullable|DynamicFieldImagePayload`
- **override**: if `validation` meta is set, it returns `explode('|', $validation)` instead of defaults.

### Image payload rule (`DynamicFieldImagePayload`)

Accepted values:

- `null` / `""`
- `'n.a.'` (treated as reset)
- `UploadedFile` with `image/`\* MIME type
- `data:image/...;base64,...` (jpeg/png/gif/webp/bmp/svg+xml)
- `http(s)://...` URL
- absolute path-like string ending in `.jpg|.jpeg|.png|.gif|.webp` (e.g. `/media/x.png`)

Rejected:

- non-strings (unless UploadedFile)
- strings larger than 5MB
- strings that are neither data URL nor URL nor allowed path format

## Serialization and API shape

`DynamicModel::toArray()` merges:

- every dynamic field name → its computed value
- plus the parent model array
- then filters out null/empty scalar values (arrays are kept)

This means API responses for a `DynamicModel` typically expose dynamic fields as top-level properties **even though** they are stored in a hidden JSON attribute (like `data`).

## Fillable + Strict Mode

`DynamicModel::getFillable()` includes:

- the model’s `$fillable`
- plus **all** dynamic field names

So bulk assignment (mass update) can accept dynamic fields by name.

If a model uses the `StrictMode` trait:

- extra keys not in `getFillable()`:
  - **throw** a `StrictModeException` when strict mode is enabled via request header
  - otherwise, add warnings like:
    - code `4006`
    - message `"Trying to fill non-existing fields: [<field>]."`

Also note: strict mode normalizes incoming keys with `trim()` (helps when front-end sends keys with stray spaces).

## Front-end behavior (display/formatting)

The Vue filter `dynamic-field` in `lncshared/resources/assets/js/extension.js` expects a field object shaped like:

- `type` (dynamic field type, e.g. `long`, `date`, `image`)
- `value` (actual value)
- optional meta used for formatting:
  - `number_format`
  - `is_currency`
  - `input_type` (textarea/select/image)

Key formatting rules:

- **numbers** (`double`, `long`):
  - formatted via numeral.js using `number_format` (or provided `format`)
  - can prefix currency symbol if `model.Currency.value` exists and field `is_currency` is truthy
- **date**:
  - default display format `MMM-YY`
  - if `number_format` is set (on the field object), it is used as the **moment.js output format** when the caller does not pass an explicit `format`
  - uses moment.js; accepts values as string or `{ date: "..." }`
- **string**:
  - textarea turns newlines into `<br>`
- **boolean**:
  - displays `Yes`/`No`/`-`
- **image**:
  - list component uses an `<img>` branch when `field.type === 'image'`

## Examples

### 1) Create a string select dynamic field

Definition (conceptually):

- `model`: `App\Models\Party`
- `display_name`: `Investor Type`
- `field_type`: `string`
- `input_type`: `select`
- `select_values`:
  - `Institutional`
  - `Family Office`
  - `Individual`
- `target_json`: `data` (or leave null to use default)

Value payload example:

```json
{
  "InvestorType": "Institutional"
}
```

The server-side validation will enforce the value is one of the `select_values`.

### 2) Reset a dynamic field value

Send either:

- `null` (removes the key from JSON), or
- `'n.a.'` (also resets to null)

### 3) Image field payload

Any of the following are accepted:

- a base64 data URL:num
  - `"data:image/png;base64,iVBORw0KGgo..."`
- an existing URL:
  - `"https://cdn.example.com/media/logo.png"`

On write, the backend stores the final URL returned by `Image::save()`.

## Gotchas / troubleshooting

- `**target_json` must be an array cast\*\*: ensure your `DynamicModel` child casts `data` (or the chosen target) to `array`. If not, `setDynamicFieldValue()` may `dd()` the request.
- **Renaming** `field_name` **migrates old values**: updating `field_name` triggers a loop over _all_ model records and moves values from old name to new name inside JSON. This can be expensive on large tables.
- `**validation` overrides defaults\*\*: if you set `validation`, it replaces the automatic type rule entirely. Include `nullable` if you want null allowed.
