Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input type="number">` 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 `<select>` compares its `<option value>` 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.

Expand Down
2 changes: 2 additions & 0 deletions docs/core/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<option>` 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
Expand Down
2 changes: 2 additions & 0 deletions docs/cs/core/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<option>`. 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
Expand Down
12 changes: 11 additions & 1 deletion docs/cs/forms/custom-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'`.
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions docs/cs/forms/fields/text-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion docs/forms/custom-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions docs/forms/fields/text-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/Actions/Concerns/InteractsWithActions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $data
* @return array<string, mixed>
*/
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
Expand Down
22 changes: 22 additions & 0 deletions packages/core/tests/Feature/Actions/InteractsWithActionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $data
* @return array<string, mixed>
*/
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
{
Expand All @@ -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')
Expand Down
27 changes: 26 additions & 1 deletion packages/forms/src/Components/TextInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -14,7 +16,7 @@
*/
use NyonCode\WireForms\Concerns\HasCharacterLimits;

class TextInput extends Field
class TextInput extends Field implements DehydratesState
{
use HasCharacterLimits;
use HasExtraInputAttributes;
Expand Down Expand Up @@ -110,6 +112,29 @@ public function search(): static
return $this;
}

// ─── State ─────────────────────────────────────────────────────

/**
* A cleared number input stores null, not an empty string.
*
* An emptied `<input type="number">` 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). */
Expand Down
Loading
Loading