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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to the Wire ecosystem will be documented in this file.

## [1.17.4]

### Fixed
- **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.

## [1.17.1]

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions docs/core/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ Action::make('edit')
->action(fn ($record, array $data) => $record->update($data));
```

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.

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 @@ -184,6 +184,8 @@ Action::make('edit')
->action(fn ($record, array $data) => $record->update($data));
```

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.

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
2 changes: 2 additions & 0 deletions packages/boost/resources/boost/docs/core/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ Action::make('edit')
->action(fn ($record, array $data) => $record->update($data));
```

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.

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
8 changes: 8 additions & 0 deletions packages/core/resources/views/partials/copy-assets.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@
`hidden` and the viewport coordinates are driven by the controller; the
transition is the browser's, so no Alpine is involved in showing it. --}}
@once
{{-- `hidden` alone does not hide it: Tailwind's preflight rule for the attribute
is `[hidden]:where(:not([hidden="until-found"]))`, whose `:where()` counts for
nothing — so it ties with the `inline-flex` utility below on specificity and
loses on order. Without this the pill is painted from first render, at the
static position a `fixed` element falls back to, which is the table's corner.
Scoped to the pill's own attribute so it beats the utility (0,2,0) without
touching anything else on the page. --}}
<style>[data-copy-feedback][hidden] { display: none; }</style>
<span
data-copy-feedback
hidden
Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/Actions/Concerns/HasModal.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use NyonCode\WireCore\Foundation\Enums\Breakpoint;
use NyonCode\WireCore\Foundation\Enums\ModalWidth;
use NyonCode\WireCore\Foundation\Icons\Icon;
use NyonCode\WireCore\Foundation\Support\EnumResolver;
use NyonCode\WireCore\Infolists\Infolist;
use NyonCode\WireCore\Modals\Contracts\ModalContract;
use NyonCode\WireCore\Modals\Modal;
Expand Down Expand Up @@ -794,6 +795,14 @@ public function getRawValidationAttributes(mixed $context = null): array
* fillFormUsing still runs for header actions (context null): its
* zero-argument closure may seed keys the schema cannot know about.
*
* The bag is collapsed to scalars on the way out ({@see EnumResolver::scalarDeep()}).
* A closure prefilling from a record hands back whatever the attribute holds,
* and on an enum-cast column that is the case object — but this bag is written
* straight into Livewire state, which has to round-trip to the browser and match
* the scalar keys a `<select>` compares its options against. A form runtime
* applies the same collapse when it fills its own state; this is the one seeding
* path that never goes through it.
*
* @return array<string, mixed>
*/
public function getFormDefaults(mixed $context = null): array
Expand All @@ -804,7 +813,10 @@ public function getFormDefaults(mixed $context = null): array

$seed = $this->resolveFormForSeeding($context)?->getInitialState() ?? [];

return $overrides + $seed;
/** @var array<string, mixed> $defaults */
$defaults = EnumResolver::scalarDeep($overrides + $seed);

return $defaults;
}

/**
Expand Down
34 changes: 34 additions & 0 deletions packages/core/tests/Unit/Concerns/HasModalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
use NyonCode\WireForms\Components\Toggle;
use NyonCode\WireForms\Forms\Form;

enum ModalDefaultsStatus: string
{
case Draft = 'draft';
case Published = 'published';
}

// Using Action as a concrete class that uses HasModal trait

// ─── Confirmation ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -278,6 +284,34 @@
]);
});

it('collapses an enum handed back by fillFormUsing to its scalar key', function () {
// A record's enum-cast attribute arrives as the case object. The bag is
// written into Livewire state, which carries scalars only — and the field's
// options are keyed by the backing value, so the case object would match no
// option even if it survived the trip.
$action = Action::make('edit')
->form([TextInput::make('status')])
->fillFormUsing(fn () => ['status' => ModalDefaultsStatus::Published]);

expect($action->getFormDefaults())->toBe(['status' => 'published']);
});

it('collapses enums nested inside the seeded bag', function () {
// Repeater rows and multi-selects hand back arrays of cases, not one case.
$action = Action::make('edit')
->form([TextInput::make('title')])
->fillFormUsing(fn () => [
'tags' => [ModalDefaultsStatus::Draft, ModalDefaultsStatus::Published],
'rows' => [['status' => ModalDefaultsStatus::Draft]],
]);

expect($action->getFormDefaults())->toBe([
'tags' => ['draft', 'published'],
'rows' => [['status' => 'draft']],
'title' => null,
]);
});

it('can use fillFormUsing to provide defaults', function () {
$action = Action::make('edit')
->form([TextInput::make('title')])
Expand Down
10 changes: 10 additions & 0 deletions packages/core/tests/Unit/Foundation/View/CopyButtonTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,13 @@

expect(substr_count($html, 'data-copy-feedback-text'))->toBe(1);
});

it('keeps the feedback pill hidden until the controller places it', function () {
// `hidden` alone loses to the pill's own `inline-flex` utility — Tailwind's
// preflight rule for the attribute is wrapped in `:where()`, so it ties on
// specificity and loses on order. The pill then sat painted at the static
// position a `fixed` element falls back to: the table's top-left corner.
$html = Blade::render("@include('wire-core::partials.copy-assets')");

expect($html)->toContain('[data-copy-feedback][hidden] { display: none; }');
});
8 changes: 8 additions & 0 deletions packages/forms/src/Components/Select.php
Original file line number Diff line number Diff line change
Expand Up @@ -473,11 +473,19 @@ public function getOptionLabels(array $values): array
* Label map for the field's current selection, used to keep the trigger
* readable when the option was never preloaded.
*
* The state is normalised to scalar keys first. Options are keyed by the enum's
* backing value ({@see EnumResolver::normalizeOptions()}), so a case object
* handed in by a host that wrote the bag itself — an `$set`, a public property
* assigned from a cast model — has to collapse the same way before the lookup,
* which takes the scalar key alone.
*
* @param mixed $value The field's current bound state.
* @return array<string|int, string>
*/
public function getSelectedOptionLabels(mixed $value): array
{
$value = EnumResolver::scalarDeep($value);

if ($this->isMultiple()) {
$values = array_values(array_filter(
is_array($value) ? $value : [],
Expand Down
127 changes: 127 additions & 0 deletions packages/forms/tests/Feature/ActionFormEnumStateTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Livewire\Component;
use Livewire\Livewire;
use NyonCode\WireCore\Actions\Action;
use NyonCode\WireCore\Foundation\Contracts\Enum\HasLabel;
use NyonCode\WireForms\Components\Select;
use NyonCode\WireForms\Concerns\WithActions;

/*
* An action modal prefilled from a record whose column is cast to an enum.
*
* `fillFormUsing` hands back whatever the attribute holds, and on a cast column
* that is the enum case object — not its backing value. The seeded bag is
* Livewire state, so it has to carry the scalar key: it is what travels to the
* browser, and it is what a <select> compares its <option value> against. An
* enum left in there fataled the field's own render before anything could
* compare anything.
*/

enum ActionFormStatus: string implements HasLabel
{
case Draft = 'draft';
case Published = 'published';

public function getLabel(): ?string
{
return ucfirst($this->value);
}
}

class ActionFormArticle extends Model
{
protected $table = 'action_form_articles';

protected $guarded = [];

protected $casts = ['status' => ActionFormStatus::class];
}

class ActionFormEnumHost extends Component
{
use WithActions;

public ?ActionFormArticle $record = null;

public function mount(?int $id = null): void
{
$this->record = $id ? ActionFormArticle::query()->find($id) : null;
}

protected function actions(): array
{
return [$this->editStatusAction()];
}

public function editStatusAction(): Action
{
return Action::make('editStatus')
->form([Select::make('status')->options(ActionFormStatus::class)])
// Exactly what an app writes: the record's attribute, straight through.
->fillFormUsing(fn () => ['status' => $this->record?->status])
->action(fn (array $data) => $this->record?->update(['status' => $data['status']]));
}

public function render(): string
{
return <<<'BLADE'
<div>
<x-wire-actions::button :action="$this->editStatusAction()" />
<x-wire-actions::modal-host :component="$this" />
</div>
BLADE;
}
}

beforeEach(function () {
Schema::dropIfExists('action_form_articles');
Schema::create('action_form_articles', function (Blueprint $table): void {
$table->id();
$table->string('status')->nullable();
$table->timestamps();
});
});

it('seeds an enum-cast attribute into the modal state as its scalar key', function () {
$article = ActionFormArticle::query()->create(['status' => ActionFormStatus::Published]);

Livewire::test(ActionFormEnumHost::class, ['id' => $article->id])
->call('mountAction', 'editStatus')
->assertSet('mountedActions.0.data.status', 'published');
});

it('renders the select for an enum-cast attribute instead of fataling on its type', function () {
$article = ActionFormArticle::query()->create(['status' => ActionFormStatus::Published]);

Livewire::test(ActionFormEnumHost::class, ['id' => $article->id])
->call('mountAction', 'editStatus')
->assertOk()
->assertSee('Published');
});

it('saves the choice back through the cast', function () {
$article = ActionFormArticle::query()->create(['status' => ActionFormStatus::Published]);

Livewire::test(ActionFormEnumHost::class, ['id' => $article->id])
->call('mountAction', 'editStatus')
->set('mountedActions.0.data.status', 'draft')
->call('callMountedAction');

expect($article->fresh()->status)->toBe(ActionFormStatus::Draft);
});

it('resolves the selected label from an enum instance handed to it directly', function () {
// The render path takes `mixed`: a host can also write the bag itself
// ($set, a public property assigned from a model), so the label lookup
// normalises rather than trusting the caller.
$select = Select::make('status')->options(ActionFormStatus::class);

expect($select->getSelectedOptionLabels(ActionFormStatus::Published))
->toBe(['published' => 'Published']);
});
7 changes: 7 additions & 0 deletions workbench/scripts/verify-copy-cell.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ try {
return JSON.stringify({
present: true,
hidden: p.hidden,
// The attribute alone does not hide it: the pill also carries inline-flex,
// which ties with Tailwind's preflight rule for the attribute on
// specificity and wins on order. Reading p.hidden only would report a
// pill parked, painted, in the table's corner as hidden.
display: getComputedStyle(p).display,
text: p.querySelector('[data-copy-feedback-text]')?.textContent ?? '',
left: p.style.left,
});
Expand Down Expand Up @@ -101,12 +106,14 @@ try {

const before = JSON.parse(await pillState());
check('pill starts hidden', before.present && before.hidden === true);
check('pill starts unpainted, not just flagged hidden', before.display === 'none', `display ${before.display}`);

// ── 2. A copy actually copies ────────────────────────────────────────
await clickCopy(0);

const after = JSON.parse(await pillState());
check('pill shows after a copy', after.hidden === false, `text "${after.text}"`);
check('pill is actually painted once shown', after.display !== 'none', `display ${after.display}`);
check('pill carries the column message', after.text.length > 0, `"${after.text}"`);
check('pill is positioned at the button', after.left !== '' && after.left !== undefined, `left ${after.left}`);
await shot('01-copied');
Expand Down
Loading