From fbc3869a0d75e3a330ecf3297aacb33c36f1ec18 Mon Sep 17 00:00:00 2001 From: ondrejnyklicek Date: Sat, 1 Aug 2026 07:46:24 +0200 Subject: [PATCH 01/18] Show a table's writes at once, to every session looking at it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An inline-editable cell was a write-only surface. Its root carries `wire:ignore.self` so a morph cannot reset the optimistic state it holds mid-edit — and Livewire then stops updating that element's own attributes for the rest of the page's life. `data-server-value` and `data-record-version` were whatever the FIRST render wrote, the MutationObserver watching them had nothing to wake it, and the documented reconcile could not run. Confirmed in a browser: after a header action rewrote the same column, a plain TextColumn in that row refreshed while the editable cell kept the old value, kept the old version, and the user's own next edit came back "Record was modified by another user". The value now arrives on a sync node — a child element the morph does reach — so a poll tick, a modal write or another session's change reconcile the value and the lock version together. The optimistic lock was silently OFF for any model naming its timestamp column something other than `updated_at`. Three hand-rolled copies of the stamp survived on the table side, all reading the literal attribute; on such a model the client rendered the '0' sentinel, which RecordVersion reads as "the client never had a version", so the check was skipped and the write went through unguarded. All three now delegate to RecordVersion. Inline edits no longer skip the render — summaries, rollups and anything derived from the edited value were stale until something else forced one. `refreshAfterEdit(false)` opts back out. `cacheQuery()` served pre-write rows for the whole TTL after any write; keys now carry a write generation that one bump retires. A page change riding in the same commit as a cell edit was swallowed: `setPage()` is a call, the browser queues the edit first, so marking the request has to take a granted skip back rather than refuse a later one. And a save that failed without a server reply reported nothing at all, because init() read its messages out of itself. New: `Table::live()` (poll + change detection, plus the write generation that lets detection see a write landing in the same second as the last checksum), `live(broadcast: true)` for push, and `Action::optimisticLock()` for the modal window, which was the longer and entirely unguarded one. `TableRecordsChanged` is `ShouldBroadcastNow` deliberately: as a queued broadcast it was swallowed whole by a configured queue with no worker, found against a real Reverb where the socket connected, the channel authorized, and no event ever arrived — silently, because polling covered for it. No broadcaster is a dependency; the client half calls only `window.Echo.private()` and `.leave()`, pinned by BroadcasterAgnosticTest. Verified by the browser drivers (verify-live-refresh, verify-live-broadcast, and verify-live-broadcast-real against a real Reverb, which installs on demand and skips otherwise). --- CHANGELOG.md | 8 + docs/cs/table/actions.md | 25 ++ docs/cs/table/advanced.md | 84 +++++ docs/cs/table/columns/editing.md | 27 +- docs/table/actions.md | 25 ++ docs/table/advanced.md | 86 +++++ docs/table/columns/editing.md | 36 +- package.json | 3 +- .../resources/boost/docs/table/actions.md | 25 ++ .../resources/boost/docs/table/advanced.md | 86 +++++ .../boost/docs/table/columns/editing.md | 36 +- .../boost/guidelines/wire-table.blade.php | 34 +- packages/core/dist/wire-core-dropdown.js | 2 +- packages/core/resources/js/dropdown.js | 76 ++-- packages/core/resources/js/editable/sync.js | 50 +++ packages/core/resources/js/fill/controller.js | 9 +- packages/core/resources/js/fill/grid.js | 32 +- .../views/panels/entries/checkbox.blade.php | 3 +- .../views/panels/entries/select.blade.php | 3 +- .../views/panels/entries/text-input.blade.php | 3 +- .../views/panels/entries/toggle.blade.php | 3 +- .../views/partials/cell-sync.blade.php | 26 ++ packages/core/src/Actions/Action.php | 5 + .../Actions/Concerns/HasOptimisticLock.php | 46 +++ .../core/src/Foundation/View/CellSync.php | 66 ++++ .../src/Panels/Components/EditableEntry.php | 15 + packages/core/src/WireCoreServiceProvider.php | 6 + .../Feature/EditableCellVersionSourceTest.php | 32 +- packages/table/dist/wire-table-live.js | 1 + packages/table/resources/js/record-live.js | 110 ++++++ .../views/tables/columns/select.blade.php | 7 +- .../columns/text-input-editable.blade.php | 7 +- .../views/tables/columns/toggle.blade.php | 4 +- .../resources/views/tables/index.blade.php | 15 +- .../tables/partials/live-assets.blade.php | 33 ++ packages/table/src/Columns/SelectColumn.php | 9 + .../table/src/Columns/TextInputColumn.php | 7 + packages/table/src/Columns/ToggleColumn.php | 2 + packages/table/src/Concerns/CanFillCells.php | 24 +- .../table/src/Concerns/HasRecordVersion.php | 21 +- .../Concerns/InteractsWithTableActions.php | 14 + .../src/Concerns/InteractsWithTableModals.php | 66 ++++ packages/table/src/Concerns/WithTable.php | 177 ++++++++- .../table/src/Events/TableRecordsChanged.php | 99 +++++ .../table/src/Services/TableQueryCacheKey.php | 26 +- .../table/src/Services/WriteGeneration.php | 87 +++++ packages/table/src/Support/FillResult.php | 12 + packages/table/src/Table.php | 98 +++++ .../table/src/WireTableServiceProvider.php | 3 + .../Feature/ActionOptimisticLockTest.php | 190 ++++++++++ .../tests/Feature/BroadcasterAgnosticTest.php | 72 ++++ .../Feature/PerPageAndQueryCacheTest.php | 73 +++- .../tests/Feature/PerPageMergedCommitTest.php | 144 +++++++- .../tests/Feature/TableLiveRefreshTest.php | 236 ++++++++++++ .../tests/Feature/WireStackScriptsTest.php | 7 +- .../Unit/Concerns/RecordConcernsTest.php | 25 ++ testbench.yaml | 6 + .../app/Livewire/Previews/TablePreview.php | 11 +- .../WorkbenchBroadcastServiceProvider.php | 177 +++++++++ workbench/resources/js/echo-bootstrap.js | 49 +++ .../resources/views/layouts/preview.blade.php | 12 + workbench/routes/web.php | 12 + .../scripts/verify-editable-per-page.mjs | 31 +- .../scripts/verify-live-broadcast-real.mjs | 340 ++++++++++++++++++ workbench/scripts/verify-live-broadcast.mjs | 263 ++++++++++++++ workbench/scripts/verify-live-refresh.mjs | 261 ++++++++++++++ 66 files changed, 3416 insertions(+), 167 deletions(-) create mode 100644 packages/core/resources/js/editable/sync.js create mode 100644 packages/core/resources/views/partials/cell-sync.blade.php create mode 100644 packages/core/src/Actions/Concerns/HasOptimisticLock.php create mode 100644 packages/core/src/Foundation/View/CellSync.php create mode 100644 packages/table/dist/wire-table-live.js create mode 100644 packages/table/resources/js/record-live.js create mode 100644 packages/table/resources/views/tables/partials/live-assets.blade.php create mode 100644 packages/table/src/Events/TableRecordsChanged.php create mode 100644 packages/table/src/Services/WriteGeneration.php create mode 100644 packages/table/tests/Feature/ActionOptimisticLockTest.php create mode 100644 packages/table/tests/Feature/BroadcasterAgnosticTest.php create mode 100644 packages/table/tests/Feature/TableLiveRefreshTest.php create mode 100644 workbench/app/Providers/WorkbenchBroadcastServiceProvider.php create mode 100644 workbench/resources/js/echo-bootstrap.js create mode 100644 workbench/scripts/verify-live-broadcast-real.mjs create mode 100644 workbench/scripts/verify-live-broadcast.mjs create mode 100644 workbench/scripts/verify-live-refresh.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d91264c..9b115312 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.15.0] ### Added +- **A table can now stay current for everyone looking at it — `Table::live()`.** Polling and change detection turned on together (`live()` is exactly `->poll($interval)->pollChangeDetection()`), plus the piece that was missing under both: a **write generation**, a cross-process counter scoped by model that every write through a table moves on. Without it change detection is blind to a write landing in the same second as the previous checksum — `updated_at` is stored to the second, so such an edit is indistinguishable from no edit, and the next tick compares against that same second again. It was not shown late; it was not shown at all. The counter also retires every cached slice of the table at once, which is what makes `cacheQuery()` and a live table usable together — the cache namespace is derived from the SQL, so every filter and search term a user ever opened has its own entry and the writer knows none of them to delete. `live(broadcast: true)` adds the push half: `TableRecordsChanged` fires wherever a write retires the cache, and the page subscribes, so another session's write arrives on commit instead of on the next tick (measured at ~690ms end to end against a 2s interval). The event carries **no data** — a nudge to re-read, not a payload to apply — so each client refreshes through its own component and re-evaluates its own authorization, filters, sort and page server-side; the channel therefore has nothing on it but the scope name, and is authorized by the app like any other (`TableRecordsChanged::channelFor()` names it readably: `wire-table.App.Models.Invoice`). No broadcaster is a dependency and none is privileged — the client half calls nothing but `window.Echo.private()`/`.leave()`, a surface `BroadcasterAgnosticTest` pins — though Reverb is the only one the path has actually been run against, by a driver that runs on demand locally and is not part of CI. It is **`ShouldBroadcastNow`**, not `ShouldBroadcast`, and that word is the feature: a queued broadcast is swallowed whole by the very common setup of a configured queue with no worker running for it — silently, because polling covers for it and the table refreshes a moment later anyway. Found exactly that way, against a real Reverb: the socket connected, the private channel authorized, and no event ever arrived, while the run looked healthy. The trade is stated in the docs — the write now waits on the broadcaster's HTTP call before it answers. Every way the transport can fail is harmless — no Echo on the page, no connection configured, authorization refused, a dropped socket — because the interval is still running underneath: a slower table, never a stale one. A burst of writes coalesces into one re-read, and a re-read is held off while one of your own cells has a save in flight. See `docs/table/advanced.md` § Live Tables. +- **`Action::optimisticLock()` — refuse a record action whose record moved while its modal was open.** An inline cell edit has been locked since it shipped; the modal path, which has by far the longer window (open, read, type, walk away, submit), carried nothing and overwrote whatever had happened in between. The baseline is captured when the frame is pushed and compared on submit through `RecordVersion` — the same object and the same convention the cell edit compares with, so there is one answer to "has this row moved" rather than two that can drift. On refusal the modal **closes** and a warning is raised; leaving the form up would put the user back in front of values that are no longer there with no way to tell. **Off by default**, deliberately: a moved record only invalidates an action that decided something from what it read. Approving an invoice whose total changed underneath is a lost update; deleting a record someone else renamed is not, and refusing it would be a new failure mode in exchange for nothing. Lives on `Action` rather than `BaseAction` because only a row action has one record to lock against. See `docs/table/actions.md`. - **`TimePicker` — a time picked from a list of slots, the way Flux UI does it.** `NyonCode\WireForms\Components\TimePicker` opens a scrollable list of times at a fixed interval (`00:00`, `00:30`, `01:00`, …); clicking one commits it and closes the panel. That is a different gesture from `DateTimePicker::asTime()`, which winds hour and minute steppers and stays open — and both remain available, because they suit different fields: a list is right when the times are *slots* (opening hours, appointments, schedules) and wrong when any minute of the day is valid. The interval is the **inherited `minutesStep()`** rather than a new `interval()` setter — one concept keeps one name — defaulted to 30 here because a list at the picker-wide default of one minute would be 1440 rows long, and floored at 1 because the view walks the day in these strides and a 0 would not terminate rather than render an empty list. `minDate()` / `maxDate()` read as times and **disable** the slots outside them so the range stays visible, and the panel opens on the current value or on the first slot the bounds allow, so a morning-to-evening field does not open at midnight. Everything on the value side is inherited and unchanged: the `H:i` / `H:i:s` state format, `withSeconds()`, `displayFormat()`, the native `` fallback, the mobile sheet, hydration/dehydration. It is an `instanceof DateTimePicker`, so the save pipeline and state hydrator see it unchanged. `mode()` is locked to `time` — `mode('date')`, and the inherited `asDate()` / `asMonth()` / `asDateTime()` aliases that route through it, throw the new `FormConfigurationException::fixedPickerMode()` — which is load-bearing rather than cosmetic here, since the view renders a slot list and nothing else, so a field that reached `date` mode would render a picker with no calendar in it. **The cost, stated plainly:** this is the one place the package now carries two implementations of "pick a time" — two views, two Alpine components, two sets of bounds logic — which is exactly the duplication ADR 0008 exists to prevent, and it can drift. It was accepted deliberately; the native branch was extracted to a shared partial (`wire-forms::partials.date-time-native-input`) because that half would have drifted first. On a phone the panel is a bottom sheet like every other floating surface, which needed one addition to the shared vocabulary: **`MobileSheet::scrollArea()`** (new, reusable), the class pair for a scrolling region *inside* a sheet — full width, and a 60vh cap in place of its dropdown-sized one. The cap is deliberately raised rather than removed: `max-h-none` hands scrolling to the panel, which carries its own `max-h-[85vh]` + `overflow-y-auto`, and any list that reveals its current value by setting `scrollTop` then silently stops working, because the element it scrolls is no longer the one that scrolls. That is not hypothetical — the first implementation did exactly this, and the sheet opened at 00:00 with every selectable slot below the fold; the browser driver caught it and both the helper's tests and the driver now pin it. Browser-verified over CDP by `workbench/scripts/verify-time-picker.mjs` (12/12) against a new `/previews/field-time-picker` — 96 slots at a 15-minute interval, exactly the 37 inside 08:00–17:00 selectable, a disabled slot refusing to commit, a click committing and closing, the value marked active on reopen, the list opening scrolled past midnight — and by `verify-time-picker-sheet.mjs` (17/17) for the phone/desktop split: bottom-pinned full-width sheet with backdrop and grabber below the breakpoint, trigger-anchored panel with neither above it, the list widening and its cap growing on a phone while staying the scroll container, and a slot picked inside the sheet committing and closing. All seven pre-existing sheet drivers still pass. See `docs/forms/fields/time-picker.md` and ADR 0008 § Amendment. **Not done:** `DatePicker` has no equivalent, and `datetime` mode's time half keeps its steppers. - **An empty table can now offer the way out of being empty — `Table::emptyStateActions()`.** The empty state used to be a heading, a description and an icon: it could say "no posts yet" but never "write the first one", so the one thing a visitor wants at that moment lived somewhere else on the page. It now takes actions — `->emptyStateActions([Action::make('create')->label('Create post')->url(route('posts.create'))])` — rendered through the same canonical empty-state surface as the filter-empty reset, and accepting both `Action` and `HeaderAction`. The empty state is a **record-less** surface, so its actions run through the host methods header actions use (`executeHeaderAction` / `openHeaderActionModal`) rather than the row pipeline, which would carry an empty record key: `->form()`, `->requiresConfirmation()` and the whole modal stack work unchanged, and `findHeaderAction()` now searches both surfaces so a click resolves. Two consequences worth knowing: only a **static** `->url()` resolves without a record (a per-record `->url(fn ($record) => …)` closure is left unset and the action renders as a plain button), and an empty-state action should carry a **name of its own** — reusing a header action's name renders both when the table is empty, duplicating its `data-testid` and, if it carries a `keyboardShortcut()`, registering that window listener twice. They are deliberately **not** shown when a filter emptied the table: the records exist behind the filter, so that state keeps offering to clear it. Browser-verified over CDP by `workbench/scripts/verify-empty-state-actions.mjs` (13/13) against a new `/previews/table-empty-state`: the record-less button opens its modal form and closes again at desktop width, the card copy does the same at 390px, and the shortcut binding appears once across both layouts. See `docs/table/overview.md` § Empty State Actions. - **A table can now say which of its records may have sub-rows at all — `Table::subRowsVisible()`.** `subRows()` was a table-wide switch, so in a table mixing record kinds every row got an expand chevron, including rows that can never have children; the only escape hatch, `subRowView()`, changes what the panel *renders*, not whether the chevron is there. `->subRowsVisible(fn (Model $record) => $record->isPiecework())` takes the decision per record (a plain `bool` works too): a rejected record loses the chevron, the child panel and its share of the eager load — but **keeps an empty expander cell**, because dropping the `` would shift every other column on that row out of alignment with the header. Two placements are load-bearing rather than incidental: the short-circuit sits *ahead of* the detail-row branch in `getSubRows()`, or a hidden panel would still resolve to `[$record]` and render anyway, and it sits *inside* `eagerLoadSubRows()`, so a default-expanded table stops fetching children for parents that render none. Both layouts are in the document at every width, so the condition is asked two or three times per record per render: the result is **memoized for the request**, keyed by class+key, or by object identity for an unsaved record — a callback doing `$record->items()->exists()` would otherwise be that many queries, which is also why the docs push you toward a value already on the record. Note what this is **not**: it is not "has no children right now". A record that may have children but currently has none still expands, onto the `no_sub_rows` message — that case is `subRowsHideWhenEmpty()` below. New public API alongside it: `Table::hasSubRowsFor($record)`, the per-record counterpart to the structural, record-less `hasSubRows()` (which still decides whether the expander *column* exists at all). See `docs/table/sub-rows.md`. @@ -14,6 +16,12 @@ All notable changes to the Wire ecosystem will be documented in this file. - **A row `Action` renders without a record.** `Action::render()` and `Action::getUrl()` now take an optional record, matching the `RendersAsButton::toButtonRenderArray()` contract the class already implemented with a nullable one — which is what lets the same action object serve a record-less surface such as the empty state above. `->url()` now keeps a static string apart from a per-record closure, so the string resolves with or without a record while the closure stays null rather than being called with one; a purely additive change for every existing caller, which passes a record either way. ### Fixed +- **An inline-editable cell never picked up a server-side change — not from a modal, not from polling — and then falsely accused the user of a conflict.** The cell root carries `wire:ignore.self` so a morph cannot reset its Alpine state; what was not accounted for is that Livewire then stops updating that element's **own attributes** for the rest of the page's life. `data-server-value` and `data-record-version` were therefore whatever the FIRST render wrote, the `MutationObserver` watching them had nothing to wake it, and `syncFromServer()` could not run. Confirmed in a real browser rather than reasoned about: after a header action rewrote the same column, a plain `TextColumn` in that row refreshed while the editable cell kept the old value, kept the old version, and the user's own next inline edit came back "Record was modified by another user". The fresh value now arrives on a **sync node** — a small child element the morph does update and the cell watches — so a poll tick, a modal write or another session's change all reconcile the value *and* the lock version. Two comments and `docs/table/columns/editing.md` had asserted the opposite ("Polling refreshes each cell's version on the next cycle"); the claim was never true. +- **Inline edits no longer skip the table render, because everything else on the row was going stale.** `updateTableCell()` skipped unconditionally to protect the cell's Alpine state — which the sync node above now does properly — so summaries, rollups, any column derived from the edited value and the row's own position under the current sort all kept their pre-write values until something else forced a render. A write renders by default; `Table::refreshAfterEdit(false)` opts back out for a table where the query is expensive and nothing on screen depends on the edited value. +- **The optimistic lock was silently OFF for any model naming its timestamp column something other than `updated_at`.** Three hand-rolled copies of the version stamp survived on the table side — the `HasRecordVersion` trait plus inline `@php` in the text-input and select cell views — all reading the literal `->updated_at`. On a model with `const UPDATED_AT` that attribute is absent, so the client rendered the `'0'` sentinel, and `'0'` means "the client never had a version" to `RecordVersion::conflicts()`: the check was skipped and the edit went through unguarded. Demonstrated end to end — a concurrent change overwritten with `success: true` and no warning, while the same edit carrying the real stamp is correctly refused as a conflict. All three now delegate to `RecordVersion`, the canonical owner, which resolves the column via `getUpdatedAtColumn()`; the panel side had already been moved onto it when this was first found, and the table side had not. +- **`cacheQuery()` served stale rows for the whole TTL after any write.** `invalidateTable()` cleared the in-memory caches and never touched the `Cache::remember()` entry, so a modal action, an inline edit or a fill left the table showing pre-write data — through a full page reload, not merely until the next poll. Cache keys now carry the write generation, so one counter bump retires every cached slice at once. +- **A page change riding in the same Livewire commit as a cell edit was swallowed.** Same class of bug as the per-page one fixed earlier, but not fixable the same way: a per-page change is a property *update*, and Livewire applies every update before any call, so a flag consulted by `skipTableRender()` was enough. `setPage()` is a *call*, ordered by when the browser queued it, and the browser queues the edit **first** — clicking a pagination link blurs the input on the way, and the blur is what commits the cell. The skip was already granted by then. `markTableViewChanged()` therefore takes a skip back rather than merely refusing to grant one, via the same store `skipRender()` writes to; it is now the one way to say "this request changed what the table renders", and covers the page, column visibility and `resetColumns()` alongside the paths that already worked. +- **A save that failed without a server reply reported nothing at all.** `wireEditableCell.init()` read its three messages out of `this.messages` and assigned them straight back to it, so all three were `undefined` for the life of the component while the `data-msg-*` attributes carrying the real translations were never read by anyone. The visible consequence was in the `catch` branch: offline, or on a 500, the value rolled back and `error` was set to `undefined`, which `x-show` treats as false — the user's edit disappeared with no message. Read off the DOM now. - **Every Alpine bundle registered its components only from `alpine:init` — an event that fires exactly once per document — so a bundle reaching a page late registered nothing at all.** This is the whole of `wireRecordSelection is not defined` after a `wire:navigate`, and of the same failure inside a lazily rendered table or an AJAX-loaded modal: the script arrives, it runs, it subscribes to an event that already fired, and `initTree` then evaluates `x-data="wireRecordSelection(…)"` against an empty registry. The visible symptom was worse than dead dropdowns — each mobile sheet backdrop is an `x-show="open"` over state that was never created, so *every one of them rendered* and the page came up as a full-screen grey scrim over a dead table. All six bundles (core dropdown, core chart, table records, table selection, forms image, sortable) now register **unconditionally and idempotently**, the idiom `tiptap-editor.js` already used and whose comment named the bug: register at script top level when `window.Alpine` exists, fall back to the `alpine:init` listener when it does not, and guard with a `registered` flag. That guard is load-bearing rather than defensive — the directive and a per-surface partial can both emit the same `src`, so the browser will execute the bundle twice. Late registration is legal by Alpine's own design (`Alpine.data()` is a plain assignment with no timing guard) and affects only trees initialised afterwards, which is exactly what a late-arriving surface needs. - **`Table::lazy()` no longer force-ships the table's bundles with the placeholder render.** That existed only to dodge the `alpine:init` problem above, and it undercut the point of the feature: a lever meant to defer work pulled every bundle onto a page whose whole purpose was to defer them. Livewire awaits `payload.intercept` — which loads and runs the response's new `@assets` to completion — before `handleSuccess` morphs the markup in, so the factory exists before the deferred table is ever initialised. `lazy()` is now a lever for first-paint script weight as well as query and render cost. - **The stacked-card layout had its own hand-rolled empty state, and it ignored most of what the table said about it.** A phone rendered a fixed inbox icon and the heading, dropping the custom `->emptyState()` icon and description entirely — and when a *filter* emptied the table, the cards showed the generic "no records" state with no way to clear the filter, while the desktop table two elements away showed the search icon, the filter wording and a reset button. It now renders through the same canonical `wire-core::partials.empty-state` surface as the desktop table, so the icon, description, the filter-empty reset and the new `emptyStateActions()` all reach a phone. Both layouts sit in the document at every width (CSS decides which is shown), so the card copy of an action drops its `keyboardShortcut()` — a rendered button binds that as a *window* listener, and two of them would answer one keypress twice; the same reason the mobile row actions clone (`getMobileEmptyStateActionsHtml()`). diff --git a/docs/cs/table/actions.md b/docs/cs/table/actions.md index 8775cc3d..bde923ab 100644 --- a/docs/cs/table/actions.md +++ b/docs/cs/table/actions.md @@ -206,6 +206,31 @@ Action::make('edit') Kompletní API formuláře viz [Přehled formulářů](../forms/overview.md) a [Pole formulářů](../forms/fields/index.md). +### Odmítnutí zastaralého záznamu — `optimisticLock()` + +Okno modalu je dlouhé: otevře se, uživatel si záznam přečte, píše, případně +odejde a odešle to až za chvíli. `optimisticLock()` akci odmítne, pokud se záznam +mezitím změnil. + +```php +Action::make('approve') + ->optimisticLock() + ->form(fn () => [/* … */]) + ->action(fn (Invoice $record, array $data) => $record->approve($data)) +``` + +Baseline se zachytí při otevření modalu a porovná při odeslání, přes stejnou +konvenci verzí (`RecordVersion`, `updated_at` modelu), jakou vždycky používá +[inline edit buňky](columns/editing.md#jak-funguji-inline-ulozeni) — jedna +odpověď na „pohnul se ten řádek?", ne dvě, které se můžou rozejít. Když akci +odmítne, modal se zavře a vyskočí varování; nechat formulář otevřený by uživatele +vrátilo před hodnoty, které už neplatí, a nijak by to nepoznal. + +Ve výchozím stavu je **vypnutý**, protože posunutý záznam znehodnotí jen akci, +která se podle přečteného rozhodovala. Schválení faktury, které se pod rukama +změnila částka, je ztracený zápis; smazání záznamu, který někdo přejmenoval, +není. Zapnout to všude by koupilo nový způsob, jak selhat, a nic víc. + ## Viditelnost, stav a oprávnění Všechny typy akcí podporují podmíněnou viditelnost a autorizaci. diff --git a/docs/cs/table/advanced.md b/docs/cs/table/advanced.md index 18125775..4ae6eb52 100644 --- a/docs/cs/table/advanced.md +++ b/docs/cs/table/advanced.md @@ -321,6 +321,90 @@ hledání, filtr nebo řazení. V takovém požadavku vyhrává změna a tabulka vyrenderuje; přeskočení by nechalo v prohlížeči starý pohled až do další akce uživatele. +--- + +## Živé tabulky (více uživatelů) + +`live()` je polling a detekce změn zapnuté společně, pro případ, kvůli kterému +existují: nad stejnými záznamy sedí víc lidí a každý čeká, že uvidí, co dělají +ostatní. + +```php +$table->live() // každých 5 s, render jen když se něco pohnulo +$table->live('2s') +$table->live(broadcast: true) // …a okamžitě, kde je nastavené Echo +``` + +`live()` je přesně `->poll($interval)->pollChangeDetection()`, takže platí vše ze +sekce výše. Navíc přidává **write generation**: čítač sdílený napříč procesy a +scopovaný podle modelu, který posune každý zápis přes tabulku. Bez něj je detekce +změn slepá vůči zápisu, který dopadne do stejné sekundy jako předchozí checksum — +`updated_at` se ukládá po sekundách, takže takový edit je nerozeznatelný od +žádného, a další tick porovnává proti téže sekundě. Nezobrazil by se pozdě; +nezobrazil by se vůbec. Čítač zároveň naráz zneplatní každý cachovaný řez +tabulky, díky čemuž jde [cachování dotazů](#cachovani-dotazu) a živá tabulka +kombinovat. + +### Push místo čekání — `broadcast: true` + +`live(broadcast: true)` navíc při každém zápisu přes tabulku vypustí +`TableRecordsChanged` a stránka se na něj přihlásí. Zápis se pak k ostatním +relacím dostane hned po commitu, ne až na jejich dalším ticku. + +Událost **nenese žádná data** — je to pobídka „přečti si to znovu", ne payload +k aplikaci. Každý klient se obnoví přes vlastní komponentu, takže se serverově +znovu vyhodnotí jeho autorizace, filtry, řazení i stránka, přesně jako u pollingu. +Na kanálu tím pádem není nic, co by stálo za odposlech: jméno scopu a nic víc. + +**Žádný broadcaster není závislost tohohle balíčku a žádný není zvýhodněný.** +`TableRecordsChanged` je obyčejná laravelí broadcast událost se jmény kanálů jako +stringy a klientská půlka nevolá nic než `window.Echo.private()` a +`window.Echo.leave()`. Takže broadcaster, který Echo v tvé aplikaci řídí — Pusher, +Ably, Reverb — by to měl přenést bez jakékoli změny tady, nastavený přesně tak, +jak už broadcasting v aplikaci nastavený máš. + +Stojí za to oddělit, co je *ověřené*, od toho, co z toho *plyne*: jediný +broadcaster, proti kterému tahle cesta opravdu běžela, je Reverb — driverem +`workbench/scripts/verify-live-broadcast-real.mjs`, který si to potřebné doinstaluje +na vyžádání a **není** součástí CI ani sweepu driverů: žádný broadcaster není +závislost tohohle repozitáře, v žádné sekci žádného manifestu, takže se driver +přeskočí, dokud si to někdo vědomě nepostaví. Že bude fungovat Pusher nebo Ably se +čeká proto, že se balíček dotýká jen těch dvou Echo metod výše — což hlídá +`BroadcasterAgnosticTest` — ne proto, že by to někdo viděl na vlastní oči. + +Událost je `ShouldBroadcastNow`, takže **nejde přes frontu**. Zařazený broadcast +by v běžné situaci „fronta nastavená, worker neběží" zmizel úplně — a *tiše*, +protože polling to zakryje a tabulka se stejně o chvíli později obnoví. Cena za +odeslání inline řečeno na rovinu: zápis čeká na HTTP volání broadcasteru, než +odpoví. Proti lokálnímu Reverbu je to pod milisekundu; proti vzdálenému +broadcasteru, který má špatný den, se to připočte ke každému zápisu — a tabulka, +která si to nemůže dovolit, ať `broadcast` nechá vypnutý a spolehne se na interval. + +Vyžaduje to Echo-kompatibilního klienta a broadcast připojení v aplikaci. Obojí +patří aplikaci, ne tomuhle balíčku, a **každé selhání je neškodné**: žádné Echo na +stránce, nenastavené připojení, odmítnutá autorizace kanálu, spadlý socket — +tabulka spadne zpátky na svůj interval. Uživatel dostane pomalejší tabulku, nikdy +ne zastaralou. + +Kanál autorizujte jako každý jiný. `TableRecordsChanged::channelFor()` ho +pojmenuje podle modelu, záměrně čitelně: + +```php +// routes/channels.php +Broadcast::channel('wire-table.App.Models.Invoice', function ($user) { + return $user->can('viewAny', Invoice::class); +}); +``` + +Dávka zápisů — fill přes padesát řádků, hromadná akce — je jeden broadcast na +záznam; klient je slije do jednoho přečtení. Přečtení se také odloží, dokud má +některá vlastní buňka rozepsaný zápis, protože odpověď by dorazila ve stavu před +ním a buňka by ji stejně právem ignorovala. + +```php +->live(string $interval = '5s', bool $broadcast = false) +``` + ### Polling řádku/sloupce Použijte `PollColumn` pro živé aktualizace per buňka bez obnovování celé tabulky: diff --git a/docs/cs/table/columns/editing.md b/docs/cs/table/columns/editing.md index fd0c8fc0..878c872b 100644 --- a/docs/cs/table/columns/editing.md +++ b/docs/cs/table/columns/editing.md @@ -104,20 +104,31 @@ jako dedikovaný `SelectColumn`/`SelectFilter`. Viz [Enum Options](select.md#opt ### Jak fungují inline uložení -Uložení buňky (`updateTableCell`) záměrně **nepřekresluje tabulku** — DOM morph by resetoval -Alpine stav všech editovatelných buněk. Místo toho každá buňka přepne svůj vzhled **optimisticky** -a sesynchronizuje se serverem přes jednu sdílenou Alpine komponentu (`wireEditableCell`): text -inputy, selecty i toggly ji používají, takže se chovají konzistentně. +Uložení buňky (`updateTableCell`) **překreslí tabulku** a buňka si přitom ochrání vlastní stav. +Všechno odvozené od zapsané hodnoty — summary, rollup, badge počítaný ze stejného sloupce, pozice +řádku pod aktuálním řazením — je v okamžiku zápisu zastaralé a spravit to umí jen render. + +Buňka morph přežije proto, že její root nese `wire:ignore.self`, takže jí Livewire nesahá na +atributy ani na Alpine stav. Právě proto se k ní ale nová hodnota nemůže dostat přes tento root: +doručuje se na **sync uzlu**, malém potomkovi, který morph aktualizuje a který si buňka hlídá. +Všechno tohle dělá jedna sdílená Alpine komponenta (`wireEditableCell`): text inputy, selecty +i toggly ji používají, takže se chovají konzistentně. - **Optimistic + rollback.** Buňka hned ukáže novou hodnotu, pak zavolá server; když uložení selže (validace, oprávnění, chyba), vrátí se na poslední serverem potvrzenou hodnotu a zobrazí zprávu. -- **Optimistic locking.** Každý edit nese verzi řádku (`updated_at`). Když se řádek od načtení +- **Optimistic locking.** Každý edit nese verzi řádku (`updated_at`, resolvovaný přes vlastní + timestamp sloupec modelu, takže `const UPDATED_AT` je respektován). Když se řádek od načtení stránky změnil, uložení se odmítne jako konflikt: buňka načte aktuální hodnotu a zobrazí zprávu o konfliktu **přímo na buňce** (červený stav na text/select/toggle, bez toastu nebo nastavení `NotificationManager`) — dva lidé (nebo dva rychlé edity, které řádek bumpnou) se tak tiše - nepřepíšou. Polling verzi buněk obnoví v dalším cyklu. Volitelně lze pro konflikty vyvolat i - (nápadnější) toast přes `Table::notifyEditConflicts()` — ten už vyžaduje zapojený notifikační - systém (toast container); inline hláška funguje i bez něj. + nepřepíšou. Jakýkoli re-render — tick pollingu, zápis z modalu, příchozí změna z jiné relace — + obnoví přes sync uzel hodnotu buňky *i* její verzi, takže další edit se porovnává proti tomu, co + je opravdu v databázi. Volitelně lze pro konflikty vyvolat i (nápadnější) toast přes + `Table::notifyEditConflicts()` — ten už vyžaduje zapojený notifikační systém (toast container); + inline hláška funguje i bez něj. +- **Vypnutí renderu.** `Table::refreshAfterEdit(false)` se vrací k odpovědi bez HTML. Vyplatí se + jen u tabulky, kde je dotaz za renderem drahý a na editované hodnotě nic na obrazovce nezávisí: + buňka se z odpovědi sesynchronizuje pořád, okolí ne. - **Serverová autorizace.** Klientský `disabled()` stav je jen kosmetika — per-record `disabled()` buňka (i oprávnění sloupce) se znovu vynutí na serveru v `updateTableCell`, takže forged request nemůže zapsat do zamčené buňky. diff --git a/docs/table/actions.md b/docs/table/actions.md index 3fbf6f6d..ae1d6f5c 100644 --- a/docs/table/actions.md +++ b/docs/table/actions.md @@ -207,6 +207,31 @@ Action::make('edit') For the full form API, see [Forms Overview](../forms/overview.md) and [Form Fields](../forms/fields/index.md). +### Refusing a stale record — `optimisticLock()` + +A modal's window is a long one: it opens, the user reads the record, types, maybe +walks away, and submits some time later. `optimisticLock()` refuses the action if +the record changed in the meantime. + +```php +Action::make('approve') + ->optimisticLock() + ->form(fn () => [/* … */]) + ->action(fn (Invoice $record, array $data) => $record->approve($data)) +``` + +The baseline is captured when the modal opens and compared on submit, using the +same version convention (`RecordVersion`, the model's `updated_at`) an +[inline cell edit](columns/editing.md#how-inline-saves-work) always uses — one +answer to "has this row moved", not two that can drift. When it refuses, the +modal closes and a warning is raised; leaving the form up would put the user back +in front of values that are no longer there, with no way to tell. + +It is **off by default**, because a moved record only invalidates an action that +decided something from what it read. Approving an invoice whose total changed +underneath is a lost update; deleting a record someone else renamed is not. +Turning it on everywhere would buy a new way to fail and nothing else. + ## Visibility, State, and Permissions All action types support conditional visibility and authorization. diff --git a/docs/table/advanced.md b/docs/table/advanced.md index 34276ba0..5c795642 100644 --- a/docs/table/advanced.md +++ b/docs/table/advanced.md @@ -319,6 +319,92 @@ search, a filter or the sort. In that request the change wins and the table renders; a skip there would leave the browser showing the previous view until the user did something else. +--- + +## Live Tables (Multi-User) + +`live()` is polling and change detection turned on together, for the case they +exist to serve: several people looking at the same records, each expecting to see +what the others do. + +```php +$table->live() // every 5s, only rendering when something moved +$table->live('2s') +$table->live(broadcast: true) // …and immediately, where Echo is set up +``` + +`live()` is exactly `->poll($interval)->pollChangeDetection()`, so everything in +the section above applies. What it adds is a **write generation**: a counter, +shared across processes and scoped by model, that every write through a table +moves on. Without it, change detection is blind to a write that lands in the same +second as the previous checksum — `updated_at` is stored to the second, so that +edit is indistinguishable from nothing at all, and the next tick compares against +the same second again. It would not be shown late; it would not be shown. The +counter also retires every cached slice of the table at once, which is how +[query caching](#query-caching) and a live table can be used together. + +### Pushing instead of waiting — `broadcast: true` + +`live(broadcast: true)` also fires `TableRecordsChanged` whenever a write happens +through the table, and the page subscribes to it. A write then reaches the other +sessions as soon as it commits rather than on their next tick. + +The event carries **no data** — it is a nudge to re-read, not a payload to apply. +Each client refreshes through its own component, so its own authorization, +filters, sort and page are re-evaluated server-side, exactly as for a poll. That +also means the channel has nothing on it worth intercepting: the scope name and +nothing else. + +**No broadcaster is a dependency of this package, and none is privileged.** +`TableRecordsChanged` is a plain Laravel broadcast event with string channel +names, and the client half calls nothing but `window.Echo.private()` and +`window.Echo.leave()`. So whichever broadcaster Echo drives in your app — Pusher, +Ably, Reverb — should carry it with no change here, configured exactly as your +app already configures broadcasting. + +Worth separating what is *verified* from what *follows from that*: the only +broadcaster this path has actually been run against is Reverb, by +`workbench/scripts/verify-live-broadcast-real.mjs`, which installs what it needs +on demand and is **not** part of CI or of the driver sweep — no broadcaster is a +dependency of this repository, in any section of any manifest, so the driver +skips unless somebody deliberately sets it up. Pusher and Ably are expected to +work because the package touches only the two Echo methods above — a surface +pinned by `BroadcasterAgnosticTest` — not because anyone has watched them do it. + +The event is `ShouldBroadcastNow`, so it does **not** go through your queue. A +queued broadcast would be swallowed entirely by the common setup of a configured +queue with no worker running for it — and swallowed *silently*, because polling +covers for it and the table still refreshes a moment later. The cost of sending +it inline is stated plainly: the write waits on the broadcaster's HTTP call +before it answers. Against a local Reverb that is sub-millisecond; against a +distant broadcaster having a bad day it is added to every write, and a table that +cannot afford that should leave `broadcast` off and keep the interval. + +It needs an Echo-compatible client and a broadcast connection in your app. Both +are the app's, not this package's, and **every way this can fail is harmless**: +no Echo on the page, no connection configured, channel authorization refused, a +socket that drops in the afternoon — the table falls back to its interval. The +user gets a slower table, never a stale one. + +Authorize the channel as you would any other. `TableRecordsChanged::channelFor()` +names it after the model, readably on purpose: + +```php +// routes/channels.php +Broadcast::channel('wire-table.App.Models.Invoice', function ($user) { + return $user->can('viewAny', Invoice::class); +}); +``` + +A burst of writes — a fill over fifty rows, a bulk action — is one broadcast per +record; the client coalesces them into a single re-read. A re-read is also held +off while one of your own cells has a save in flight, since the answer would +arrive as of before that write and the cell would rightly ignore it. + +```php +->live(string $interval = '5s', bool $broadcast = false) +``` + ### Row/Column Polling Use `PollColumn` for per-cell live updates without refreshing the entire table: diff --git a/docs/table/columns/editing.md b/docs/table/columns/editing.md index 533b066e..7ae03b9f 100644 --- a/docs/table/columns/editing.md +++ b/docs/table/columns/editing.md @@ -104,22 +104,34 @@ like the dedicated `SelectColumn`/`SelectFilter`. See [Enum Options](select.md#e ### How inline saves work -Saving a cell (`updateTableCell`) deliberately **does not re-render the table** — a DOM morph -would reset the Alpine state of every editable cell. Instead each cell updates its own appearance -**optimistically** and reconciles with the server, via one shared Alpine component -(`wireEditableCell`): text inputs, selects and toggles all use it, so they behave consistently. +Saving a cell (`updateTableCell`) **re-renders the table**, and the cell protects its own state +while that happens. Everything derived from the written value — a summary, a rollup, a badge +computed from the same column, the row's position under the current sort — is stale the moment the +edit lands, and only a render can put it right. + +The cell survives the morph because its root carries `wire:ignore.self`, so Livewire leaves its +attributes and its Alpine state alone. That is also why the value the server just rendered cannot +reach the cell through that root: it is delivered on a **sync node**, a small child element the +morph *does* update, which the cell watches. One shared Alpine component (`wireEditableCell`) does +all of this — text inputs, selects and toggles use it, so they behave consistently. - **Optimistic + rollback.** The cell shows the new value immediately, then calls the server; if the save fails (validation, permission, error) it rolls back to the last server-confirmed value and surfaces the message. -- **Optimistic locking.** Each edit carries the row's version (`updated_at`). If the row changed - since the page loaded, the save is rejected as a conflict: the cell loads the current value and - shows the conflict message **inline on the cell itself** (a red state on the text/select/toggle, - no toast or `NotificationManager` setup required) — so two people (or two quick edits that bump - the row) can't silently clobber each other. Polling refreshes each cell's version on the next - cycle. Opt in to *also* raise a (more prominent) toast for conflicts with - `Table::notifyEditConflicts()` — this one needs the notification system wired up (a toast - container); the inline message works without it. +- **Optimistic locking.** Each edit carries the row's version (`updated_at`, resolved through the + model's own timestamp column, so `const UPDATED_AT` is honoured). If the row changed since the + page loaded, the save is rejected as a conflict: the cell loads the current value and shows the + conflict message **inline on the cell itself** (a red state on the text/select/toggle, no toast + or `NotificationManager` setup required) — so two people (or two quick edits that bump the row) + can't silently clobber each other. Any re-render — a poll tick, a modal write, another session's + change arriving — refreshes each cell's value *and* its version through the sync node, so the + next edit is compared against what is actually in the database. Opt in to *also* raise a (more + prominent) toast for conflicts with `Table::notifyEditConflicts()` — this one needs the + notification system wired up (a toast container); the inline message works without it. +- **Opting out of the render.** `Table::refreshAfterEdit(false)` goes back to answering an edit + with no HTML at all. Worth it only for a table where the query behind a render is expensive and + nothing on screen depends on the edited value: the cell still reconciles itself from the + response, nothing around it does. - **Server-side authorization.** The client `disabled()` state is only cosmetic — a per-record `disabled()` cell (and any column permission) is enforced again on the server in `updateTableCell`, so a forged request can't write to a locked cell. diff --git a/package.json b/package.json index aa2c9157..c46d12e0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "build:forms-assets": "rm -rf packages/forms/dist/tiptap && esbuild packages/forms/resources/js/tiptap-editor.js packages/forms/resources/js/tiptap-editor-addons.js --bundle --minify --format=esm --splitting --outdir=packages/forms/dist/tiptap --entry-names=[name] --chunk-names=chunk-[hash] --legal-comments=none && esbuild packages/forms/resources/js/image-processor.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/forms/dist/wire-forms-image.js", "build:core-assets": "esbuild packages/core/resources/js/dropdown.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/core/dist/wire-core-dropdown.js && esbuild packages/core/resources/js/chart.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/core/dist/wire-core-chart.js", "build:sortable-assets": "esbuild packages/sortable/resources/js/sortable.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/sortable/dist/wire-sortable.js", - "build:table-assets": "esbuild packages/table/resources/js/record-actions.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/table/dist/wire-table-records.js && esbuild packages/table/resources/js/record-selection.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/table/dist/wire-table-selection.js", + "build:table-assets": "esbuild packages/table/resources/js/record-actions.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/table/dist/wire-table-records.js && esbuild packages/table/resources/js/record-selection.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/table/dist/wire-table-selection.js && esbuild packages/table/resources/js/record-live.js --bundle --minify --format=iife --legal-comments=none --outfile=packages/table/dist/wire-table-live.js", + "build:workbench-echo": "esbuild workbench/resources/js/echo-bootstrap.js --bundle --format=iife --outfile=workbench/resources/dist/echo-bootstrap.js", "docs:api": "php docs-site/scripts/verify-api-docs.php ." }, "dependencies": { diff --git a/packages/boost/resources/boost/docs/table/actions.md b/packages/boost/resources/boost/docs/table/actions.md index 3fbf6f6d..ae1d6f5c 100644 --- a/packages/boost/resources/boost/docs/table/actions.md +++ b/packages/boost/resources/boost/docs/table/actions.md @@ -207,6 +207,31 @@ Action::make('edit') For the full form API, see [Forms Overview](../forms/overview.md) and [Form Fields](../forms/fields/index.md). +### Refusing a stale record — `optimisticLock()` + +A modal's window is a long one: it opens, the user reads the record, types, maybe +walks away, and submits some time later. `optimisticLock()` refuses the action if +the record changed in the meantime. + +```php +Action::make('approve') + ->optimisticLock() + ->form(fn () => [/* … */]) + ->action(fn (Invoice $record, array $data) => $record->approve($data)) +``` + +The baseline is captured when the modal opens and compared on submit, using the +same version convention (`RecordVersion`, the model's `updated_at`) an +[inline cell edit](columns/editing.md#how-inline-saves-work) always uses — one +answer to "has this row moved", not two that can drift. When it refuses, the +modal closes and a warning is raised; leaving the form up would put the user back +in front of values that are no longer there, with no way to tell. + +It is **off by default**, because a moved record only invalidates an action that +decided something from what it read. Approving an invoice whose total changed +underneath is a lost update; deleting a record someone else renamed is not. +Turning it on everywhere would buy a new way to fail and nothing else. + ## Visibility, State, and Permissions All action types support conditional visibility and authorization. diff --git a/packages/boost/resources/boost/docs/table/advanced.md b/packages/boost/resources/boost/docs/table/advanced.md index 34276ba0..5c795642 100644 --- a/packages/boost/resources/boost/docs/table/advanced.md +++ b/packages/boost/resources/boost/docs/table/advanced.md @@ -319,6 +319,92 @@ search, a filter or the sort. In that request the change wins and the table renders; a skip there would leave the browser showing the previous view until the user did something else. +--- + +## Live Tables (Multi-User) + +`live()` is polling and change detection turned on together, for the case they +exist to serve: several people looking at the same records, each expecting to see +what the others do. + +```php +$table->live() // every 5s, only rendering when something moved +$table->live('2s') +$table->live(broadcast: true) // …and immediately, where Echo is set up +``` + +`live()` is exactly `->poll($interval)->pollChangeDetection()`, so everything in +the section above applies. What it adds is a **write generation**: a counter, +shared across processes and scoped by model, that every write through a table +moves on. Without it, change detection is blind to a write that lands in the same +second as the previous checksum — `updated_at` is stored to the second, so that +edit is indistinguishable from nothing at all, and the next tick compares against +the same second again. It would not be shown late; it would not be shown. The +counter also retires every cached slice of the table at once, which is how +[query caching](#query-caching) and a live table can be used together. + +### Pushing instead of waiting — `broadcast: true` + +`live(broadcast: true)` also fires `TableRecordsChanged` whenever a write happens +through the table, and the page subscribes to it. A write then reaches the other +sessions as soon as it commits rather than on their next tick. + +The event carries **no data** — it is a nudge to re-read, not a payload to apply. +Each client refreshes through its own component, so its own authorization, +filters, sort and page are re-evaluated server-side, exactly as for a poll. That +also means the channel has nothing on it worth intercepting: the scope name and +nothing else. + +**No broadcaster is a dependency of this package, and none is privileged.** +`TableRecordsChanged` is a plain Laravel broadcast event with string channel +names, and the client half calls nothing but `window.Echo.private()` and +`window.Echo.leave()`. So whichever broadcaster Echo drives in your app — Pusher, +Ably, Reverb — should carry it with no change here, configured exactly as your +app already configures broadcasting. + +Worth separating what is *verified* from what *follows from that*: the only +broadcaster this path has actually been run against is Reverb, by +`workbench/scripts/verify-live-broadcast-real.mjs`, which installs what it needs +on demand and is **not** part of CI or of the driver sweep — no broadcaster is a +dependency of this repository, in any section of any manifest, so the driver +skips unless somebody deliberately sets it up. Pusher and Ably are expected to +work because the package touches only the two Echo methods above — a surface +pinned by `BroadcasterAgnosticTest` — not because anyone has watched them do it. + +The event is `ShouldBroadcastNow`, so it does **not** go through your queue. A +queued broadcast would be swallowed entirely by the common setup of a configured +queue with no worker running for it — and swallowed *silently*, because polling +covers for it and the table still refreshes a moment later. The cost of sending +it inline is stated plainly: the write waits on the broadcaster's HTTP call +before it answers. Against a local Reverb that is sub-millisecond; against a +distant broadcaster having a bad day it is added to every write, and a table that +cannot afford that should leave `broadcast` off and keep the interval. + +It needs an Echo-compatible client and a broadcast connection in your app. Both +are the app's, not this package's, and **every way this can fail is harmless**: +no Echo on the page, no connection configured, channel authorization refused, a +socket that drops in the afternoon — the table falls back to its interval. The +user gets a slower table, never a stale one. + +Authorize the channel as you would any other. `TableRecordsChanged::channelFor()` +names it after the model, readably on purpose: + +```php +// routes/channels.php +Broadcast::channel('wire-table.App.Models.Invoice', function ($user) { + return $user->can('viewAny', Invoice::class); +}); +``` + +A burst of writes — a fill over fifty rows, a bulk action — is one broadcast per +record; the client coalesces them into a single re-read. A re-read is also held +off while one of your own cells has a save in flight, since the answer would +arrive as of before that write and the cell would rightly ignore it. + +```php +->live(string $interval = '5s', bool $broadcast = false) +``` + ### Row/Column Polling Use `PollColumn` for per-cell live updates without refreshing the entire table: diff --git a/packages/boost/resources/boost/docs/table/columns/editing.md b/packages/boost/resources/boost/docs/table/columns/editing.md index 533b066e..7ae03b9f 100644 --- a/packages/boost/resources/boost/docs/table/columns/editing.md +++ b/packages/boost/resources/boost/docs/table/columns/editing.md @@ -104,22 +104,34 @@ like the dedicated `SelectColumn`/`SelectFilter`. See [Enum Options](select.md#e ### How inline saves work -Saving a cell (`updateTableCell`) deliberately **does not re-render the table** — a DOM morph -would reset the Alpine state of every editable cell. Instead each cell updates its own appearance -**optimistically** and reconciles with the server, via one shared Alpine component -(`wireEditableCell`): text inputs, selects and toggles all use it, so they behave consistently. +Saving a cell (`updateTableCell`) **re-renders the table**, and the cell protects its own state +while that happens. Everything derived from the written value — a summary, a rollup, a badge +computed from the same column, the row's position under the current sort — is stale the moment the +edit lands, and only a render can put it right. + +The cell survives the morph because its root carries `wire:ignore.self`, so Livewire leaves its +attributes and its Alpine state alone. That is also why the value the server just rendered cannot +reach the cell through that root: it is delivered on a **sync node**, a small child element the +morph *does* update, which the cell watches. One shared Alpine component (`wireEditableCell`) does +all of this — text inputs, selects and toggles use it, so they behave consistently. - **Optimistic + rollback.** The cell shows the new value immediately, then calls the server; if the save fails (validation, permission, error) it rolls back to the last server-confirmed value and surfaces the message. -- **Optimistic locking.** Each edit carries the row's version (`updated_at`). If the row changed - since the page loaded, the save is rejected as a conflict: the cell loads the current value and - shows the conflict message **inline on the cell itself** (a red state on the text/select/toggle, - no toast or `NotificationManager` setup required) — so two people (or two quick edits that bump - the row) can't silently clobber each other. Polling refreshes each cell's version on the next - cycle. Opt in to *also* raise a (more prominent) toast for conflicts with - `Table::notifyEditConflicts()` — this one needs the notification system wired up (a toast - container); the inline message works without it. +- **Optimistic locking.** Each edit carries the row's version (`updated_at`, resolved through the + model's own timestamp column, so `const UPDATED_AT` is honoured). If the row changed since the + page loaded, the save is rejected as a conflict: the cell loads the current value and shows the + conflict message **inline on the cell itself** (a red state on the text/select/toggle, no toast + or `NotificationManager` setup required) — so two people (or two quick edits that bump the row) + can't silently clobber each other. Any re-render — a poll tick, a modal write, another session's + change arriving — refreshes each cell's value *and* its version through the sync node, so the + next edit is compared against what is actually in the database. Opt in to *also* raise a (more + prominent) toast for conflicts with `Table::notifyEditConflicts()` — this one needs the + notification system wired up (a toast container); the inline message works without it. +- **Opting out of the render.** `Table::refreshAfterEdit(false)` goes back to answering an edit + with no HTML at all. Worth it only for a table where the query behind a render is expensive and + nothing on screen depends on the edited value: the cell still reconciles itself from the + response, nothing around it does. - **Server-side authorization.** The client `disabled()` state is only cosmetic — a per-record `disabled()` cell (and any column permission) is enforced again on the server in `updateTableCell`, so a forged request can't write to a locked cell. diff --git a/packages/boost/resources/boost/guidelines/wire-table.blade.php b/packages/boost/resources/boost/guidelines/wire-table.blade.php index d72be13b..9a1231b0 100644 --- a/packages/boost/resources/boost/guidelines/wire-table.blade.php +++ b/packages/boost/resources/boost/guidelines/wire-table.blade.php @@ -204,7 +204,7 @@ class pins `query()` to the owner record's relationship, so a subclass cannot wi - Sub-rows: expandable child records via `->subRows('relation')` + `->subRowColumns([...])`, with per-parent subtotals, `->subRowsLimit()` ("show more"), and an interactive filter bar (`->subRowsFilterable()`, filters the **children**). Expansion is one baseline, not a per-row list: `->subRowsDefaultExpanded()` sets where rows start, the master chevron in the expander column header (or `toggleAllRowExpansion()`) moves it, and it survives pagination + is stored per user with `rememberColumns()`. `flattenSubRows()`/`toggleFlattenMode()` are **deprecated** aliases of the default-expanded baseline — they never flattened anything. `subRows()` is table-wide, so **every** row gets a chevron unless `->subRowsVisible(fn ($record) => ...)` says which records can have children at all — rejected records lose the chevron, the panel and their share of the eager load, but keep an empty expander cell so the columns stay aligned (the result is memoized per record, so the callback may query — prefer a `withCount` attribute). It is not "has none right now" — that case is `->subRowsHideWhenEmpty()`, which is **not** the same closure written by hand: it makes the table's own query carry a constrained presence count (its own alias, so a rollup count column keeps `{relation}_count`), so the per-row check is an attribute read rather than a `COUNT` per row. That count honours `subRowQuery()` and `Filter::subRows()` but deliberately **not** the interactive `subRowsFilterable()` bar, whose values change per parent. Both conditions compose, cheap one first. - **A large selection is a query, not a `Collection`.** Besides the keyed selection, the user can "select all matching the filter" (`selectAllMatchingRecords()` / the bulk-bar escalation), stored as a mode whose list holds the *exclusions* — a filter/search change drops it back to explicit keys. A bulk-action callback still receives a `Collection`, but `Table::bulkMaxRecords()` (default 1000) caps what one action loads and the action **refuses out loud** past it. For an action that must handle any size, walk it: `->eachSelectedRecord(fn (Model $r) => ..., chunk: 500)` or `selectedRecordsQuery()` — never expand it into keys. - Grouping with subtotals, and exports (`withSummaries`). -- Inline editing via `TextInputColumn` / `ToggleColumn` / `SelectColumn`. All three share one canonical Alpine component (`wireEditableCell`): the save (`updateTableCell`) skips the table render, so the cell updates **optimistically**, rolls back on failure, and carries the row version for **optimistic-lock** conflict detection (conflict shown inline on the cell; opt-in toast via `Table::notifyEditConflicts()`). Server-side `canEdit(Model $record)` enforces per-record `disabled()`/permission — client `disabled()` is cosmetic only. +- Inline editing via `TextInputColumn` / `ToggleColumn` / `SelectColumn`. All three share one canonical Alpine component (`wireEditableCell`): the cell updates **optimistically**, rolls back on failure, and carries the row version for **optimistic-lock** conflict detection (conflict shown inline on the cell; opt-in toast via `Table::notifyEditConflicts()`). The version is `RecordVersion` — the model's own `updated_at` column, so `const UPDATED_AT` is honoured; do not hand-roll the stamp, a literal `->updated_at` read renders the `'0'` sentinel for such a model and `conflicts()` reads `'0'` as "the client never had a version", leaving every edit on it unguarded. The save (`updateTableCell`) **renders**, and the cell reconciles from a sync node rather than being reset by the morph (see gotchas). Server-side `canEdit(Model $record)` enforces per-record `disabled()`/permission — client `disabled()` is cosmetic only. - **Fill (Excel-style), server side.** `Table::fillHandle()` opts a table in to writing one value across many rows in **one** request (`fillTableCells`); `Column::fillable(false)` excludes a column that is otherwise editable (a unique code, an invoice number), and `Table::fillMaxRecords(int)` caps a single request (default 500). Each record still goes through the full per-record path — `canEdit()`, its own rules, its own optimistic-lock version — so a fill is deliberately **not** all-or-nothing: one row losing its race is reported as a per-record failure while the rest land. Records are resolved through the table's own query, so a key outside it is never written. The endpoint refuses outright unless `fillHandle()` is on. Per-cell `CellUpdating`/`CellUpdated` fire exactly as for a single edit — there is no separate bulk event. The payload is a **list** of `{column, value, records}` entries where `records` maps record key to the optimistic-lock version the client holds (a map, not a bare list of keys — PHP casts a numeric string array key to an int, so `{"15": "…"}` and `["…"]` would be indistinguishable). Driving `fillTableCells` repeatedly means sending the versions the previous call **returned**, never the ones you started with; the version is `updated_at` to the second, so two writes inside one second are indistinguishable and a stale version is not caught there. - Conditional row styling: `Table::rowColor(string|Closure|null)` tints a whole row with a semantic/hue color resolved by the canonical `HasColor` owner (return `null` from the Closure for no tint; a tinted row gets a same-hue hover and drops the neutral hover/striping). `Table::rowClass(string|Closure|null)` adds arbitrary classes (the Closure receives the record). Prefer `rowColor()` over hand-written `bg-*` classes; combine both for e.g. a danger tint + `font-semibold`. - Per-user column memory: `Table::rememberColumns('key')` loads each user's saved hidden-column set on mount and persists it on every toggle, scoped to `auth()->user()` (one key serves all users; stale column names are ignored). Storage is a driver chosen in `config('wire-table.preferences')` — `null` (default, no persistence), `session`, or `database` (publish `wire-table::migrations` → `table_preferences` table). `Table::preferenceDriver($driver)` overrides per table; a "Reset columns" control clears the saved layout. Implement `TablePreferenceDriver` for a custom store. @@ -233,16 +233,28 @@ class pins `query()` to the owner record's relationship, so a subclass cannot wi Blade renders (an eager group renders one view per item per row). Opt-in; the default is eager. Trade-offs: keyboard shortcuts and `wire:click` modifiers on menu items are not wired in lazy mode. Reach for it on large tables whose every row carries a multi-item action dropdown. -- **Inline edits skip the table render.** `TextInputColumn` / `ToggleColumn` / `SelectColumn` - commit through `updateTableCell` — the edited cell updates optimistically without re-rendering - every other row. Do not wrap the whole table in your own `wire:model` polling that would defeat - this. The skip is **conditional**, and every table endpoint that wants one must go through - `WithTable::skipTableRender()` rather than calling `skipRender()` directly: Livewire merges - everything queued for one component into a single request, so a cell save (or a poll tick) can - arrive together with the user changing the page size, search, a filter or the sort. Skipping - there answers with no HTML at all — the new state lands in the snapshot while the browser keeps - the old rows until the user does something else. A request that changed what the table displays - always renders. +- **An inline edit re-renders the table, and the cell survives it.** `TextInputColumn` / + `ToggleColumn` / `SelectColumn` commit through `updateTableCell`, which renders — everything + derived from the written value (summaries, rollups, a badge computed from the same column, the + row's position under the current sort) is stale otherwise. The cell keeps its own Alpine state + because its root carries `wire:ignore.self`, which is also why the fresh value cannot reach it + through that root: Livewire stops updating an ignored element's own attributes after the first + render. It arrives on a **sync node**, a child element the morph does update and the cell + watches. Never move `data-server-value` / `data-record-version` back onto the ignored root, and + never write one without the other — that wakes the observer against a frozen partner and the + edit vanishes from the screen a second after it reached the database. + `Table::refreshAfterEdit(false)` opts back out for a table where the render is expensive and + nothing on screen depends on the edited value. +- **Every skip goes through `WithTable::skipTableRender()`, every view change through + `markTableViewChanged()`.** Never call `skipRender()` directly. Livewire merges everything queued + for one component into a single request, so a cell save, a fill or a poll tick can arrive + together with the user changing the page size, search, a filter, the sort, the page or which + columns are visible. Skipping there answers with no HTML at all — the new state lands in the + snapshot while the browser keeps the old rows until the user does something else. Note the + asymmetry that makes `markTableViewChanged()` more than a flag: property updates (the per-page + select) always reach the component before any call, but `setPage()` is itself a *call*, and the + browser queues the cell edit **first** — the pagination click blurs the input on its way — so the + skip is already granted by then and has to be taken back. - **Relation display never N+1s.** Dot-notation columns (`TextColumn::make('company.name')`) eager-load via `with()` for display — never add a manual per-row query in `displayUsing`. - **Eager-load closure relations.** A relation dereferenced ONLY inside a closure — diff --git a/packages/core/dist/wire-core-dropdown.js b/packages/core/dist/wire-core-dropdown.js index 2db36759..17f5d7bd 100644 --- a/packages/core/dist/wire-core-dropdown.js +++ b/packages/core/dist/wire-core-dropdown.js @@ -1 +1 @@ -(()=>{var F=Math.min,L=Math.max,tt=Math.round,et=Math.floor,T=t=>({x:t,y:t}),ye={left:"right",right:"left",bottom:"top",top:"bottom"};function Pt(t,e,n){return L(t,F(e,n))}function X(t,e){return typeof t=="function"?t(e):t}function k(t){return t.split("-")[0]}function q(t){return t.split("-")[1]}function ht(t){return t==="x"?"y":"x"}function mt(t){return t==="y"?"height":"width"}function _(t){let e=t[0];return e==="t"||e==="b"?"y":"x"}function pt(t){return ht(_(t))}function $t(t,e,n){n===void 0&&(n=!1);let i=q(t),o=pt(t),s=mt(o),l=o==="x"?i===(n?"end":"start")?"right":"left":i==="start"?"bottom":"top";return e.reference[s]>e.floating[s]&&(l=Q(l)),[l,Q(l)]}function Tt(t){let e=Q(t);return[st(t),e,st(e)]}function st(t){return t.includes("start")?t.replace("start","end"):t.replace("end","start")}var Lt=["left","right"],St=["right","left"],xe=["top","bottom"],be=["bottom","top"];function Ae(t,e,n){switch(t){case"top":case"bottom":return n?e?St:Lt:e?Lt:St;case"left":case"right":return e?xe:be;default:return[]}}function _t(t,e,n,i){let o=q(t),s=Ae(k(t),n==="start",i);return o&&(s=s.map(l=>l+"-"+o),e&&(s=s.concat(s.map(st)))),s}function Q(t){let e=k(t);return ye[e]+t.slice(e.length)}function Ee(t){var e,n,i,o;return{top:(e=t.top)!=null?e:0,right:(n=t.right)!=null?n:0,bottom:(i=t.bottom)!=null?i:0,left:(o=t.left)!=null?o:0}}function Mt(t){return typeof t!="number"?Ee(t):{top:t,right:t,bottom:t,left:t}}function H(t){let{x:e,y:n,width:i,height:o}=t;return{width:i,height:o,top:n,left:e,right:e+i,bottom:n+o,x:e,y:n}}function Dt(t,e,n){let{reference:i,floating:o}=t,s=_(e),l=pt(e),r=mt(l),c=k(e),a=s==="y",f=i.x+i.width/2-o.width/2,d=i.y+i.height/2-o.height/2,m=i[r]/2-o[r]/2,u;switch(c){case"top":u={x:f,y:i.y-o.height};break;case"bottom":u={x:f,y:i.y+i.height};break;case"right":u={x:i.x+i.width,y:d};break;case"left":u={x:i.x-o.width,y:d};break;default:u={x:i.x,y:i.y}}let h=q(e);return h&&(u[l]+=m*(h==="end"?1:-1)*(n&&a?-1:1)),u}async function Vt(t,e){var n;e===void 0&&(e={});let{x:i,y:o,platform:s,rects:l,elements:r,strategy:c}=t,{boundary:a="clippingAncestors",rootBoundary:f="viewport",elementContext:d="floating",altBoundary:m=!1,padding:u=0}=X(e,t),h=Mt(u),g=r[m?d==="floating"?"reference":"floating":d],w=H(await s.getClippingRect({element:(n=await(s.isElement==null?void 0:s.isElement(g)))==null||n?g:g.contextElement||await(s.getDocumentElement==null?void 0:s.getDocumentElement(r.floating)),boundary:a,rootBoundary:f,strategy:c})),v=d==="floating"?{x:i,y:o,width:l.floating.width,height:l.floating.height}:l.reference,y=await(s.getOffsetParent==null?void 0:s.getOffsetParent(r.floating)),x=await(s.isElement==null?void 0:s.isElement(y))&&await(s.getScale==null?void 0:s.getScale(y))||{x:1,y:1},A=H(s.convertOffsetParentRelativeRectToViewportRelativeRect?await s.convertOffsetParentRelativeRectToViewportRelativeRect({elements:r,rect:v,offsetParent:y,strategy:c}):v);return{top:(w.top-A.top+h.top)/x.y,bottom:(A.bottom-w.bottom+h.bottom)/x.y,left:(w.left-A.left+h.left)/x.x,right:(A.right-w.right+h.right)/x.x}}var Oe=50,Ft=async(t,e,n)=>{let{placement:i="bottom",strategy:o="absolute",middleware:s=[],platform:l}=n,r=l.detectOverflow?l:{...l,detectOverflow:Vt},c=await(l.isRTL==null?void 0:l.isRTL(e)),a=await l.getElementRects({reference:t,floating:e,strategy:o}),{x:f,y:d}=Dt(a,i,c),m=i,u=0,h={};for(let p=0;pI<=0)){var Ot,Rt;let I=(((Ot=s.flip)==null?void 0:Ot.index)||0)+1,dt=R[I];if(dt&&(!(d==="alignment"?v!==_(dt):!1)||$.every(C=>_(C.placement)===v?C.overflows[0]>0:!0)))return{data:{index:I,overflows:$},reset:{placement:dt}};let J=(Rt=$.filter(W=>W.overflows[0]<=0).sort((W,C)=>W.overflows[1]-C.overflows[1])[0])==null?void 0:Rt.placement;if(!J)switch(u){case"bestFit":{var Ct;let W=(Ct=$.filter(C=>{if(b){let V=_(C.placement);return V===v||V==="y"}return!0}).map(C=>[C.placement,C.overflows.filter(V=>V>0).reduce((V,ve)=>V+ve,0)]).sort((C,V)=>C[1]-V[1])[0])==null?void 0:Ct[0];W&&(J=W);break}case"initialPlacement":J=r;break}if(o!==J)return{reset:{placement:J}}}return{}}}};var Re=new Set(["left","top"]);async function Ce(t,e){let{placement:n,platform:i,elements:o}=t,s=await(i.isRTL==null?void 0:i.isRTL(o.floating)),l=k(n),r=q(n),c=_(n)==="y",a=Re.has(l)?-1:1,f=s&&c?-1:1,d=X(e,t),{mainAxis:m,crossAxis:u,alignmentAxis:h}=typeof d=="number"?{mainAxis:d,crossAxis:0,alignmentAxis:null}:{mainAxis:d.mainAxis||0,crossAxis:d.crossAxis||0,alignmentAxis:d.alignmentAxis};return r&&typeof h=="number"&&(u=r==="end"?h*-1:h),c?{x:u*f,y:m*a}:{x:m*a,y:u*f}}var Nt=function(t){return t===void 0&&(t=0),{name:"offset",options:t,async fn(e){var n,i;let{x:o,y:s,placement:l,middlewareData:r}=e,c=await Ce(e,t);return l===((n=r.offset)==null?void 0:n.placement)&&(i=r.arrow)!=null&&i.alignmentOffset?{}:{x:o+c.x,y:s+c.y,data:{...c,placement:l}}}}},Bt=function(t){return t===void 0&&(t={}),{name:"shift",options:t,async fn(e){let{x:n,y:i,placement:o,platform:s}=e,{mainAxis:l=!0,crossAxis:r=!1,limiter:c={fn:v=>{let{x:y,y:x}=v;return{x:y,y:x}}},...a}=X(t,e),f={x:n,y:i},d=await s.detectOverflow(e,a),m=_(o),u=ht(m),h=f[u],p=f[m],g=(v,y)=>Pt(y+d[v==="y"?"top":"left"],y,y-d[v==="y"?"bottom":"right"]);l&&(h=g(u,h)),r&&(p=g(m,p));let w=c.fn({...e,[u]:h,[m]:p});return{...w,data:{x:w.x-n,y:w.y-i,enabled:{[u]:l,[m]:r}}}}}};var It=function(t){return t===void 0&&(t={}),{name:"size",options:t,async fn(e){let{placement:n,rects:i,platform:o,elements:s}=e,{apply:l=()=>{},...r}=X(t,e),c=await o.detectOverflow(e,r),a=k(n),f=q(n),d=_(n)==="y",{width:m,height:u}=i.floating,h,p;a==="top"||a==="bottom"?(h=a,p=f===(await(o.isRTL==null?void 0:o.isRTL(s.floating))?"start":"end")?"left":"right"):(p=a,h=f==="end"?"top":"bottom");let g=u-c.top-c.bottom,w=m-c.left-c.right,v=F(u-c[h],g),y=F(m-c[p],w),x=e.middlewareData.shift,A=!x,b=v,R=y;x!=null&&x.enabled.x&&(R=w),x!=null&&x.enabled.y&&(b=g),A&&!f&&(d?R=m-2*L(c.left,c.right):b=u-2*L(c.top,c.bottom)),await l({...e,availableWidth:R,availableHeight:b});let O=await o.getDimensions(s.floating);return m!==O.width||u!==O.height?{reset:{rects:!0}}:{}}}};function rt(){return typeof window<"u"}function K(t){return Ht(t)?(t.nodeName||"").toLowerCase():"#document"}function E(t){var e;return(t==null||(e=t.ownerDocument)==null?void 0:e.defaultView)||window}function M(t){var e;return(e=(Ht(t)?t.ownerDocument:t.document)||window.document)==null?void 0:e.documentElement}function Ht(t){return rt()?t instanceof Node||t instanceof E(t).Node:!1}function S(t){return rt()?t instanceof Element||t instanceof E(t).Element:!1}function D(t){return rt()?t instanceof HTMLElement||t instanceof E(t).HTMLElement:!1}function Wt(t){return!rt()||typeof ShadowRoot>"u"?!1:t instanceof ShadowRoot||t instanceof E(t).ShadowRoot}function nt(t){let{overflow:e,overflowX:n,overflowY:i,display:o}=P(t);return/auto|scroll|overlay|hidden|clip/.test(e+i+n)&&o!=="inline"&&o!=="contents"}function zt(t){return/^(table|td|th)$/.test(K(t))}function it(t){try{if(t.matches(":popover-open"))return!0}catch{}try{return t.matches(":modal")}catch{return!1}}var Le=/transform|translate|scale|rotate|perspective|filter/,Se=/paint|layout|strict|content/,z=t=>!!t&&t!=="none",gt;function lt(t){let e=S(t)?P(t):t;return z(e.transform)||z(e.translate)||z(e.scale)||z(e.rotate)||z(e.perspective)||!ct()&&(z(e.backdropFilter)||z(e.filter))||Le.test(e.willChange||"")||Se.test(e.contain||"")}function Kt(t){let e=N(t);for(;D(e)&&!j(e);){if(lt(e))return e;if(it(e))return null;e=N(e)}return null}function ct(){return gt==null&&(gt=typeof CSS<"u"&&CSS.supports&&CSS.supports("-webkit-backdrop-filter","none")),gt}function j(t){return/^(html|body|#document)$/.test(K(t))}function P(t){return E(t).getComputedStyle(t)}function ot(t){return S(t)?{scrollLeft:t.scrollLeft,scrollTop:t.scrollTop}:{scrollLeft:t.scrollX,scrollTop:t.scrollY}}function N(t){if(K(t)==="html")return t;let e=t.assignedSlot||t.parentNode||Wt(t)&&t.host||M(t);return Wt(e)?e.host:e}function Yt(t){let e=N(t);return j(e)?(t.ownerDocument||t).body:D(e)&&nt(e)?e:Yt(e)}function G(t,e,n){var i;e===void 0&&(e=[]),n===void 0&&(n=!0);let o=Yt(t),s=o===((i=t.ownerDocument)==null?void 0:i.body),l=E(o);if(s){let r=at(l);return e.concat(l,l.visualViewport||[],nt(o)?o:[],r&&n?G(r):[])}else return e.concat(o,G(o,[],n))}function at(t){return t.parent&&Object.getPrototypeOf(t.parent)?t.frameElement:null}function Gt(t){let e=P(t),n=parseFloat(e.width)||0,i=parseFloat(e.height)||0,o=D(t),s=o?t.offsetWidth:n,l=o?t.offsetHeight:i,r=tt(n)!==s||tt(i)!==l;return r&&(n=s,i=l),{width:n,height:i,$:r}}function vt(t){return S(t)?t:t.contextElement}function U(t){let e=vt(t);if(!D(e))return T(1);let n=e.getBoundingClientRect(),{width:i,height:o,$:s}=Gt(e),l=(s?tt(n.width):n.width)/i,r=(s?tt(n.height):n.height)/o;return(!l||!Number.isFinite(l))&&(l=1),(!r||!Number.isFinite(r))&&(r=1),{x:l,y:r}}var Pe=T(0);function jt(t){let e=E(t);return!ct()||!e.visualViewport?Pe:{x:e.visualViewport.offsetLeft,y:e.visualViewport.offsetTop}}function $e(t,e,n){return e===void 0&&(e=!1),!!n&&e&&n===E(t)}function Y(t,e,n,i){e===void 0&&(e=!1),n===void 0&&(n=!1);let o=t.getBoundingClientRect(),s=vt(t),l=T(1);e&&(i?S(i)&&(l=U(i)):l=U(t));let r=$e(s,n,i)?jt(s):T(0),c=(o.left+r.x)/l.x,a=(o.top+r.y)/l.y,f=o.width/l.x,d=o.height/l.y;if(s&&i){let m=E(s),u=S(i)?E(i):i,h=m,p=at(h);for(;p&&u!==h;){let g=U(p),w=p.getBoundingClientRect(),v=P(p),y=w.left+(p.clientLeft+parseFloat(v.paddingLeft))*g.x,x=w.top+(p.clientTop+parseFloat(v.paddingTop))*g.y;c*=g.x,a*=g.y,f*=g.x,d*=g.y,c+=y,a+=x,h=E(p),p=at(h)}}return H({width:f,height:d,x:c,y:a})}function ft(t,e){let n=ot(t).scrollLeft;return e?e.left+n:Y(M(t)).left+n}function Ut(t,e){let n=t.getBoundingClientRect(),i=n.left+e.scrollLeft-ft(t,n),o=n.top+e.scrollTop;return{x:i,y:o}}function Te(t){let{elements:e,rect:n,offsetParent:i,strategy:o}=t,s=o==="fixed",l=M(i),r=e?it(e.floating):!1;if(i===l||r&&s)return n;let c={scrollLeft:0,scrollTop:0},a=T(1),f=T(0),d=D(i);if((d||!s)&&((K(i)!=="body"||nt(l))&&(c=ot(i)),d)){let u=Y(i);a=U(i),f.x=u.x+i.clientLeft,f.y=u.y+i.clientTop}let m=l&&!d&&!s?Ut(l,c):T(0);return{width:n.width*a.x,height:n.height*a.y,x:n.x*a.x-c.scrollLeft*a.x+f.x+m.x,y:n.y*a.y-c.scrollTop*a.y+f.y+m.y}}function _e(t){return t.getClientRects?Array.from(t.getClientRects()):[]}function Me(t){let e=ot(t),n=t.ownerDocument.body,i=L(t.scrollWidth,t.clientWidth,n.scrollWidth,n.clientWidth),o=L(t.scrollHeight,t.clientHeight,n.scrollHeight,n.clientHeight),s=-e.scrollLeft+ft(t),l=-e.scrollTop;return P(n).direction==="rtl"&&(s+=L(t.clientWidth,n.clientWidth)-i),{width:i,height:o,x:s,y:l}}var De=25;function Ve(t,e,n){n===void 0&&(n="viewport");let i=n==="layoutViewport",o=E(t),s=M(t),l=o.visualViewport,r=s.clientWidth,c=s.clientHeight,a=0,f=0;if(l){let m=!ct()||e==="fixed";i?m||(a=-l.offsetLeft,f=-l.offsetTop):(r=l.width,c=l.height,m&&(a=l.offsetLeft,f=l.offsetTop))}if(ft(s)<=0){let m=s.ownerDocument,u=m.body,h=getComputedStyle(u),p=m.compatMode==="CSS1Compat"&&parseFloat(h.marginLeft)+parseFloat(h.marginRight)||0,g=Math.abs(s.clientWidth-u.clientWidth-p),w=getComputedStyle(s).scrollbarGutter==="stable both-edges"?g/2:g;w<=De&&(r-=w)}return{width:r,height:c,x:a,y:f}}function Fe(t,e){let n=Y(t,!0,e==="fixed"),i=n.top+t.clientTop,o=n.left+t.clientLeft,s=U(t),l=t.clientWidth*s.x,r=t.clientHeight*s.y,c=o*s.x,a=i*s.y;return{width:l,height:r,x:c,y:a}}function Xt(t,e,n){let i;if(e==="viewport"||e==="layoutViewport")i=Ve(t,n,e);else if(e==="document")i=Me(M(t));else if(S(e))i=Fe(e,n);else{let o=jt(t);i={x:e.x-o.x,y:e.y-o.y,width:e.width,height:e.height}}return H(i)}function ke(t,e){let n=e.get(t);if(n)return n;let i=G(t,[],!1).filter(r=>S(r)&&K(r)!=="body"),o=null,s=P(t).position==="fixed",l=s?N(t):t;for(;S(l)&&!j(l);){let r=P(l),c=lt(l),a=o?o.position:s?"fixed":"";!c&&(a==="fixed"||a==="absolute"&&r.position==="static")?i=i.filter(d=>d!==l):o=r,l=N(l)}return e.set(t,i),i}function Ne(t){let{element:e,boundary:n,rootBoundary:i,strategy:o}=t,l=[...n==="clippingAncestors"?it(e)?[]:ke(e,this._c):[].concat(n),i],r=Xt(e,l[0],o),c=r.top,a=r.right,f=r.bottom,d=r.left;for(let m=1;m{r(!1,1e-7)},1e3)}R=!1}try{i=new IntersectionObserver(O,{...b,root:s.ownerDocument})}catch{i=new IntersectionObserver(O,b)}i.observe(t)}let c=E(t),a=()=>r(n);return c.addEventListener("resize",a),r(!0),()=>{c.removeEventListener("resize",a),l()}}function Qt(t,e,n,i){i===void 0&&(i={});let{ancestorScroll:o=!0,ancestorResize:s=!0,elementResize:l=typeof ResizeObserver=="function",layoutShift:r=typeof IntersectionObserver=="function",animationFrame:c=!1}=i,a=vt(t),f=o||s?[...a?G(a):[],...e?G(e):[]]:[];f.forEach(w=>{o&&w.addEventListener("scroll",n),s&&w.addEventListener("resize",n)});let d=a&&r?Ke(a,n,s):null,m=-1,u=null;l&&(u=new ResizeObserver(w=>{let[v]=w;v&&v.target===a&&u&&e&&(u.unobserve(e),cancelAnimationFrame(m),m=requestAnimationFrame(()=>{var y;(y=u)==null||y.observe(e)})),n()}),a&&!c&&u.observe(a),e&&u.observe(e));let h,p=c?Y(t):null;c&&g();function g(){let w=Y(t);p&&!Jt(p,w)&&n(),p=w,h=requestAnimationFrame(g)}return n(),()=>{var w;f.forEach(v=>{o&&v.removeEventListener("scroll",n),s&&v.removeEventListener("resize",n)}),d?.(),(w=u)==null||w.disconnect(),u=null,c&&cancelAnimationFrame(h)}}var te=Nt;var ee=Bt,ne=kt,ie=It;var oe=(t,e,n)=>{let i=new Map,o=n??{},s={...ze,...o.platform,_c:i};return Ft(t,e,{...o,platform:s})};var Ye=t=>{let e=t?.parentElement;for(;e;){let n=getComputedStyle(e);if(/(auto|scroll|overlay)/.test(n.overflowY)&&e.scrollHeight>e.clientHeight)return e;e=e.parentElement}return null},se=t=>{let e=null,n=0,i=null,o=()=>i?i.getBoundingClientRect():{top:0,bottom:window.innerHeight},s=r=>i?i.scrollTop+=r:window.scrollBy(0,r),l=()=>{let{top:r,bottom:c}=o(),a=r+60-n,f=n-(c-60);a>0?s(-Math.min(22,a/60*22)):f>0&&s(Math.min(22,f/60*22)),e=requestAnimationFrame(l)};return{start(){i=Ye(t),e===null&&(e=requestAnimationFrame(l))},update(r){n=r},stop(){e!==null&&cancelAnimationFrame(e),e=null}}};var re=t=>{let e=t?.tBodies?.[0];return e?Array.from(e.children).filter(n=>n.matches("tr[data-row-key]")):[]},le=(t,e)=>{if(t.length===0)return null;let n=0;for(let i=0;i=o.top&&e<=o.bottom)return i;e>o.bottom&&(n=i)}return e{try{return(window.Alpine.$data(t)?.recordVersion??t.dataset.recordVersion)||null}catch{return t.dataset.recordVersion||null}},ce=t=>{let e=[];try{e=JSON.parse(t.dataset.fillColumns||"[]")}catch{e=[]}let n=t.querySelector("table"),i={columns:e,rows:()=>re(n),columnAt:o=>e[o]??null,colOf:o=>{let s=e.indexOf(o);return s===-1?null:s},cellAt(o,s){let l=e[s];return l==null?null:this.rows()[o]?.querySelector(`:scope > td[data-column="${CSS.escape(l)}"]`)??null},rootIn(o){return o?.querySelector("[data-record-key][data-column-name]")??null},describe(o,s){let l=this.cellAt(o,s),r=this.rootIn(l);return!l||!r?null:{row:o,col:s,cell:l,el:r,recordKey:r.dataset.recordKey,version:ut(r),serialized:r.dataset.serverValue??""}},locate(o){let s=o?.closest?.("td[data-column]"),l=s?.closest("tr[data-row-key]");if(!s||!l)return null;let r=i.colOf(s.dataset.column),c=i.rows().indexOf(l);return r===null||c===-1?null:{row:c,col:r}},rowAtY(o){return le(this.rows(),o)}};return i};var yt=(t,e=t)=>({anchor:t,focus:e}),ae=t=>({anchor:t.anchor,focus:{row:t.focus.row,col:t.anchor.col}}),xt=t=>({top:Math.min(t.anchor.row,t.focus.row),bottom:Math.max(t.anchor.row,t.focus.row),left:Math.min(t.anchor.col,t.focus.col),right:Math.max(t.anchor.col,t.focus.col)}),bt=t=>t.anchor.row===t.focus.row&&t.anchor.col===t.focus.col,At=t=>{let{top:e,bottom:n,left:i,right:o}=xt(t),s=[];for(let l=e;l<=n;l++)for(let r=i;r<=o;r++)l===t.anchor.row&&r===t.anchor.col||s.push({row:l,col:r});return s};var fe="wire-fill-target",ue=18,de=12,Et=new Set,he=!1,Xe=()=>{he||!window.Livewire||(he=!0,window.Livewire.hook("morph.updating",({skip:t})=>{Et.size>0&&t()}))},qe=()=>({grid:null,handle:null,overlay:null,scroller:null,max:1/0,active:null,range:null,dragging:!1,painted:[],_pending:null,init(){this.grid=ce(this.$el),this.handle=this.$el.querySelector("[data-fill-handle]"),this.overlay=this.$el.querySelector("[data-fill-overlay]"),this.scroller=se(this.$el);let t=parseInt(this.$el.dataset.fillMax||"",10);this.max=Number.isFinite(t)&&t>0?t:1/0,Xe(),this._onFocusIn=e=>this.onFocusIn(e),this._onPointerOver=e=>this.onHover(e),this._onPointerLeave=()=>this.onLeave(),this._onPointerDown=e=>this.startDrag(e),this._reposition=()=>{this.active&&!this.dragging&&this.place()},this.$el.addEventListener("focusin",this._onFocusIn),this.$el.addEventListener("pointerover",this._onPointerOver),this.$el.addEventListener("pointerleave",this._onPointerLeave),this.handle?.addEventListener("pointerdown",this._onPointerDown),window.addEventListener("resize",this._reposition),window.addEventListener("scroll",this._reposition,!0)},destroy(){this.stopDrag(),this.$el.removeEventListener("focusin",this._onFocusIn),this.$el.removeEventListener("pointerover",this._onPointerOver),this.$el.removeEventListener("pointerleave",this._onPointerLeave),this.handle?.removeEventListener("pointerdown",this._onPointerDown),window.removeEventListener("resize",this._reposition),window.removeEventListener("scroll",this._reposition,!0)},onFocusIn(t){if(this.dragging||this.handle?.contains(t.target))return;let e=this.grid.locate(t.target),n=e&&this.grid.describe(e.row,e.col);if(!n||this.isLocked(n)){this.deactivate();return}this.active=e,this.place()},onHover(t){if(this.dragging||this.handle?.contains(t.target)||this.withinGrabRadius(t)||this.focusedPoint())return;let e=this.grid.locate(t.target),n=e&&this.grid.describe(e.row,e.col);!n||this.isLocked(n)||(this.active=e,this.place())},withinGrabRadius(t){if(!this.handle||this.handle.hidden)return!1;let e=this.handle.getBoundingClientRect();return Math.abs(t.clientX-(e.left+e.width/2))<=ue&&Math.abs(t.clientY-(e.top+e.height/2))<=ue},onLeave(){this.dragging||this.focusedPoint()||this.deactivate()},focusedPoint(){let t=document.activeElement;return!t||!this.$el.contains(t)?null:this.grid.locate(t)},isLocked(t){return!!t.el.querySelector("input, select, textarea, button")?.disabled},deactivate(){this.active=null,this.handle&&(this.handle.hidden=!0)},place(){let t=this.active&&this.grid.describe(this.active.row,this.active.col);if(!t||!this.handle){this.deactivate();return}let e=this.$el.getBoundingClientRect(),n=t.cell.getBoundingClientRect();this.handle.style.left=`${n.right-e.left+this.$el.scrollLeft-de}px`,this.handle.style.top=`${n.bottom-e.top+this.$el.scrollTop-de}px`,this.handle.hidden=!1},startDrag(t){if(this.active){t.preventDefault();try{this.handle.setPointerCapture?.(t.pointerId)}catch{}this.dragging=!0,this.range=yt(this.active),Et.add(this),document.body.classList.add("wire-filling"),this.scroller.start(),this._onMove=e=>this.onMove(e),this._onUp=e=>this.finish(e),this._onCancel=()=>this.cancel(),this._onKey=e=>{e.key==="Escape"&&this.cancel()},window.addEventListener("pointermove",this._onMove),window.addEventListener("pointerup",this._onUp),window.addEventListener("pointercancel",this._onCancel),window.addEventListener("keydown",this._onKey)}},onMove(t){if(!this.dragging)return;this.scroller.update(t.clientY);let e=this.grid.rowAtY(t.clientY);if(e===null)return;let n=this.range.anchor.row,i=e>=n?Math.min(e,n+this.max):Math.max(e,n-this.max);this.range=ae(yt(this.range.anchor,{row:i,col:this.range.anchor.col})),this.paint()},paint(){this.clearPaint();for(let t of At(this.range)){let e=this.grid.describe(t.row,t.col);!e||this.isLocked(e)||(e.cell.classList.add(fe),this.painted.push(e.cell))}this.placeOverlay()},clearPaint(){for(let t of this.painted)t.classList.remove(fe);this.painted=[]},placeOverlay(){if(!this.overlay)return;let t=xt(this.range),e=this.grid.cellAt(t.top,t.left),n=this.grid.cellAt(t.bottom,t.right);if(!e||!n)return;let i=this.$el.getBoundingClientRect(),o=e.getBoundingClientRect(),s=n.getBoundingClientRect();this.overlay.style.left=`${o.left-i.left+this.$el.scrollLeft}px`,this.overlay.style.top=`${o.top-i.top+this.$el.scrollTop}px`,this.overlay.style.width=`${s.right-o.left}px`,this.overlay.style.height=`${s.bottom-o.top}px`,this.overlay.hidden=bt(this.range)},stopDrag(){this.dragging&&(window.removeEventListener("pointermove",this._onMove),window.removeEventListener("pointerup",this._onUp),window.removeEventListener("pointercancel",this._onCancel),window.removeEventListener("keydown",this._onKey)),this.dragging=!1,Et.delete(this),document.body.classList.remove("wire-filling"),this.scroller?.stop(),this.clearPaint(),this.overlay&&(this.overlay.hidden=!0)},cancel(){this.stopDrag(),this.range=null},finish(){let t=this.range,e=this.active&&this.grid.describe(this.active.row,this.active.col);if(this.stopDrag(),this.range=null,!t||bt(t)||!e)return;let n=At(t).map(i=>this.grid.describe(i.row,i.col)).filter(i=>i&&!this.isLocked(i));n.length!==0&&this.write(this.grid.columnAt(e.col),e,n)},write(t,e,n){let i=this.liveValue(e),o=new Map;for(let s of n)o.set(s.recordKey,{value:this.liveValue(s),version:ut(s.el)}),this.applyValue(s.el,i);return this._pending=(this._pending??Promise.resolve()).catch(()=>{}).then(()=>this.send(t,i,n,o)),this._pending},async send(t,e,n,i){let o={};for(let r of n)o[r.recordKey]=ut(r.el);let s=null;try{s=await this.$wire.fillTableCells([{column:t,value:e,records:o}])}catch{s=null}let l=s?.results?.[t]??null;for(let r of n){let c=l?.[r.recordKey];if(c?.success){this.applyVersion(r.el,c.version),this.announce(r,t,c.version);continue}let a=i.get(r.recordKey);if(c?.conflict){let f=this.stateOf(r.el);this.applyValue(r.el,f?f.parse(c.currentValue):c.currentValue),this.applyVersion(r.el,c.currentVersion)}else this.applyValue(r.el,a.value),this.applyVersion(r.el,a.version)}},stateOf(t){try{return window.Alpine.$data(t)}catch{return null}},liveValue(t){let e=this.stateOf(t.el);return e?e.value:t.serialized},applyValue(t,e){t.dataset.serverValue=this.serialize(e);let n=this.stateOf(t);n&&(n.value=e,n.serverValue=e,n.error=null)},serialize(t){return typeof t=="boolean"?t?"1":"0":t==null?"":String(t)},applyVersion(t,e){if(!e)return;t.dataset.recordVersion=e;let n=this.stateOf(t);n&&(n.recordVersion=e)},announce(t,e,n){n&&window.dispatchEvent(new CustomEvent("wire-editable-committed",{detail:{componentId:this.$el.closest("[wire\\:id]")?.getAttribute("wire:id")??null,recordKey:t.recordKey,column:e,version:n}}))}}),me=qe;var Ge=1,je=t=>{let e=null;for(let n=t.parentElement;n&&n!==document.body;n=n.parentElement){let i=parseInt(getComputedStyle(n).zIndex,10);Number.isNaN(i)||(e=i)}return e===null?null:e+Ge},we=(t,e,n={})=>{if(!t||!e)return()=>{};let i=n.placement||"bottom-end",o=n.offset??6,s=n.matchWidth??!1,l=n.sheetBreakpoint??640,r=n.sheetOnMobile?window.matchMedia(`(max-width: ${l-.02}px)`):null,c=parseFloat(getComputedStyle(e).maxHeight),a=Number.isNaN(c)?1/0:c,f=parseInt(getComputedStyle(e).zIndex,10),d=Number.isNaN(f)?-1/0:f,m=[te(o),ne(),ee({padding:8}),ie({padding:8,apply({availableHeight:A,rects:b,elements:R}){Object.assign(R.floating.style,{maxHeight:`${Math.round(Math.min(A,a))}px`,overflowY:"auto"}),s&&(R.floating.style.minWidth=`${b.reference.width}px`)}})],u=null,h=()=>{!t.isConnected||!e.isConnected||oe(t,e,{placement:i,middleware:m}).then(({x:A,y:b})=>{Object.assign(e.style,{left:`${A}px`,top:`${b}px`}),u!==null&&(e.style.zIndex=`${u}`)})},p=null,g=null,w=()=>!!r&&r.matches,v=()=>{if(w()){Object.assign(e.style,{position:"",top:"",left:"",maxHeight:"",overflowY:"",minWidth:"",zIndex:""}),u=null;return}let A=je(t);u=A!==null&&A>d?A:null,Object.assign(e.style,{position:"absolute",top:"0",left:"0"}),u!==null&&(e.style.zIndex=`${u}`),p=Qt(t,e,h),g=new MutationObserver(b=>{b.every(O=>O.target===e&&O.type==="attributes"&&O.attributeName==="style")||h()}),g.observe(e,{childList:!0,subtree:!0,attributes:!0})},y=()=>{p&&(p(),p=null),g&&(g.disconnect(),g=null)},x=()=>{y(),v()};return r?.addEventListener("change",x),v(),()=>{y(),r?.removeEventListener("change",x)}},Ue=(t,e)=>{if(!t||!e)return!1;let n=e;for(;n;){if(n===t)return!0;n=n._x_teleportBack??n.parentElement}return!1},Ze=(t={},e=null)=>({open:!1,_cleanup:null,items:e,_wire:null,init(){this._wireId=(this.$root??this.$el)?.closest("[wire\\:id]")?.getAttribute("wire:id")??null},runAction(n){if(!n||!n.method||!this._wireId)return;let i=window.Livewire?.find(this._wireId);i&&typeof i.call=="function"&&i.call(n.method,...n.args||[])},toggle(){this.open?this.close():this.show()},show(){this.open=!0,this.$nextTick(()=>{this._cleanup=we(this.$refs.trigger,this.$refs.panel,t)})},close(){this.open=!1,this.stop()},stop(){this._cleanup&&(this._cleanup(),this._cleanup=null)},destroy(){this.stop()}}),Je=t=>{t.directive("sheet-dismiss",(e,{expression:n},{evaluateLater:i,cleanup:o})=>{let s=i(n),l=()=>e.parentElement,r=()=>window.matchMedia(`(max-width: ${parseFloat(e.dataset.sheetBp)||639.98}px)`).matches,c=0,a=0,f=!1,d=h=>{if(!r())return;f=!0,a=0,c=h.touches[0].clientY;let p=l();p&&(p.style.transition="none")},m=h=>{if(!f)return;a=Math.max(0,h.touches[0].clientY-c);let p=l();p&&(p.style.transform=`translateY(${a}px)`)},u=()=>{if(!f)return;f=!1;let h=l();h&&(h.style.transition="",h.style.transform=""),a>90&&s(()=>{})};e.addEventListener("touchstart",d,{passive:!0}),e.addEventListener("touchmove",m,{passive:!0}),e.addEventListener("touchend",u),o(()=>{e.removeEventListener("touchstart",d),e.removeEventListener("touchmove",m),e.removeEventListener("touchend",u)})})},Qe='a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])',tn=t=>{t.directive("focus-trap",(e,{expression:n},{evaluateLater:i,effect:o,cleanup:s})=>{let l=i(n),r=()=>window.matchMedia(`(max-width: ${parseFloat(e.dataset.sheetBp)||639.98}px)`).matches,c=()=>[...e.querySelectorAll(Qe)].filter(h=>h.offsetParent!==null),a=null,f=null,d=!1,m=()=>{if(d||!r())return;d=!0,a=document.activeElement,e.setAttribute("aria-modal","true"),(c()[0]??e).focus({preventScroll:!0}),f=p=>{if(p.key!=="Tab")return;let g=c();if(g.length===0){p.preventDefault();return}let w=g[0],v=g[g.length-1];p.shiftKey&&document.activeElement===w?(p.preventDefault(),v.focus()):!p.shiftKey&&document.activeElement===v&&(p.preventDefault(),w.focus())},e.addEventListener("keydown",f)},u=()=>{if(!d)return;d=!1,e.removeAttribute("aria-modal"),f&&(e.removeEventListener("keydown",f),f=null);let h=a;a=null,h&&typeof h.focus=="function"&&requestAnimationFrame(()=>h.focus({preventScroll:!0}))};o(()=>{l(h=>{h?requestAnimationFrame(m):u()})}),s(u)})},en=(t=0)=>({tabs:[],active:t,registerTab(e){return this.tabs.push(e),this.tabs.length-1}}),nn=(t=0)=>({steps:[],current:t,registerStep(e){return this.steps.push(e),this.steps.length-1},get isFirst(){return this.current===0},get isLast(){return this.current>=this.steps.length-1},next(){this.isLast||this.current++},prev(){this.isFirst||this.current--}}),on=(t={})=>({value:t.value,serverValue:t.value,recordVersion:t.recordVersion??"0",commitMethod:t.commitMethod??"updateTableCell",validateMethod:t.validateMethod??"validateTableCell",recordKey:null,columnName:null,componentId:null,saving:!1,error:null,success:!1,focused:!1,get dirty(){return this.value!==this.serverValue},parse(e){return t.parse?t.parse(e):e},messages:{},init(){this.recordKey=this.$el.dataset.recordKey,this.columnName=this.$el.dataset.columnName,this.messages={error:this.messages.error,saveFailed:this.messages.saveFailed,invalid:this.messages.invalid},t.liveValidation&&this.$watch("value",window.Alpine.debounce(()=>{this.dirty&&this.validate()},t.debounce??500));let e=new MutationObserver(n=>{for(let i of n)if(i.attributeName==="data-server-value"||i.attributeName==="data-record-version"){let o=this.parse(this.$el.dataset.serverValue);o!==this.serverValue&&this.syncFromServer(o,this.$el.dataset.recordVersion)}});e.observe(this.$el,{attributes:!0,attributeFilter:["data-server-value","data-record-version"]}),this._observer=e,this.componentId=this.$el.closest("[wire\\:id]")?.getAttribute("wire:id")??null,this._onSiblingCommit=n=>{let i=n.detail||{};i.componentId===this.componentId&&String(i.recordKey)===String(this.recordKey)&&i.column!==this.columnName&&(this.saving||this.focused&&this.dirty||this.setRecordVersion(i.version))},window.addEventListener("wire-editable-committed",this._onSiblingCommit)},destroy(){this._observer?.disconnect(),window.removeEventListener("wire-editable-committed",this._onSiblingCommit)},setRecordVersion(e){e&&(this.recordVersion=e)},syncFromServer(e,n){this.saving||this.focused&&this.dirty||(this.value=e,this.serverValue=e,this.setRecordVersion(n),this.error=null)},onFocus(){this.focused=!0},onBlur(){this.focused=!1,t.saveOnBlur&&this.dirty&&this.save()},onEnter(){t.saveOnEnter&&this.dirty&&this.save()},onEscape(){this.value=this.serverValue,this.error=null,this.$refs.input?.blur()},save(){this.dirty&&this.commit(this.value)},async commit(e){if(!this.saving){this.value=e,this.saving=!0,this.error=null;try{let n=await this.$wire[this.commitMethod](this.recordKey,this.columnName,e,this.recordVersion);n?.success===!1?(this.value=this.serverValue,this.error=n.message||n.errors?.[0]||this.messages.error,n?.conflict&&(this.value=this.parse(n.currentValue),this.serverValue=this.value,this.setRecordVersion(n.currentVersion))):(this.serverValue=e,this.setRecordVersion(n.version),this.success=!0,setTimeout(()=>{this.success=!1},1500),n?.version&&window.dispatchEvent(new CustomEvent("wire-editable-committed",{detail:{componentId:this.componentId,recordKey:this.recordKey,column:this.columnName,version:n.version}})))}catch{this.value=this.serverValue,this.error=this.messages.saveFailed}finally{this.saving=!1}}},async validate(){try{let e=await this.$wire[this.validateMethod](this.recordKey,this.columnName,this.value);this.error=e&&!e.valid?e.errors?.[0]||this.messages.invalid:null}catch{}}}),Z=null,sn=()=>({open:!1,x:0,y:0,openAt(t){Z&&Z!==this&&Z.close(),Z=this,this.x=t.clientX,this.y=t.clientY,this.open=!0,this.$nextTick(()=>this.place())},place(){let t=this.$refs.panel;if(!t)return;let e=8,{width:n,height:i}=t.getBoundingClientRect(),o=this.x,s=this.y;o+n+e>window.innerWidth&&(o=window.innerWidth-n-e),s+i+e>window.innerHeight&&(s=window.innerHeight-i-e),t.style.left=`${Math.max(e,o)}px`,t.style.top=`${Math.max(e,s)}px`},close(){this.open=!1,Z===this&&(Z=null)}}),pe=!1,ge=()=>{pe||!window.Alpine||(pe=!0,window.Alpine.magic("float",()=>we),window.Alpine.magic("clickedInside",t=>e=>Ue(t,e?.target)),window.Alpine.data("wireDropdown",Ze),window.Alpine.data("wireContextMenu",sn),window.Alpine.data("wireTabs",en),window.Alpine.data("wireWizard",nn),window.Alpine.data("wireEditableCell",on),window.Alpine.data("wireFillHandle",me),Je(window.Alpine),tn(window.Alpine))};window.Alpine?ge():document.addEventListener("alpine:init",ge);})(); +(()=>{var F=Math.min,L=Math.max,et=Math.round,nt=Math.floor,$=t=>({x:t,y:t}),be={left:"right",right:"left",bottom:"top",top:"bottom"};function Pt(t,e,n){return L(t,F(e,n))}function q(t,e){return typeof t=="function"?t(e):t}function N(t){return t.split("-")[0]}function G(t){return t.split("-")[1]}function mt(t){return t==="x"?"y":"x"}function pt(t){return t==="y"?"height":"width"}function T(t){let e=t[0];return e==="t"||e==="b"?"y":"x"}function gt(t){return mt(T(t))}function $t(t,e,n){n===void 0&&(n=!1);let i=G(t),o=gt(t),s=pt(o),l=o==="x"?i===(n?"end":"start")?"right":"left":i==="start"?"bottom":"top";return e.reference[s]>e.floating[s]&&(l=tt(l)),[l,tt(l)]}function Tt(t){let e=tt(t);return[lt(t),e,lt(e)]}function lt(t){return t.includes("start")?t.replace("start","end"):t.replace("end","start")}var St=["left","right"],_t=["right","left"],Ae=["top","bottom"],Ee=["bottom","top"];function Oe(t,e,n){switch(t){case"top":case"bottom":return n?e?_t:St:e?St:_t;case"left":case"right":return e?Ae:Ee;default:return[]}}function Mt(t,e,n,i){let o=G(t),s=Oe(N(t),n==="start",i);return o&&(s=s.map(l=>l+"-"+o),e&&(s=s.concat(s.map(lt)))),s}function tt(t){let e=N(t);return be[e]+t.slice(e.length)}function Re(t){var e,n,i,o;return{top:(e=t.top)!=null?e:0,right:(n=t.right)!=null?n:0,bottom:(i=t.bottom)!=null?i:0,left:(o=t.left)!=null?o:0}}function Dt(t){return typeof t!="number"?Re(t):{top:t,right:t,bottom:t,left:t}}function z(t){let{x:e,y:n,width:i,height:o}=t;return{width:i,height:o,top:n,left:e,right:e+i,bottom:n+o,x:e,y:n}}function Vt(t,e,n){let{reference:i,floating:o}=t,s=T(e),l=gt(e),r=pt(l),c=N(e),a=s==="y",f=i.x+i.width/2-o.width/2,d=i.y+i.height/2-o.height/2,m=i[r]/2-o[r]/2,u;switch(c){case"top":u={x:f,y:i.y-o.height};break;case"bottom":u={x:f,y:i.y+i.height};break;case"right":u={x:i.x+i.width,y:d};break;case"left":u={x:i.x-o.width,y:d};break;default:u={x:i.x,y:i.y}}let h=G(e);return h&&(u[l]+=m*(h==="end"?1:-1)*(n&&a?-1:1)),u}async function Ft(t,e){var n;e===void 0&&(e={});let{x:i,y:o,platform:s,rects:l,elements:r,strategy:c}=t,{boundary:a="clippingAncestors",rootBoundary:f="viewport",elementContext:d="floating",altBoundary:m=!1,padding:u=0}=q(e,t),h=Dt(u),g=r[m?d==="floating"?"reference":"floating":d],w=z(await s.getClippingRect({element:(n=await(s.isElement==null?void 0:s.isElement(g)))==null||n?g:g.contextElement||await(s.getDocumentElement==null?void 0:s.getDocumentElement(r.floating)),boundary:a,rootBoundary:f,strategy:c})),v=d==="floating"?{x:i,y:o,width:l.floating.width,height:l.floating.height}:l.reference,y=await(s.getOffsetParent==null?void 0:s.getOffsetParent(r.floating)),x=await(s.isElement==null?void 0:s.isElement(y))&&await(s.getScale==null?void 0:s.getScale(y))||{x:1,y:1},A=z(s.convertOffsetParentRelativeRectToViewportRelativeRect?await s.convertOffsetParentRelativeRectToViewportRelativeRect({elements:r,rect:v,offsetParent:y,strategy:c}):v);return{top:(w.top-A.top+h.top)/x.y,bottom:(A.bottom-w.bottom+h.bottom)/x.y,left:(w.left-A.left+h.left)/x.x,right:(A.right-w.right+h.right)/x.x}}var Ce=50,Nt=async(t,e,n)=>{let{placement:i="bottom",strategy:o="absolute",middleware:s=[],platform:l}=n,r=l.detectOverflow?l:{...l,detectOverflow:Ft},c=await(l.isRTL==null?void 0:l.isRTL(e)),a=await l.getElementRects({reference:t,floating:e,strategy:o}),{x:f,y:d}=Vt(a,i,c),m=i,u=0,h={};for(let p=0;pW<=0)){var Rt,Ct;let W=(((Rt=s.flip)==null?void 0:Rt.index)||0)+1,ht=R[W];if(ht&&(!(d==="alignment"?v!==T(ht):!1)||P.every(C=>T(C.placement)===v?C.overflows[0]>0:!0)))return{data:{index:W,overflows:P},reset:{placement:ht}};let Q=(Ct=P.filter(H=>H.overflows[0]<=0).sort((H,C)=>H.overflows[1]-C.overflows[1])[0])==null?void 0:Ct.placement;if(!Q)switch(u){case"bestFit":{var Lt;let H=(Lt=P.filter(C=>{if(b){let V=T(C.placement);return V===v||V==="y"}return!0}).map(C=>[C.placement,C.overflows.filter(V=>V>0).reduce((V,xe)=>V+xe,0)]).sort((C,V)=>C[1]-V[1])[0])==null?void 0:Lt[0];H&&(Q=H);break}case"initialPlacement":Q=r;break}if(o!==Q)return{reset:{placement:Q}}}return{}}}};var Le=new Set(["left","top"]);async function Se(t,e){let{placement:n,platform:i,elements:o}=t,s=await(i.isRTL==null?void 0:i.isRTL(o.floating)),l=N(n),r=G(n),c=T(n)==="y",a=Le.has(l)?-1:1,f=s&&c?-1:1,d=q(e,t),{mainAxis:m,crossAxis:u,alignmentAxis:h}=typeof d=="number"?{mainAxis:d,crossAxis:0,alignmentAxis:null}:{mainAxis:d.mainAxis||0,crossAxis:d.crossAxis||0,alignmentAxis:d.alignmentAxis};return r&&typeof h=="number"&&(u=r==="end"?h*-1:h),c?{x:u*f,y:m*a}:{x:m*a,y:u*f}}var Bt=function(t){return t===void 0&&(t=0),{name:"offset",options:t,async fn(e){var n,i;let{x:o,y:s,placement:l,middlewareData:r}=e,c=await Se(e,t);return l===((n=r.offset)==null?void 0:n.placement)&&(i=r.arrow)!=null&&i.alignmentOffset?{}:{x:o+c.x,y:s+c.y,data:{...c,placement:l}}}}},It=function(t){return t===void 0&&(t={}),{name:"shift",options:t,async fn(e){let{x:n,y:i,placement:o,platform:s}=e,{mainAxis:l=!0,crossAxis:r=!1,limiter:c={fn:v=>{let{x:y,y:x}=v;return{x:y,y:x}}},...a}=q(t,e),f={x:n,y:i},d=await s.detectOverflow(e,a),m=T(o),u=mt(m),h=f[u],p=f[m],g=(v,y)=>Pt(y+d[v==="y"?"top":"left"],y,y-d[v==="y"?"bottom":"right"]);l&&(h=g(u,h)),r&&(p=g(m,p));let w=c.fn({...e,[u]:h,[m]:p});return{...w,data:{x:w.x-n,y:w.y-i,enabled:{[u]:l,[m]:r}}}}}};var Wt=function(t){return t===void 0&&(t={}),{name:"size",options:t,async fn(e){let{placement:n,rects:i,platform:o,elements:s}=e,{apply:l=()=>{},...r}=q(t,e),c=await o.detectOverflow(e,r),a=N(n),f=G(n),d=T(n)==="y",{width:m,height:u}=i.floating,h,p;a==="top"||a==="bottom"?(h=a,p=f===(await(o.isRTL==null?void 0:o.isRTL(s.floating))?"start":"end")?"left":"right"):(p=a,h=f==="end"?"top":"bottom");let g=u-c.top-c.bottom,w=m-c.left-c.right,v=F(u-c[h],g),y=F(m-c[p],w),x=e.middlewareData.shift,A=!x,b=v,R=y;x!=null&&x.enabled.x&&(R=w),x!=null&&x.enabled.y&&(b=g),A&&!f&&(d?R=m-2*L(c.left,c.right):b=u-2*L(c.top,c.bottom)),await l({...e,availableWidth:R,availableHeight:b});let O=await o.getDimensions(s.floating);return m!==O.width||u!==O.height?{reset:{rects:!0}}:{}}}};function ct(){return typeof window<"u"}function Y(t){return zt(t)?(t.nodeName||"").toLowerCase():"#document"}function E(t){var e;return(t==null||(e=t.ownerDocument)==null?void 0:e.defaultView)||window}function M(t){var e;return(e=(zt(t)?t.ownerDocument:t.document)||window.document)==null?void 0:e.documentElement}function zt(t){return ct()?t instanceof Node||t instanceof E(t).Node:!1}function S(t){return ct()?t instanceof Element||t instanceof E(t).Element:!1}function D(t){return ct()?t instanceof HTMLElement||t instanceof E(t).HTMLElement:!1}function Ht(t){return!ct()||typeof ShadowRoot>"u"?!1:t instanceof ShadowRoot||t instanceof E(t).ShadowRoot}function it(t){let{overflow:e,overflowX:n,overflowY:i,display:o}=_(t);return/auto|scroll|overlay|hidden|clip/.test(e+i+n)&&o!=="inline"&&o!=="contents"}function Kt(t){return/^(table|td|th)$/.test(Y(t))}function ot(t){try{if(t.matches(":popover-open"))return!0}catch{}try{return t.matches(":modal")}catch{return!1}}var _e=/transform|translate|scale|rotate|perspective|filter/,Pe=/paint|layout|strict|content/,K=t=>!!t&&t!=="none",wt;function at(t){let e=S(t)?_(t):t;return K(e.transform)||K(e.translate)||K(e.scale)||K(e.rotate)||K(e.perspective)||!ft()&&(K(e.backdropFilter)||K(e.filter))||_e.test(e.willChange||"")||Pe.test(e.contain||"")}function Yt(t){let e=k(t);for(;D(e)&&!U(e);){if(at(e))return e;if(ot(e))return null;e=k(e)}return null}function ft(){return wt==null&&(wt=typeof CSS<"u"&&CSS.supports&&CSS.supports("-webkit-backdrop-filter","none")),wt}function U(t){return/^(html|body|#document)$/.test(Y(t))}function _(t){return E(t).getComputedStyle(t)}function st(t){return S(t)?{scrollLeft:t.scrollLeft,scrollTop:t.scrollTop}:{scrollLeft:t.scrollX,scrollTop:t.scrollY}}function k(t){if(Y(t)==="html")return t;let e=t.assignedSlot||t.parentNode||Ht(t)&&t.host||M(t);return Ht(e)?e.host:e}function Xt(t){let e=k(t);return U(e)?(t.ownerDocument||t).body:D(e)&&it(e)?e:Xt(e)}function j(t,e,n){var i;e===void 0&&(e=[]),n===void 0&&(n=!0);let o=Xt(t),s=o===((i=t.ownerDocument)==null?void 0:i.body),l=E(o);if(s){let r=ut(l);return e.concat(l,l.visualViewport||[],it(o)?o:[],r&&n?j(r):[])}else return e.concat(o,j(o,[],n))}function ut(t){return t.parent&&Object.getPrototypeOf(t.parent)?t.frameElement:null}function jt(t){let e=_(t),n=parseFloat(e.width)||0,i=parseFloat(e.height)||0,o=D(t),s=o?t.offsetWidth:n,l=o?t.offsetHeight:i,r=et(n)!==s||et(i)!==l;return r&&(n=s,i=l),{width:n,height:i,$:r}}function yt(t){return S(t)?t:t.contextElement}function Z(t){let e=yt(t);if(!D(e))return $(1);let n=e.getBoundingClientRect(),{width:i,height:o,$:s}=jt(e),l=(s?et(n.width):n.width)/i,r=(s?et(n.height):n.height)/o;return(!l||!Number.isFinite(l))&&(l=1),(!r||!Number.isFinite(r))&&(r=1),{x:l,y:r}}var $e=$(0);function Ut(t){let e=E(t);return!ft()||!e.visualViewport?$e:{x:e.visualViewport.offsetLeft,y:e.visualViewport.offsetTop}}function Te(t,e,n){return e===void 0&&(e=!1),!!n&&e&&n===E(t)}function X(t,e,n,i){e===void 0&&(e=!1),n===void 0&&(n=!1);let o=t.getBoundingClientRect(),s=yt(t),l=$(1);e&&(i?S(i)&&(l=Z(i)):l=Z(t));let r=Te(s,n,i)?Ut(s):$(0),c=(o.left+r.x)/l.x,a=(o.top+r.y)/l.y,f=o.width/l.x,d=o.height/l.y;if(s&&i){let m=E(s),u=S(i)?E(i):i,h=m,p=ut(h);for(;p&&u!==h;){let g=Z(p),w=p.getBoundingClientRect(),v=_(p),y=w.left+(p.clientLeft+parseFloat(v.paddingLeft))*g.x,x=w.top+(p.clientTop+parseFloat(v.paddingTop))*g.y;c*=g.x,a*=g.y,f*=g.x,d*=g.y,c+=y,a+=x,h=E(p),p=ut(h)}}return z({width:f,height:d,x:c,y:a})}function dt(t,e){let n=st(t).scrollLeft;return e?e.left+n:X(M(t)).left+n}function Zt(t,e){let n=t.getBoundingClientRect(),i=n.left+e.scrollLeft-dt(t,n),o=n.top+e.scrollTop;return{x:i,y:o}}function Me(t){let{elements:e,rect:n,offsetParent:i,strategy:o}=t,s=o==="fixed",l=M(i),r=e?ot(e.floating):!1;if(i===l||r&&s)return n;let c={scrollLeft:0,scrollTop:0},a=$(1),f=$(0),d=D(i);if((d||!s)&&((Y(i)!=="body"||it(l))&&(c=st(i)),d)){let u=X(i);a=Z(i),f.x=u.x+i.clientLeft,f.y=u.y+i.clientTop}let m=l&&!d&&!s?Zt(l,c):$(0);return{width:n.width*a.x,height:n.height*a.y,x:n.x*a.x-c.scrollLeft*a.x+f.x+m.x,y:n.y*a.y-c.scrollTop*a.y+f.y+m.y}}function De(t){return t.getClientRects?Array.from(t.getClientRects()):[]}function Ve(t){let e=st(t),n=t.ownerDocument.body,i=L(t.scrollWidth,t.clientWidth,n.scrollWidth,n.clientWidth),o=L(t.scrollHeight,t.clientHeight,n.scrollHeight,n.clientHeight),s=-e.scrollLeft+dt(t),l=-e.scrollTop;return _(n).direction==="rtl"&&(s+=L(t.clientWidth,n.clientWidth)-i),{width:i,height:o,x:s,y:l}}var Fe=25;function Ne(t,e,n){n===void 0&&(n="viewport");let i=n==="layoutViewport",o=E(t),s=M(t),l=o.visualViewport,r=s.clientWidth,c=s.clientHeight,a=0,f=0;if(l){let m=!ft()||e==="fixed";i?m||(a=-l.offsetLeft,f=-l.offsetTop):(r=l.width,c=l.height,m&&(a=l.offsetLeft,f=l.offsetTop))}if(dt(s)<=0){let m=s.ownerDocument,u=m.body,h=getComputedStyle(u),p=m.compatMode==="CSS1Compat"&&parseFloat(h.marginLeft)+parseFloat(h.marginRight)||0,g=Math.abs(s.clientWidth-u.clientWidth-p),w=getComputedStyle(s).scrollbarGutter==="stable both-edges"?g/2:g;w<=Fe&&(r-=w)}return{width:r,height:c,x:a,y:f}}function ke(t,e){let n=X(t,!0,e==="fixed"),i=n.top+t.clientTop,o=n.left+t.clientLeft,s=Z(t),l=t.clientWidth*s.x,r=t.clientHeight*s.y,c=o*s.x,a=i*s.y;return{width:l,height:r,x:c,y:a}}function qt(t,e,n){let i;if(e==="viewport"||e==="layoutViewport")i=Ne(t,n,e);else if(e==="document")i=Ve(M(t));else if(S(e))i=ke(e,n);else{let o=Ut(t);i={x:e.x-o.x,y:e.y-o.y,width:e.width,height:e.height}}return z(i)}function Be(t,e){let n=e.get(t);if(n)return n;let i=j(t,[],!1).filter(r=>S(r)&&Y(r)!=="body"),o=null,s=_(t).position==="fixed",l=s?k(t):t;for(;S(l)&&!U(l);){let r=_(l),c=at(l),a=o?o.position:s?"fixed":"";!c&&(a==="fixed"||a==="absolute"&&r.position==="static")?i=i.filter(d=>d!==l):o=r,l=k(l)}return e.set(t,i),i}function Ie(t){let{element:e,boundary:n,rootBoundary:i,strategy:o}=t,l=[...n==="clippingAncestors"?ot(e)?[]:Be(e,this._c):[].concat(n),i],r=qt(e,l[0],o),c=r.top,a=r.right,f=r.bottom,d=r.left;for(let m=1;m{r(!1,1e-7)},1e3)}R=!1}try{i=new IntersectionObserver(O,{...b,root:s.ownerDocument})}catch{i=new IntersectionObserver(O,b)}i.observe(t)}let c=E(t),a=()=>r(n);return c.addEventListener("resize",a),r(!0),()=>{c.removeEventListener("resize",a),l()}}function te(t,e,n,i){i===void 0&&(i={});let{ancestorScroll:o=!0,ancestorResize:s=!0,elementResize:l=typeof ResizeObserver=="function",layoutShift:r=typeof IntersectionObserver=="function",animationFrame:c=!1}=i,a=yt(t),f=o||s?[...a?j(a):[],...e?j(e):[]]:[];f.forEach(w=>{o&&w.addEventListener("scroll",n),s&&w.addEventListener("resize",n)});let d=a&&r?Xe(a,n,s):null,m=-1,u=null;l&&(u=new ResizeObserver(w=>{let[v]=w;v&&v.target===a&&u&&e&&(u.unobserve(e),cancelAnimationFrame(m),m=requestAnimationFrame(()=>{var y;(y=u)==null||y.observe(e)})),n()}),a&&!c&&u.observe(a),e&&u.observe(e));let h,p=c?X(t):null;c&&g();function g(){let w=X(t);p&&!Qt(p,w)&&n(),p=w,h=requestAnimationFrame(g)}return n(),()=>{var w;f.forEach(v=>{o&&v.removeEventListener("scroll",n),s&&v.removeEventListener("resize",n)}),d?.(),(w=u)==null||w.disconnect(),u=null,c&&cancelAnimationFrame(h)}}var ee=Bt;var ne=It,ie=kt,oe=Wt;var se=(t,e,n)=>{let i=new Map,o=n??{},s={...Ye,...o.platform,_c:i};return Nt(t,e,{...o,platform:s})};var B=t=>t?.querySelector?.("[data-cell-sync]")??t??null,re=t=>B(t)?.dataset?.serverValue??"",rt=t=>{try{return(window.Alpine.$data(t)?.recordVersion??B(t)?.dataset?.recordVersion)||null}catch{return B(t)?.dataset?.recordVersion||null}};var qe=t=>{let e=t?.parentElement;for(;e;){let n=getComputedStyle(e);if(/(auto|scroll|overlay)/.test(n.overflowY)&&e.scrollHeight>e.clientHeight)return e;e=e.parentElement}return null},le=t=>{let e=null,n=0,i=null,o=()=>i?i.getBoundingClientRect():{top:0,bottom:window.innerHeight},s=r=>i?i.scrollTop+=r:window.scrollBy(0,r),l=()=>{let{top:r,bottom:c}=o(),a=r+60-n,f=n-(c-60);a>0?s(-Math.min(22,a/60*22)):f>0&&s(Math.min(22,f/60*22)),e=requestAnimationFrame(l)};return{start(){i=qe(t),e===null&&(e=requestAnimationFrame(l))},update(r){n=r},stop(){e!==null&&cancelAnimationFrame(e),e=null}}};var ce=t=>{let e=t?.tBodies?.[0];return e?Array.from(e.children).filter(n=>n.matches("tr[data-row-key]")):[]},ae=(t,e)=>{if(t.length===0)return null;let n=0;for(let i=0;i=o.top&&e<=o.bottom)return i;e>o.bottom&&(n=i)}return e{let e=[];try{e=JSON.parse(t.dataset.fillColumns||"[]")}catch{e=[]}let n=t.querySelector("table"),i={columns:e,rows:()=>ce(n),columnAt:o=>e[o]??null,colOf:o=>{let s=e.indexOf(o);return s===-1?null:s},cellAt(o,s){let l=e[s];return l==null?null:this.rows()[o]?.querySelector(`:scope > td[data-column="${CSS.escape(l)}"]`)??null},rootIn(o){return o?.querySelector("[data-record-key][data-column-name]")??null},describe(o,s){let l=this.cellAt(o,s),r=this.rootIn(l);return!l||!r?null:{row:o,col:s,cell:l,el:r,recordKey:r.dataset.recordKey,version:rt(r),serialized:re(r)}},locate(o){let s=o?.closest?.("td[data-column]"),l=s?.closest("tr[data-row-key]");if(!s||!l)return null;let r=i.colOf(s.dataset.column),c=i.rows().indexOf(l);return r===null||c===-1?null:{row:c,col:r}},rowAtY(o){return ae(this.rows(),o)}};return i};var xt=(t,e=t)=>({anchor:t,focus:e}),ue=t=>({anchor:t.anchor,focus:{row:t.focus.row,col:t.anchor.col}}),bt=t=>({top:Math.min(t.anchor.row,t.focus.row),bottom:Math.max(t.anchor.row,t.focus.row),left:Math.min(t.anchor.col,t.focus.col),right:Math.max(t.anchor.col,t.focus.col)}),At=t=>t.anchor.row===t.focus.row&&t.anchor.col===t.focus.col,Et=t=>{let{top:e,bottom:n,left:i,right:o}=bt(t),s=[];for(let l=e;l<=n;l++)for(let r=i;r<=o;r++)l===t.anchor.row&&r===t.anchor.col||s.push({row:l,col:r});return s};var de="wire-fill-target",he=18,me=12,Ot=new Set,pe=!1,Ge=()=>{pe||!window.Livewire||(pe=!0,window.Livewire.hook("morph.updating",({skip:t})=>{Ot.size>0&&t()}))},je=()=>({grid:null,handle:null,overlay:null,scroller:null,max:1/0,active:null,range:null,dragging:!1,painted:[],_pending:null,init(){this.grid=fe(this.$el),this.handle=this.$el.querySelector("[data-fill-handle]"),this.overlay=this.$el.querySelector("[data-fill-overlay]"),this.scroller=le(this.$el);let t=parseInt(this.$el.dataset.fillMax||"",10);this.max=Number.isFinite(t)&&t>0?t:1/0,Ge(),this._onFocusIn=e=>this.onFocusIn(e),this._onPointerOver=e=>this.onHover(e),this._onPointerLeave=()=>this.onLeave(),this._onPointerDown=e=>this.startDrag(e),this._reposition=()=>{this.active&&!this.dragging&&this.place()},this.$el.addEventListener("focusin",this._onFocusIn),this.$el.addEventListener("pointerover",this._onPointerOver),this.$el.addEventListener("pointerleave",this._onPointerLeave),this.handle?.addEventListener("pointerdown",this._onPointerDown),window.addEventListener("resize",this._reposition),window.addEventListener("scroll",this._reposition,!0)},destroy(){this.stopDrag(),this.$el.removeEventListener("focusin",this._onFocusIn),this.$el.removeEventListener("pointerover",this._onPointerOver),this.$el.removeEventListener("pointerleave",this._onPointerLeave),this.handle?.removeEventListener("pointerdown",this._onPointerDown),window.removeEventListener("resize",this._reposition),window.removeEventListener("scroll",this._reposition,!0)},onFocusIn(t){if(this.dragging||this.handle?.contains(t.target))return;let e=this.grid.locate(t.target),n=e&&this.grid.describe(e.row,e.col);if(!n||this.isLocked(n)){this.deactivate();return}this.active=e,this.place()},onHover(t){if(this.dragging||this.handle?.contains(t.target)||this.withinGrabRadius(t)||this.focusedPoint())return;let e=this.grid.locate(t.target),n=e&&this.grid.describe(e.row,e.col);!n||this.isLocked(n)||(this.active=e,this.place())},withinGrabRadius(t){if(!this.handle||this.handle.hidden)return!1;let e=this.handle.getBoundingClientRect();return Math.abs(t.clientX-(e.left+e.width/2))<=he&&Math.abs(t.clientY-(e.top+e.height/2))<=he},onLeave(){this.dragging||this.focusedPoint()||this.deactivate()},focusedPoint(){let t=document.activeElement;return!t||!this.$el.contains(t)?null:this.grid.locate(t)},isLocked(t){return!!t.el.querySelector("input, select, textarea, button")?.disabled},deactivate(){this.active=null,this.handle&&(this.handle.hidden=!0)},place(){let t=this.active&&this.grid.describe(this.active.row,this.active.col);if(!t||!this.handle){this.deactivate();return}let e=this.$el.getBoundingClientRect(),n=t.cell.getBoundingClientRect();this.handle.style.left=`${n.right-e.left+this.$el.scrollLeft-me}px`,this.handle.style.top=`${n.bottom-e.top+this.$el.scrollTop-me}px`,this.handle.hidden=!1},startDrag(t){if(this.active){t.preventDefault();try{this.handle.setPointerCapture?.(t.pointerId)}catch{}this.dragging=!0,this.range=xt(this.active),Ot.add(this),document.body.classList.add("wire-filling"),this.scroller.start(),this._onMove=e=>this.onMove(e),this._onUp=e=>this.finish(e),this._onCancel=()=>this.cancel(),this._onKey=e=>{e.key==="Escape"&&this.cancel()},window.addEventListener("pointermove",this._onMove),window.addEventListener("pointerup",this._onUp),window.addEventListener("pointercancel",this._onCancel),window.addEventListener("keydown",this._onKey)}},onMove(t){if(!this.dragging)return;this.scroller.update(t.clientY);let e=this.grid.rowAtY(t.clientY);if(e===null)return;let n=this.range.anchor.row,i=e>=n?Math.min(e,n+this.max):Math.max(e,n-this.max);this.range=ue(xt(this.range.anchor,{row:i,col:this.range.anchor.col})),this.paint()},paint(){this.clearPaint();for(let t of Et(this.range)){let e=this.grid.describe(t.row,t.col);!e||this.isLocked(e)||(e.cell.classList.add(de),this.painted.push(e.cell))}this.placeOverlay()},clearPaint(){for(let t of this.painted)t.classList.remove(de);this.painted=[]},placeOverlay(){if(!this.overlay)return;let t=bt(this.range),e=this.grid.cellAt(t.top,t.left),n=this.grid.cellAt(t.bottom,t.right);if(!e||!n)return;let i=this.$el.getBoundingClientRect(),o=e.getBoundingClientRect(),s=n.getBoundingClientRect();this.overlay.style.left=`${o.left-i.left+this.$el.scrollLeft}px`,this.overlay.style.top=`${o.top-i.top+this.$el.scrollTop}px`,this.overlay.style.width=`${s.right-o.left}px`,this.overlay.style.height=`${s.bottom-o.top}px`,this.overlay.hidden=At(this.range)},stopDrag(){this.dragging&&(window.removeEventListener("pointermove",this._onMove),window.removeEventListener("pointerup",this._onUp),window.removeEventListener("pointercancel",this._onCancel),window.removeEventListener("keydown",this._onKey)),this.dragging=!1,Ot.delete(this),document.body.classList.remove("wire-filling"),this.scroller?.stop(),this.clearPaint(),this.overlay&&(this.overlay.hidden=!0)},cancel(){this.stopDrag(),this.range=null},finish(){let t=this.range,e=this.active&&this.grid.describe(this.active.row,this.active.col);if(this.stopDrag(),this.range=null,!t||At(t)||!e)return;let n=Et(t).map(i=>this.grid.describe(i.row,i.col)).filter(i=>i&&!this.isLocked(i));n.length!==0&&this.write(this.grid.columnAt(e.col),e,n)},write(t,e,n){let i=this.liveValue(e),o=new Map;for(let s of n)o.set(s.recordKey,{value:this.liveValue(s),version:rt(s.el)}),this.applyValue(s.el,i);return this._pending=(this._pending??Promise.resolve()).catch(()=>{}).then(()=>this.send(t,i,n,o)),this._pending},async send(t,e,n,i){let o={};for(let r of n)o[r.recordKey]=rt(r.el);let s=null;try{s=await this.$wire.fillTableCells([{column:t,value:e,records:o}])}catch{s=null}let l=s?.results?.[t]??null;for(let r of n){let c=l?.[r.recordKey];if(c?.success){this.applyVersion(r.el,c.version),this.announce(r,t,c.version);continue}let a=i.get(r.recordKey);if(c?.conflict){let f=this.stateOf(r.el);this.applyValue(r.el,f?f.parse(c.currentValue):c.currentValue),this.applyVersion(r.el,c.currentVersion)}else this.applyValue(r.el,a.value),this.applyVersion(r.el,a.version)}},stateOf(t){try{return window.Alpine.$data(t)}catch{return null}},liveValue(t){let e=this.stateOf(t.el);return e?e.value:t.serialized},applyValue(t,e){let n=B(t);n&&(n.dataset.serverValue=this.serialize(e));let i=this.stateOf(t);i&&(i.value=e,i.serverValue=e,i.error=null)},serialize(t){return typeof t=="boolean"?t?"1":"0":t==null?"":String(t)},applyVersion(t,e){if(!e)return;let n=B(t);n&&(n.dataset.recordVersion=e);let i=this.stateOf(t);i&&(i.recordVersion=e)},announce(t,e,n){n&&window.dispatchEvent(new CustomEvent("wire-editable-committed",{detail:{componentId:this.$el.closest("[wire\\:id]")?.getAttribute("wire:id")??null,recordKey:t.recordKey,column:e,version:n}}))}}),ge=je;var Ue=1,Ze=t=>{let e=null;for(let n=t.parentElement;n&&n!==document.body;n=n.parentElement){let i=parseInt(getComputedStyle(n).zIndex,10);Number.isNaN(i)||(e=i)}return e===null?null:e+Ue},ye=(t,e,n={})=>{if(!t||!e)return()=>{};let i=n.placement||"bottom-end",o=n.offset??6,s=n.matchWidth??!1,l=n.sheetBreakpoint??640,r=n.sheetOnMobile?window.matchMedia(`(max-width: ${l-.02}px)`):null,c=parseFloat(getComputedStyle(e).maxHeight),a=Number.isNaN(c)?1/0:c,f=parseInt(getComputedStyle(e).zIndex,10),d=Number.isNaN(f)?-1/0:f,m=[ee(o),ie(),ne({padding:8}),oe({padding:8,apply({availableHeight:A,rects:b,elements:R}){Object.assign(R.floating.style,{maxHeight:`${Math.round(Math.min(A,a))}px`,overflowY:"auto"}),s&&(R.floating.style.minWidth=`${b.reference.width}px`)}})],u=null,h=()=>{!t.isConnected||!e.isConnected||se(t,e,{placement:i,middleware:m}).then(({x:A,y:b})=>{Object.assign(e.style,{left:`${A}px`,top:`${b}px`}),u!==null&&(e.style.zIndex=`${u}`)})},p=null,g=null,w=()=>!!r&&r.matches,v=()=>{if(w()){Object.assign(e.style,{position:"",top:"",left:"",maxHeight:"",overflowY:"",minWidth:"",zIndex:""}),u=null;return}let A=Ze(t);u=A!==null&&A>d?A:null,Object.assign(e.style,{position:"absolute",top:"0",left:"0"}),u!==null&&(e.style.zIndex=`${u}`),p=te(t,e,h),g=new MutationObserver(b=>{b.every(O=>O.target===e&&O.type==="attributes"&&O.attributeName==="style")||h()}),g.observe(e,{childList:!0,subtree:!0,attributes:!0})},y=()=>{p&&(p(),p=null),g&&(g.disconnect(),g=null)},x=()=>{y(),v()};return r?.addEventListener("change",x),v(),()=>{y(),r?.removeEventListener("change",x)}},Je=(t,e)=>{if(!t||!e)return!1;let n=e;for(;n;){if(n===t)return!0;n=n._x_teleportBack??n.parentElement}return!1},Qe=(t={},e=null)=>({open:!1,_cleanup:null,items:e,_wire:null,init(){this._wireId=(this.$root??this.$el)?.closest("[wire\\:id]")?.getAttribute("wire:id")??null},runAction(n){if(!n||!n.method||!this._wireId)return;let i=window.Livewire?.find(this._wireId);i&&typeof i.call=="function"&&i.call(n.method,...n.args||[])},toggle(){this.open?this.close():this.show()},show(){this.open=!0,this.$nextTick(()=>{this._cleanup=ye(this.$refs.trigger,this.$refs.panel,t)})},close(){this.open=!1,this.stop()},stop(){this._cleanup&&(this._cleanup(),this._cleanup=null)},destroy(){this.stop()}}),tn=t=>{t.directive("sheet-dismiss",(e,{expression:n},{evaluateLater:i,cleanup:o})=>{let s=i(n),l=()=>e.parentElement,r=()=>window.matchMedia(`(max-width: ${parseFloat(e.dataset.sheetBp)||639.98}px)`).matches,c=0,a=0,f=!1,d=h=>{if(!r())return;f=!0,a=0,c=h.touches[0].clientY;let p=l();p&&(p.style.transition="none")},m=h=>{if(!f)return;a=Math.max(0,h.touches[0].clientY-c);let p=l();p&&(p.style.transform=`translateY(${a}px)`)},u=()=>{if(!f)return;f=!1;let h=l();h&&(h.style.transition="",h.style.transform=""),a>90&&s(()=>{})};e.addEventListener("touchstart",d,{passive:!0}),e.addEventListener("touchmove",m,{passive:!0}),e.addEventListener("touchend",u),o(()=>{e.removeEventListener("touchstart",d),e.removeEventListener("touchmove",m),e.removeEventListener("touchend",u)})})},en='a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])',nn=t=>{t.directive("focus-trap",(e,{expression:n},{evaluateLater:i,effect:o,cleanup:s})=>{let l=i(n),r=()=>window.matchMedia(`(max-width: ${parseFloat(e.dataset.sheetBp)||639.98}px)`).matches,c=()=>[...e.querySelectorAll(en)].filter(h=>h.offsetParent!==null),a=null,f=null,d=!1,m=()=>{if(d||!r())return;d=!0,a=document.activeElement,e.setAttribute("aria-modal","true"),(c()[0]??e).focus({preventScroll:!0}),f=p=>{if(p.key!=="Tab")return;let g=c();if(g.length===0){p.preventDefault();return}let w=g[0],v=g[g.length-1];p.shiftKey&&document.activeElement===w?(p.preventDefault(),v.focus()):!p.shiftKey&&document.activeElement===v&&(p.preventDefault(),w.focus())},e.addEventListener("keydown",f)},u=()=>{if(!d)return;d=!1,e.removeAttribute("aria-modal"),f&&(e.removeEventListener("keydown",f),f=null);let h=a;a=null,h&&typeof h.focus=="function"&&requestAnimationFrame(()=>h.focus({preventScroll:!0}))};o(()=>{l(h=>{h?requestAnimationFrame(m):u()})}),s(u)})},on=(t=0)=>({tabs:[],active:t,registerTab(e){return this.tabs.push(e),this.tabs.length-1}}),sn=(t=0)=>({steps:[],current:t,registerStep(e){return this.steps.push(e),this.steps.length-1},get isFirst(){return this.current===0},get isLast(){return this.current>=this.steps.length-1},next(){this.isLast||this.current++},prev(){this.isFirst||this.current--}}),rn=(t={})=>({value:t.value,serverValue:t.value,recordVersion:t.recordVersion??"0",commitMethod:t.commitMethod??"updateTableCell",validateMethod:t.validateMethod??"validateTableCell",recordKey:null,columnName:null,componentId:null,saving:!1,error:null,success:!1,focused:!1,_sync:null,_observer:null,_onSiblingCommit:null,get dirty(){return this.value!==this.serverValue},parse(e){return t.parse?t.parse(e):e},messages:{},init(){this.recordKey=this.$el.dataset.recordKey,this.columnName=this.$el.dataset.columnName,this.messages={error:this.$el.dataset.msgError,saveFailed:this.$el.dataset.msgSaveFailed,invalid:this.$el.dataset.msgInvalid},t.liveValidation&&this.$watch("value",window.Alpine.debounce(()=>{this.dirty&&this.validate()},t.debounce??500)),this._sync=B(this.$el);let e=new MutationObserver(n=>{for(let i of n)if(i.attributeName==="data-server-value"||i.attributeName==="data-record-version"){let o=this.parse(this._sync.dataset.serverValue);if(o!==this.serverValue){this.syncFromServer(o,this._sync.dataset.recordVersion);continue}this.saving||this.setRecordVersion(this._sync.dataset.recordVersion)}});e.observe(this._sync,{attributes:!0,attributeFilter:["data-server-value","data-record-version"]}),this._observer=e,this.componentId=this.$el.closest("[wire\\:id]")?.getAttribute("wire:id")??null,this._onSiblingCommit=n=>{let i=n.detail||{};i.componentId===this.componentId&&String(i.recordKey)===String(this.recordKey)&&i.column!==this.columnName&&(this.saving||this.focused&&this.dirty||this.setRecordVersion(i.version))},window.addEventListener("wire-editable-committed",this._onSiblingCommit)},destroy(){this._observer?.disconnect(),window.removeEventListener("wire-editable-committed",this._onSiblingCommit)},setRecordVersion(e){e&&(this.recordVersion=e)},syncFromServer(e,n){this.saving||this.focused&&this.dirty||(this.value=e,this.serverValue=e,this.setRecordVersion(n),this.error=null)},onFocus(){this.focused=!0},onBlur(){this.focused=!1,t.saveOnBlur&&this.dirty&&this.save()},onEnter(){t.saveOnEnter&&this.dirty&&this.save()},onEscape(){this.value=this.serverValue,this.error=null,this.$refs.input?.blur()},save(){this.dirty&&this.commit(this.value)},async commit(e){if(!this.saving){this.value=e,this.saving=!0,this.error=null;try{let n=await this.$wire[this.commitMethod](this.recordKey,this.columnName,e,this.recordVersion);n?.success===!1?(this.value=this.serverValue,this.error=n.message||n.errors?.[0]||this.messages.error,n?.conflict&&(this.value=this.parse(n.currentValue),this.serverValue=this.value,this.setRecordVersion(n.currentVersion))):(this.serverValue=e,this.setRecordVersion(n.version),this.success=!0,setTimeout(()=>{this.success=!1},1500),n?.version&&window.dispatchEvent(new CustomEvent("wire-editable-committed",{detail:{componentId:this.componentId,recordKey:this.recordKey,column:this.columnName,version:n.version}})))}catch{this.value=this.serverValue,this.error=this.messages.saveFailed}finally{this.saving=!1}}},async validate(){try{let e=await this.$wire[this.validateMethod](this.recordKey,this.columnName,this.value);this.error=e&&!e.valid?e.errors?.[0]||this.messages.invalid:null}catch{}}}),J=null,ln=()=>({open:!1,x:0,y:0,openAt(t){J&&J!==this&&J.close(),J=this,this.x=t.clientX,this.y=t.clientY,this.open=!0,this.$nextTick(()=>this.place())},place(){let t=this.$refs.panel;if(!t)return;let e=8,{width:n,height:i}=t.getBoundingClientRect(),o=this.x,s=this.y;o+n+e>window.innerWidth&&(o=window.innerWidth-n-e),s+i+e>window.innerHeight&&(s=window.innerHeight-i-e),t.style.left=`${Math.max(e,o)}px`,t.style.top=`${Math.max(e,s)}px`},close(){this.open=!1,J===this&&(J=null)}}),we=!1,ve=()=>{we||!window.Alpine||(we=!0,window.Alpine.magic("float",()=>ye),window.Alpine.magic("clickedInside",t=>e=>Je(t,e?.target)),window.Alpine.data("wireDropdown",Qe),window.Alpine.data("wireContextMenu",ln),window.Alpine.data("wireTabs",on),window.Alpine.data("wireWizard",sn),window.Alpine.data("wireEditableCell",rn),window.Alpine.data("wireFillHandle",ge),tn(window.Alpine),nn(window.Alpine))};window.Alpine?ve():document.addEventListener("alpine:init",ve);})(); diff --git a/packages/core/resources/js/dropdown.js b/packages/core/resources/js/dropdown.js index de12eccc..17cb589c 100644 --- a/packages/core/resources/js/dropdown.js +++ b/packages/core/resources/js/dropdown.js @@ -1,5 +1,6 @@ import { computePosition, autoUpdate, flip, shift, offset, size } from '@floating-ui/dom' +import { syncNodeOf } from './editable/sync' import wireFillHandle from './fill/controller' /** @@ -479,8 +480,9 @@ const wireWizard = (initial = 0) => ({ * - commit(next): optimistic value → $wire.updateTableCell(key, col, next, ver); * on failure it rolls back to the last server-confirmed value, and on an * optimistic-lock conflict it adopts the server's current value + version. - * - a MutationObserver on data-server-value / data-record-version reconciles - * the cell when polling (or any external re-render) changes the row. + * - a MutationObserver on the cell's sync node (data-server-value / + * data-record-version — see editable/sync.js) reconciles the cell when a + * poll, a modal write or any other re-render changes the row underneath it. * * recordKey / columnName are read from data-* attributes (never interpolated * into this JS), so a primary key containing a quote can't break out. Text-style @@ -507,6 +509,19 @@ const wireEditableCell = (config = {}) => ({ success: false, focused: false, + // Declared, not just assigned in init(). Alpine resolves `this` inside a + // component method to the MERGED scope stack, and its setter writes a name no + // scope owns yet to the OUTERMOST one — here the table root that wraps every + // cell. So `this._x = …` in init() did not give each cell its own `_x`; it + // gave all of them one shared slot on the table, and the last cell to + // initialise won. Every other cell then held the last cell's sync node, + // observer and listener: a cell reconciled itself from a different column's + // value, and destroy() disconnected an observer that was never its own while + // its real one leaked. Naming them here puts the write back on the component. + _sync: null, + _observer: null, + _onSiblingCommit: null, + get dirty() { return this.value !== this.serverValue }, @@ -523,10 +538,14 @@ const wireEditableCell = (config = {}) => ({ // the event target instead). this.recordKey = this.$el.dataset.recordKey this.columnName = this.$el.dataset.columnName + // Off the DOM, not off `this.messages` — reading the property back into + // itself left all three undefined, so a save that failed without a server + // reply (offline, a 500) set `error` to undefined and the cell reported + // nothing at all: the value rolled back with no explanation. this.messages = { - error: this.messages.error, - saveFailed: this.messages.saveFailed, - invalid: this.messages.invalid, + error: this.$el.dataset.msgError, + saveFailed: this.$el.dataset.msgSaveFailed, + invalid: this.$el.dataset.msgInvalid, } if (config.liveValidation) { @@ -535,17 +554,36 @@ const wireEditableCell = (config = {}) => ({ }, config.debounce ?? 500)) } + // The channel the server keeps current: a child node, because this root + // carries `wire:ignore.self` and Livewire therefore stops refreshing its + // own attributes after the first render. See editable/sync.js. + this._sync = syncNodeOf(this.$el) + const observer = new MutationObserver((mutations) => { for (const m of mutations) { if (m.attributeName === 'data-server-value' || m.attributeName === 'data-record-version') { - const next = this.parse(this.$el.dataset.serverValue) + const next = this.parse(this._sync.dataset.serverValue) if (next !== this.serverValue) { - this.syncFromServer(next, this.$el.dataset.recordVersion) + this.syncFromServer(next, this._sync.dataset.recordVersion) + + continue + } + + // The value is what we already hold, but the record moved — + // somebody wrote another column of this row, or we did from a + // modal. Adopt the version anyway, or our next write goes out + // with a stale one and is refused as somebody else's edit. + // + // Not while a write of ours is in flight: that response is + // about to hand back the authoritative version, and this + // render was generated before our write landed. + if (! this.saving) { + this.setRecordVersion(this._sync.dataset.recordVersion) } } } }) - observer.observe(this.$el, { attributes: true, attributeFilter: ['data-server-value', 'data-record-version'] }) + observer.observe(this._sync, { attributes: true, attributeFilter: ['data-server-value', 'data-record-version'] }) this._observer = observer // Sibling version sync. Every editable cell captures the record's @@ -581,19 +619,17 @@ const wireEditableCell = (config = {}) => ({ * server→client attribute channel all come through here. * * State ONLY — it deliberately does not write `data-record-version` back. - * The root carries `wire:ignore.self`, so both data attributes are what the - * FIRST render wrote and nothing keeps them current; whoever needs the live - * version reads the component (see `versionOf()` in fill/grid.js). + * The sync node belongs to the server: it says what the last render knew, + * and the component says what has happened since. Whoever needs the live + * version asks the component first (see `versionOf()` in editable/sync.js). * - * Writing the attribute here looks like the tidier fix and is a trap: this - * element is the one the MutationObserver above watches. Touching - * `data-record-version` wakes it, it re-reads the equally frozen - * `data-server-value`, finds it different from the value just committed, and - * "syncs" the cell back to what the page loaded with. The edit reaches the - * database and vanishes from the screen a second later. Keeping the pair - * honest would mean serialising the value back into the attribute too — which - * is what the fill handle's own applyValue() has to do for exactly this - * reason, and why it writes BOTH or neither. + * Writing the attribute here looks like the tidier fix and is a trap: that + * node is the one the MutationObserver above watches. Touching + * `data-record-version` alone wakes it with `data-server-value` still on the + * previous render's value, and the cell "syncs" itself back to it — the edit + * reaches the database and vanishes off the screen a moment later. Keeping + * the pair honest means writing BOTH or neither, which is exactly what the + * fill handle's applyValue()/applyVersion() do. */ setRecordVersion(version) { if (! version) return diff --git a/packages/core/resources/js/editable/sync.js b/packages/core/resources/js/editable/sync.js new file mode 100644 index 00000000..f334bc7b --- /dev/null +++ b/packages/core/resources/js/editable/sync.js @@ -0,0 +1,50 @@ +/** + * The server→client channel of an inline-editable cell. + * + * Every editable surface — a table's text/select/toggle column, a panel entry — + * mounts `wireEditableCell` on a root carrying `wire:ignore.self`, so a Livewire + * morph cannot stomp the optimistic state it holds mid-edit. Livewire honours + * that by leaving the element's OWN attributes alone (`childrenOnly()` in its + * morph hook) while still morphing its children. So the two things the server + * has to keep telling the cell — the value it now holds, and the optimistic-lock + * version to send with the next write — ride on a child node instead, rendered + * by `wire-core::partials.cell-sync`. + * + * They used to sit on the ignored root, which made an editable cell write-only: + * whatever the FIRST render put there stood for the lifetime of the page, so no + * re-render, poll tick or modal write could ever put a newer value on screen, + * and the version the cell kept sending was the one the page loaded with — the + * user's own next edit came back refused as somebody else's. + * + * One module, because three readers have to agree on where the channel is: the + * cell component's MutationObserver, the fill handle's grid, and the fill + * controller that writes results back into it. + */ + +/** + * The node carrying `data-server-value` / `data-record-version` for a cell root. + * + * Falls back to the root itself, which is not dead code: a surface outside this + * repo may still render the attributes the old way, and a cell with no sync node + * at all should read `undefined` rather than throw. + */ +export const syncNodeOf = (el) => el?.querySelector?.('[data-cell-sync]') ?? el ?? null + +/** The value the server last rendered for this cell, as a string. */ +export const serverValueOf = (el) => syncNodeOf(el)?.dataset?.serverValue ?? '' + +/** + * The live optimistic-lock version of a cell. + * + * Component state first: between two renders the cell's own commits move the + * version and the DOM has not caught up yet. The sync node is the fallback for a + * cell that has no component — a plain, non-editable cell the fill handle passes + * over. + */ +export const versionOf = (el) => { + try { + return (window.Alpine.$data(el)?.recordVersion ?? syncNodeOf(el)?.dataset?.recordVersion) || null + } catch (e) { + return syncNodeOf(el)?.dataset?.recordVersion || null + } +} diff --git a/packages/core/resources/js/fill/controller.js b/packages/core/resources/js/fill/controller.js index a368c10d..12c28d67 100644 --- a/packages/core/resources/js/fill/controller.js +++ b/packages/core/resources/js/fill/controller.js @@ -1,3 +1,4 @@ +import { syncNodeOf } from '../editable/sync' import { createAutoScroller } from '../support/autoscroll' import { createGrid, versionOf } from './grid' import { bounds, clampToColumn, isEmpty, makeRange, targets } from './range' @@ -469,7 +470,9 @@ const wireFillHandle = () => ({ // means the next attribute change — applyVersion touching // data-record-version — wakes the observer, which re-reads the stale // value and quietly undoes the fill a moment after it landed. - el.dataset.serverValue = this.serialize(value) + const sync = syncNodeOf(el) + + if (sync) sync.dataset.serverValue = this.serialize(value) const state = this.stateOf(el) @@ -495,7 +498,9 @@ const wireFillHandle = () => ({ applyVersion(el, version) { if (! version) return - el.dataset.recordVersion = version + const sync = syncNodeOf(el) + + if (sync) sync.dataset.recordVersion = version const state = this.stateOf(el) diff --git a/packages/core/resources/js/fill/grid.js b/packages/core/resources/js/fill/grid.js index 939a3357..6d94f8e4 100644 --- a/packages/core/resources/js/fill/grid.js +++ b/packages/core/resources/js/fill/grid.js @@ -12,31 +12,15 @@ * (later) horizontally. */ +import { serverValueOf, versionOf } from '../editable/sync' import { bodyRows, rowAtY } from '../support/rows' -/** - * The live optimistic-lock version of a cell root. - * - * The one thing above that the document does NOT keep current. `wireEditableCell` - * mounts with `wire:ignore.self` on purpose, so a morph cannot overwrite the - * optimistic state it is holding — and the cost is that Livewire never refreshes - * that element's attributes either. The server does return a fresh version and - * `commit()` moves the component's own `recordVersion`, but `data-record-version` - * keeps whatever the first render wrote. - * - * So a cell edited inline still advertises the version the page loaded with, and - * sending that makes the server reject the write as someone else's edit — which - * the fill then rolls back with no error shown, because the refusal looked - * legitimate. The component's state is the only current copy; the attribute is - * the fallback for a cell that has no component (a plain, non-editable cell). - */ -export const versionOf = (el) => { - try { - return (window.Alpine.$data(el)?.recordVersion ?? el.dataset.recordVersion) || null - } catch (e) { - return el.dataset.recordVersion || null - } -} +// The value and the lock version are NOT on the cell root — `wire:ignore.self` +// there means Livewire stops refreshing that element's attributes after the +// first render, so both live on a child sync node and are read through the one +// canonical owner. Re-exported because the fill controller reads versions too, +// and a second import path is a second chance to reach for the attribute again. +export { versionOf } export const createGrid = (root) => { let columns = [] @@ -95,7 +79,7 @@ export const createGrid = (root) => { el, recordKey: el.dataset.recordKey, version: versionOf(el), - serialized: el.dataset.serverValue ?? '', + serialized: serverValueOf(el), } }, diff --git a/packages/core/resources/views/panels/entries/checkbox.blade.php b/packages/core/resources/views/panels/entries/checkbox.blade.php index c80bd811..3f366e4b 100644 --- a/packages/core/resources/views/panels/entries/checkbox.blade.php +++ b/packages/core/resources/views/panels/entries/checkbox.blade.php @@ -30,11 +30,10 @@ data-record-key="{{ $field->getRecordKey() }}" data-column-name="{{ $name }}" data-testid="panel-editable-{{ $name }}" - data-server-value="{{ $state ? '1' : '0' }}" - data-record-version="{{ $field->getRecordVersion() }}" data-msg-error="{{ __('wire-core::messages.error') }}" data-msg-save-failed="{{ __('wire-core::messages.save_failed') }}" > + {!! $field->getSyncHtml($state ? '1' : '0') !!}