softr-vibe-coding 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to this skill are documented here. Versions follow [Semantic
4
4
 
5
5
  Entries from 1.3.1 onward are generated automatically from git commit subjects between version bumps (see `.github/workflows/publish.yml`). Entries before 1.3.1 were backfilled by hand from the existing commit history.
6
6
 
7
+ ## [2.8.0] - 2026-09-09
8
+ - Dropdowns searchable by default, reset controls only when dirty, create → open the new record
9
+
10
+ ## [2.7.0] - 2026-09-09
11
+ - Add the searchable dropdown pattern (Combo) as a reference
12
+
7
13
  ## [2.6.0] - 2026-09-09
8
14
  - Drag-to-reorder: the pattern, the shadow-DOM hit-test trap, and two affordance rules
9
15
 
package/README.md CHANGED
@@ -220,9 +220,16 @@ softr-vibe-coding/
220
220
  │ │ # MCP/CLI install (@latest npx + browser step),
221
221
  │ │ # extract → poll → findings → generate → write
222
222
  │ │ # flow, DESIGN.md anatomy, drift QA
223
- └── quick-reference.md # Syntax cheat sheet
224
- # Imports, hook signatures, mutation shapes,
225
- # field mapping, component skeleton
223
+ ├── quick-reference.md # Syntax cheat sheet
224
+ # Imports, hook signatures, mutation shapes,
225
+ # field mapping, component skeleton
226
+ │ └── searchable-dropdown.md # THE dropdown pattern for blocks
227
+ │ # why native <select> and shadcn <Select> both
228
+ │ # break in the shadow DOM, composedPath()
229
+ │ # click-outside, A-Z inside the component,
230
+ │ # multi-token filter, searchable BY DEFAULT
231
+ │ # (bare = click-only; searchable={false} only
232
+ │ # for a fixed enum being set — Sep 10 2026)
226
233
 
227
234
  ├── tools/ # Bundled CLI scripts (run, not read)
228
235
  │ ├── get-airtable-base # Full Airtable base schema export (bash + jq)
package/SKILL.md CHANGED
@@ -85,6 +85,7 @@ You generate complete, production-ready Softr Vibe Coding blocks as TypeScript R
85
85
  - `useRecordUpdate` payload is `{ recordId, fields: { ... } }` — nested. `useRecordCreate` payload is **flat** (no `fields` wrapper). The two shapes are asymmetric by design (verified live 2026-08-25)
86
86
  - Sequential multi-row saves use `await hook.mutateAsync(...)` per row, in order, with stop-on-failure + retry state — `mutateAsync` is fully supported on the current platform (verified 2026-08-25; the old ".mutate() only" Action-parser rule is gone — see [datasources/writing.md](datasources/writing.md)). Independent writes to **different tables** may run in parallel via `Promise.all`; drag/reassign UIs should be optimistic with an Undo toast — see [writing.md → Parallel writes across tables](datasources/writing.md#parallel-writes-across-tables-the-one-sanctioned-parallelism)
87
87
  - No hardcoded domains in links -- use relative paths (`/page?recordId=...`); same-page anchors written relative too (`/#section`)
88
+ - **No `<select>` and no shadcn `<Select>`** — both break inside a block's shadow DOM (native hands the list to the OS; shadcn portals outside the shadow root and arrives unstyled). Use the `Combo` pattern in [references/searchable-dropdown.md](references/searchable-dropdown.md) — **searchable by default** for every framed filter or form field whatever the option count; `bare` inline editors are click-only; `searchable={false}` only on a short fixed enum the user is setting (a status, a location, a group-by)
88
89
  - Static block: no hardcoded user-visible copy — every string/image/link is an editable setting (see [references/editable-settings.md](references/editable-settings.md#granularity-doctrine-settings-first-static-blocks))
89
90
  - Array-setting rows keyed by **index**, never by a builder-editable field value
90
91
  - Media settings that may start empty (`src: ""`) gated with a conditional render or placeholder — never an unconditional `<img src={setting.src}>`
@@ -167,6 +168,7 @@ For advanced patterns beyond data fetching, load the relevant reference when the
167
168
  | Embedding third-party libraries with their own CSS (Leaflet, Mapbox, TinyMCE, Quill, FullCalendar) | [references/advanced-integrations.md](references/advanced-integrations.md) |
168
169
  | Debugging a broken block, checking patterns before delivery, full violation catalog | [references/anti-patterns.md](references/anti-patterns.md) |
169
170
  | Quick syntax check — import paths, hook signatures, mutation call shapes, field mapping | [references/quick-reference.md](references/quick-reference.md) |
171
+ | Any **dropdown / picker / combobox** in a block — why shadcn `<Select>` and native `<select>` both fail inside the shadow DOM, the `composedPath()` click-outside, sorting A→Z inside the component, multi-token filtering, **searchable by default** regardless of option count (`bare` inline editors click-only; `searchable={false}` only for a short fixed enum being set), the `bare` inline-editor variant | [references/searchable-dropdown.md](references/searchable-dropdown.md) |
170
172
  | Small reusable patterns — `localStorage` cross-page state, clipboard copy button | [references/common-patterns.md](references/common-patterns.md) |
171
173
  | Writing Airtable Automation Scripts / Scripting Extension scripts / Airtable formulas — companion to Softr blocks for cross-table cascades and computed values | [references/airtable-automations.md](references/airtable-automations.md) |
172
174
  | The official **Softr MCP server** — Softr DB schema + full record/table/field/database CRUD (deletes included), field-level browsing of connected Airtable / Google Sheets / Notion / Supabase integrations, **creating, editing, versioning, and deploying Vibe Coding blocks directly** (`get_vibe_coding_docs`, `create_vibe_coding_block`, ...), app management/scaffolding, the **Softr Workflows** suite (26 tools, 418-node catalog), and **per-application MCP servers** | [references/softr-mcp.md](references/softr-mcp.md) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "softr-vibe-coding",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Claude Code skill for generating production-ready Softr Vibe Coding blocks (JSX). Installs into ~/.claude/skills/ and auto-updates on each Claude Code session.",
5
5
  "bin": {
6
6
  "softr-vibe-coding": "./bin/cli.js"
@@ -15,6 +15,8 @@ Small reusable patterns that come up across Vibe Coding blocks but don't warrant
15
15
  - [Decorative Background Blobs (Editorial Layering)](#decorative-background-blobs-editorial-layering)
16
16
  - [Dot-Separated Inline List](#dot-separated-inline-list)
17
17
  - [Drag-to-Reorder Rows](#drag-to-reorder-rows)
18
+ - [Create → open](#create--open)
19
+ - [Clickable Row with an Inner Link](#clickable-row-with-an-inner-link)
18
20
 
19
21
  ## Cross-Page State with localStorage + URL Parameters
20
22
 
@@ -392,3 +394,123 @@ on failure, clear the override and refetch, because a half-applied renumber is w
392
394
  **Only gate the drag on permissions, not on a sort mode.** If the list has an alternative sort, let the
393
395
  drag switch to manual order rather than disabling the handle — see the disabled-control note in
394
396
  [../ui-ux-guidelines.md](../ui-ux-guidelines.md#26-finishing-touches).
397
+
398
+ ## Create → open
399
+
400
+ When the user creates a record they are about to work on — a new item, a new saved list — land them on it. They made it in order to fill it in, so closing the dialog back into the list and leaving them to find the row they just made is a step nobody asked for. Leo, 2026-09-10: "when you create a new item anywhere in the interface, once it's created, you need to open the item details page of the item you just created … when you create a new list, once it's created, you need to open this list details, as you'll likely want to fill the list straight after."
401
+
402
+ `useRecordCreate`'s `onSuccess` receives the created record, but the wrapper around its id has moved before. Read it defensively — a miss is survivable, a crash is not:
403
+
404
+ ```jsx
405
+ /* What useRecordCreate hands onSuccess is the created record. The exact wrapper has moved
406
+ before, so read the id defensively — a miss is survivable (the row still gets created and
407
+ the list still refetches, the user just picks it) but a crash is not. */
408
+ function getCreatedId(created) {
409
+ if (!created) return "";
410
+ if (typeof created === "string") return created;
411
+ if (created.id) return String(created.id);
412
+ if (created.recordId) return String(created.recordId);
413
+ if (created.record && created.record.id) return String(created.record.id);
414
+ if (created.data && created.data.id) return String(created.data.id);
415
+ return "";
416
+ }
417
+ ```
418
+
419
+ Then navigate on success — and only when there is an id to navigate to:
420
+
421
+ ```jsx
422
+ createItem.mutate(payload, {
423
+ onSuccess: function (created) {
424
+ setBusy(false);
425
+ var id = getCreatedId(created);
426
+ if (id) {
427
+ window.location.href = "/item?recordId=" + encodeURIComponent(id);
428
+ return;
429
+ }
430
+ // The record exists; we just can't address it. Stay put, refetch, say so.
431
+ setOpen(false);
432
+ itemsResult.refetch();
433
+ toast.success('"' + name + '" created — it is in the list below.');
434
+ },
435
+ onError: function () {
436
+ setBusy(false);
437
+ },
438
+ });
439
+ ```
440
+
441
+ **Never navigate with an empty id.** `/item?recordId=` would render the detail page's own "record not found" state, and the user would conclude the create failed when it did not. The fallback keeps them where the new row will appear once the refetch lands.
442
+
443
+ **Keep the path relative.** `/item?recordId=…`, never the app's domain — the same block runs on the preview URL and on the custom domain (SKILL.md's no-hardcoded-domains rule).
444
+
445
+ **Where the id comes from is not always where it goes.** For an `onCreate` inside a Combo — a vendor typed into a picker — the created id is patched into the form and the user keeps editing; there is nothing to open. See [searchable-dropdown.md](searchable-dropdown.md#variants-worth-having). Navigation is for records that have their own page and that the user will work on next.
446
+
447
+ ## Clickable Row with an Inner Link
448
+
449
+ An index table exists to get the user into a record, so the hit area is the whole row. But a row is not a link: cmd-click, middle-click, right-click → "Copy link" and hover-to-see-the-URL all come from a real `<a>`. Keep both — the row handler for the plain click, an anchor on the name for everything the browser does with anchors — and make sure they do not fight:
450
+
451
+ ```jsx
452
+ function ProjectRow(props) {
453
+ var row = props.row;
454
+ var href = "/project?recordId=" + encodeURIComponent(row.id);
455
+
456
+ // The row handler bows out the moment a modifier is held — those clicks belong to the
457
+ // anchor (new tab, new window, select).
458
+ function openRow(event) {
459
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
460
+ if (typeof window !== "undefined") window.location.href = href;
461
+ }
462
+
463
+ return (
464
+ <TableRow className="cursor-pointer hover:bg-[#FFF7EF]" onClick={openRow}>
465
+ <TableCell>
466
+ <a
467
+ href={href}
468
+ className="hover:underline"
469
+ onClick={function (e) {
470
+ e.stopPropagation(); // the anchor navigates itself; without this the row handler ALSO fires
471
+ }}
472
+ >
473
+ {row.name}
474
+ </a>
475
+ </TableCell>
476
+ {/* … */}
477
+ </TableRow>
478
+ );
479
+ }
480
+ ```
481
+
482
+ Two things go wrong without the two guards. Without `stopPropagation` on the anchor, a cmd-click on the name opens the new tab AND navigates the current one, because the row handler runs too. Without the modifier check on the row, a cmd-click on the cell padding (next to the anchor, not on it) navigates the current tab — the opposite of what the user asked for.
483
+
484
+ Any *other* control inside the row — an inline status chip, a checkbox, a menu — needs `stopPropagation` on its own handler as well, or every click on it opens the record. (The drag handle in [Drag-to-Reorder Rows](#drag-to-reorder-rows) already does this.)
485
+
486
+ ### Keyboard picker over the same rows
487
+
488
+ Where a table has a quick-find box, wire it like a picker so the keyboard alone gets into a record: the box takes focus, ↑ ↓ move a highlight, Enter opens the highlighted row, Escape clears the query (and the highlight with it).
489
+
490
+ ```jsx
491
+ var [active, setActive] = useState(0);
492
+ // Filtering shrinks the list under the highlight, so clamp rather than index past the end.
493
+ var activeIdx = rows.length === 0 ? 0 : Math.min(active, rows.length - 1);
494
+
495
+ function onKeyDown(e) {
496
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
497
+ e.preventDefault();
498
+ var step = e.key === "ArrowDown" ? 1 : -1;
499
+ setActive(function (a) {
500
+ return Math.min(Math.max(a + step, 0), Math.max(rows.length - 1, 0));
501
+ });
502
+ } else if (e.key === "Enter") {
503
+ e.preventDefault();
504
+ var hit = rows[activeIdx];
505
+ if (hit) window.location.href = "/project?recordId=" + encodeURIComponent(hit.id);
506
+ } else if (e.key === "Escape") {
507
+ e.preventDefault();
508
+ setQuery("");
509
+ setActive(0);
510
+ }
511
+ }
512
+
513
+ <input autoFocus value={query} onChange={function (e) { setQuery(e.target.value); setActive(0); }} onKeyDown={onKeyDown} … />
514
+ ```
515
+
516
+ Paint the highlighted row with the same colour the mouse hover gets (`data-active="true"` + `bg-[#FFF7EF]`) and scroll it into view when it moves (`querySelector('[data-active="true"]').scrollIntoView({ block: "nearest" })` in a `useEffect` on `activeIdx`) — the Combo in [searchable-dropdown.md](searchable-dropdown.md#one-flat-row-list-for-the-keyboard) does the same. Reset `active` to 0 whenever the query changes: the old index points at a row that may no longer be in the list. `autoFocus` is right only when the block *is* the page's reason to exist — an index page whose first act is always a search; on a page with content above the table, a focus steal scrolls the page to the box.
@@ -0,0 +1,179 @@
1
+ # The searchable dropdown (`Combo`)
2
+
3
+ **Use this instead of shadcn's `<Select>` and instead of a native `<select>`, always.**
4
+ A Vibe Coding block renders inside a **shadow DOM**, and that one fact rules out both of
5
+ the obvious choices:
6
+
7
+ | Option | Why it fails in a block |
8
+ |---|---|
9
+ | Native `<select>` | Hands the list to the OS. No keyword filter, none of your styling, and on macOS it paints a grey slab over the page. Fine for 5 options, unusable at 90. |
10
+ | shadcn `<Select>` / `<Command>` | **Portals to `document.body`, which is outside the block's shadow root**, so the styles arrive stripped. It also cannot be searched. |
11
+ | `Combo` (below) | Local DOM, brand-styled, keyword filter, A→Z, keyboard, create-new, drop-up. |
12
+
13
+ Copy the component into the block. A Vibe block is one self-contained file — there is no
14
+ shared module to import, so each block carries its own copy. Keep one canonical copy in the
15
+ project (e.g. `Assets/Softr App/Shared/combo.jsx`) and port changes from there.
16
+
17
+ ## The three things that will bite you
18
+
19
+ **1. Click-outside must use `composedPath()`, not `contains()`.**
20
+ By the time a click reaches `document`, the shadow DOM has *retargeted* its `target` to the
21
+ shadow **host**. So the usual `root.contains(e.target)` test calls the panel's own rows
22
+ "outside" and closes the panel before the click can land on one — the dropdown appears to
23
+ ignore every selection.
24
+
25
+ ```jsx
26
+ function onDown(e) {
27
+ var root = rootRef.current;
28
+ if (!root) return;
29
+ var path = e.composedPath ? e.composedPath() : [];
30
+ for (var i = 0; i < path.length; i++) {
31
+ if (path[i] === root) return; // inside — leave it open
32
+ }
33
+ if (path.length === 0 && e.target && root.contains(e.target)) return; // fallback
34
+ setOpen(false);
35
+ }
36
+ document.addEventListener("mousedown", onDown, true);
37
+ ```
38
+
39
+ **2. Define it at MODULE scope, never inside `Block()`.**
40
+ A component redefined inside `Block()` gets a fresh identity every render, so React unmounts
41
+ and remounts the `<input>` and the search box loses focus after one keystroke. This is the
42
+ single most common Vibe Coding bug and it looks like a platform fault.
43
+
44
+ **3. `onMouseDown` on a row must `preventDefault()`.**
45
+ Otherwise focus leaves the search input before the click resolves.
46
+
47
+ ## Sort A→Z *inside* the component
48
+
49
+ Sorting at the call site gets forgotten. Do it in the component, with an opt-out:
50
+
51
+ ```jsx
52
+ function comboSortLabels(a, b) {
53
+ return String(a.label || "").localeCompare(String(b.label || ""), undefined, {
54
+ sensitivity: "base", // case-insensitive
55
+ numeric: true, // "Item 2" before "Item 10" — a plain compare gets this backwards
56
+ });
57
+ }
58
+ ```
59
+
60
+ `autoSort` defaults to **true**. Pass `autoSort={false}` only where the given order *is* the
61
+ meaning — a pipeline of statuses (Not ordered → Ordered → … → Delivered), a physical
62
+ journey, a curated short list. Alphabetising a workflow puts "Damaged" first and buries the
63
+ starting state in the middle, which is worse than not sorting at all.
64
+
65
+ Grouped lists sort **within** a group and keep first-seen group order, so a "this project's
66
+ rooms first, everyone else after" grouping survives being alphabetised.
67
+
68
+ ## Filter on every token, in any order
69
+
70
+ ```jsx
71
+ var tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
72
+ var filtered = options.filter(function (o) {
73
+ if (tokens.length === 0) return true;
74
+ var hay = String(o.label || "").toLowerCase();
75
+ for (var i = 0; i < tokens.length; i++) {
76
+ if (hay.indexOf(tokens[i]) === -1) return false;
77
+ }
78
+ return true;
79
+ });
80
+ ```
81
+
82
+ So `rural art` finds *The Rural Art Company*. A single `indexOf(query)` would not.
83
+
84
+ ## Searchable by default — not by option count
85
+
86
+ ```jsx
87
+ var searchable = props.onCreate
88
+ ? true
89
+ : props.searchable === true || (props.searchable !== false && !props.bare);
90
+ ```
91
+
92
+ Every **framed** dropdown — a table filter, a form field — gets the search box, whatever the
93
+ option count. `bare` inline editors are click-only. `onCreate` forces the box on, because the
94
+ typed text is what gets created.
95
+
96
+ **Why not a threshold (changed 2026-09-10).** The first version of this component showed the
97
+ search box at 8+ options. On one filter row that made "All projects" (2 options) a plain
98
+ picker and "All vendors" (99) a type-to-filter, side by side, and the client read the
99
+ difference as a bug — the project filter looked like the broken one. The count of options is
100
+ *data*; whether a dropdown is searchable is *design*, and design must not change under the
101
+ user's hands the day a third project is added. Leo, 2026-09-10: type-to-filter is the default
102
+ for table filters, even a two-option one, so the desk never has to check whether *this*
103
+ dropdown is the searchable kind. The other half of the same instruction: a status update in a
104
+ table row is a click, not a search — which is what `bare` already is.
105
+
106
+ ### Which dropdowns get a search box
107
+
108
+ | Dropdown | Search box | How |
109
+ |---|---|---|
110
+ | Table filter — project, vendor, status, room, any of them | yes | default |
111
+ | Open-ended or data-driven picker in a form — vendor, project, room, item, purchase order, saved list | yes | default |
112
+ | Short **fixed** enum the user is **setting** — a status, a location, a purpose, a group-by / sort-by | no | `searchable={false}` |
113
+ | Inline `bare` editor in a table cell — a status chip, a location string | no | `bare` is click-only |
114
+ | Anything with `onCreate` | yes, always | forced |
115
+
116
+ The third row is the only place `searchable={false}` belongs: four fixed options the user is
117
+ choosing *between*, where a search box is noise. A *filter* on that same status field still
118
+ gets the box — filtering and setting are different jobs, and the filter row is exactly where
119
+ the rule has to hold uniformly. If a wrapper sits between the call site and `Combo`
120
+ (`ColumnFilter`, `SelectInput`, …), thread `searchable={props.searchable}` through it rather
121
+ than reaching past the wrapper.
122
+
123
+ ## One flat row list for the keyboard
124
+
125
+ Build `rows` as a single array — the optional *clear* row, then the filtered options, then
126
+ the optional *create* row — so arrow-key navigation has one index to walk. Clamp the active
127
+ index (`Math.min(active, rows.length - 1)`): filtering shrinks the list under the highlight.
128
+
129
+ ## Drop up near the fold
130
+
131
+ ```jsx
132
+ var r = rootRef.current.getBoundingClientRect();
133
+ var below = window.innerHeight - r.bottom;
134
+ setDropUp(below < 300 && r.top > below);
135
+ ```
136
+
137
+ A filter row near the bottom of the viewport otherwise opens into nothing.
138
+
139
+ ## Variants worth having
140
+
141
+ - **`bare`** — inline-editor mode. No border, no fill; `triggerContent` (a status chip, a
142
+ cell's text) *is* the trigger. Lets a table cell become editable without every row growing
143
+ a form control. Click-only: the search box is off in this variant, because a status update
144
+ in a row is a click, not a search (Leo, 2026-09-10).
145
+ ⚠ If any column width in your table is derived from the trigger's chrome, keep the
146
+ chevron the SAME size in both variants. Shrinking it in `bare` silently changes those
147
+ widths in a different file.
148
+ - **`triggerStyle`** — merged over the defaults, for a trigger that is part of the design
149
+ (a chip painted in its own status colour) rather than a plain field.
150
+ - **`onCreate(text)`** — offers `Add "<typed>"` when nothing matches. If creating the record
151
+ and *linking* it happen at different times, say so in the toast: the two halves landing
152
+ separately is exactly what gets reported as "it didn't save".
153
+ - **`emptyLabel`** — a row that clears the selection. Call sites that pass a leading
154
+ `{ value: "" }` "any / none" option should have it lifted into `emptyLabel` rather than
155
+ rendered twice.
156
+
157
+ ## The one shadow you are allowed
158
+
159
+ DESIGN-system rules that ban shadows are about cards. A white panel floating over a white
160
+ card with only a hairline between them reads as part of the card, so the menu gets a shadow:
161
+
162
+ ```js
163
+ boxShadow: "0 10px 30px rgba(0, 0, 0, 0.16)"
164
+ ```
165
+
166
+ Everything else — the trigger, the card, the rows — stays flat.
167
+
168
+ ## Checklist before shipping a dropdown
169
+
170
+ - [ ] Defined at module scope
171
+ - [ ] `composedPath()` click-outside
172
+ - [ ] Sorted A→Z inside the component, with `autoSort={false}` only where order is meaning
173
+ - [ ] Multi-token filter
174
+ - [ ] Searchable by default; `bare` is click-only; `searchable={false}` only on a short fixed enum the user is setting
175
+ - [ ] Keyboard: ↑ ↓ Enter Esc Tab, active row scrolled into view
176
+ - [ ] `aria-haspopup="listbox"`, `aria-expanded`, `role="listbox"` / `role="option"`,
177
+ `aria-selected`, and an `aria-label` on the trigger
178
+ - [ ] Loading and empty states (`"Nothing matches that."`)
179
+ - [ ] No `@/components/ui/select` import anywhere in the file
@@ -342,6 +342,8 @@ Vestibular disorders affect ~35% of adults over 40. Always respect `prefers-redu
342
342
  - **Never disable without explaining why.** Show tooltip or helper text.
343
343
  - **Destructive buttons** must: use `variant="destructive"`, be positioned away from primary actions, trigger confirmation for irreversible actions.
344
344
  - **DO NOT make every button primary.** Use ghost, outline, and secondary styles for hierarchy.
345
+ - **A reset / undo control renders only when there is something to reset.** "Reset column widths" appears the moment a column has been dragged and is gone again once the widths are back at their defaults — not disabled as a placeholder, not `visibility: hidden` holding its space. A reset for something nobody changed is a control that can only ever do nothing, and a greyed one reads as broken (see [§26](#26-finishing-touches)). Mount it in a slot whose appearance cannot reflow its neighbours — a right-aligned end slot of the toolbar row, never the middle of a wrapping flex row, where mounting it would re-wrap the line on the first drag and un-wrap it on reset. (Leo, 2026-09-10: "ANY reset column width should ONLY show when you change the column width.")
346
+ - **After creating a record the user will work on next, open it.** A new item, a new saved list — the user made it in order to fill it in, so `onSuccess` navigates to that record's detail page instead of closing the dialog back into the list and leaving them to find the row they just made. Read the created id defensively with `getCreatedId()` ([common-patterns.md → Create → open](references/common-patterns.md#create--open)); when the id cannot be read, stay put, refetch and toast — never navigate to a URL with an empty id. (Leo, 2026-09-10: "when you create a new item anywhere in the interface, once it's created, you need to open the item details page of the item you just created … when you create a new list, once it's created, you need to open this list details, as you'll likely want to fill the list straight after.")
345
347
 
346
348
  ---
347
349