diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 611eb21f..e900720d 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -82,3 +82,17 @@ jobs: - name: Verify docs build run: node docs-site/scripts/verify-docs.mjs "$GITHUB_WORKSPACE" + + # AI_DOCS_STANDARD.md, the checkable half: focus spotlights on long + # examples, well-formed markers, and an EN/CS pair that stays structurally + # identical. Pages that predate the standard live in + # docs-site/docs-standard-baseline.txt and lose the exemption when edited. + - name: Verify docs standard + run: node docs-site/scripts/verify-docs-standard.mjs "$GITHUB_WORKSPACE" + + # The static check reads the markup; this one drives it in a browser at + # phone metrics. Both defects it was written for (a search sheet buried + # under its own backdrop, a landing page that bounced every language + # switch back) were invisible to every static check we had. + - name: Verify docs site behaviour in a browser + run: node docs-site/scripts/verify-site-ui.mjs "$GITHUB_WORKSPACE" diff --git a/AI_CHANGE_PROTOCOL.md b/AI_CHANGE_PROTOCOL.md index 69689fd9..cd4b532c 100644 --- a/AI_CHANGE_PROTOCOL.md +++ b/AI_CHANGE_PROTOCOL.md @@ -61,6 +61,7 @@ Always read: Read as needed: +- `AI_DOCS_STANDARD.md` whenever the change touches `docs/` — binding - `AI_RECIPES.md` for implementation recipes - `AI_COMPONENT_CATALOG.md` to find reusable building blocks - `architecture/integrations.md` for cross-package behavior @@ -163,6 +164,23 @@ composer analyse Use quality checks when touching shared PHP APIs, broad refactors, or before a release-style change. +Documentation: + +A public API change is not done when the code passes. The class's reference page +documents the new surface, its `## How It Works` still describes reality, an +example uses it (with a `[tl! focus]` spotlight if the block is long), and the +Czech mirror moves in the same commit. `AI_DOCS_STANDARD.md` is binding; these +gates enforce the checkable half: + +```bash +npm run docs:check +npm run docs:standard +npm run docs:api +``` + +Never widen a baseline to make a docs gate pass — a baseline records what +predates the rule, not what you just wrote. + Coverage: ```bash @@ -200,6 +218,7 @@ Stop and reassess before editing if any of these appear: - a state serialization bug is being fixed only in a component class - a plugin behavior is hard-coded instead of using `PluginManager` - a public API method must be renamed or removed +- a docs gate is about to be silenced by updating a baseline instead of the page - a generated directory appears in the diff without explicit request ## Review Protocol diff --git a/AI_COMPONENT_CATALOG.md b/AI_COMPONENT_CATALOG.md index ff308847..ff8686d1 100644 --- a/AI_COMPONENT_CATALOG.md +++ b/AI_COMPONENT_CATALOG.md @@ -265,7 +265,8 @@ Fields: - `OtpInput` - `Radio` - `Rating` -- `Repeater` +- `Repeater` (card layout, or `table()` for row layout) +- `Builder` (extends `Repeater`; per-item `Block` type) + `Block` - `RichEditor` - `Select` - `Slider` @@ -380,11 +381,15 @@ Columns: - `ImageColumn` - `ButtonColumn` - `ToggleColumn` +- `CheckboxColumn` - `PollColumn` - `SelectColumn` - `TextInputColumn` - `SplitColumn` - `StackedColumn` +- `ColorColumn` +- `RatingColumn` +- `TagsColumn` Summaries: @@ -440,6 +445,7 @@ Filters: - `DateFilter` - `NumberRangeFilter` - `TernaryFilter` +- `TrashedFilter` (soft-delete scope, not a column constraint) Filter views: diff --git a/AI_DOCS_STANDARD.md b/AI_DOCS_STANDARD.md new file mode 100644 index 00000000..22587e07 --- /dev/null +++ b/AI_DOCS_STANDARD.md @@ -0,0 +1,274 @@ +# WireStack Documentation Standard + +Binding standard for everything under `docs/`, human- or AI-authored. Where this +file and habit disagree, this file wins. `CLAUDE.md` routes here; read it before +writing documentation, not after. Code is governed by +[`AI_CODING_STANDARD.md`](AI_CODING_STANDARD.md) — this file governs the pages +that explain it. + +## Philosophy + +**A page earns its place by explaining how something works, not by listing that +it exists.** + +A reader arrives with a task and a half-formed model of the framework. A list of +method names does not fix the model — it only tells them the names of things +they still do not understand. So every page owes three things, in this order: + +1. **The mechanism.** What runs, when, in what order, and what wins when two + things disagree. Resolution order, fallbacks, defaults, server vs client, + what a call costs. +2. **The complete fluent surface.** Every configuration method the class + declares, with the types it accepts and what it does — nothing omitted + because it seemed obvious. +3. **Examples that survive being copied.** Real, runnable, in context, with the + lines that matter spotlighted so the reader's eye lands where the prose is + pointing. + +Documentation that only satisfies (2) is a signature dump. Documentation that +only satisfies (3) is a cookbook. The framework needs all three on every page. + +## Rules + +Numbered so a review can cite them. **S**-rules are checked by +`npm run docs:standard`; the rest are review obligations. + +### D1 — Structure follows the page's kind + +Three kinds of page, three shapes. Do not invent a fourth. + +**Class reference page** (`BadgeColumn`, `SelectFilter`, `TextInput`, …): + +```text +# ClassName <- H1 is the class short name, nothing else +one-paragraph statement of what it is and when to reach for it +```php use NyonCode\...\ClassName; ``` <- the import, on its own, immediately +## How It Works <- the mechanism (D2). Not optional. +## Basic Usage <- the smallest example that does something real +## <- one per capability, each with an example +## Extended Example <- one full, in-context example (D4) +## ClassName API <- the complete fluent surface (D3) +## Related <- links to the pages a reader needs next +``` + +The H1 + import pair is not decoration: `docs-site/scripts/verify-api-docs.php` +uses exactly that signal to decide which class a page is the reference for. + +**Guide** (`authorization.md`, `testing.md`, save lifecycle, gestures …): opens +with what the guide gets you, then works through the topic in the order a reader +meets it. It documents a *flow*, so it carries no `## X API` section — the +classes it touches have their own pages, which it links to. + +**Overview / index page** (`table/overview.md`, `forms/fields/index.md`): the map +of a section. Quick start first, then the shape of the subsystem, then a table +of the pages beneath it. Every child page must be reachable from it. + +### D2 — Mechanism before signatures + +Every reference page carries a `## How It Works` section that answers, for that +class, whichever of these apply: + +- **Resolution order.** What is consulted first, second, last — + `->colors()` map, then the enum's `HasColor` contract, then `->color()`, then + `gray`. Write the chain, not "it can also come from an enum". +- **Defaults.** What happens when you configure nothing. +- **Where it runs.** Server render, Livewire roundtrip, or Alpine in the browser + — and what that means for closures, state and cost. +- **What it touches.** Query impact (a join? a `whereHas`? N+1 risk?), state it + persists, events it fires. +- **The traps.** The mistake the maintainers actually made or fixed. If a bug + was worth fixing, its cause is worth one sentence here. + +If a behaviour is decided in code by a `match`, an `??` chain or a guard clause, +that decision belongs in prose on the page. The reader cannot see your `match`. + +### D3 — The API section is complete and typed + +Reference pages end with the full configuration surface, in the canonical +code-block form (the corpus uses it 107 pages to 4; it greps, highlights and +copy-pastes): + +```php +->colors(array $map) // ['state' => 'color_name'|Color, ...] +->colorUsing(Closure $fn) // fn ($state) => 'color_name'|Color|null +->size(string|Size $size) // 'xs'|'sm'|'md'|'lg'|'xl' — default 'md' +->getColorForState($state): ?string +``` + +Binding details: + +- **One method per line, starting with `->`.** The API gate parses this form. +- **Complete.** Every public fluent setter the class *declares* must appear. + Inherited and trait-provided configuration is documented centrally (the shared + field/column API pages), never copied per page. `verify-api-docs.php` enforces + this in both directions — an undocumented method and a documented method that + does not exist both fail. +- **Typed.** Real parameter types, including unions (`string|Icon`) and + nullability. If a method takes a closure, the comment gives the closure's + signature. +- **The comment carries the vocabulary and the default.** Closed sets are listed + (`'xs'|'sm'|'md'|'lg'`), defaults are stated (`— default 'md'`). +- **Getters last**, with their return type, and only the ones a user calls. +- A method that is deliberately undocumented is marked `@docs-ignore` in its + docblock — in the code, where the next reader of the code will see it. + +Tables are for matrices, not for the API surface: use one only when a row needs +more than a signature and a note (for example an "On" column naming which class +of a pair owns the method). + +### D4 — Examples are extended, real, and in context + +- **At least one example per capability section**, and one `## Extended Example` + per reference page showing the class inside a real host — a Livewire component + with `use WithTable;`, a form class, a real model — not a floating fragment. +- **Runnable.** Imports present, class and method scaffolding present, real + model and column names. A reader must be able to paste it and run it. +- **Show the wiring once, then stop.** Sections after the first may drop the + host component and show the chain alone, once the extended example has + established where it lives. +- **Comment the non-obvious argument**, not the language. `// null keeps the + previous page's order` earns its place; `// set the label` does not. +- **State maps read state-first.** `['active' => 'success']` — the state is the + key, the colour is the value. Both `->colors()` and `->icons()` have shipped + backwards in these docs; `verify-api-docs.php` now detects it. + +### S1 — Long examples spotlight what they are about + +Any PHP or Blade block of **12 lines or more** must use Torchlight focus so the +lines under discussion stay bright and the scaffolding dims: + +```php +class ListUsers extends Component +{ + use WithTable; + + public function table(Table $table): Table + { + return $table + ->model(User::class) + ->columns([ + BadgeColumn::make('status') // [tl! focus:start] + ->colors(['active' => 'success', 'banned' => 'danger']) + ->icons(['banned' => 'x-circle']), // [tl! focus:end] + ]); + } +} +``` + +Rules for the spotlight: + +- **Focus the answer, dim the scaffold.** Imports, class declaration and the + `table()` boilerplate are context; the chain the section is about is the + point. +- **Single line** → `// [tl! focus]`. **Range** → `// [tl! focus:start]` … + `// [tl! focus:end]` on the first and last line of the range, inclusive. +- The token is appended to an existing `//` comment when there is one, otherwise + it becomes the line's own comment. Torchlight strips the token and keeps the + effect. +- **Never focus every line** — that is the same as focusing nothing, and the + gate rejects it. +- **Multiple ranges are fine** when a section compares two spots (see + `docs/forms/overview.md`, which focuses both auto-detected form schemas). +- Short blocks (a bare fluent chain, a `use` line, a shell command) take no + focus. The whole block is already the point. + +Pages written before this standard are recorded in +`docs-site/docs-standard-baseline.txt`. **Editing a block removes its exemption** +— the ledger is keyed by a hash of the block's code — so the corpus converges as +it is touched. Shrink the ledger deliberately with +`npm run docs:standard -- --update-baseline` after fixing pages. + +### S2 — Focus markers are well formed + +Every `focus:start` is closed by a `focus:end` in the same block; no orphan +`focus:end`; markers never leak into prose (a `[tl!` outside a code block is +caught by `npm run docs:check`). + +### S3 — Czech mirrors English, structurally + +Every English page has a Czech mirror at `docs/cs/`, and: + +- **Prose is translated**, including headings. +- **Code is not rewritten.** Same blocks in the same order, same fluent chain in + each. Translate the `//` comments and user-facing string literals (labels, + messages); leave method names, models, columns and structure alone. +- **Focus markers are mirrored** line for line. + +The gate compares block count, the fluent call sequence of each block, and the +focus markers of each block. It starts at zero violations — keep it there. + +### D5 — Front matter earns the navigation + +```md +--- +order: 23 # position inside the section +section: Table # overrides the section inferred from the path +summary: One sentence used as the hero intro and the search excerpt. +nav: false # hide from the sidebar (child pages of an index) +preview: table # preview bundle, or 'none' +--- +``` + +`summary` is worth writing on every page: it is what the search results and the +page hero show. Without it the builder falls back to the first paragraph, which +usually reads as a fragment out of context. + +### D6 — Links and anchors are real + +Relative `.md` links between pages, and anchors that exist. `npm run docs:check` +resolves every link and every `#anchor` — including same-page anchors in the +Czech tree, which rot silently when a heading is translated (49 had drifted when +the check was added). + +### D7 — What changing an API obliges + +A public API change is not finished until: + +1. The class's reference page documents the new/changed method (D3). +2. Its `## How It Works` still describes reality (D2). +3. At least one example uses it, with focus if the block is long (S1). +4. The Czech mirror is updated in the same commit (S3) — not "later". +5. `npm run docs:api` passes without touching the baseline. + +Never fix a gate by widening a baseline. Baselines record what predates the +rule, not what you just wrote. + +## Verification + +```bash +npm run docs:check # markdown integrity, links, anchors, a clean build per locale +npm run docs:standard # S1–S3: focus spotlights, marker syntax, EN/CS parity +npm run docs:api # docs vs the real public API, both directions +npm run docs:verify-ui # the built site in a browser (search, language, head tags) +``` + +The first three are cheap and run on every docs change. All four run in CI +(`.github/workflows/docs-check.yml`). + +## Canonical Examples + +Copy the shape from these rather than inventing one: + +- **Reference page** — `docs/table/columns/badge.md`: mechanism first + (resolution order and fallbacks), capability sections, one extended example in + a real component, complete typed API, related links. +- **Focus in a quick start** — `docs/table/overview.md`, `docs/getting-started.md`: + the full component is shown, the table definition is what glows. +- **Multi-range focus** — `docs/forms/overview.md`: two schemas spotlighted in + one block. +- **Guide** — `docs/forms/save-lifecycle.md`: a flow documented in the order it + runs, with the hook points focused in the closing example. + +## Checklist + +New or rewritten reference page: + +- [ ] H1 is the class short name; the import follows immediately +- [ ] `## How It Works` covers resolution order, defaults, where it runs, traps +- [ ] every capability has its own section and example +- [ ] one extended example in a real host component +- [ ] API section lists every declared fluent setter, typed, with defaults +- [ ] every PHP/Blade block ≥ 12 lines carries a focus spotlight +- [ ] `summary` front matter written +- [ ] Czech mirror updated: prose translated, code and focus identical +- [ ] `npm run docs:check && npm run docs:standard && npm run docs:api` pass diff --git a/AI_RECIPES.md b/AI_RECIPES.md index 2d042938..124c8c12 100644 --- a/AI_RECIPES.md +++ b/AI_RECIPES.md @@ -366,6 +366,7 @@ Use when public docs, previews, screenshots, or static docs site output changes. Read first: +- `AI_DOCS_STANDARD.md` — binding for every page under `docs/` - `docs/` - `docs-site/README.md` - `docs-site/build.php` @@ -376,12 +377,20 @@ Read first: Implementation shape: 1. Update source docs, not generated `docs-site/dist/`, unless explicitly asked. -2. Update workbench preview only when behavior or visual output changed. -3. Run a dry-run changed-docs check before refreshing screenshots. +2. Follow `AI_DOCS_STANDARD.md`: mechanism (`## How It Works`) before signatures, + the complete typed fluent API, an extended in-context example, and a + `[tl! focus]` spotlight on every PHP/Blade block of 12 lines or more. +3. Mirror the page in `docs/cs/` in the same change — prose translated, code and + focus markers identical. +4. Update workbench preview only when behavior or visual output changed. +5. Run a dry-run changed-docs check before refreshing screenshots. Verification: ```bash +npm run docs:check +npm run docs:standard +npm run docs:api php docs-site/build.php npm run docs:changed -- --dry-run ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d91264c..66c638b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to the Wire ecosystem will be documented in this file. +## [1.16.0] + +### Added +- **Search understands more than one substring — `Table::search()`.** The box matched whatever was typed as a single `LIKE '%term%'` across every searchable column, which meant `Ada Lovelace` could not find the row whose first name is in one column and surname in another, and a number could only ever be searched *for*, never compared. Three capabilities are now opt-in per table, through a fluent `SearchConfig`: `tokenize()` splits on spaces and ANDs the words — each word still ORs across all columns, which is exactly what makes a name spanning two columns match — with double quotes keeping a phrase together and never being read as an operator; `ranges()` reads `>100`, `>=100`, `<10`, `<=10`, `=42`, `10..20`, `10..`, `..20` and the same over dates; `wildcards()` lets `*` and `?` stand for runs and single characters. **Everything is off by default**, so an unconfigured table matches byte-for-byte what it always did — the whole term, one group, one substring. A typed date is read at the granularity it was written (`2026-01-31` is that day, `2026-01` that month, `2026` that year), so `<=2026-01-31` still includes a row stamped 23:30 on the 31st, which is the off-by-a-day this kind of feature usually ships with. A comparison is only ever asked of a column that can answer it — the value type comes from the model's casts, or from the new `Column::searchAs('numeric'|'date')` where the casts cannot speak for the column — and a comparison **no** column can answer (`>100` on a table of names) is searched as the literal text that was typed rather than contributing an empty WHERE group that matches every row. The engine lives in `wire-core` (`Core\Query\Search\`) as a parser producing tokens, a compiler turning a token plus a clause into SQL, and the three driver strategies reduced to the one thing that genuinely differs between engines: `LIKE` versus `ILIKE`. Comparisons are plain portable SQL and are therefore built once rather than three times over. `searchAs('code')` covers the structured reference — `8866 01`, `8866 02` — where the series is shared and the tail is zero-padded: typing `8866 01..08` yields one `BETWEEN '8866 01' AND '8866 08'` rather than a `LIKE` per number in the range. The space inside such a code is also what splits the term, so the range arrives separated from its series; rather than letting one reading win at parse time (`8866 01..08` and `praha 10..20` are the same shape and cannot be told apart syntactically), the range **carries the word typed before it** and each column takes the reading it can answer — a code column completes both bounds with the series, a numeric column ignores it and compares `1..8`. Comparing as text only orders correctly while the width is constant, which is the assertion `searchAs('code')` makes, so the number must be typed as it is stored; a range crossing a width boundary is completed rather than refused (`8866 50..100` reads as `050..100`, since a hundredth member can only exist in a three-digit series). See `docs/table/overview.md` § Search syntax. +- **Four new columns, closing the gap where the table could not show what an infolist entry already could.** `ColorColumn` renders a stored CSS color as a swatch plus its value (`swatchOnly()` for a narrow column), the table-side counterpart of `ColorEntry`. `CheckboxColumn` is an inline checkbox writing a boolean straight to the record — the same optimistic write path, the same server-side `canEdit()` guard and the same sync node as `ToggleColumn`, for tables too dense for a switch track. `RatingColumn` draws a numeric score as stars (`max()`, `allowHalf()`, `showValue()`), the read-only half of the `Rating` field's vocabulary. `TagsColumn` renders a multi-value state — array, JSON cast, `Arrayable` relation collection, or a `separator()`-split string — as chips, with `limitList()` collapsing the overflow into a "+N" chip. None of them re-encodes a palette: the tag chip is the *same* `RendersBadgeSurface` chrome as `BadgeColumn` and takes the same `colors()` map, and rating/checkbox colors resolve through the canonical Foundation owners. The three state-driven ones (`ColorColumn`, `RatingColumn`, `TagsColumn`) memoise their view render by its data, so a page of rows sharing a color, a score or a tag set costs one render each rather than one per row. `ToggleColumn` and `CheckboxColumn` now share `CanEditBooleanCell`, which owns the server-side disabled guard — the point being that a *new* boolean cell cannot ship without it. Browser-verified over CDP by `workbench/scripts/verify-column-surfaces.mjs` (20/20) against a new `/previews/table-column-surfaces`: the swatch colors as the browser actually parsed them, the seeded row whose stored value is `red; background-image: url(…)` drawing no background and issuing no request for it, a half star clipped only where halves are allowed, the `+N` overflow chip, and a checkbox cell committing through Livewire and surviving a fresh GET. See `docs/table/columns/`. +- **`TrashedFilter` — soft deletes were not covered by any filter at all.** Unlike every other filter it constrains no column: it decides which global scope applies, mapping to `withTrashed()` / `onlyTrashed()`. Three states of which only two are options — "without deleted" is the placeholder, i.e. clearing the filter — rendered through the same select surface as `SelectFilter`, so an open soft-delete filter looks like any other. It `bypassesPlanner()`, since a scope change is not a column/operator/value definition. A model without `SoftDeletes` now fails with a `TableConfigurationException` naming both the filter and the model, rather than as an undefined `onlyTrashed()` deep inside the query builder — and the check runs only when the filter is *active*, so a cleared filter never inspects the model. It **extends `SelectFilter`** rather than `Filter`: the shared select panel calls `isSearchable()` on whatever it is handed, so the first version rendered a 500 on any table that used it — a failure every unit test missed, because they only asked the filter for its view's *name*. Browser-verified by `workbench/scripts/verify-trashed-filter.mjs` (14/14) against a new `/previews/table-trashed-filter`, which counts the rows that actually come back: 4 live, 2 with `only`, 6 with `with`, back to 4 when cleared. See `docs/table/filters/trashed.md`. +- **`CheckboxList::segmented()` / `::buttons()` — the multiple-choice half of the toggle-button vocabulary.** `Radio` has had `segmented()` and `buttons()` for a while; picking *several* options in that shape had no equivalent, so a multi-select of three short values was a column of checkboxes. Rather than adding a parallel field, the shared part of Radio's API — the variants, per-option `icons()` and `colors()`, `inline()`, and the size/color resolvers — moved into `HasChoiceVariants`, which both fields now use: one vocabulary, one chrome, and a single-choice and multi-choice control that look alike. In these variants the field shows the options alone; search, bulk toggle, grouping and columns are list chrome and do not apply. Radio's own `cards` variant stays with Radio. Browser-verified by `workbench/scripts/verify-choice-variants.mjs` (15/15): the peer-checked pill actually paints, and — the part markup cannot show — a second click *adds* to the selection rather than replacing it. See `docs/forms/fields/checkbox-list.md`. +- **`Repeater::table()` — repeat short rows as a table instead of a card per item.** One column per schema field, headed once, with the per-cell label hidden so it is not repeated on every row; same state paths, same add/remove/reorder endpoints, only the arrangement differs. Hiding that label is a new canonical `HasLabel::hiddenLabel()` — the label still *resolves*, so it can head the column and serve accessibility, which is what separates it from clearing the label. Per-item collapsing has no meaning for a row, so `collapsible()` is ignored in this layout. Browser-verified by `workbench/scripts/verify-repeater-table.mjs` (16/16): each field heads one column, every row's inputs still bind to their own item path, and add/remove run through the same endpoints. See `docs/forms/fields/repeater.md` § Table Layout. +- **The CDP drivers have a shared harness — `workbench/scripts/lib/cdp.mjs`.** Each of the ~50 existing drivers carries its own copy of the same 90 lines: spawn headless Chrome, speak DevTools over a raw WebSocket, collect console errors and 4xx/5xx, screenshot, print the summary the sweep greps for. The five new drivers import it instead, so a driver file is now only its checks. `finish()` always asserts a clean console and no 419 — a driver that renders the right markup over a broken Livewire roundtrip has verified nothing. Existing drivers are deliberately left alone: they pass, and rewriting 50 working files to prove a point is how a green suite stops being trustworthy. New workbench fixture alongside it: a `Document` model with soft deletes, a stored CSS color, a score and a tag list — its own model rather than columns bolted onto `Task`, which dozens of tests query and which adding `SoftDeletes` to would change every one of. +- **`Builder` — a repeater whose every item picks its own block type.** `Builder::make('content')->blocks([Block::make('heading')->schema([…]), …])` renders an "add" trigger that opens a picker of the declared blocks; each stored item is `['type' => …, 'data' => […]]` and its fields bind under `..data`, so a field named `type` inside a block cannot collide with the item's own discriminator. It **extends `Repeater`** deliberately rather than standing beside it: the form runtime identifies a repeated subtree by `instanceof Repeater` in ten places (reactivity, flattening, save, relationship handling), and a sibling class would have had to be threaded through every one of them. It therefore inherits add/remove/reorder, per-item reactivity and item limits unchanged; only `relationship()` does not apply, since mixed block types have no single related model. Block rules mount at `.*.data.` — and because the resolver validates by wildcard path, **blocks sharing a field name share its rules**, which is stated in the docs rather than papered over. An item whose stored type names no declared block renders its type and no fields instead of breaking the form: stored content outlives the code that declared it, and a renamed block must leave the content recognisable, movable and deletable. Browser-verified by `workbench/scripts/verify-builder.mjs` (15/15) against a new `/previews/forms-builder`: the picker lists every declared block, choosing one appends an item edited with *that* block's schema, typing stays inside the item it was typed into, and removing one re-binds the paths of the rest. See `docs/forms/fields/builder.md`. +- **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`. + +### Fixed +- **A paginated table had no defined order, so editing a row could hide another one entirely.** A page is a slice of an ordering, and the query carried none unless the user had sorted: `LIMIT`/`OFFSET` over an unordered result is undefined, and nothing says two pages were sliced from the same order. SQLite and MySQL/InnoDB happen to hand back primary-key order so it never showed — but PostgreSQL stores rows in a heap and an `UPDATE` writes a **new** tuple at the end of it rather than in place. Editing a row on page one therefore shifted everything behind it forward by one, and the record that would have led page two was skipped: the user never saw it, and nothing reported an error. Demonstrated against a live PostgreSQL — page 1 shows T1, T2; edit T1; page 2 shows T4, T5, and T3 is simply gone. The same hole exists on every engine whenever the sort column has duplicate values, since ties are returned in whatever order the engine found them. Every table query now ends with its primary key as a tiebreaker (`Core\Query\StableOrder`), following the direction already in force so newest-first stays newest-first among equals. It is appended **after everything else that orders — including a column's own `sortUsing()` callback, which runs outside the query pipeline**; applied any earlier it becomes the primary sort and silently replaces the ordering it exists to stabilise, which is exactly what the first attempt at this did. Skipped where a key is not a legal ordering term: `GROUP BY` (PostgreSQL rejects an ungrouped term, MySQL too under ONLY_FULL_GROUP_BY), `DISTINCT` (PostgreSQL requires ordering terms in the select list) and unions. Found while diagnosing seven PostgreSQL test failures that looked like a caching bug and were not: the write and the cache invalidation were both correct, the rows had simply moved. +- **A `%` typed into the search box matched every row.** The term went into the LIKE pattern raw, so its `%` and `_` were live metacharacters: searching `50%` returned the whole table and turned every search into a full scan, and `_` quietly matched any character. They are escaped now — and the escape character is `!`, not the backslash. **This was fixed once before and reverted**, because `ESCAPE '\'` is a syntax error on MySQL and MariaDB (the backslash inside a string literal escapes the closing quote) while SQLite and PostgreSQL accept it happily, so the test suite stayed green and every search on MariaDB died. `!` has no special meaning in a string literal on any supported engine, so one pattern shape works everywhere; the clause is always declared explicitly because SQLite's LIKE has no default escape character at all. A test now pins that the escape character is not a backslash, with the reason next to it. +- **A searchable number or date column took the whole page down on PostgreSQL.** PostgreSQL has no implicit cast for the pattern operators, so `amount ILIKE '%50%'` on a `numeric` column is not a search that misses — it is `operator does not exist: numeric ~~* text`, an error. MySQL and SQLite coerce silently, so `TextColumn::make('amount')->searchable()` worked everywhere else and failed only on PostgreSQL, and only once somebody typed into the box. The column is now cast to text there. The cast is unconditional rather than driven by the inferred value type: the type is a guess assembled from casts and registered schema and can be absent or wrong, while the column's real type is what the server enforces — and `ILIKE '%…%'` was never going to use an index either way. Found by running the behaviour suite against a real PostgreSQL 16, not by reading the code. +- **`Column::searchable(['first_name', 'last_name'])` did nothing on an ordinary column.** The list was stored and never read: only `StackedColumn` and `SplitColumn` declared `HasSearchColumns`, so the planner searched a plain `TextColumn`'s own name alone while `docs/table/columns/index.md` documented the array form as working. `Column` implements the contract now, so the columns listed are the columns searched. +- **Searching for `0` searched for nothing.** `! empty($search)` treated the string `"0"` as an absent term, so the table answered with every row instead of the ones containing a zero. A whitespace-only term is still no search. +- **The search documentation described an implementation that does not exist.** `docs/table/overview.md` claimed MySQL used `MATCH … AGAINST` fulltext and PostgreSQL `to_tsvector / ts_query`, with SQLite `LIKE` as a "fallback". There is no fulltext code anywhere in the repository and there never was: all three engines do a `LIKE`/`ILIKE` substring match. Both language versions now describe what actually runs. +- **`Column::editable()` stopped pretending it can choose an editor.** Its `$type` / `$options` arguments were documented as picking a `'text'` / `'select'` / `'toggle'` editor, and **no view has read an editor type in any revision since the first commit** — verified against every historical revision, not inferred. `TextInputColumn`, `SelectColumn` and `ToggleColumn` have existed since that same commit and always did the actual rendering, so this was never a regression: it was a second route that was drawn and never connected. An ordinary column with `->editable(true, 'select', […])` rendered the plain value, and the fill handle skipped it too, since the client looks for an editable root (`[data-record-key][data-column-name]`) that only a dedicated column emits. The parameters are gone from the signature; a variadic swallows and refuses them, naming the column type to use instead, and the properties and their two getters are deleted. The variadic is not decoration: PHP drops surplus *positional* arguments without a word, so simply removing the parameters would have let `editable(true, 'select', […])` — the exact call the docs taught — go on doing the silent nothing this removed. A named `type:` argument lands in the same variadic, so both call styles get the same message. **`editable(bool)` stays and is not deprecated** — on a dedicated column it is the switch that renders the editor or the plain value (`TextInputColumn::make('name')->editable(false)` shows text), and `isEditable()` has three real consumers: the write guard, `isFillable()`, and suppressing the row link on an editable cell. The docs that taught the dead form — `table/columns/editing.md` and `authorization.md`, in both languages — are rewritten, which mattered most: they were the only place the pattern was coming from. +- **`Column::authorizeInline()` was a silent no-op — the ability it names was never checked, and every inline edit went through.** `permission()` guards *seeing* a column; `authorizeInline()` was added to guard *writing* it inline, which is a different and narrower question (show a price to everyone, let only a manager edit it). `CellEditPipeline::guard()` consulted the first and never the second, so `canInlineEdit()` had **zero** callers in the whole repository: an author who wrote `->authorizeInline('edit-prices')` believed the cell was protected, the UI rendered an editable cell, and the write was accepted. Now checked alongside the other guards, and refused with the same message. Note the fail-closed consequence, which is intended: `Gate::allows()` denies a guest unless the ability accepts a nullable user, so an unauthenticated visitor cannot edit an ability-guarded cell. Found by an audit that walked every fluent setter on every built-in component type and asked whether the value it stores ever reaches anything that renders or acts on it. +- **`hintIcon()` and `hintColor()` did nothing, on all 41 field types.** The hint vocabulary is `hint()` + an icon + a color, but the shared field wrapper rendered only the text — both other setters stored their value and nothing ever read it. The wrapper now renders the icon next to the hint and colors the row through the canonical `HasColor` palette, defaulting to the muted gray it always used. Closures and `Color` enums resolve as they do everywhere else. +- **`extraInputAttributes()` moved to the fields that actually have an input, and now works there.** It lived on `HasExtraAttributes`, which every component shares, so 49 types offered it — widgets, infolist entries, `Placeholder`, `Alert` — and not one view implemented it. Counting the field views showed why a blanket implementation was the wrong answer: 13 have exactly one input, 8 have two to four (a `Radio` has one per option, `KeyValue` one per row — no single element the attributes could mean), and 10 have none at all. So the setter moved to its own `HasExtraInputAttributes` concern, mixed into the ten fields where one element carries the value (TextInput, Textarea, Checkbox, Hidden, Toggle, Slider, Select — and through it BelongsToSelect/MorphToSelect — ColorPicker, DateTimePicker, TimePicker), and is rendered on that element. The attribute fragment is built and escaped once in PHP (`getExtraInputAttributesHtml()`) rather than looped in ten views, `true` renders as a bare boolean attribute and `false`/`null` are dropped. **Removed from every other type** — where calling it was already a no-op, so no working code can break. The shared combobox partial takes the fragment as a parameter, since more than one host renders it. `extraAttributes()` (the outer element) stays universal. +- **`extraAttributes()` reached nothing on a form field, and neither `extraAttributes()` nor `extraHeaderAttributes()` reached anything on a table column.** Three setters, declared and documented, whose values no view read: on a field the attributes now land on the wrapper element (one place, every field type), and on a column they land on the cell and on the header cell respectively. Both column values are resolved once per column in the render-once preamble rather than per cell, so a table of N rows does not pay for them N times; header attribute values are escaped on the way out, while the cell setter stays the raw attribute string its signature promises. +- **A `Select` whose column is cast to an enum threw the moment a user cleared it.** The empty choice is what a native `` option: + +```php +use NyonCode\WireCore\Foundation\Contracts\Enum\HasColor; +use NyonCode\WireCore\Foundation\Contracts\Enum\HasIcon; +use NyonCode\WireCore\Foundation\Contracts\Enum\HasLabel; + +enum OrderStatus: string implements HasColor, HasIcon, HasLabel +{ + case Pending = 'pending'; + case Shipped = 'shipped'; + case Cancelled = 'cancelled'; + + public function getLabel(): string // [tl! focus:start] + { + return match ($this) { + self::Pending => 'Čeká na platbu', + self::Shipped => 'Na cestě', + self::Cancelled => 'Zrušeno', + }; + } + + public function getColor(): string + { + return match ($this) { + self::Pending => 'warning', + self::Shipped => 'success', + self::Cancelled => 'danger', + }; + } + + public function getIcon(): ?string + { + return $this === self::Cancelled ? 'x-circle' : null; + } // [tl! focus:end] +} + +// S atributem castnutým na enum je sloupec jen ten atribut. +BadgeColumn::make('status') +``` + +Mapa pořád přebíjí vlastní barvu enumu — právě tak může jedna tabulka +prezentovat sdílený enum jinak, aniž by se enum měnil. + ## Vlastní popisek + badge +`->formatStateUsing()` přepíše text pilulky, aniž by sáhl na barevný žebříček — +mapa zůstává klíčovaná syrovým stavem: + ```php BadgeColumn::make('role') ->formatStateUsing(fn (string $state) => match($state) { @@ -89,18 +189,90 @@ BadgeColumn::make('role') ```php BadgeColumn::make('tag') - ->size('xs') // xs, sm, md, lg + ->size('xs') // xs, sm, md, lg — výchozí md +``` + +`->xl()` na sdíleném size API existuje, ale badge povrch ho vykreslí s paddingem +`md`; největší pilulka je `lg`. + +## Rozšířený příklad + +Moderační tabulka, kde jeden sloupec nese tři signály najednou: barva pochází z +mapy, ikona označí jen stavy, které vyžadují pozornost, a popisek je přepsaný pro +čtenáře, kteří nemyslí ve slugách. + +```php +use Livewire\Component; +use NyonCode\WireTable\Concerns\WithTable; +use NyonCode\WireTable\Table; +use NyonCode\WireTable\Columns\BadgeColumn; +use NyonCode\WireTable\Columns\TextColumn; + +class ArticleTable extends Component +{ + use WithTable; + + public function table(Table $table): Table + { + return $table + ->model(Article::class) + ->columns([ + TextColumn::make('title') + ->searchable() + ->weight('bold'), + + BadgeColumn::make('status') // [tl! focus:start] + ->colors([ + 'published' => 'success', + 'in_review' => 'warning', + 'rejected' => 'danger', + 'draft' => 'gray', + ]) + ->icons([ + 'in_review' => 'clock', // ikonu dostanou jen stavy, + 'rejected' => 'x-circle', // které chtějí druhý pohled + ]) + ->formatStateUsing(fn (string $state) => str($state)->headline()) + ->size('sm'), // [tl! focus:end] + + TextColumn::make('published_at') + ->dateTime('d.m.Y') + ->sortable(), + ]) + ->defaultSort('published_at', 'desc') + ->paginated(); + } + + public function render() + { + return view('livewire.article-table'); + } +} ``` ## API BadgeColumn +Samotný badge povrch. Všechno ostatní, co sloupec umí — `->label()`, +`->sortable()`, `->visible()`, formátování, editace — je sdílené API sloupců, +popsané v [Sloupce](index.md). + ```php -->colors(array $map) // ['state_value' => 'color_name'|Color, ...] -->colorUsing(Closure $fn) // fn($state) => 'color_name'|Color|null -->icons(array $map) // ['state_value' => 'icon_name'|Icon, ...] -->iconUsing(Closure $fn) // fn($state) => 'icon_name'|Icon|null -->size(string $size) // 'xs', 'sm', 'md', 'lg' +->colors(array $map) // ['state' => 'color_name'|Color, ...] +->colorUsing(Closure $fn) // fn ($state) => 'color_name'|Color|null — přebíjí mapu +->icons(array $map) // ['state' => 'icon_name'|Icon, ...] +->iconUsing(Closure $fn) // fn ($state) => 'icon_name'|Icon|null — přebíjí mapu +->color(string|Color $color) // záložní barva, když stav nemapuje na nic +->icon(string|Icon $icon) // záložní ikona, když stav nemapuje na nic +->size(string|Size $size) // 'xs'|'sm'|'md'|'lg' — výchozí 'md' +->xs() / ->sm() / ->md() / ->lg() // presety velikosti ->getSize(): string -->getColorForState($state): ?string -->getIconForState($state): ?string +->getColorForState($state): ?string // resolvovaná barva včetně celého žebříčku +->getIconForState($state): ?string // resolvovaná ikona včetně celého žebříčku ``` + +## Související + +- [Sloupce](index.md) — sdílené API sloupců, které dědí každý sloupec +- [IconColumn](icon.md) — tentýž stavový žebříček vykreslený jako samotná ikona +- [PollColumn](poll.md) — badge nad živě pollovanou hodnotou +- [Theming](../../theming.md) — barevný slovník, ze kterého tyto mapy čerpají diff --git a/docs/cs/table/columns/checkbox.md b/docs/cs/table/columns/checkbox.md new file mode 100644 index 00000000..46c51720 --- /dev/null +++ b/docs/cs/table/columns/checkbox.md @@ -0,0 +1,48 @@ +--- +order: 23 +nav: false +--- + +# CheckboxColumn + +Inline zaškrtávátko, které zapisuje boolean přímo do záznamu — stejná optimistická +cesta zápisu jako [ToggleColumn](toggle.md), pro případy, kdy checkbox působí +přirozeněji než přepínač nebo je tabulka příliš hustá na celý přepínač. + +```php +use NyonCode\WireTable\Columns\CheckboxColumn; +``` + +## Základní použití + +```php +CheckboxColumn::make('is_active') +``` + +Kliknutí se ukládá okamžitě a při odmítnutí zápisu se vrátí zpět s inline chybou +(včetně konfliktu optimistického zámku — viz [Editace](editing.md)). + +## Barva zaškrtnutí + +```php +CheckboxColumn::make('is_active') + ->accentColor('success') +``` + +## Zakázání pro konkrétní záznam + +```php +CheckboxColumn::make('is_active') + ->disabled(fn ($record) => $record->is_locked) +``` + +Zakázaný stav se vynucuje i na serveru, nejen v prohlížeči: podvržený požadavek +na `updateTableCell()` u zakázaného řádku je odmítnut. + +## CheckboxColumn API + +```php +->accentColor(string|Color|null $color) // barva zaškrtnutí, výchozí: 'primary' +->disabled(bool|Closure $condition = true) +->getAccentColorClass(): string +``` diff --git a/docs/cs/table/columns/color.md b/docs/cs/table/columns/color.md new file mode 100644 index 00000000..f737c8f7 --- /dev/null +++ b/docs/cs/table/columns/color.md @@ -0,0 +1,59 @@ +--- +order: 23 +nav: false +--- + +# ColorColumn + +Vykreslí uloženou CSS barvu jako vzorník vedle její textové hodnoty. Tabulkový +protějšek `ColorEntry` z infolistu. + +```php +use NyonCode\WireTable\Columns\ColorColumn; +``` + +## Základní použití + +```php +ColorColumn::make('brand_color') // "#1a2b3c" → vzorník + "#1a2b3c" +``` + +Stavem je CSS barva *uložená u záznamu* — hex, `rgb()`, `hsl()` nebo klíčové +slovo. Není to název z palety: pro barvy řízené paletou (stavová pilulka, ikona +podle stavu) použijte [BadgeColumn](badge.md) nebo [IconColumn](icon.md). + +## Jen vzorník + +Tam, kde je sloupec úzký a vzorník stačí, textovou hodnotu vynechte: + +```php +ColorColumn::make('brand_color') + ->swatchOnly() +``` + +## Kopírování do schránky + +Sdílené `copyable()` zkopíruje hodnotu barvy: + +```php +ColorColumn::make('brand_color') + ->copyable() +``` + +## Hodnoty, které se nevykreslí + +Vzorník je jediná buňka, která vkládá data záznamu do atributu `style`, kde +escapování HTML nestačí — `;` by otevřelo další deklaraci. Hodnoty, které nejsou +rozpoznatelná CSS barva, se odmítnou a buňka zobrazí svůj prázdný text: + +```php +// Vykreslí se: #1a2b3c, rgb(255 0 0 / 50%), rebeccapurple +// Nevykreslí se: "red; background-image: url(…)", "url(…)", "expression(…)" +``` + +## ColorColumn API + +```php +->swatchOnly(bool $condition = true) // skryje textovou hodnotu vedle vzorníku +->isSwatchOnly(): bool +``` diff --git a/docs/cs/table/columns/editing.md b/docs/cs/table/columns/editing.md index fd0c8fc0..6b88f070 100644 --- a/docs/cs/table/columns/editing.md +++ b/docs/cs/table/columns/editing.md @@ -82,42 +82,57 @@ engine, chipy a query-string persistenci viz [Filtry na úrovni sloupce](../filt ## Inline editace -Sloupce mohou také použít generické API `editable()` (kromě dedikovaných TextInputColumn/SelectColumn/ToggleColumn): +**Editor určuje typ sloupce**, ne přepínač: použijte +[TextInputColumn](text-input.md), [SelectColumn](select.md), +[ToggleColumn](toggle.md) nebo [CheckboxColumn](checkbox.md). Obyčejný sloupec +žádný editor nevykreslí. + +`editable()` je vypínač editoru u dedikovaného sloupce a zároveň serverová brána +pro zápis do toho sloupce: ```php -TextColumn::make('name') - ->editable() // typ výchozí 'text' +TextInputColumn::make('name') + ->editable(fn () => auth()->user()->isAdmin()) // false vykreslí prostou hodnotu ->editableRules(fn ($record) => ['required', 'max:255']) ->editableUsing(function ($record, $column, $value) { $record->update([$column => $value]); }) - -TextColumn::make('category') - // editable(enabled, type, options) — 'text' | 'select' | 'toggle' - ->editable(true, 'select', ['a' => 'Category A', 'b' => 'Category B']) - ->editableRules(fn ($record) => ['required', 'in:a,b']) ``` -Argument `options` u `editable(type: 'select', …)` i `filterable()` / -`filterAsSelect()` přijímá i třídu PHP enumu — rozvine se na `value => label` přesně -jako dedikovaný `SelectColumn`/`SelectFilter`. Viz [Enum Options](select.md#options-z-enumu). +Pojmenování typu editoru — `editable(true, 'select', […])` — vyhodí výjimku: +žádná view ho nikdy nečetla, takže by tiše nedělalo nic. Použijte `SelectColumn`. + +Argument `options` u `filterable()` / `filterAsSelect()` přijímá i PHP enum — +rozbalí se na `value => label` stejně jako u `SelectColumn`/`SelectFilter`. +Viz [Options z enumu](select.md#options-z-enumu). ### 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/cs/table/columns/index.md b/docs/cs/table/columns/index.md index f751ea45..72699337 100644 --- a/docs/cs/table/columns/index.md +++ b/docs/cs/table/columns/index.md @@ -4,7 +4,7 @@ order: 20 # Sloupce -Wire Table poskytuje **12 typů sloupců**. Všechny sdílejí stejné základní API +Wire Table poskytuje **16 typů sloupců**. Všechny sdílejí stejné základní API sloupce pro popisky, viditelnost, autorizaci, řazení, formátování a inline editaci — dokumentované níže. Typ vyberte podle vykreslení buňky; sdílené API sáhněte na kterýkoli z nich. @@ -20,11 +20,15 @@ sáhněte na kterýkoli z nich. | [ImageColumn](image.md) | Avatary a náhledy | | [ButtonColumn](button.md) | Tlačítko s odkazem nebo Livewire akcí v buňce | | [ToggleColumn](toggle.md) | Inline editovatelný přepínač on/off | +| [CheckboxColumn](checkbox.md) | Inline editovatelné zaškrtávátko (hustší ToggleColumn) | | [SelectColumn](select.md) | Inline editovatelný dropdown (options, relace, enumy) | | [TextInputColumn](text-input.md) | Inline editovatelný text/číslo/email input | | [StackedColumn](stacked.md) | Layouty avatar + jméno + email na sobě | | [SplitColumn](split.md) | Poskládat několik sloupců vedle sebe | | [PollColumn](poll.md) | Buňky se živě pollovaným stavem/postupem | +| [ColorColumn](color.md) | Uložená CSS barva jako vzorník | +| [RatingColumn](rating.md) | Číselné hodnocení jako hvězdičky | +| [TagsColumn](tags.md) | Vícehodnotový stav jako chipsy | ## Koncepty @@ -78,6 +82,9 @@ TextColumn::make('full_name') // Vlastní logika hledání ->searchUsing(Closure $fn) +// Deklarovat, co sloupec drží, aby šlo do hledání psát >100 a 10..20 +->searchAs(SearchValueType|string $type) // 'text' | 'numeric' | 'date' | 'code' + // Získat resolvované sloupce hledání ->getSearchColumns(): array ``` @@ -97,6 +104,28 @@ TextColumn::make('full_name') }) ``` +`searchAs()` má smysl teprve tehdy, když tabulka zapne +[hledání rozsahů](../overview.md#syntaxe-hledani). Typ hodnoty se obvykle odvodí +z castů modelu — cast `decimal:2` nebo `datetime` stačí — deklarujte ho tedy jen +tam, kde za sloupec casty mluvit nemohou: + +```php +// Model nemá pro `amount` žádný cast, takže se z něj nedá nic odvodit. +TextColumn::make('amount') + ->searchable() + ->searchAs('numeric') // ">1000" a "10..20" se teď dostanou i na tento sloupec +``` + +Sloupec ponechaný jako text porovnání přeskočí, místo aby porovnával +lexikograficky — chybná nebo chybějící deklarace tak jen zúží, čemu hledání +rozumí, nikdy nevrátí špatné řádky. + +`'code'` je jediný typ, který se **nikdy** neodvozuje: říká, že hodnota je řada +plus číslo **doplněné nulami** (`8866 01`, `8866 02`), což je právě to, co dělá +porovnání textem správným — a ví to jen vlastník. Odemyká +[rozsahy uvnitř řady](../overview.md#rozsahy-uvnitr-strukturovaneho-kodu) — +`8866 01..08`. + ### Viditelnost a přepínatelnost ```php diff --git a/docs/cs/table/columns/rating.md b/docs/cs/table/columns/rating.md new file mode 100644 index 00000000..f42a0109 --- /dev/null +++ b/docs/cs/table/columns/rating.md @@ -0,0 +1,59 @@ +--- +order: 23 +nav: false +--- + +# RatingColumn + +Vykreslí číselný stav jako řadu plných a prázdných hvězdiček — read-only tabulkový +protějšek formulářového pole `Rating`, se stejným slovníkem. + +```php +use NyonCode\WireTable\Columns\RatingColumn; +``` + +## Základní použití + +```php +RatingColumn::make('score') // 3 → ★★★☆☆ +``` + +Nečíselný nebo prázdný stav vykreslí prázdný text sloupce, ne řadu prázdných +hvězdiček. + +## Škála, poloviny a hodnota + +```php +RatingColumn::make('score') + ->max(10) // výchozí: 5 + ->allowHalf() // 2,5 vykreslí poloviční hvězdičku + ->showValue() // vypíše číslo vedle hvězdiček +``` + +Bez `allowHalf()` se desetinná hodnota jen zaokrouhlí dolů na počet plných +hvězdiček. + +## Barvy a ikony + +```php +RatingColumn::make('score') + ->color('warning') // barva plných hvězdiček, výchozí: 'warning' + ->icons('star', 'outline:star') // plná, prázdná +``` + +## Přístupnost + +Řada hvězdiček je jediný prvek `role="img"` s popiskem „3 z 5" (přeloženo), takže +čtečka oznámí hodnotu jednou místo předčítání pěti ikon. + +## RatingColumn API + +```php +->max(int $max) // výchozí: 5 +->allowHalf(bool $condition = true) +->color(string|Color|null $color) // výchozí: 'warning' +->icons(string|Icon $filled, string|Icon $empty) +->showValue(bool $condition = true) +->getMax(): int +->isAllowHalf(): bool +``` diff --git a/docs/cs/table/columns/tags.md b/docs/cs/table/columns/tags.md new file mode 100644 index 00000000..3c79ee02 --- /dev/null +++ b/docs/cs/table/columns/tags.md @@ -0,0 +1,66 @@ +--- +order: 23 +nav: false +--- + +# TagsColumn + +Vykreslí vícehodnotový stav jako řadu chipsů. Vzhled chipsu je tentýž badge +povrch jako u [BadgeColumn](badge.md), takže se štítek a badge nemohou rozejít. + +```php +use NyonCode\WireTable\Columns\TagsColumn; +``` + +## Základní použití + +```php +TagsColumn::make('tags') // ['php', 'laravel'] → dva chipsy +``` + +Přijímá pole, cast na pole/JSON i cokoli `Arrayable` — včetně kolekce relace +načtené přes tečkovou cestu: + +```php +TagsColumn::make('skills.name') +``` + +## Řetězce s oddělovačem + +Prostý řetězec je jeden štítek, dokud neřeknete, jak ho rozdělit: + +```php +TagsColumn::make('tags') + ->separator() // výchozí ',' → "php,laravel" = 2 chipsy + ->separator('|') +``` + +Prázdné položky se zahazují, takže koncový oddělovač nevytvoří prázdný chips. + +## Omezení počtu + +```php +TagsColumn::make('tags') + ->limitList(3) // 3 chipsy, pak chips „+2" +``` + +## Barvy + +Barvy podle hodnoty používají stejný slovník `colors()` / `colorUsing()` jako +BadgeColumn, včetně samobarvicích enumů (viz [Casty](casts.md)): + +```php +TagsColumn::make('tags') + ->colors(['urgent' => 'danger', 'later' => 'gray']) +``` + +## TagsColumn API + +```php +->separator(?string $separator = ',') // rozdělí řetězcový stav na štítky +->limitList(?int $limit) // zobrazí N chipsů, zbytek sloučí do „+N" +->colors(array|Closure $colors) // mapa barev podle hodnoty +->colorUsing(Closure $fn) +->getSeparator(): ?string +->getLimitList(): ?int +``` diff --git a/docs/cs/table/filters/index.md b/docs/cs/table/filters/index.md index 1669ac68..6f60615b 100644 --- a/docs/cs/table/filters/index.md +++ b/docs/cs/table/filters/index.md @@ -4,7 +4,7 @@ order: 30 # Filtry -Wire Table poskytuje **5 vestavěných typů filtrů** plus možnost postavit vlastní +Wire Table poskytuje **6 vestavěných typů filtrů** plus možnost postavit vlastní filtry. Filtry žijí v liště filtrů nad tabulkou a přetrvávají ve stavu Livewire přes `$tableFilters`. Tato stránka pokrývá tok a sdílené API; každý typ má svou vlastní stránku. @@ -18,6 +18,7 @@ vlastní stránku. | [DateFilter](date.md) | Jedno datum, rozsah dat nebo měsíc + rok | | [NumberRangeFilter](number-range.md) | Min/max číselný rozsah | | [TernaryFilter](ternary.md) | Trojstavový boolean (vše / true / false) | +| [TrashedFilter](trashed.md) | Měkké mazání: živé / smazané / všechny záznamy | ## Více diff --git a/docs/cs/table/filters/trashed.md b/docs/cs/table/filters/trashed.md new file mode 100644 index 00000000..3c71f766 --- /dev/null +++ b/docs/cs/table/filters/trashed.md @@ -0,0 +1,81 @@ +--- +order: 31 +nav: false +--- + +# TrashedFilter + +Přepíná dotaz mezi živými, měkce smazanými a všemi záznamy. + +```php +use NyonCode\WireTable\Filters\TrashedFilter; +``` + +Na rozdíl od ostatních filtrů neomezuje žádný sloupec: mění, který globální scope +platí — mapuje se na `withTrashed()` / `onlyTrashed()`, ne na klauzuli where. + +## Základní použití + +```php +Table::make() + ->filters([ + TrashedFilter::make('trashed'), + ]) +``` + +Tři stavy, z nichž jen dva jsou volbami — „bez smazaných" je placeholder, tedy +zrušení filtru: + +| Stav | Dotaz | +|------|-------| +| *(prázdný)* | jen živé záznamy — platí výchozí scope | +| `with` | `withTrashed()` — živé i smazané dohromady | +| `only` | `onlyTrashed()` — jen smazané záznamy | + +## Popisky + +```php +TrashedFilter::make('trashed') + ->label('Záznamy') + ->withTrashedLabel('Včetně archivovaných') + ->onlyTrashedLabel('Jen archivované') +``` + +## Obnovení z filtrovaného pohledu + +Zkombinujte ho s řádkovou akcí viditelnou jen u smazaných záznamů: + +```php +Table::make() + ->filters([TrashedFilter::make('trashed')]) + ->actions([ + Action::make('restore') + ->visible(fn ($record) => $record->trashed()) + ->action(fn ($record) => $record->restore()), + ]) +``` + +## Požadavky + +Model tabulky musí používat `SoftDeletes`. Pokud ne, aplikace filtru vyhodí +`TableConfigurationException` s názvem filtru i modelu — místo aby selhala jako +nedefinovaná metoda `onlyTrashed()` hluboko v query builderu. + +## TrashedFilter API + +```php +->withTrashedLabel(?string $label) // výchozí: „Včetně smazaných" +->onlyTrashedLabel(?string $label) // výchozí: „Jen smazané" +->getWithoutTrashedLabel(): string // placeholder, „Bez smazaných" +->options(array $options) // vyhazuje výjimku, viz níže + +TrashedFilter::WITH // 'with' +TrashedFilter::ONLY // 'only' +``` + +`options()` je zděděné ze `SelectFilter` a nedává tu smysl: tenhle filtr přepíná +scope měkkého mazání, místo aby porovnával sloupec s hodnotou, takže libovolná +volba nemá co dělat. Vlastní `getOptions()` přepíše cokoli nastaveného, takže +setter vypadal přijatě a přitom nic neměnil — nyní vyhodí +`TableConfigurationException` a odkáže na `withTrashedLabel()` a +`onlyTrashedLabel()`, což jsou dvě věci, které změnit *lze*. diff --git a/docs/cs/table/overview.md b/docs/cs/table/overview.md index fb5a915e..6fc7b757 100644 --- a/docs/cs/table/overview.md +++ b/docs/cs/table/overview.md @@ -266,12 +266,95 @@ Kompletní API akcí viz [Akce](../core/actions.md). ```php // Zapnout globální hledání napříč všemi searchable sloupci ->searchable(bool $searchable = true) + +// Nastavit, jak se zadaný výraz čte (viz níže) +->search(Closure|SearchConfig $config) +``` + +Ve výchozím stavu se celý výraz hledá jako jeden podřetězec napříč všemi +searchable sloupci, spojený přes OR: `LIKE '%výraz%'` na MySQL/MariaDB a SQLite, +`ILIKE` na PostgreSQL. Znaky `%` a `_`, které uživatel napíše, se escapují — +hledá se tedy po nich, místo aby fungovaly jako zástupné znaky. + +### Syntaxe hledání + +Každou schopnost zapínáte pro danou tabulku zvlášť — bez toho se nic +neinterpretuje, takže se stávajícímu hledání pod rukama nezmění chování. + +```php +use NyonCode\WireCore\Core\Query\Search\SearchConfig; + +$table->search(fn (SearchConfig $s) => $s + ->tokenize() // mezery znamenají AND, uvozovky drží frázi pohromadě + ->ranges() // >100, <=20, 10..20, 2026-01-01..2026-03-31 + ->wildcards() // nov* najde novak +); +``` + +| Schopnost | Co uživatel napíše | Co to udělá | +| --- | --- | --- | +| `tokenize()` | `Ada Lovelace` | Každé slovo musí sedět, každé napříč všemi sloupci — takže se trefí i křestní jméno v jednom sloupci a příjmení v druhém. | +| `tokenize()` | `"Ada Lovelace"` | Fráze v uvozovkách zůstane jedním slovem a nikdy se nečte jako operátor. | +| `ranges()` | `>100`, `>=100`, `<10`, `<=10`, `=42` | Porovnává proti sloupcům, které drží číslo nebo datum. | +| `ranges()` | `10..20`, `10..`, `..20` | Uzavřený nebo jednostranně otevřený rozsah. | +| `ranges()` | `2026-01-01..2026-03-31`, `31.01.2026` | Totéž nad daty. | +| `ranges()` | `8866 01..08` | Rozsah uvnitř jedné řady strukturovaného kódu — viz níže. | +| `wildcards()` | `nov*`, `a?b` | `*` zastoupí libovolný počet znaků, `?` právě jeden. | +| `literal()` | — | Vypne všechno zpět (výchozí stav). | + +Zadané datum se čte v té podrobnosti, v jaké bylo napsáno: `2026-01-31` znamená +celý ten den, `2026-01` celý měsíc a `2026` celý rok — takže `<=2026-01-31` +zahrne i záznam pořízený 31. v 23:30. + +Porovnání se ptá jen sloupce, který na ně umí odpovědět. Typ hodnoty se odvodí +z castů modelu (`decimal:2`, `datetime`, …); tam, kde casty za sloupec mluvit +nemohou, ho deklarujte přes +[`Column::searchAs()`](columns/index.md#hledani). Porovnání, na které nemůže +odpovědět žádný sloupec — `>100` v tabulce jmen — se hledá jako doslovný text, +který uživatel napsal, místo aby tiše sedělo na všechno. + +```php +// Na ">1000" umí odpovědět jen `amount`; slova výběr dál zúží. +$table->search(fn (SearchConfig $s) => $s->tokenize()->ranges()); + +// Uživatel napíše: praha >1000 +// Zůstanou řádky: něco obsahuje "praha" A ZÁROVEŇ amount > 1000 ``` -Hledání používá strategii závislou na databázi: -- **MySQL**: `MATCH ... AGAINST` fulltext (pokud existuje index) nebo `LIKE` -- **PostgreSQL**: `to_tsvector / ts_query` -- **SQLite**: fallback `LIKE '%term%'` +### Rozsahy uvnitř strukturovaného kódu + +Kód jako `8866 01`, `8866 02`, … má společnou řadu a končí číslem doplněným +nulami. Označte sloupec přes +[`searchAs('code')`](columns/index.md#hledani) a přes pořadové číslo lze rovnou +zadávat rozsah: + +```php +TextColumn::make('reference')->searchable()->searchAs('code'); + +// Uživatel napíše: 8866 01..08 +// SQL: reference BETWEEN '8866 01' AND '8866 08' +``` + +Mezera uvnitř kódu je zároveň tím, co výraz dělí — `8866 01..08` tedy přijde +jako slovo `8866` a rozsah `01..08`. Rozsah si nese slovo, které mu přímo +předchází, a sloupec typu kód jím doplní obě meze — řadu tedy píšete jednou, ne +na obou stranách. Každý jiný sloupec to slovo ignoruje a `01..08` čte jako +obyčejný rozsah, takže `praha 10..20` na téže tabulce dál znamená „obsahuje praha +a částka mezi 10 a 20“. Jednostranná porovnání fungují stejně: `8866 >=09`. + +Dvě pravidla, která to drží poctivé: + +- **Číslo musí být uložené doplněné nulami a psát se tak, jak je uložené.** + Porovnání textem je správně jen dokud je šířka konstantní (`01 … 08` se + abecedně řadí stejně jako číselně, `9 … 10` už ne). Napsat `1..8` proti + uloženým `01 … 08` nenajde nic. Rozsah přes hranici šířky se doplní za vás — + `8866 50..100` se čte jako `050..100`, protože stý člen může existovat jen + v třímístné řadě. +- **Řada je jedno slovo před rozsahem.** `faktura 8866 01..08` hledá rozsah uvnitř + `8866` a `faktura` musí sedet zvlášť; kód se dvěma mezerami je mimo dosah. + +Hledání se s filtry kombinuje přes AND, při změně vrací stránkování na první +stranu a při zapnutém [`queryString()`](advanced.md#perzistence-stavu-v-url) se ukládá do URL. ### Řazení @@ -283,17 +366,25 @@ Hledání používá strategii závislou na databázi: ->defaultSort(string $column, string $direction = 'asc') ``` +Každý dotaz tabulky končí primárním klíčem jako rozhodčím kritériem, ve směru, +který už platí. Stránka je výřez z nějakého uspořádání — bez něj je ten výřez +nedefinovaný: dva řádky, které řazení považuje za shodné, se můžou vrátit +v libovolném pořadí, a na PostgreSQL, kde `UPDATE` zapíše řádek nově na konec +haldy, editace řádku na první stránce protlačí dosud nezobrazený záznam před +začátek druhé stránky. Rozhodčí kritérium se vynechá tam, kde klíč není +přípustný člen řazení: `GROUP BY`, `DISTINCT` a sjednocení. + ### Stránkování ```php // Zapnout stránkování ->paginated(bool $paginated = true) -// Výchozí počet na stránku -->perPage(int $perPage = 10) +// Výchozí počet na stránku — int, nebo 'all' pro jednu stránku se vším +->perPage(int|string $perPage = 10) // [tl! focus:start] -// Volby dropdownu počtu na stránku -->perPageOptions(array $options = [10, 25, 50, 100]) +// Volby dropdownu počtu na stránku; velikostí smí být slovo 'all' +->perPageOptions(array $options = [10, 25, 50, 100]) // [tl! focus:end] // Jednoduché stránkování — bez COUNT(*) dotazu, jen Předchozí/Další ->simplePagination() @@ -318,6 +409,26 @@ Hledání používá strategii závislou na databázi: zobrazit, místo aby si protiřečil s řádky na obrazovce. Hodnota per-page přicházející od klienta, kterou tabulka nenabízí, spadne zpět na `perPage()`. +**Zobrazit vše na jedné stránce.** Velikostí stránky smí být slovo `'all'`, +které přidá poslední volbu bez jakéhokoli limitu: + +```php +->perPageOptions([10, 25, 50, 'all']) +``` + +Řadí se vždy nakonec, ať byla deklarovaná kdekoli, a ukládá se jako celé číslo +`Table::PER_PAGE_ALL` — hodnota, kterou select posílá zpět, kterou nese query +string a kterou porovnává cache key, protože všechny tři pracují s velikostmi +stránky jako s inty. `->perPage('all')` z ní udělá výchozí nastavení tabulky. + +`'all'` záměrně **není** mezi dodávanými volbami. Velikost stránky je jediná +věc, která stojí mezi tabulkou a načtením celého jejího zdroje do paměti, a +výše popsané spadnutí zpět existuje právě proto, aby si o to podvržený požadavek +nemohl říct — podstrčené `perPage: -1` spadne zpět na tabulce, která `'all'` +nikdy nenabídla. Napsat ho je způsob, jak tabulka řekne, že u *jejích* dat je +ten kompromis přijatelný. Žádný strop za tím není: dávej ho na tabulku, jejíž +počet řádků znáš, ne na tu nad zdrojem, který roste bez omezení. + **Stránky mimo rozsah se samy zakotví zpět.** Standardní stránkování ořízne na poslední zaplněnou stránku vždy, když uložené číslo stránky ukazuje za konec výsledků — sdílený odkaz `?page=5`, filtr, který množinu zmenšil, řádky smazané diff --git a/docs/forms/fields/builder.md b/docs/forms/fields/builder.md new file mode 100644 index 00000000..0cb973a7 --- /dev/null +++ b/docs/forms/fields/builder.md @@ -0,0 +1,114 @@ +# Builder + +Block builder for heterogeneous content: a list of items where each item picks +its own block type and is edited with that block's schema. Where a +[Repeater](repeater.md) repeats *one* schema, a Builder chooses among several — +the shape behind a page builder or a rich content field. + +## Basic Usage + +```php +use NyonCode\WireForms\Components\Block; +use NyonCode\WireForms\Components\Builder; + +Builder::make('content') + ->blocks([ + Block::make('heading')->icon('star')->schema([ + TextInput::make('text')->rules(['required']), + ]), + Block::make('paragraph')->schema([ + Textarea::make('body'), + ]), + Block::make('image')->schema([ + FileUpload::make('file'), + TextInput::make('alt'), + ]), + ]) + ->reorderable() +``` + +The "add" trigger opens a picker listing every declared block; choosing one +appends an item of that type. + +## Stored Shape + +Each item is stored as its type plus its data: + +```php +[ + ['type' => 'heading', 'data' => ['text' => 'Hello']], + ['type' => 'paragraph', 'data' => ['body' => 'World']], +] +``` + +Fields bind under `..data`, so a block's schema needs no +knowledge of its position — and a field named `type` inside a block cannot +collide with the item's own discriminator. Cast the attribute to `array` (or +`json`) on the model. + +## It Is a Repeater + +`Builder` extends `Repeater`, so it shares add/remove/reorder, per-item +reactivity, item limits and the form runtime's treatment of a repeated subtree: + +```php +Builder::make('content') + ->blocks([...]) + ->minItems(1) + ->maxItems(20) + ->collapsible() + ->addButtonLabel('Add block') +``` + +Only `relationship()` does not apply: mixed block types have no single related +model, so a builder is stored as an array rather than saved through a relation. + +## Validation + +Block field rules mount under the item's `data` envelope, at +`.*.data.`. Because the resolver validates by wildcard path, blocks +sharing a field *name* share its rules — rules are only as strict as the loosest +block declaring that name. Name fields distinctly where blocks must validate +differently. + +## Blocks That No Longer Exist + +Stored content outlives the code that declared it. An item whose stored type +names no declared block renders its type as the header and no fields, rather +than making the whole form unrenderable — so the content can still be +recognised, reordered, or removed. + +## Declaring a Block + +```php +Block::make(string $name) +->label(string|Closure $label) // header label (auto-generated from name) +->icon(string|Icon $icon) // shown in the picker and the item header +->schema(array $components) // the fields this block is edited with +``` + +A `Block` is a definition, not a rendered surface: placing one directly in a form +schema throws a `FormConfigurationException`. + +## Builder API + +```php +->blocks(array $blocks) // the block types this builder can place +->getBlocks(): array +->getBlock(string $name): ?Block +->getItemType(mixed $item): ?string +->table(bool $condition = true) // throws: see below +// plus the whole Repeater API: addable, deletable, reorderable, +// collapsible, collapsed, minItems, maxItems, addButtonLabel +``` + +`table()` is the one part of the Repeater API that does not carry over. The +table layout lays a *single* schema out as columns, and a builder's items each +carry a different block's schema, so there is no shared set of columns to head. +Calling it throws a `FormConfigurationException` rather than accepting the flag +and rendering the ordinary builder regardless. + +## Related Docs + +- [Repeater](repeater.md) — repeat one schema instead of choosing among several +- [Validation](../validation.md) diff --git a/docs/forms/fields/checkbox-list.md b/docs/forms/fields/checkbox-list.md index 18dc7229..01e97f05 100644 --- a/docs/forms/fields/checkbox-list.md +++ b/docs/forms/fields/checkbox-list.md @@ -78,6 +78,30 @@ CheckboxList::make('permissions') Calling `groups()` automatically enables the grouped layout. You can also call `grouped()` explicitly. +## Toggle-Button Variants + +Where the list is short, the same options read better as a row of toggle buttons +than as a column of checkboxes. `segmented()` and `buttons()` render the exact +chrome of the matching [Radio](radio.md) variants — this is the multiple-choice +half of that shared vocabulary, so a single-choice and a multi-choice control +look alike: + +```php +CheckboxList::make('days') + ->options(['mon' => 'Mon', 'tue' => 'Tue', 'wed' => 'Wed']) + ->segmented() + +CheckboxList::make('roles') + ->options(['admin' => 'Admin', 'editor' => 'Editor']) + ->buttons() + ->inline() + ->icons(['admin' => 'shield-check']) + ->colors(['admin' => 'danger']) +``` + +These variants show the options alone: search, bulk toggle, grouping and columns +are list chrome and do not apply. + ## Methods | Method | Type | Description | diff --git a/docs/forms/fields/index.md b/docs/forms/fields/index.md index 4350b19c..08d884f2 100644 --- a/docs/forms/fields/index.md +++ b/docs/forms/fields/index.md @@ -33,6 +33,7 @@ Reference for the built-in Wire Forms field and layout components. | Select a related record | [BelongsToSelect](belongs-to-select.md) | | Select a polymorphic target | [MorphToSelect](morph-to-select.md) | | Manage repeated groups or child rows | [Repeater](repeater.md) | +| Compose content from mixed block types | [Builder](builder.md) | ## Layout Components diff --git a/docs/forms/fields/repeater.md b/docs/forms/fields/repeater.md index abbe313e..fbc9f3be 100644 --- a/docs/forms/fields/repeater.md +++ b/docs/forms/fields/repeater.md @@ -76,6 +76,26 @@ Repeater::make('contacts') | `disabled(bool\|Closure)` | Disable add/delete/reorder controls | | `mutateRelationshipDataBeforeSaveUsing(Closure)` | Transform item data before persistence | +## Table Layout + +Short, uniform rows (invoice lines, key/value pairs) read better as a table than +as a card per item. `table()` lays the items out as rows under one header — same +state paths, same add/remove/reorder wiring, only the arrangement differs: + +```php +Repeater::make('lines') + ->table() + ->reorderable() + ->schema([ + TextInput::make('description')->label('What'), + TextInput::make('amount')->label('How much'), + ]) +``` + +Each schema field becomes a column headed by its own label, and the per-cell +label is hidden so it is not repeated on every row. Per-item collapsing does not +apply to a row, so `collapsible()` is ignored in this layout. + ## Per-Item Reactivity Reactive behavior inside a repeater resolves **per item**: `afterStateUpdated()`, live @@ -103,3 +123,4 @@ If the child records need independent filtering, pagination, or heavy workflows, - [Forms Overview](../overview.md) - [Validation](../validation.md) +- [Builder](builder.md) — items that each pick their own block type diff --git a/docs/forms/fields/select.md b/docs/forms/fields/select.md index 475e387e..4a83a553 100644 --- a/docs/forms/fields/select.md +++ b/docs/forms/fields/select.md @@ -70,6 +70,21 @@ outside the enum is rejected without you restating it. It is skipped for `multip > [`CheckboxList`](checkbox-list.md), table `SelectColumn`, and the table > [`SelectFilter`](../../table/filters/index.md). +## Clearing a Selection + +Picking the empty (placeholder) choice stores **null**, not an empty string — +which matters most against an enum-cast column, where `''` is not a valid backing +value and the cast would throw on save: + +```php +Select::make('status') + ->options(Status::class) // enum-cast column + ->placeholder('No status') // choosing this stores null +``` + +Multi-selects are unaffected: their empty state is `[]`, which an array cast +stores as it is. + ## Searchable ```php 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..6f11a4e9 100644 --- a/docs/table/advanced.md +++ b/docs/table/advanced.md @@ -319,6 +319,121 @@ 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 every live table with one callback**, not a line per model: + +```php +// routes/channels.php +use NyonCode\WireTable\Support\LiveChannel; + +LiveChannel::authorize(fn ($user, string $model) => $user->can('viewAny', $model)); +``` + +The callback is handed the **class name** the channel belongs to, already decoded, +so the wire format never leaves the package. Branch on `$model` when different +tables need different rules; return false to refuse, as in any channel callback. + +That is why the channel keeps the class to a single segment +(`wire-table.App-Models-Invoice`, `-` for `\`): Laravel compiles a `{placeholder}` +to `([^.]+)`, so a dotted class name could not be matched by a wildcard at all and +every model would have needed its own hand-written `Broadcast::channel()` line. +Worth insisting on, because a mistyped one raises nothing — the subscription is +refused, the push stops arriving, and polling covers for it, so the broadcast half +is dead and the table looks fine. + +`LiveChannel::for(Invoice::class)` gives the name if you need it directly. + +**Pausing the poll pauses the push.** The listener rides the polling wrapper, so +the Stop control — and a `pollWhen()` condition turning false — take the +broadcast with them. For Stop that is the point: "stop the table changing under +me" should mean all of it. For `pollWhen()` it is worth knowing, because that +condition is about the cost of polling rather than about wanting updates: a table +combining it with `broadcast: true` is not pushed to while the condition is +false. Leave `pollWhen()` off if you want the push to survive it. + +**The package never authorizes for you.** It registers no channel and calls no +policy of its own — who may listen is the application's decision, stated where +Laravel expects it. What it does instead is refuse to be quiet about the +omission: a subscription the server turns down is reported in the console, +naming the call that fixes it. That is the one failure worth being loud about, +because it looks exactly like success — the table keeps refreshing on its +interval, so nothing appears broken while the push half is dead. + +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: @@ -886,7 +1001,7 @@ Tracked parameters: |---|---|---| | `search` | global search | only when the table is searchable | | `sort`, `direction` | sort state | only sortable column names are accepted | -| `per_page` | page size | only values from `perPageOptions()` are accepted | +| `per_page` | page size | only values from `perPageOptions()` are accepted; `-1` is the `'all'` option, on a table that offers it | | `filter_{name}` | filter value | one parameter per filter | | `page` | current page | handled by Livewire's `WithPagination`; a page past the end re-anchors to the last populated one | diff --git a/docs/table/columns/badge.md b/docs/table/columns/badge.md index ee8da70a..e570391a 100644 --- a/docs/table/columns/badge.md +++ b/docs/table/columns/badge.md @@ -1,16 +1,62 @@ --- order: 23 nav: false +summary: Renders a cell as a colored pill whose color and icon are derived from the record's state. --- # BadgeColumn -Colored badge/tag display with state-based color and icon mapping. +Renders the cell as a colored pill. Reach for it when the value is a *state* — a +status, a role, a priority — and the colour is what the reader actually scans +for. For a plain value that just needs an accent colour, `TextColumn` with +`->color()` is the lighter tool. ```php use NyonCode\WireTable\Columns\BadgeColumn; ``` +## How It Works + +The badge asks two questions per cell — *what colour* and *what icon* — and +answers each by walking a ladder until a rung responds. **Colour**, in order: + +1. **`->colorUsing()`** — the closure runs first and wins outright when it + returns anything but `null`. Returning `null` for some states hands those + back to the rungs below. +2. **`->colors()`** — the map, looked up by the state as key. An enum state is + unwrapped to its backing scalar first, so `Status::Active` finds the + `'active'` key. +3. **The state's own colour** — an enum implementing the `HasColor` contract + names its own colour, and no map is needed at all. +4. **`->color()`** — the column's static colour, so setting one is not silently + ignored on a stateful column. +5. **`gray`** — the neutral floor. A badge always renders with *some* colour. + +**Icons** walk the same ladder — `->iconUsing()`, `->icons()`, the enum's +`HasIcon` contract, then the column's `->icon()` — with one difference: there is +no floor. Nothing matching means no icon, and the badge renders as a plain pill. + +Four more things worth knowing before writing the chain: + +- **The label is resolved separately from the colour.** The pill's text comes + from the normal column formatting pipeline (`->formatStateUsing()`, casts, + enum labels), never from the colour map. An enum state without a `HasLabel` + contract reads as a headline of its case name — `InReview` → "In Review". +- **Pass an array to `->colors()`, not a closure.** The signature accepts + `array|Closure` because it is shared with record-aware surfaces (infolist + entries evaluate the closure against their record). A column configures itself + before any record exists, so a closure map here matches nothing and every + badge drops to the floor colour. Use `->colorUsing()` for dynamic colours. +- **Cost is per distinct state, not per row.** States are low-cardinality by + nature, so the rendered markup is memoised by its resolved data: a thousand + rows sharing four statuses render four badges. The closures above therefore + run per state value, not per record — do not put record-specific logic in + them. +- **The value is escaped.** Like every text cell, the label is escaped unless + the column opts into `->html()` — a record value such as `` is + text, not markup. A `null` or empty state renders the column's empty-cell + text, not an empty pill. + ## Basic Usage The map is keyed by the **state**, and each value is the colour to wear for it: @@ -27,9 +73,9 @@ BadgeColumn::make('status') ]) ``` -A state the map does not mention falls back to the column's own `->color()`, and -to `gray` when that is unset. Values may also be given as the `Color` enum -(`'active' => Color::Success`). +A state the map does not mention falls through the ladder above. Values may also +be given as the `Color` enum (`'active' => Color::Success`), and the whole +Tailwind palette is available — see [Theming](../../theming.md). ## With Icons @@ -56,6 +102,10 @@ BadgeColumn::make('priority') ## Dynamic Colors +When the colour is a function of the value rather than a fixed vocabulary — a +score, an amount, an age — derive it. The closure receives the state and runs +before the map: + ```php // Closure-based color resolution BadgeColumn::make('score') @@ -68,8 +118,60 @@ BadgeColumn::make('score') ->iconUsing(fn (int $state) => $state >= 90 ? 'star' : null) ``` +## Enum States + +An enum that implements the `HasLabel` / `HasColor` / `HasIcon` contracts +carries its own presentation, and the column needs no maps at all. The same enum +then reads identically on a table cell, an infolist entry and a `` +option: + +```php +use NyonCode\WireCore\Foundation\Contracts\Enum\HasColor; +use NyonCode\WireCore\Foundation\Contracts\Enum\HasIcon; +use NyonCode\WireCore\Foundation\Contracts\Enum\HasLabel; + +enum OrderStatus: string implements HasColor, HasIcon, HasLabel +{ + case Pending = 'pending'; + case Shipped = 'shipped'; + case Cancelled = 'cancelled'; + + public function getLabel(): string // [tl! focus:start] + { + return match ($this) { + self::Pending => 'Awaiting payment', + self::Shipped => 'On its way', + self::Cancelled => 'Cancelled', + }; + } + + public function getColor(): string + { + return match ($this) { + self::Pending => 'warning', + self::Shipped => 'success', + self::Cancelled => 'danger', + }; + } + + public function getIcon(): ?string + { + return $this === self::Cancelled ? 'x-circle' : null; + } // [tl! focus:end] +} + +// With the attribute cast to the enum, the column is just the attribute. +BadgeColumn::make('status') +``` + +A map still beats the enum's own colour, which is how one table can present a +shared enum differently without touching the enum. + ## Custom Label + Badge +`->formatStateUsing()` rewrites the pill's text without touching the colour +ladder — the map stays keyed by the raw state: + ```php BadgeColumn::make('role') ->formatStateUsing(fn (string $state) => match($state) { @@ -89,18 +191,90 @@ BadgeColumn::make('role') ```php BadgeColumn::make('tag') - ->size('xs') // xs, sm, md, lg + ->size('xs') // xs, sm, md, lg — default md +``` + +`->xl()` exists on the shared size API, but the badge surface renders it with +the `md` padding; `lg` is the largest pill. + +## Extended Example + +A moderation table where one column carries three signals at once: the colour +comes from a map, the icon marks only the states that need attention, and the +label is rewritten for readers who do not think in slugs. + +```php +use Livewire\Component; +use NyonCode\WireTable\Concerns\WithTable; +use NyonCode\WireTable\Table; +use NyonCode\WireTable\Columns\BadgeColumn; +use NyonCode\WireTable\Columns\TextColumn; + +class ArticleTable extends Component +{ + use WithTable; + + public function table(Table $table): Table + { + return $table + ->model(Article::class) + ->columns([ + TextColumn::make('title') + ->searchable() + ->weight('bold'), + + BadgeColumn::make('status') // [tl! focus:start] + ->colors([ + 'published' => 'success', + 'in_review' => 'warning', + 'rejected' => 'danger', + 'draft' => 'gray', + ]) + ->icons([ + 'in_review' => 'clock', // only the states that need a + 'rejected' => 'x-circle', // second glance get an icon + ]) + ->formatStateUsing(fn (string $state) => str($state)->headline()) + ->size('sm'), // [tl! focus:end] + + TextColumn::make('published_at') + ->dateTime('d.m.Y') + ->sortable(), + ]) + ->defaultSort('published_at', 'desc') + ->paginated(); + } + + public function render() + { + return view('livewire.article-table'); + } +} ``` ## BadgeColumn API +The badge surface itself. Everything else a column can do — `->label()`, +`->sortable()`, `->visible()`, formatting, editing — is the shared column API, +documented in [Columns](index.md). + ```php -->colors(array $map) // ['state_value' => 'color_name'|Color, ...] -->colorUsing(Closure $fn) // fn($state) => 'color_name'|Color|null -->icons(array $map) // ['state_value' => 'icon_name'|Icon, ...] -->iconUsing(Closure $fn) // fn($state) => 'icon_name'|Icon|null -->size(string $size) // 'xs', 'sm', 'md', 'lg' +->colors(array $map) // ['state' => 'color_name'|Color, ...] +->colorUsing(Closure $fn) // fn ($state) => 'color_name'|Color|null — beats the map +->icons(array $map) // ['state' => 'icon_name'|Icon, ...] +->iconUsing(Closure $fn) // fn ($state) => 'icon_name'|Icon|null — beats the map +->color(string|Color $color) // fallback colour when the state maps to nothing +->icon(string|Icon $icon) // fallback icon when the state maps to nothing +->size(string|Size $size) // 'xs'|'sm'|'md'|'lg' — default 'md' +->xs() / ->sm() / ->md() / ->lg() // size presets ->getSize(): string -->getColorForState($state): ?string -->getIconForState($state): ?string +->getColorForState($state): ?string // the resolved colour, whole ladder included +->getIconForState($state): ?string // the resolved icon, whole ladder included ``` + +## Related + +- [Columns](index.md) — the shared column API every column inherits +- [IconColumn](icon.md) — the same state ladder, rendered as an icon alone +- [PollColumn](poll.md) — a badge over a live-polled value +- [Theming](../../theming.md) — the colour vocabulary these maps draw from diff --git a/packages/boost/resources/boost/docs/table/columns/checkbox.md b/packages/boost/resources/boost/docs/table/columns/checkbox.md new file mode 100644 index 00000000..fc056159 --- /dev/null +++ b/packages/boost/resources/boost/docs/table/columns/checkbox.md @@ -0,0 +1,49 @@ +--- +order: 23 +nav: false +--- + +# CheckboxColumn + +An inline checkbox that writes a boolean straight to the record — the same +optimistic write path as [ToggleColumn](toggle.md), where a checkbox reads more +naturally than a switch or the table is too dense for a track. + +```php +use NyonCode\WireTable\Columns\CheckboxColumn; +``` + +## Basic Usage + +```php +CheckboxColumn::make('is_active') +``` + +Clicking commits immediately and rolls back with an inline error if the write is +rejected (including an optimistic-lock conflict — see +[Editing](editing.md)). + +## Accent Color + +```php +CheckboxColumn::make('is_active') + ->accentColor('success') +``` + +## Disabling Per Record + +```php +CheckboxColumn::make('is_active') + ->disabled(fn ($record) => $record->is_locked) +``` + +The disabled state is enforced on the server as well, not only in the browser: a +forged request to `updateTableCell()` for a disabled row is refused. + +## CheckboxColumn API + +```php +->accentColor(string|Color|null $color) // checked color, default: 'primary' +->disabled(bool|Closure $condition = true) +->getAccentColorClass(): string +``` diff --git a/packages/boost/resources/boost/docs/table/columns/color.md b/packages/boost/resources/boost/docs/table/columns/color.md new file mode 100644 index 00000000..de4013ab --- /dev/null +++ b/packages/boost/resources/boost/docs/table/columns/color.md @@ -0,0 +1,60 @@ +--- +order: 23 +nav: false +--- + +# ColorColumn + +Renders a stored CSS color as a swatch next to its literal value. The table-side +counterpart of the infolist `ColorEntry`. + +```php +use NyonCode\WireTable\Columns\ColorColumn; +``` + +## Basic Usage + +```php +ColorColumn::make('brand_color') // "#1a2b3c" → swatch + "#1a2b3c" +``` + +The state is a CSS color *stored on the record* — hex, `rgb()`, `hsl()`, or a +keyword. It is not a palette name: for palette-driven coloring (a status pill, +a state icon) use [BadgeColumn](badge.md) or [IconColumn](icon.md). + +## Swatch Only + +Drop the literal value where the column is narrow and the swatch is enough: + +```php +ColorColumn::make('brand_color') + ->swatchOnly() +``` + +## Copy to Clipboard + +The shared `copyable()` API copies the color value: + +```php +ColorColumn::make('brand_color') + ->copyable() +``` + +## Values it will not draw + +A swatch is the one cell that puts record data into a `style` attribute, where +HTML escaping alone is not enough — `;` would open a second declaration. Values +that are not a recognisable CSS color are rejected and the cell falls back to its +empty text: + +```php +// Rendered: #1a2b3c, rgb(255 0 0 / 50%), rebeccapurple +// Not rendered: "red; background-image: url(…)", "url(…)", "expression(…)" +``` + +## ColorColumn API + +```php +->swatchOnly(bool $condition = true) // hide the literal value beside the swatch +->isSwatchOnly(): bool +``` diff --git a/packages/boost/resources/boost/docs/table/columns/editing.md b/packages/boost/resources/boost/docs/table/columns/editing.md index 533b066e..f337c9c9 100644 --- a/packages/boost/resources/boost/docs/table/columns/editing.md +++ b/packages/boost/resources/boost/docs/table/columns/editing.md @@ -82,44 +82,60 @@ and query-string persistence. ## Inline Editing -Columns can also use the generic `editable()` API (in addition to dedicated TextInputColumn/SelectColumn/ToggleColumn): +**The editor comes from the column type**, not from a setting: use +[TextInputColumn](text-input.md), [SelectColumn](select.md), +[ToggleColumn](toggle.md) or [CheckboxColumn](checkbox.md). An ordinary column +renders no editor. + +`editable()` is the switch that turns a dedicated column's editor on and off, +and the server-side gate for writing that column: ```php -TextColumn::make('name') - ->editable() // type defaults to 'text' +TextInputColumn::make('name') + ->editable(fn () => auth()->user()->isAdmin()) // false renders the plain value ->editableRules(fn ($record) => ['required', 'max:255']) ->editableUsing(function ($record, $column, $value) { $record->update([$column => $value]); }) - -TextColumn::make('category') - // editable(enabled, type, options) — 'text' | 'select' | 'toggle' - ->editable(true, 'select', ['a' => 'Category A', 'b' => 'Category B']) - ->editableRules(fn ($record) => ['required', 'in:a,b']) ``` -The `options` argument of both `editable(type: 'select', …)` and `filterable()` / -`filterAsSelect()` accepts a PHP enum class as well — it expands to `value => label` exactly -like the dedicated `SelectColumn`/`SelectFilter`. See [Enum Options](select.md#enum-options). +Naming an editor type — `editable(true, 'select', […])` — throws: no view has +ever read one, so it would silently do nothing. Use `SelectColumn` instead. + +The `options` argument of `filterable()` / `filterAsSelect()` accepts a PHP enum +class — it expands to `value => label` exactly like the dedicated +`SelectColumn`/`SelectFilter`. See [Enum Options](select.md#enum-options). ### 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/docs/table/columns/index.md b/packages/boost/resources/boost/docs/table/columns/index.md index 4c0b79b5..31d2d39b 100644 --- a/packages/boost/resources/boost/docs/table/columns/index.md +++ b/packages/boost/resources/boost/docs/table/columns/index.md @@ -4,7 +4,7 @@ order: 20 # Columns -Wire Table provides **12 column types**. They all share the same base column API +Wire Table provides **16 column types**. They all share the same base column API for labels, visibility, authorization, sorting, formatting, and inline editing — documented below. Pick a type for its cell rendering; reach for the shared API on any of them. @@ -20,11 +20,15 @@ any of them. | [ImageColumn](image.md) | Avatars and thumbnails | | [ButtonColumn](button.md) | Link or Livewire-action button in a cell | | [ToggleColumn](toggle.md) | Inline editable on/off switch | +| [CheckboxColumn](checkbox.md) | Inline editable checkbox (a denser ToggleColumn) | | [SelectColumn](select.md) | Inline editable dropdown (options, relations, enums) | | [TextInputColumn](text-input.md) | Inline editable text/number/email input | | [StackedColumn](stacked.md) | Avatar + name + email stacked layouts | | [SplitColumn](split.md) | Compose several columns side by side | | [PollColumn](poll.md) | Live-polling status/progress cells | +| [ColorColumn](color.md) | A stored CSS color as a swatch | +| [RatingColumn](rating.md) | A numeric score as stars | +| [TagsColumn](tags.md) | A multi-value state as chips | ## Concepts @@ -78,6 +82,9 @@ TextColumn::make('full_name') // Custom search logic ->searchUsing(Closure $fn) +// Declare what the column holds, so >100 and 10..20 can be typed into search +->searchAs(SearchValueType|string $type) // 'text' | 'numeric' | 'date' | 'code' + // Get resolved search columns ->getSearchColumns(): array ``` @@ -97,6 +104,28 @@ TextColumn::make('full_name') }) ``` +`searchAs()` matters only once the table opts into +[range search](../overview.md#search-syntax). The value type is normally +inferred from the model's casts — a `decimal:2` or `datetime` cast is enough — +so declare it only where the casts cannot speak for the column: + +```php +// The model has no cast for `amount`, so nothing can be inferred from it. +TextColumn::make('amount') + ->searchable() + ->searchAs('numeric') // now ">1000" and "10..20" reach this column +``` + +A column left as text is skipped by a comparison rather than compared +lexically, so a wrong or missing declaration narrows what search understands — +it never returns wrong rows. + +`'code'` is the one type that is *never* inferred: it says the value is a series +plus a **zero-padded** number (`8866 01`, `8866 02`), which is what makes +comparing it as text correct, and only the owner knows that. It unlocks +[ranges inside a series](../overview.md#ranges-inside-a-structured-code) — +`8866 01..08`. + ### Visibility & Toggleability ```php diff --git a/packages/boost/resources/boost/docs/table/columns/rating.md b/packages/boost/resources/boost/docs/table/columns/rating.md new file mode 100644 index 00000000..c23f8bd3 --- /dev/null +++ b/packages/boost/resources/boost/docs/table/columns/rating.md @@ -0,0 +1,58 @@ +--- +order: 23 +nav: false +--- + +# RatingColumn + +Renders a numeric state as a row of filled and empty stars — the read-only table +counterpart of the `Rating` form field, sharing its vocabulary. + +```php +use NyonCode\WireTable\Columns\RatingColumn; +``` + +## Basic Usage + +```php +RatingColumn::make('score') // 3 → ★★★☆☆ +``` + +A non-numeric or null state renders the column's empty text instead of an empty +star row. + +## Scale, Halves and the Value + +```php +RatingColumn::make('score') + ->max(10) // default: 5 + ->allowHalf() // 2.5 draws a half-filled star + ->showValue() // print the number beside the stars +``` + +Without `allowHalf()` a fractional value simply fills the stars it has passed. + +## Colors and Icons + +```php +RatingColumn::make('score') + ->color('warning') // filled-star color, default: 'warning' + ->icons('star', 'outline:star') // filled, empty +``` + +## Accessibility + +The star row is a single `role="img"` labelled "3 out of 5" (translated), so a +screen reader announces the value once rather than reading five icons. + +## RatingColumn API + +```php +->max(int $max) // default: 5 +->allowHalf(bool $condition = true) +->color(string|Color|null $color) // default: 'warning' +->icons(string|Icon $filled, string|Icon $empty) +->showValue(bool $condition = true) +->getMax(): int +->isAllowHalf(): bool +``` diff --git a/packages/boost/resources/boost/docs/table/columns/tags.md b/packages/boost/resources/boost/docs/table/columns/tags.md new file mode 100644 index 00000000..af801ec1 --- /dev/null +++ b/packages/boost/resources/boost/docs/table/columns/tags.md @@ -0,0 +1,66 @@ +--- +order: 23 +nav: false +--- + +# TagsColumn + +Renders a multi-value state as a row of chips. The chip chrome is the same badge +surface as [BadgeColumn](badge.md), so a tag and a badge cannot drift apart. + +```php +use NyonCode\WireTable\Columns\TagsColumn; +``` + +## Basic Usage + +```php +TagsColumn::make('tags') // ['php', 'laravel'] → two chips +``` + +Accepts an array, a JSON/array cast, or anything `Arrayable` — including a +relation collection loaded through a dot path: + +```php +TagsColumn::make('skills.name') +``` + +## Delimited Strings + +A plain string is one tag unless you say how to split it: + +```php +TagsColumn::make('tags') + ->separator() // default ',' → "php,laravel" = 2 chips + ->separator('|') +``` + +Blank entries are dropped, so a trailing separator does not produce an empty chip. + +## Limiting the Row + +```php +TagsColumn::make('tags') + ->limitList(3) // 3 chips, then a "+2" chip +``` + +## Colors + +Per-value colors use the same `colors()` / `colorUsing()` vocabulary as +BadgeColumn, including enum self-coloring (see [Casts](casts.md)): + +```php +TagsColumn::make('tags') + ->colors(['urgent' => 'danger', 'later' => 'gray']) +``` + +## TagsColumn API + +```php +->separator(?string $separator = ',') // split a string state into tags +->limitList(?int $limit) // show N chips, collapse the rest into "+N" +->colors(array|Closure $colors) // per-value color map +->colorUsing(Closure $fn) +->getSeparator(): ?string +->getLimitList(): ?int +``` diff --git a/packages/boost/resources/boost/docs/table/filters/index.md b/packages/boost/resources/boost/docs/table/filters/index.md index ef695627..a4b2e64d 100644 --- a/packages/boost/resources/boost/docs/table/filters/index.md +++ b/packages/boost/resources/boost/docs/table/filters/index.md @@ -4,7 +4,7 @@ order: 30 # Filters -Wire Table provides **5 built-in filter types** plus the ability to build custom +Wire Table provides **6 built-in filter types** plus the ability to build custom filters. Filters live in the filter bar above the table and persist in Livewire state via `$tableFilters`. This page covers the flow and the shared API; each type has its own page. @@ -18,6 +18,7 @@ type has its own page. | [DateFilter](date.md) | Single date, date range, or month + year | | [NumberRangeFilter](number-range.md) | Min/max numeric range | | [TernaryFilter](ternary.md) | Three-state boolean (all / true / false) | +| [TrashedFilter](trashed.md) | Soft deletes: live / deleted / all records | ## More diff --git a/packages/boost/resources/boost/docs/table/filters/trashed.md b/packages/boost/resources/boost/docs/table/filters/trashed.md new file mode 100644 index 00000000..877a2118 --- /dev/null +++ b/packages/boost/resources/boost/docs/table/filters/trashed.md @@ -0,0 +1,82 @@ +--- +order: 31 +nav: false +--- + +# TrashedFilter + +Switches the query between live, soft-deleted, and all records. + +```php +use NyonCode\WireTable\Filters\TrashedFilter; +``` + +Unlike every other filter this one constrains no column: it changes which global +scope applies, mapping to `withTrashed()` / `onlyTrashed()` rather than to a +where clause. + +## Basic Usage + +```php +Table::make() + ->filters([ + TrashedFilter::make('trashed'), + ]) +``` + +Three states, of which only two are options — "without deleted" is the +placeholder, i.e. clearing the filter: + +| State | Query | +|-------|-------| +| *(cleared)* | live records only — the default scope stands | +| `with` | `withTrashed()` — live and deleted together | +| `only` | `onlyTrashed()` — deleted records only | + +## Labels + +```php +TrashedFilter::make('trashed') + ->label('Records') + ->withTrashedLabel('Including archived') + ->onlyTrashedLabel('Archived only') +``` + +## Restoring from the filtered view + +Pair it with a row action that only shows for trashed records: + +```php +Table::make() + ->filters([TrashedFilter::make('trashed')]) + ->actions([ + Action::make('restore') + ->visible(fn ($record) => $record->trashed()) + ->action(fn ($record) => $record->restore()), + ]) +``` + +## Requirements + +The table's model must use `SoftDeletes`. If it does not, applying the filter +throws a `TableConfigurationException` naming the filter and the model, rather +than failing as an undefined `onlyTrashed()` deep inside the query builder. + +## TrashedFilter API + +```php +->withTrashedLabel(?string $label) // default: "With deleted" +->onlyTrashedLabel(?string $label) // default: "Only deleted" +->getWithoutTrashedLabel(): string // the placeholder, "Without deleted" +->options(array $options) // throws: see below + +TrashedFilter::WITH // 'with' +TrashedFilter::ONLY // 'only' +``` + +`options()` is inherited from `SelectFilter` and does not apply: this filter +switches a soft-delete scope rather than matching a column against a value, so +an arbitrary option has nothing to do. Its own `getOptions()` overrides whatever +was set, which made the setter look accepted while changing nothing — it now +throws a `TableConfigurationException` pointing at `withTrashedLabel()` and +`onlyTrashedLabel()`, the two things that *can* be changed. diff --git a/packages/boost/resources/boost/docs/table/overview.md b/packages/boost/resources/boost/docs/table/overview.md index 79ec4b37..a98ba579 100644 --- a/packages/boost/resources/boost/docs/table/overview.md +++ b/packages/boost/resources/boost/docs/table/overview.md @@ -266,12 +266,97 @@ See [Actions](../core/actions.md) for the full Actions API. ```php // Enable global search across all searchable columns ->searchable(bool $searchable = true) + +// Configure how the typed term is interpreted (see below) +->search(Closure|SearchConfig $config) +``` + +By default the whole term is matched as one substring against every searchable +column, OR-ed together: `LIKE '%term%'` on MySQL/MariaDB and SQLite, `ILIKE` on +PostgreSQL. The `%` and `_` a user types are escaped, so they are searched for +rather than acting as wildcards. + +### Search syntax + +Each capability is opted into per table — nothing is interpreted unless you ask +for it, so an existing search never changes shape underneath you. + +```php +use NyonCode\WireCore\Core\Query\Search\SearchConfig; + +$table->search(fn (SearchConfig $s) => $s + ->tokenize() // spaces mean AND, quotes keep a phrase together + ->ranges() // >100, <=20, 10..20, 2026-01-01..2026-03-31 + ->wildcards() // nov* matches novak +); +``` + +| Capability | What the user can type | What it does | +| --- | --- | --- | +| `tokenize()` | `Ada Lovelace` | Every word must match, each across all columns — so a first name in one column and a surname in another match together. | +| `tokenize()` | `"Ada Lovelace"` | A quoted phrase stays one word and is never read as an operator. | +| `ranges()` | `>100`, `>=100`, `<10`, `<=10`, `=42` | Compares against columns that hold a number or a date. | +| `ranges()` | `10..20`, `10..`, `..20` | A closed or open-ended range. | +| `ranges()` | `2026-01-01..2026-03-31`, `31.01.2026` | The same over dates. | +| `ranges()` | `8866 01..08` | A range inside one series of a structured code — see below. | +| `wildcards()` | `nov*`, `a?b` | `*` stands for any run of characters, `?` for exactly one. | +| `literal()` | — | Switches everything back off (the default). | + +A typed date is read at the granularity it was written: `2026-01-31` means that +whole day, `2026-01` that month and `2026` that year — so `<=2026-01-31` still +includes a row placed at 23:30 on the 31st. + +Comparisons are only ever asked of a column that can answer them. The value type +is inferred from the model's casts (`decimal:2`, `datetime`, …); where the casts +cannot speak for a column, declare it with +[`Column::searchAs()`](columns/index.md#searching). A comparison no column can +answer — `>100` on a table of names — is searched as the literal text that was +typed rather than silently matching everything. + +```php +// Only `amount` can answer ">1000"; the words narrow it further. +$table->search(fn (SearchConfig $s) => $s->tokenize()->ranges()); + +// User types: praha >1000 +// Rows kept: something contains "praha" AND amount > 1000 ``` -Search uses a database-aware strategy: -- **MySQL**: `MATCH ... AGAINST` fulltext (if index exists) or `LIKE` -- **PostgreSQL**: `to_tsvector / ts_query` -- **SQLite**: `LIKE '%term%'` fallback +### Ranges inside a structured code + +A code such as `8866 01`, `8866 02`, … shares a series and ends in a padded +number. Declare the column with +[`searchAs('code')`](columns/index.md#searching) and the sequence can be ranged +over directly: + +```php +TextColumn::make('reference')->searchable()->searchAs('code'); + +// User types: 8866 01..08 +// SQL: reference BETWEEN '8866 01' AND '8866 08' +``` + +The space inside the code is also what splits the term, so `8866 01..08` +arrives as the word `8866` and the range `01..08`. The range carries the word +directly before it, and a code column completes both bounds with it — write the +series once, not on both sides. Every other column ignores the word and reads +`01..08` as the plain range it is, so `praha 10..20` still means "contains praha, +amount between 10 and 20" on the same table. One-sided comparisons work the same +way: `8866 >=09`. + +Two rules keep it honest: + +- **The number must be stored padded, and typed the way it is stored.** + Comparing as text is only correct while the width is constant (`01 … 08` + sorts alphabetically in the same order it sorts numerically; `9 … 10` does + not). Typing `1..8` against stored `01 … 08` finds nothing. A range typed + across a width boundary is completed for you — `8866 50..100` is read as + `050..100`, since a hundredth member can only exist in a three-digit series. +- **The series is the one word before the range.** `faktura 8866 01..08` ranges + inside `8866` and requires `faktura` separately; a code containing two spaces + is out of reach. + +Search combines with filters (AND), is reset to page one when it changes, and is +persisted in the URL when [`queryString()`](advanced.md#url-state-persistence) is on. ### Sorting @@ -283,17 +368,25 @@ Search uses a database-aware strategy: ->defaultSort(string $column, string $direction = 'asc') ``` +Every table query ends with the primary key as a tiebreaker, in whichever +direction is already in force. A page is a slice of an ordering, so without one +the slice is undefined: two rows the sort calls equal can come back in either +order, and on PostgreSQL — where an `UPDATE` rewrites the row at the end of the +heap — editing a row on page one pushes an unseen record past the start of page +two. The tiebreaker is skipped where a key is not a legal ordering term: +`GROUP BY`, `DISTINCT` and unions. + ### Pagination ```php // Enable pagination ->paginated(bool $paginated = true) -// Default per-page count -->perPage(int $perPage = 10) +// Default per-page count — an int, or 'all' for one page holding everything +->perPage(int|string $perPage = 10) // [tl! focus:start] -// Per-page dropdown options -->perPageOptions(array $options = [10, 25, 50, 100]) +// Per-page dropdown options; a size may be the word 'all' +->perPageOptions(array $options = [10, 25, 50, 100]) // [tl! focus:end] // Simple pagination — no COUNT(*) query, just Previous/Next ->simplePagination() @@ -318,6 +411,26 @@ Search uses a database-aware strategy: show `3` instead of contradicting the rows on screen. A per-page value arriving from the client that the table does not offer falls back to `perPage()`. +**Showing everything on one page.** A page size may be the word `'all'`, which +adds a final option that drops the limit entirely: + +```php +->perPageOptions([10, 25, 50, 'all']) +``` + +It always sorts last, whatever position it was declared in, and it is stored as +the integer `Table::PER_PAGE_ALL` — the value the select posts back, the query +string carries and the cache key compares, since every one of those handles page +sizes as integers. `->perPage('all')` makes it the table's own default. + +`'all'` is deliberately **not** among the shipped options. A page size is the +one thing standing between a table and reading its whole source into memory, and +the fallback described above exists precisely so a crafted request cannot ask +for that — a forged `perPage: -1` still falls back on a table that never offered +`'all'`. Writing it is how a table says the trade is acceptable for *its* data. +There is no ceiling behind it: put it on a table whose row count you know, not +on one backed by a table that grows without limit. + **Out-of-range pages re-anchor themselves.** Standard pagination clamps to the last populated page whenever the stored page points past the end of the result set — a shared `?page=5` link, a filter that shrank the set, rows deleted by diff --git a/packages/boost/resources/boost/guidelines/wire-table.blade.php b/packages/boost/resources/boost/guidelines/wire-table.blade.php index d72be13b..04089d77 100644 --- a/packages/boost/resources/boost/guidelines/wire-table.blade.php +++ b/packages/boost/resources/boost/guidelines/wire-table.blade.php @@ -30,7 +30,8 @@ public function table(Table $table): Table ### Columns `TextColumn`, `BadgeColumn`, `BooleanColumn`, `IconColumn`, `ImageColumn`, `ButtonColumn`, `ToggleColumn`, -`PollColumn`, `SelectColumn`, `TextInputColumn`, `SplitColumn`, `StackedColumn`. +`CheckboxColumn`, `PollColumn`, `SelectColumn`, `TextInputColumn`, `SplitColumn`, `StackedColumn`, +`ColorColumn`, `RatingColumn`, `TagsColumn`. `BadgeColumn` (and `IconColumn`) color/icon resolution — pick by intent: - one fixed color for every row: `->color('success')` (takes `string|Color|null`, never a Closure); @@ -50,10 +51,12 @@ public function table(Table $table): Table ### Filters -`SelectFilter`, `DateFilter`, `NumberRangeFilter`, `TernaryFilter`. A filter query callback must return the +`SelectFilter`, `DateFilter`, `NumberRangeFilter`, `TernaryFilter`, `TrashedFilter`. A filter query callback must return the Builder. It receives the value already normalized for its filter type — a `TernaryFilter` callback gets a real `bool`, never the `'true'`/`'false'` option key, so branch with `$value ? … : …` and never compare to a -string. Use `->indicator()` for filter chips and `->subRows()` to scope sub-row filtering. +string. Use `->indicator()` for filter chips and `->subRows()` to scope sub-row filtering. `TrashedFilter` constrains no +column — it switches the soft-delete scope (`'with'` → `withTrashed()`, `'only'` → `onlyTrashed()`, cleared → live +records) and requires the model to use `SoftDeletes`. Filtering by a relation aggregate uses the `orders->count()` / `orders->exists()` path syntax (`Filter::make('orders->count()')`). It is applied as a `WHERE` over the aggregate subquery via Eloquent's @@ -74,6 +77,40 @@ public function table(Table $table): Table fall back to `Filter::apply()`), and inherit authorization, **indicator chips** (removable, alongside panel chips), and **query-string persistence** (`Table::queryString()`, under a `col_` URL parameter). +### Search syntax + +`Table::searchable()` matches the whole term as one substring across every searchable column +(`LIKE`/`ILIKE`), and a `%` or `_` the user types is escaped rather than acting as a wildcard. +Richer syntax is **opt-in per table** through `Table::search()` — nothing is interpreted unless +asked for, so an unconfigured table behaves exactly as before: + +```php +use NyonCode\WireCore\Core\Query\Search\SearchConfig; + +$table->search(fn (SearchConfig $s) => $s + ->tokenize() // spaces = AND; each word ORs across all columns; "quoted phrase" stays whole + ->ranges() // >100, >=100, <10, <=10, =42, 10..20, 10.., ..20, 2026-01-01..2026-03-31 + ->wildcards() // nov* / a?b +); +``` + +Structured codes (`8866 01`, `8866 02` — shared series, zero-padded tail) get `Column::searchAs('code')`: +typing `8866 01..08` becomes one `BETWEEN '8866 01' AND '8866 08'`. The space inside the code also +splits the term, so the range carries the word typed before it and a code column completes both +bounds with it (write the series once); any other column ignores that word and reads `01..08` as the +plain range, so `praha 10..20` still works on the same table. The number must be stored padded and typed as stored +(`1..8` against stored `01 … 08` finds nothing); a range crossing a width boundary is completed — +`8866 50..100` reads as `050..100`. + +`tokenize()` is what makes a first name in one column and a surname in another match together. +`ranges()` only asks a column that can answer — the value type comes from the model's casts, or +from `Column::searchAs('numeric'|'date')` where a cast cannot speak for the column; a comparison +no column can answer is searched as the literal text typed, never as an empty group that matches +everything. A typed date means its whole span (`2026-01-31` the day, `2026-01` the month, `2026` +the year). `Column::searchable(['first_name', 'last_name'])` searches exactly the columns listed; +`Column::searchUsing(fn (Builder $q, string $term) => ...)` OR-combines with the planned columns +and receives one token at a time when tokenizing. + ### Relation managers A relationship-scoped table as a standalone Livewire component. Extend `RelationManagers\RelationManager`, @@ -204,7 +241,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. @@ -222,6 +259,7 @@ class pins `query()` to the owner record's relationship, so a subclass cannot wi rows × columns (× actions). Keep the per-row work cheap and lean on the levers the package already gives you: +- **A page size is what bounds a table's memory, and `'all'` removes it.** `Table::perPageOptions([10, 25, 50, 'all'])` adds a "show everything on one page" option (`Table::perPage('all')` makes it the default). It is stored as the integer `Table::PER_PAGE_ALL` (`-1`) — the word never survives configuration, because the select, the `per_page` query-string parameter and the query cache key all compare page sizes strictly as integers — and it is paginated by counting first, since a negative limit would give the paginator a negative page count. Deliberately **not** among the shipped `[10, 25, 50, 100]`: the host clamps any page size the table does not offer back to `perPage()`, which is the same guard that stops a forged `perPage: 500000`, so a table only reads its whole source into memory when it said `'all'` itself. There is **no** ceiling behind it (unlike `bulkMaxRecords()`), so it belongs on a table whose row count is known, not on one over an unbounded source. - **Defer off-screen tables.** `Table::lazy()` returns no rows and runs no query until the table scrolls into view (optional `->lazyPlaceholder(...)`). Use it for tables below the fold or in tabs. It defers the JS too: the table's Alpine bundles ship with the *deferred* render, @@ -233,16 +271,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/infolists/entries/color.blade.php b/packages/core/resources/views/infolists/entries/color.blade.php index ac19acb6..cb102daf 100644 --- a/packages/core/resources/views/infolists/entries/color.blade.php +++ b/packages/core/resources/views/infolists/entries/color.blade.php @@ -5,6 +5,7 @@ $spanClass = $field->getColumnSpanClass(); $value = $field->getFormattedState(); + $swatch = $field->getSwatch(); @endphp
@@ -23,8 +24,10 @@ title="{{ __('Copy') }}" @endif > - + @if($swatch !== null) + + @endif {{ $value }} @else 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') !!}
+ +
+ @if($field->isCollapsible()) + + @endif + + @if($field->isDeletable() && ($field->getMinItems() === null || $itemCount > $field->getMinItems())) + + @endif +
+ + +
+ @foreach($field->getItemSchema($index, $type ?? '') as $component) + @if($component->isVisible()) + {{ $component }} + @endif + @endforeach +
+ + @endforeach + + @if($field->isAddable() && ($field->getMaxItems() === null || $itemCount < $field->getMaxItems())) +
+ + + {{-- The picker is what makes this a builder rather than a repeater: + adding an item means choosing which block to add. --}} +
+ @foreach($blocks as $block) + + @endforeach +
+
+ @endif + diff --git a/packages/forms/resources/views/components/checkbox-list.blade.php b/packages/forms/resources/views/components/checkbox-list.blade.php index ccdff60f..f4c87590 100644 --- a/packages/forms/resources/views/components/checkbox-list.blade.php +++ b/packages/forms/resources/views/components/checkbox-list.blade.php @@ -14,6 +14,9 @@ @include('wire-forms::partials.field-wrapper-start') +@if($field->isSegmented() || $field->isButtons()) + @include('wire-forms::partials.checkbox-list-choices', ['field' => $field, 'wireAttr' => $wireAttr, 'options' => $options]) +@else
+@endif @include('wire-forms::partials.field-wrapper-end') diff --git a/packages/forms/resources/views/components/checkbox.blade.php b/packages/forms/resources/views/components/checkbox.blade.php index eada0687..7624edb3 100644 --- a/packages/forms/resources/views/components/checkbox.blade.php +++ b/packages/forms/resources/views/components/checkbox.blade.php @@ -11,6 +11,7 @@ id="{{ $field->getId() }}" data-testid="form-checkbox-{{ $field->getStatePath() }}" {{ $wireAttr }}="{{ $field->getWireModelAttribute() }}" + {!! $field->getExtraInputAttributesHtml() !!} @if($field->isDisabled()) disabled @endif @if($field->isRequired()) required @endif class="mt-0.5 rounded border-gray-300 text-primary-600 shadow-sm focus:ring-primary-500 transition-colors duration-150 dark:bg-gray-800 dark:border-gray-600" diff --git a/packages/forms/resources/views/components/color-picker.blade.php b/packages/forms/resources/views/components/color-picker.blade.php index a9f3220a..307012f4 100644 --- a/packages/forms/resources/views/components/color-picker.blade.php +++ b/packages/forms/resources/views/components/color-picker.blade.php @@ -95,6 +95,7 @@ class="flex items-center gap-2" > getExtraInputAttributesHtml() !!} id="{{ $field->getId() }}" data-testid="form-color-{{ $field->getStatePath() }}" x-model="hex" diff --git a/packages/forms/resources/views/components/date-time-picker.blade.php b/packages/forms/resources/views/components/date-time-picker.blade.php index 220dcde6..d282d962 100644 --- a/packages/forms/resources/views/components/date-time-picker.blade.php +++ b/packages/forms/resources/views/components/date-time-picker.blade.php @@ -341,6 +341,7 @@ class="relative"
getExtraInputAttributesHtml() !!} id="{{ $fieldId }}" :value="displayValue" @click="open = !open" data-testid="form-datetime-{{ $field->getStatePath() }}-trigger" diff --git a/packages/forms/resources/views/components/hidden.blade.php b/packages/forms/resources/views/components/hidden.blade.php index c1223f7a..89f383f1 100644 --- a/packages/forms/resources/views/components/hidden.blade.php +++ b/packages/forms/resources/views/components/hidden.blade.php @@ -1,5 +1,6 @@ @php /** @var \NyonCode\WireForms\Components\Hidden $field */ @endphp getExtraInputAttributesHtml() !!} type="hidden" id="{{ $field->getId() }}" wire:model{{ $field->getWireModelModifier() ? '.' . $field->getWireModelModifier() : '' }}="{{ $field->getWireModelAttribute() }}" diff --git a/packages/forms/resources/views/components/repeater-table.blade.php b/packages/forms/resources/views/components/repeater-table.blade.php new file mode 100644 index 00000000..6e50463a --- /dev/null +++ b/packages/forms/resources/views/components/repeater-table.blade.php @@ -0,0 +1,119 @@ +{{-- Repeater in its table layout: one column per schema field, headed once. + Same state paths, add/remove/reorder wiring and Livewire methods as the card + layout — only the arrangement differs. --}} +@php + use NyonCode\WireForms\Components\Repeater; + + assert($field instanceof Repeater); + + $statePath = $field->getStatePath(); + $items = data_get($this, $statePath, []); + if (!is_array($items)) $items = []; + $itemCount = count($items); + $headings = $field->getTableHeadings(); +@endphp + +
+ @if($field->getLabel()) + + @endif + +
+ + + + @if($field->isReorderable()) + + @endif + + @foreach($headings as $heading) + + @endforeach + + @if($field->isDeletable()) + + @endif + + + + isReorderable()) + x-sortable + x-on:sort-end.camel=" + let sorted = []; + $el.querySelectorAll('[x-sortable-item]').forEach(el => { + sorted.push(parseInt(el.getAttribute('x-sortable-item'))); + }); + $wire.reorderRepeaterItems('{{ $statePath }}', sorted); + " + @endif + > + @foreach($items as $index => $item) + + @if($field->isReorderable()) + + @endif + + @foreach($field->getItemSchema($index) as $component) + @php + // The column header already names the field, so a + // per-cell label would repeat it on every row. + if (method_exists($component, 'hiddenLabel')) { + $component->hiddenLabel(); + } + @endphp + + @endforeach + + @if($field->isDeletable()) + + @endif + + @endforeach + + @if($items === []) + + + + @endif + +
{{ __('Reorder') }} + {{ $heading }} + {{ __('Remove') }}
+ + + @if($component->isVisible()){{ $component }}@endif + + @if($field->getMinItems() === null || $itemCount > $field->getMinItems()) + + @endif +
+ {{ __('No items yet') }} +
+
+ + @if($field->isAddable() && ($field->getMaxItems() === null || $itemCount < $field->getMaxItems())) + + @endif +
diff --git a/packages/forms/resources/views/components/select.blade.php b/packages/forms/resources/views/components/select.blade.php index 34ca9537..f77752f9 100644 --- a/packages/forms/resources/views/components/select.blade.php +++ b/packages/forms/resources/views/components/select.blade.php @@ -61,11 +61,13 @@ // click (afterStateUpdated, visibleWhen siblings), not on the next // unrelated roundtrip. 'live' => $field->isLive() || $field->isLiveOnBlur(), + 'extraInputAttributes' => $field->getExtraInputAttributesHtml(), ]) @else getExtraInputAttributesHtml() !!} type="range" x-model.number="value" data-testid="form-slider-{{ $field->getStatePath() }}" diff --git a/packages/forms/resources/views/components/text-input.blade.php b/packages/forms/resources/views/components/text-input.blade.php index aa646e3f..6ccebc1a 100644 --- a/packages/forms/resources/views/components/text-input.blade.php +++ b/packages/forms/resources/views/components/text-input.blade.php @@ -45,6 +45,7 @@ @endif id="{{ $field->getId() }}" {{ $wireAttr }}="{{ $field->getWireModelAttribute() }}" + {!! $field->getExtraInputAttributesHtml() !!} @if($field->getPlaceholder()) placeholder="{{ $field->getPlaceholder() }}" @endif diff --git a/packages/forms/resources/views/components/textarea.blade.php b/packages/forms/resources/views/components/textarea.blade.php index 519cbdfe..cf2fdafc 100644 --- a/packages/forms/resources/views/components/textarea.blade.php +++ b/packages/forms/resources/views/components/textarea.blade.php @@ -13,6 +13,7 @@