diff --git a/CHANGELOG.md b/CHANGELOG.md
index fc179b1d..7784003f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@ All notable changes to the Wire ecosystem will be documented in this file.
## [1.17.4]
### Fixed
+- **An action modal handed its callback raw widget state, so a cleared number input arrived as `''` and killed the insert: `SQLSTATE[22007] Incorrect decimal value: '' for column 'total_discount_percent'`.** The write path has had a named seam since ADR 0021 — a field shapes its own value on the way out, which is how a cleared `Select` becomes `null` instead of `''` and how a `DateTimePicker` applies its storage format and zone — but the seam had exactly two hosts: `Form::save()` (through `SaveHandler`) and an editable table cell (`updateTableCell()`). **An action modal was not one of them.** `submitActionModal()` / `callMountedAction()` read `mountedActions.{depth}.data` and passed it to the callback untouched, so the same schema wrote different values depending on which host persisted it: `Select::dehydrateState()`'s documented `'' → null` rule simply did not apply inside a modal, and `->form([...])` plus `->action(fn (array $data) => Model::create($data))` — the shape every create modal has — put an empty string into whatever column the user had cleared. The schema walk moved out of `SaveHandler` into `Forms\Runtime\StateDehydrator`, the canonical owner both hosts now call, and the action runtime gained the seam next to the validation one it already had (`dehydrateMountedActionFormData()`, a no-op in core, overridden by the wire-forms bridge, so a form-free host is unaffected). Wizards dehydrate every step, not just the one on screen at submit; repeater children are dehydrated per item. Footer actions are deliberately excluded: they read the form mid-edit and write back into the same bag, so dehydrating there would hand the callback a value the form no longer holds — and would run a `FileUpload`'s store on a form the user never submitted.
+- **`TextInput` had no dehydration at all, so `->numeric()` was a rendering hint and nothing more.** An emptied ` ` submits `''`, which no numeric column can hold: MySQL in strict mode refuses the write and a lenient driver silently stores `0`. Neither is what an author who left an optional amount blank asked for, and unlike the modal bug above this one reached `Form::save()` too. A field whose HTML type is `number` (`numeric()`, `integer()`, `type('number')`) now stores `null` when it is left empty. Every other type is untouched on purpose — `''` is a legitimate string, and nulling it would break a non-nullable text column rather than save it.
- **An action modal prefilled from an enum-cast column died on `Select::getOptionLabel(): Argument #1 ($value) must be of type string|int|null, App\Enums\Status given`.** `fillFormUsing(fn ($record) => ['status' => $record->status])` is the obvious way to write a prefill, and on a column with an enum cast the attribute is the case object, not its backing value. That object went into the modal's form-data bag untouched: `HasModal::getFormDefaults()` returned `$overrides + $seed` raw, and the bag is written **straight into Livewire state** by the host (`WithActions`, `InteractsWithTableActions`, `WithTable`) — the one seeding path that never passes through the form runtime, whose `StateManager::fill()` has collapsed enums to scalars all along. So the enum reached the field's own render, where `getSelectedOptionLabels()` handed it to a `string|int|null` parameter and PHP fataled — a 500 on opening the modal, before the user could touch anything. Two things were wrong with it beyond the crash: an enum instance cannot round-trip through Livewire state to the browser, and a `` compares its `` against the scalar key, so even a field that survived the render would have shown nothing selected. `getFormDefaults()` now collapses the whole bag through the canonical `EnumResolver::scalarDeep()` — nested rows and multi-select arrays included — so the seeded state carries backing values, the option matches, and the choice still saves back through the cast. `Select::getSelectedOptionLabels()` normalises its own input the same way as a second line of defence, because its parameter is `mixed` and a host can also write the bag itself (an `$set`, a public property assigned from a model). Core gained no knowledge of `wire-forms` for it: `EnumResolver` is a Foundation resolver core already owns.
- **A table with a `copyable()` column parked a “Copied!” pill in its top-left corner, from first render.** The feedback pill is one element for the whole page, rendered `hidden` by `copy-assets.blade.php` and shown by the copy controller beside whichever button was pressed. `hidden` did not hide it: the pill also carries `inline-flex`, and Tailwind states the attribute as `[hidden]:where(:not([hidden="until-found"])){display:none}` — `:where()` contributes nothing to specificity, so that rule ties with `.inline-flex` and loses on order, utilities being emitted after preflight. The pill was therefore painted before anything was ever copied, and since it is `position: fixed` with no coordinates until the controller places it, it fell back to its static position: the corner of the table. Tailwind 4.1 hides the damage by marking its own rule `!important`, which is why the workbench previews never showed it and only apps on Tailwind 3.x or 4.0.x saw the stray pill — both inside the support range of ADR 0005. The partial now carries the same scoped rule the fill handle already uses, `[data-copy-feedback][hidden] { display: none; }`, which outranks the utility on its own specificity and so holds whatever Tailwind the consumer builds with. No change to the shipped `wire-core-copy.js`. The `copy-cell` browser driver read `p.hidden` — the IDL property, which reports a fully painted element as hidden — and now reads `getComputedStyle(p).display` on both sides of a copy.
diff --git a/docs/core/actions.md b/docs/core/actions.md
index 4fea0367..39614312 100644
--- a/docs/core/actions.md
+++ b/docs/core/actions.md
@@ -186,6 +186,8 @@ Action::make('edit')
The closure may hand back an enum case straight off a cast attribute (`fn ($record) => ['role' => $record->role]`): the seeded bag collapses every enum to its backing value, because that is what Livewire state carries to the browser and what a `Select` matches its ` ` values against. The choice still saves back through the cast.
+The `$data` the callback receives is what the form **would have persisted**, not the raw widget state: on submit the modal runs the same field-level dehydration as `Form::save()` (see [Custom fields](../forms/custom-fields.md#shaping-the-value-a-field-stores)). A cleared `Select` arrives as `null` rather than `''`, a cleared `numeric()` `TextInput` as `null`, a `DateTimePicker` in its storage format and zone, and a `FileUpload` as its stored path. A wizard dehydrates every step, not only the one on screen at submit. Footer actions are deliberately excluded — they read the form mid-edit, before it is submitted.
+
A `HeaderAction` form modal has **no record**, so its `fillFormUsing` closure takes no arguments. Use it to seed initial state — and always seed array-typed fields (`CheckboxList`, `Tags`, multiple `Select`) with an empty array so they bind correctly from the first interaction:
```php
diff --git a/docs/cs/core/actions.md b/docs/cs/core/actions.md
index 638e5720..e76d4481 100644
--- a/docs/cs/core/actions.md
+++ b/docs/cs/core/actions.md
@@ -186,6 +186,8 @@ Action::make('edit')
Closura může vrátit případ enumu rovnou z castovaného atributu (`fn ($record) => ['role' => $record->role]`): naplněný bag každý enum srazí na jeho backing hodnotu, protože právě ta putuje ve stavu Livewiru do prohlížeče a právě proti ní `Select` porovnává hodnoty svých ` `. Uložení pak proběhne zpátky přes cast.
+`$data`, která callback dostane, je to, co by formulář **uložil** — ne syrový stav widgetu: při odeslání proběhne nad modalem stejná dehydratace polí jako u `Form::save()` (viz [Vlastní pole](../forms/custom-fields.md#transformace-stavu-pole)). Vyprázdněný `Select` přijde jako `null` místo `''`, vyprázdněný `numeric()` `TextInput` jako `null`, `DateTimePicker` ve svém úložném formátu a zóně a `FileUpload` jako uložená cesta. Wizard dehydratuje každý krok, ne jen ten, který je při odeslání na obrazovce. Akce v patičce jsou záměrně vynechány — čtou formulář rozdělaný, ještě před odesláním.
+
Modal formuláře `HeaderAction` **nemá záznam**, takže jeho closura `fillFormUsing` nebere žádné argumenty. Použijte ji k naplnění počátečního stavu — a array-typovaná pole (`CheckboxList`, `Tags`, multiple `Select`) vždy naplňte prázdným polem, aby se správně navázala od první interakce:
```php
diff --git a/docs/cs/forms/custom-fields.md b/docs/cs/forms/custom-fields.md
index f0a1bfdf..848b5bfa 100644
--- a/docs/cs/forms/custom-fields.md
+++ b/docs/cs/forms/custom-fields.md
@@ -335,7 +335,7 @@ nezávislé — implementujte jen ten, který potřebujete:
| Kontrakt | Metoda | Kdy běží |
|---|---|---|
| `HydratesState` | `hydrateState($value, ?Model $record)` | hodnota z modelu → stav, po přetypování dle `getStateType()` |
-| `DehydratesState` | `dehydrateState($state, ?Model $record)` | stav → ukládaná hodnota, při ukládání |
+| `DehydratesState` | `dehydrateState($state, ?Model $record)` | stav → ukládaná hodnota, na každé zápisové cestě |
Všimněte si, že [`MoneyInput`](#stavba-vlastniho-pole) výše nepotřebuje *ani jeden*:
jeho stav už je ten integer, který ukládá, což plně vyjádří `getStateType(): 'int'`.
@@ -378,6 +378,16 @@ Stejné dva kontrakty pohánějí i [editovatelné sloupce tabulky](../table/col
komponenta, která je implementuje, se chová stejně ve formuláři i v inline
editované buňce.
+**Zápisovou cestu spouštějí tři hostitelé a musí se shodnout.** `Form::save()` ji
+pouští přes `SaveHandler`, editovatelná buňka v `updateTableCell()` a
+[modal akce](../core/actions.md#modal-s-formularem) při odeslání — `$data`, která callback
+akce dostane, jsou tedy hodnoty, jaké by formulář uložil, ne syrový stav Livewiru.
+Hostitel, který by ji přeskočil, by ze stejného schématu zapsal jednou `null`
+a jindy `''`. Jediná záměrná výjimka je [akce v patičce](../core/actions.md#akce-v-paticce):
+čte formulář rozdělaný a zapisuje zpátky do stejného bagu, takže dehydratace by
+callbacku podala hodnotu, kterou už formulář nedrží — a spustila by uložení
+`FileUpload`u nad formulářem, který uživatel neodeslal.
+
> **Oba směry, nebo žádný.** Pokud transformace hodnotu posouvá (převod timezone,
> změna jednotek), implementovat jen `hydrateState()` znamená, že se posunutý stav
> při uložení zapíše rovnou zpátky — a hodnota se s každým round tripem posune zas
diff --git a/docs/cs/forms/fields/text-input.md b/docs/cs/forms/fields/text-input.md
index b5ef3a14..fb83910f 100644
--- a/docs/cs/forms/fields/text-input.md
+++ b/docs/cs/forms/fields/text-input.md
@@ -31,6 +31,23 @@ TextInput::make('age')->integer()
| `search()` | `search` | Search input |
| `type(string)` | Vlastní | Nastavit HTML input typ přímo |
+## Vyprázdněné hodnoty
+
+Vyprázdněný číselný input odešle `''` a takovou hodnotu neunese žádný číselný
+sloupec: MySQL ve strict modu zápis rovnou odmítne (`Incorrect decimal value: ''`)
+a benevolentní driver tiše uloží `0`. Pole, jehož HTML typ je `number` —
+`numeric()`, `integer()`, `type('number')` — proto **uloží `null`, když zůstane
+prázdné**, a to na každé zápisové cestě: `Form::save()`, odeslání
+[modalu akce](../../core/actions.md#modal-s-formularem) i editovatelná buňka.
+
+Ostatních typů se to netýká. `''` je legitimní řetězcová hodnota a not-null
+textový sloupec ji unese; převod na `null` by zápis rozbil, ne zachránil.
+
+```php
+TextInput::make('discount')->numeric() // vyprázdněno → null
+TextInput::make('note') // vyprázdněno → ''
+```
+
## Omezení
```php
diff --git a/docs/forms/custom-fields.md b/docs/forms/custom-fields.md
index e9e2539b..9e00eb24 100644
--- a/docs/forms/custom-fields.md
+++ b/docs/forms/custom-fields.md
@@ -334,7 +334,7 @@ directions. They are independent — implement only the one you need:
| Contract | Method | Runs |
|---|---|---|
| `HydratesState` | `hydrateState($value, ?Model $record)` | model value → state, after the `getStateType()` cast |
-| `DehydratesState` | `dehydrateState($state, ?Model $record)` | state → stored value, during save |
+| `DehydratesState` | `dehydrateState($state, ?Model $record)` | state → stored value, on every write path |
Note that the [`MoneyInput`](#building-a-custom-field) above needs *neither*: its
state is already the integer it stores, which `getStateType(): 'int'` is enough to
@@ -376,6 +376,17 @@ The same two contracts drive [editable table columns](../table/columns/editing.m
`TextInputColumn` uses them for its trim/case/number pipeline — so a component
that implements them behaves the same in a form and in an inline-edited cell.
+**Three hosts run the write path, and they must agree.** `Form::save()` runs it
+through `SaveHandler`; an editable cell runs it in `updateTableCell()`; an
+[action modal](../core/actions.md#form-modal) runs it on submit, so the `$data`
+an action callback receives is what the form would have persisted rather than
+raw Livewire state. A host that skipped it would make the same schema write
+`null` through one path and `''` through another. The one deliberate exception
+is a [footer action](../core/actions.md#footer-actions): it reads the form
+mid-edit and writes back into the same bag, so dehydrating there would hand the
+callback a value the form no longer holds — and would run a `FileUpload`'s store
+on a form the user has not submitted.
+
> **Both directions, or neither.** If a transform moves the value (a timezone
> conversion, a unit change), implementing only `hydrateState()` means the shifted
> state gets written straight back on save, moving the value a little further on
diff --git a/docs/forms/fields/text-input.md b/docs/forms/fields/text-input.md
index e1bc7bc6..8307072e 100644
--- a/docs/forms/fields/text-input.md
+++ b/docs/forms/fields/text-input.md
@@ -31,6 +31,24 @@ TextInput::make('age')->integer()
| `search()` | `search` | Search input |
| `type(string)` | Custom | Set HTML input type directly |
+## Cleared Values
+
+A cleared number input submits `''`, and no numeric column can hold that: MySQL
+in strict mode refuses the write outright (`Incorrect decimal value: ''`) and a
+lenient driver silently stores `0`. So a field whose HTML type is `number` —
+`numeric()`, `integer()`, `type('number')` — **stores `null` when it is left
+empty**, on every write path: `Form::save()`, an
+[action modal](../../core/actions.md#form-modal) submit, an editable cell.
+
+Every other type is left alone. `''` is a legitimate string value, and a
+non-nullable text column holds one; turning it into `null` would break the write
+rather than save it.
+
+```php
+TextInput::make('discount')->numeric() // cleared → null
+TextInput::make('note') // cleared → ''
+```
+
## Constraints
```php
diff --git a/packages/core/src/Actions/Concerns/InteractsWithActions.php b/packages/core/src/Actions/Concerns/InteractsWithActions.php
index 94598230..0eced3b7 100644
--- a/packages/core/src/Actions/Concerns/InteractsWithActions.php
+++ b/packages/core/src/Actions/Concerns/InteractsWithActions.php
@@ -801,6 +801,26 @@ protected function validateMountedActionForm(): void
// No-op — the form-hosting layer validates the wire-forms Form.
}
+ /**
+ * Let the active modal's fields shape their own values before the action
+ * callback sees them — the write-path seam of ADR 0021, applied to the one
+ * bag an action submits. No-op in core; the wire-forms layer overrides this
+ * to walk the schema for DehydratesState.
+ *
+ * A submit is the only place it runs. A footer action reads the form
+ * mid-edit and writes back into the same bag, so dehydrating there would
+ * hand the callback a value the form no longer holds — and would run a
+ * FileUpload's store on a form the user has not submitted yet.
+ *
+ * @param array $data
+ * @return array
+ */
+ protected function dehydrateMountedActionFormData(array $data): array
+ {
+ // No-op — the form-hosting layer knows the schema behind the bag.
+ return $data;
+ }
+
/**
* Close the currently mounted action. When a parent modal is suspended behind
* it, the parent is resumed into the active slot instead of clearing (modal
diff --git a/packages/core/tests/Feature/Actions/InteractsWithActionsTest.php b/packages/core/tests/Feature/Actions/InteractsWithActionsTest.php
index 5f768cb6..38f8bf04 100644
--- a/packages/core/tests/Feature/Actions/InteractsWithActionsTest.php
+++ b/packages/core/tests/Feature/Actions/InteractsWithActionsTest.php
@@ -202,6 +202,18 @@ public function openModal(string $name): void
$this->actionModalConfigCache = $this->catalog()[$name]->getModalConfig();
}
+ /**
+ * Runs the engine's dehydration seam. A form-free host has no schema behind
+ * the bag, so the engine's own answer must be "hand it back untouched".
+ *
+ * @param array $data
+ * @return array
+ */
+ public function dehydrateBag(array $data): array
+ {
+ return $this->dehydrateMountedActionFormData($data);
+ }
+
/** Mounts a name that resolves to no action, then reads the modal config. */
public function peekGhostConfig(): void
{
@@ -227,6 +239,16 @@ public function render(): string
}
}
+it('leaves the submitted bag untouched without a form bridge', function () {
+ // The wire-forms bridge overrides this seam to walk the schema for
+ // DehydratesState; the engine itself knows of no schema and must not guess.
+ $bag = Livewire::test(CoreActionsHost::class)
+ ->instance()
+ ->dehydrateBag(['amount' => '', 'note' => 'kept']);
+
+ expect($bag)->toBe(['amount' => '', 'note' => 'kept']);
+});
+
it('runs a form-free action host with no wire-forms bridge and fires after hooks', function () {
Livewire::test(CoreActionsHost::class)
->call('fire', 'afterCb')
diff --git a/packages/forms/src/Components/TextInput.php b/packages/forms/src/Components/TextInput.php
index a99f44fd..4458869f 100644
--- a/packages/forms/src/Components/TextInput.php
+++ b/packages/forms/src/Components/TextInput.php
@@ -5,7 +5,9 @@
namespace NyonCode\WireForms\Components;
use Closure;
+use Illuminate\Database\Eloquent\Model;
use NyonCode\WireCore\Foundation\Concerns\HasExtraInputAttributes;
+use NyonCode\WireCore\Foundation\Contracts\DehydratesState;
use NyonCode\WireCore\Foundation\Support\EnumResolver;
/**
* Text input field with type variants (email, password, tel, url, numeric, integer).
@@ -14,7 +16,7 @@
*/
use NyonCode\WireForms\Concerns\HasCharacterLimits;
-class TextInput extends Field
+class TextInput extends Field implements DehydratesState
{
use HasCharacterLimits;
use HasExtraInputAttributes;
@@ -110,6 +112,29 @@ public function search(): static
return $this;
}
+ // ─── State ─────────────────────────────────────────────────────
+
+ /**
+ * A cleared number input stores null, not an empty string.
+ *
+ * An emptied ` ` submits `''`, and no numeric column can
+ * hold that: MySQL in strict mode rejects the write outright ("Incorrect
+ * decimal value: ''") and a lenient driver silently stores 0. Neither is
+ * what an author who left an optional amount blank asked for. This is the
+ * same rule Select already applies to its placeholder choice.
+ *
+ * Text is deliberately untouched: `''` is a legitimate string value, and
+ * turning it into null would break a non-nullable column that holds one.
+ */
+ public function dehydrateState(mixed $state, ?Model $record = null): mixed
+ {
+ if ($this->inputType !== 'number') {
+ return $state;
+ }
+
+ return (is_string($state) && trim($state) === '') ? null : $state;
+ }
+
// ─── Constraints ───────────────────────────────────────────────
/** Set the minimum numeric value (a value or a `$get`-aware Closure). */
diff --git a/packages/forms/src/Concerns/InteractsWithActionForms.php b/packages/forms/src/Concerns/InteractsWithActionForms.php
index ecbc4c77..ee5af018 100644
--- a/packages/forms/src/Concerns/InteractsWithActionForms.php
+++ b/packages/forms/src/Concerns/InteractsWithActionForms.php
@@ -4,6 +4,7 @@
namespace NyonCode\WireForms\Concerns;
+use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Validator;
use NyonCode\WireCore\Actions\Action;
use NyonCode\WireCore\Actions\ActionHalt;
@@ -11,6 +12,7 @@
use NyonCode\WireCore\Actions\HeaderAction;
use NyonCode\WireCore\Actions\ModalStep;
use NyonCode\WireForms\Forms\Form;
+use NyonCode\WireForms\Forms\Runtime\StateDehydrator;
use Throwable;
/**
@@ -184,6 +186,58 @@ protected function validateMountedActionForm(): void
$this->actionModalFormInstance?->validate();
}
+ /**
+ * Dehydrate the submitted bag through the active modal's own schema, so an
+ * action callback receives what the form would have persisted — a cleared
+ * Select as null rather than '', a date in its storage format and zone, a
+ * FileUpload as its stored path.
+ *
+ * Without this the two write paths disagree: the same schema saved through
+ * Form::save() goes through {@see StateDehydrator}, while an action modal
+ * handed the raw Livewire state straight to the callback.
+ *
+ * A wizard shares one bag across its steps, so every step's schema gets a
+ * pass — the fields of a step that is not on screen at submit time still
+ * own their values.
+ *
+ * @param array $data
+ * @return array
+ */
+ protected function dehydrateMountedActionFormData(array $data): array
+ {
+ [$action, $context] = $this->resolveCurrentActionForm();
+
+ // Defensive: submit paths resolve a non-null action before they get here.
+ // @codeCoverageIgnoreStart
+ if ($action === null) {
+ return $data;
+ }
+ // @codeCoverageIgnoreEnd
+
+ $record = $context instanceof Model ? $context : null;
+ $statePath = $this->actionFrameStatePath($this->topActionFrameIndex());
+
+ if ($action->hasMultipleSteps()) {
+ for ($step = 0; $step < $action->getStepCount(); $step++) {
+ $form = $action->getStepFormInstance($this, $context, $step, $statePath);
+
+ if ($form instanceof Form) {
+ $data = StateDehydrator::dehydrate($form->getSchema(), $data, $record);
+ }
+ }
+
+ return $data;
+ }
+
+ // Reuse the instance validation already resolved for this frame.
+ $form = $this->actionModalFormInstance
+ ?? $this->buildModalActionFormInstance($action, $context);
+
+ return $form instanceof Form
+ ? StateDehydrator::dehydrate($form->getSchema(), $data, $record)
+ : $data;
+ }
+
// ==========================================
// Wizard stepping
// ==========================================
diff --git a/packages/forms/src/Concerns/WithActions.php b/packages/forms/src/Concerns/WithActions.php
index 21287480..dfab868b 100644
--- a/packages/forms/src/Concerns/WithActions.php
+++ b/packages/forms/src/Concerns/WithActions.php
@@ -48,6 +48,7 @@ trait WithActions
// (no-op in core so a form-free host still works standalone).
use InteractsWithActionForms, InteractsWithActions {
InteractsWithActionForms::validateMountedActionForm insteadof InteractsWithActions;
+ InteractsWithActionForms::dehydrateMountedActionFormData insteadof InteractsWithActions;
InteractsWithActionForms::resolveHaltModalForm insteadof InteractsWithActions;
InteractsWithActionForms::getActionModalFormInstance insteadof InteractsWithActions;
InteractsWithActionForms::getActionModalFormInstanceForDepth insteadof InteractsWithActions;
@@ -209,7 +210,9 @@ public function callMountedAction(): void
$this->runStandaloneAction(
$action,
$record instanceof Model ? $record : null,
- $this->getMountedActionFormData(),
+ // The callback gets what the form would have persisted, not the raw
+ // widget state — the same seam Form::save() runs (ADR 0021).
+ $this->dehydrateMountedActionFormData($this->getMountedActionFormData()),
(array) $this->getMountedActionState('arguments', []),
);
diff --git a/packages/forms/src/Forms/Runtime/SaveHandler.php b/packages/forms/src/Forms/Runtime/SaveHandler.php
index 7b22637d..10ff28d9 100644
--- a/packages/forms/src/Forms/Runtime/SaveHandler.php
+++ b/packages/forms/src/Forms/Runtime/SaveHandler.php
@@ -15,8 +15,6 @@
use NyonCode\WireCore\Core\Plugin\Hooks\FormSavingPayload;
use NyonCode\WireCore\Core\Plugin\PluginManager;
use NyonCode\WireCore\Foundation\Components\LayoutComponent;
-use NyonCode\WireCore\Foundation\Contracts\DehydratesState;
-use NyonCode\WireForms\Components\Field;
use NyonCode\WireForms\Components\MorphToSelect;
use NyonCode\WireForms\Components\Repeater;
use NyonCode\WireForms\Components\Tags;
@@ -59,7 +57,11 @@ public function save(): mixed
// FileUpload moves validated temporary uploads to permanent storage (so
// an abandoned form leaves no orphan) and how a date field applies its
// storage format and timezone.
- $data = $this->dehydrateFields($data);
+ $data = StateDehydrator::dehydrate(
+ $this->config->schema,
+ $data,
+ $this->config->model instanceof Model ? $this->config->model : null,
+ );
// 3. Plugin hook: form.saving (can modify data)
if (app()->bound(PluginManager::class)) {
@@ -186,113 +188,6 @@ private function persist(array $data): mixed
return $instance;
}
- /**
- * Move any pending file uploads in the data to permanent storage and replace
- * them with their stored paths, keeping already-stored paths as-is (merge).
- *
- * @param array $data
- * @return array
- */
- /**
- * Apply every field's own dehydration to the data about to be persisted.
- *
- * @param array $data
- * @return array
- */
- private function dehydrateFields(array $data): array
- {
- $model = $this->config->model instanceof Model ? $this->config->model : null;
-
- // Top-level fields (not nested inside a repeater).
- foreach ($this->collectDehydratingFields($this->config->schema) as $field) {
- $name = $field->getName();
-
- if (! array_key_exists($name, $data)) {
- continue;
- }
-
- $data[$name] = $field->dehydrateState($data[$name], $model);
- }
-
- // Repeater children: a DehydratesState child (FileUpload storing its
- // upload, DateTimePicker applying format/timezone) lives under the
- // repeater key as an array of items, so the top-level pass never reaches
- // it. Without this a nested file is never moved to permanent storage and a
- // nested date keeps its raw wire value.
- foreach ($this->dehydratingRepeaters($this->config->schema) as $repeater) {
- $name = $repeater->getName();
-
- if (! isset($data[$name]) || ! is_array($data[$name])) {
- continue;
- }
-
- $childFields = $this->collectDehydratingFields($repeater->getSchema());
-
- foreach ($data[$name] as $index => $item) {
- if (! is_array($item)) {
- continue;
- }
-
- foreach ($childFields as $child) {
- $childName = $child->getName();
-
- if (array_key_exists($childName, $item)) {
- $data[$name][$index][$childName] = $child->dehydrateState($item[$childName], $model);
- }
- }
- }
- }
-
- return $data;
- }
-
- /**
- * Repeaters anywhere in the schema (used to dehydrate their child fields).
- *
- * @param array $schema
- * @return array
- */
- private function dehydratingRepeaters(array $schema): array
- {
- $repeaters = [];
-
- foreach ($schema as $component) {
- if ($component instanceof Repeater) {
- $repeaters[] = $component;
- } elseif ($component instanceof LayoutComponent) {
- $repeaters = array_merge($repeaters, $this->dehydratingRepeaters($component->getSchema()));
- }
- }
-
- return $repeaters;
- }
-
- /**
- * Collect every field that dehydrates its own state, traversing nested layouts.
- *
- * Only a Field carries the name that keys the data array — a layout could
- * implement the contract without one.
- *
- * @param array $schema
- * @return array
- */
- private function collectDehydratingFields(array $schema): array
- {
- $fields = [];
-
- foreach ($schema as $component) {
- if ($component instanceof DehydratesState && $component instanceof Field) {
- $fields[] = $component;
- } elseif ($component instanceof LayoutComponent && ! $component instanceof Repeater) {
- // Repeaters are handled per-item by dehydratingRepeaters(); their
- // children must not be flattened into the top-level key match.
- $fields = array_merge($fields, $this->collectDehydratingFields($component->getSchema()));
- }
- }
-
- return $fields;
- }
-
/**
* Collect the field names of all relationship-backed repeaters in the schema,
* traversing nested layout components.
diff --git a/packages/forms/src/Forms/Runtime/StateDehydrator.php b/packages/forms/src/Forms/Runtime/StateDehydrator.php
new file mode 100644
index 00000000..5124cb4c
--- /dev/null
+++ b/packages/forms/src/Forms/Runtime/StateDehydrator.php
@@ -0,0 +1,129 @@
+ $schema
+ * @param array $data
+ * @return array
+ */
+ public static function dehydrate(array $schema, array $data, ?Model $record = null): array
+ {
+ // Top-level fields (not nested inside a repeater).
+ foreach (self::dehydratingFields($schema) as $field) {
+ $name = $field->getName();
+
+ if (! array_key_exists($name, $data)) {
+ continue;
+ }
+
+ $data[$name] = $field->dehydrateState($data[$name], $record);
+ }
+
+ // Repeater children: a DehydratesState child (FileUpload storing its
+ // upload, DateTimePicker applying format/timezone) lives under the
+ // repeater key as an array of items, so the top-level pass never reaches
+ // it. Without this a nested file is never moved to permanent storage and a
+ // nested date keeps its raw wire value.
+ foreach (self::dehydratingRepeaters($schema) as $repeater) {
+ $name = $repeater->getName();
+
+ if (! isset($data[$name]) || ! is_array($data[$name])) {
+ continue;
+ }
+
+ $childFields = self::dehydratingFields($repeater->getSchema());
+
+ foreach ($data[$name] as $index => $item) {
+ if (! is_array($item)) {
+ continue;
+ }
+
+ foreach ($childFields as $child) {
+ $childName = $child->getName();
+
+ if (array_key_exists($childName, $item)) {
+ $data[$name][$index][$childName] = $child->dehydrateState($item[$childName], $record);
+ }
+ }
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Every field that dehydrates its own state, traversing nested layouts.
+ *
+ * Only a Field carries the name that keys the data array — a layout could
+ * implement the contract without one.
+ *
+ * @param array $schema
+ * @return array
+ */
+ private static function dehydratingFields(array $schema): array
+ {
+ $fields = [];
+
+ foreach ($schema as $component) {
+ if ($component instanceof DehydratesState && $component instanceof Field) {
+ $fields[] = $component;
+ } elseif ($component instanceof LayoutComponent && ! $component instanceof Repeater) {
+ // Repeaters are handled per-item by dehydratingRepeaters(); their
+ // children must not be flattened into the top-level key match.
+ $fields = array_merge($fields, self::dehydratingFields($component->getSchema()));
+ }
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Repeaters anywhere in the schema (used to dehydrate their child fields).
+ *
+ * @param array $schema
+ * @return array
+ */
+ private static function dehydratingRepeaters(array $schema): array
+ {
+ $repeaters = [];
+
+ foreach ($schema as $component) {
+ if ($component instanceof Repeater) {
+ $repeaters[] = $component;
+ } elseif ($component instanceof LayoutComponent) {
+ $repeaters = array_merge($repeaters, self::dehydratingRepeaters($component->getSchema()));
+ }
+ }
+
+ return $repeaters;
+ }
+}
diff --git a/packages/forms/tests/Feature/ActionFormDehydrationTest.php b/packages/forms/tests/Feature/ActionFormDehydrationTest.php
new file mode 100644
index 00000000..01a76788
--- /dev/null
+++ b/packages/forms/tests/Feature/ActionFormDehydrationTest.php
@@ -0,0 +1,159 @@
+|null What the action callback actually received. */
+ public ?array $submitted = null;
+
+ protected function actions(): array
+ {
+ return [$this->editAction(), $this->wizardAction(), $this->repeaterAction()];
+ }
+
+ public function editAction(): Action
+ {
+ return Action::make('edit')
+ ->form([
+ TextInput::make('discount')->numeric(),
+ TextInput::make('note'),
+ Select::make('status')->options(['draft' => 'Draft', 'published' => 'Published']),
+ ])
+ ->action(fn (array $data) => $this->submitted = $data);
+ }
+
+ public function wizardAction(): Action
+ {
+ return Action::make('wizard')
+ ->steps([
+ ModalStep::make('amounts')
+ ->schema([TextInput::make('discount')->numeric()]),
+ ModalStep::make('details')
+ ->schema([TextInput::make('note')]),
+ ])
+ ->action(fn (array $data) => $this->submitted = $data);
+ }
+
+ public function repeaterAction(): Action
+ {
+ return Action::make('repeater')
+ ->form([
+ Repeater::make('rows')->schema([
+ TextInput::make('quantity')->numeric(),
+ TextInput::make('label'),
+ ]),
+ ])
+ ->action(fn (array $data) => $this->submitted = $data);
+ }
+
+ public function render(): string
+ {
+ return <<<'BLADE'
+
+
+
+ BLADE;
+ }
+}
+
+/**
+ * The submitted bag as the action callback received it.
+ *
+ * Read back off the instance rather than asserted with assertSet(): Livewire
+ * compares loosely, so `'' == null` passes and a test written that way stays
+ * green with the whole seam removed — which is how it was first written here.
+ *
+ * @return array
+ */
+function submittedBag(Testable $component): array
+{
+ return $component->instance()->submitted ?? [];
+}
+
+it('stores a cleared number input as null, leaving text alone', function () {
+ $component = Livewire::test(ActionDehydrationHost::class)
+ ->call('mountAction', 'edit')
+ ->set('mountedActions.0.data.discount', '')
+ ->set('mountedActions.0.data.note', '')
+ ->call('callMountedAction');
+
+ $bag = submittedBag($component);
+
+ expect($bag['discount'])->toBeNull()
+ // '' is a legitimate string: a non-nullable text column holds one.
+ ->and($bag['note'])->toBe('');
+});
+
+it('keeps a number that was actually entered', function () {
+ $component = Livewire::test(ActionDehydrationHost::class)
+ ->call('mountAction', 'edit')
+ ->set('mountedActions.0.data.discount', '12.5')
+ ->call('callMountedAction');
+
+ expect(submittedBag($component)['discount'])->toBe('12.5');
+});
+
+it('applies a fields own dehydration to the action bag, not just to Form::save', function () {
+ // Select has implemented the seam since ADR 0021; only the save path ran it.
+ $component = Livewire::test(ActionDehydrationHost::class)
+ ->call('mountAction', 'edit')
+ ->set('mountedActions.0.data.status', '')
+ ->call('callMountedAction');
+
+ expect(submittedBag($component)['status'])->toBeNull();
+});
+
+it('dehydrates every wizard step, not only the one on screen at submit', function () {
+ $component = Livewire::test(ActionDehydrationHost::class)
+ ->call('mountAction', 'wizard')
+ ->set('mountedActions.0.data.discount', '')
+ ->call('nextActionModalStep')
+ ->set('mountedActions.0.data.note', 'ok')
+ ->call('callMountedAction');
+
+ $bag = submittedBag($component);
+
+ expect($bag['discount'])->toBeNull()
+ ->and($bag['note'])->toBe('ok');
+});
+
+it('dehydrates repeater children per item', function () {
+ $component = Livewire::test(ActionDehydrationHost::class)
+ ->call('mountAction', 'repeater')
+ ->set('mountedActions.0.data.rows', [
+ ['quantity' => '', 'label' => 'first'],
+ ['quantity' => '3', 'label' => ''],
+ ])
+ ->call('callMountedAction');
+
+ expect(submittedBag($component)['rows'])->toBe([
+ ['quantity' => null, 'label' => 'first'],
+ ['quantity' => '3', 'label' => ''],
+ ]);
+});
diff --git a/packages/forms/tests/Unit/Components/TextInputTest.php b/packages/forms/tests/Unit/Components/TextInputTest.php
index 72a40766..ab5c62d1 100644
--- a/packages/forms/tests/Unit/Components/TextInputTest.php
+++ b/packages/forms/tests/Unit/Components/TextInputTest.php
@@ -334,3 +334,24 @@ public function getLabel(): ?string
test('the search preset sets the search input type', function () {
expect(TextInput::make('q')->search()->getInputType())->toBe('search');
});
+
+/*
+ * Dehydration (ADR 0021). An emptied ` ` submits '', which
+ * no numeric column can hold: MySQL in strict mode refuses the write and a
+ * lenient driver stores 0. Text is left alone — '' is a value a string column
+ * legitimately holds, and nulling it would break a non-nullable one.
+ */
+test('a cleared numeric input dehydrates to null', function (TextInput $field, mixed $state, mixed $expected) {
+ expect($field->dehydrateState($state))->toBe($expected);
+})->with([
+ 'numeric, cleared' => [fn () => TextInput::make('amount')->numeric(), '', null],
+ 'numeric, whitespace only' => [fn () => TextInput::make('amount')->numeric(), ' ', null],
+ 'integer, cleared' => [fn () => TextInput::make('count')->integer(), '', null],
+ 'type(number), cleared' => [fn () => TextInput::make('amount')->type('number'), '', null],
+ 'numeric, entered' => [fn () => TextInput::make('amount')->numeric(), '12.5', '12.5'],
+ 'numeric, zero' => [fn () => TextInput::make('amount')->numeric(), '0', '0'],
+ 'numeric, already null' => [fn () => TextInput::make('amount')->numeric(), null, null],
+ // '' survives on every non-number type, including the ones that look numeric.
+ 'text, cleared' => [fn () => TextInput::make('name'), '', ''],
+ 'tel, cleared' => [fn () => TextInput::make('phone')->tel(), '', ''],
+]);
diff --git a/packages/forms/tests/Unit/Runtime/StateDehydratorTest.php b/packages/forms/tests/Unit/Runtime/StateDehydratorTest.php
new file mode 100644
index 00000000..b8717c07
--- /dev/null
+++ b/packages/forms/tests/Unit/Runtime/StateDehydratorTest.php
@@ -0,0 +1,59 @@
+schema([TextInput::make('discount')->numeric()])];
+
+ expect(StateDehydrator::dehydrate($schema, ['discount' => '']))
+ ->toBe(['discount' => null]);
+});
+
+it('dehydrates the children of a repeater nested in a layout component', function () {
+ $schema = [
+ Section::make('Rows')->schema([
+ Repeater::make('rows')->schema([TextInput::make('quantity')->numeric()]),
+ ]),
+ ];
+
+ expect(StateDehydrator::dehydrate($schema, ['rows' => [['quantity' => '']]]))
+ ->toBe(['rows' => [['quantity' => null]]]);
+});
+
+it('leaves keys the schema does not name alone', function () {
+ $schema = [TextInput::make('discount')->numeric()];
+
+ expect(StateDehydrator::dehydrate($schema, ['note' => '']))
+ ->toBe(['note' => '']);
+});
+
+it('skips a repeater whose bag entry is missing or is not a list of rows', function (mixed $bag, mixed $expected) {
+ // A repeater key can be absent (never touched) or hold a scalar (a host that
+ // wrote the bag itself). Neither is a set of rows to walk.
+ $schema = [Repeater::make('rows')->schema([TextInput::make('quantity')->numeric()])];
+
+ expect(StateDehydrator::dehydrate($schema, $bag))->toBe($expected);
+})->with([
+ 'key absent' => [['other' => 1], ['other' => 1]],
+ 'not an array' => [['rows' => ''], ['rows' => '']],
+]);
+
+it('skips a repeater row that is not an array of fields', function () {
+ $schema = [Repeater::make('rows')->schema([TextInput::make('quantity')->numeric()])];
+
+ expect(StateDehydrator::dehydrate($schema, ['rows' => ['scrap', ['quantity' => '']]]))
+ ->toBe(['rows' => ['scrap', ['quantity' => null]]]);
+});
diff --git a/packages/table/src/Concerns/InteractsWithTableModals.php b/packages/table/src/Concerns/InteractsWithTableModals.php
index 8f583ca4..25bd8066 100644
--- a/packages/table/src/Concerns/InteractsWithTableModals.php
+++ b/packages/table/src/Concerns/InteractsWithTableModals.php
@@ -84,6 +84,10 @@ public function submitActionModal(): void
$this->validateMountedActionForm();
+ // The callback gets what the form would have persisted, not the raw
+ // widget state — the same seam Form::save() runs (ADR 0021).
+ $formData = $this->dehydrateMountedActionFormData($formData);
+
$stackVersionBefore = $this->actionStackVersion;
// Execute action
diff --git a/packages/table/src/Concerns/WithTable.php b/packages/table/src/Concerns/WithTable.php
index 4bad6544..4e04d7b9 100644
--- a/packages/table/src/Concerns/WithTable.php
+++ b/packages/table/src/Concerns/WithTable.php
@@ -85,6 +85,7 @@ trait WithTable
// engine's defaults.
use InteractsWithActionForms, InteractsWithActions, InteractsWithTableActions {
InteractsWithActionForms::validateMountedActionForm insteadof InteractsWithActions;
+ InteractsWithActionForms::dehydrateMountedActionFormData insteadof InteractsWithActions;
InteractsWithActionForms::resolveHaltModalForm insteadof InteractsWithActions;
InteractsWithActionForms::getActionModalFormInstance insteadof InteractsWithActions;
InteractsWithActionForms::getActionModalFormInstanceForDepth insteadof InteractsWithActions;
diff --git a/packages/table/tests/Feature/ActionModalDehydrationTest.php b/packages/table/tests/Feature/ActionModalDehydrationTest.php
new file mode 100644
index 00000000..ba9b1bf6
--- /dev/null
+++ b/packages/table/tests/Feature/ActionModalDehydrationTest.php
@@ -0,0 +1,99 @@
+|null */
+ public ?array $submitted = null;
+
+ public function table(Table $table): Table
+ {
+ return $table
+ ->model(AmdOffer::class)
+ ->paginated(false)
+ ->columns([TextColumn::make('title')])
+ ->headerActions([
+ HeaderAction::make('create')
+ ->form([TextInput::make('discount')->numeric(), TextInput::make('title')])
+ ->action(fn (array $data) => $this->submitted = $data),
+ ])
+ ->actions([
+ Action::make('edit')
+ ->form([TextInput::make('discount')->numeric()])
+ ->action(fn (array $data) => $this->submitted = $data),
+ ]);
+ }
+
+ public function render()
+ {
+ return $this->getTableProperty();
+ }
+}
+
+beforeEach(function () {
+ config()->set('app.key', 'base64:'.base64_encode(random_bytes(32)));
+
+ Schema::dropIfExists('amd_offers');
+ Schema::create('amd_offers', function (Blueprint $table): void {
+ $table->id();
+ $table->string('title')->nullable();
+ $table->decimal('discount', 5, 2)->nullable();
+ });
+});
+
+it('hands a header action the value the form would have persisted', function () {
+ $component = Livewire::test(AmdComponent::class)
+ ->call('openHeaderActionModal', 'create')
+ ->set('tableState.modal.actions.0.data.discount', '')
+ ->set('tableState.modal.actions.0.data.title', '')
+ ->call('submitActionModal');
+
+ $bag = $component->instance()->submitted ?? [];
+
+ // A cleared number input is null; '' would die on a decimal column under a
+ // strict SQL mode ("Incorrect decimal value: ''").
+ expect($bag['discount'])->toBeNull()
+ ->and($bag['title'])->toBe('');
+});
+
+it('hands a row action the same', function () {
+ $offer = AmdOffer::query()->create(['title' => 'One', 'discount' => 10]);
+
+ $component = Livewire::test(AmdComponent::class)
+ ->call('openActionModal', (string) $offer->id, 'edit')
+ ->set('tableState.modal.actions.0.data.discount', '')
+ ->call('submitActionModal');
+
+ expect(($component->instance()->submitted ?? [])['discount'])->toBeNull();
+});