@jsenv/navi 0.29.156 → 0.29.158

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/docs/resource.md CHANGED
@@ -119,6 +119,64 @@ screen until the rows are asked for again. Give the screen its own way to ask
119
119
  It reads a collection, so it lives on the resource itself (or on a
120
120
  `withParams()` of it), not on a relation.
121
121
 
122
+ ## Searching the same collection
123
+
124
+ A screen showing a collection asks a second question about it soon enough: the
125
+ list draws a page, a search box goes looking in the whole base for what the page
126
+ does not hold. Same entity, same ids — so it is the same `GET_MANY`, with one
127
+ more param:
128
+
129
+ ```js
130
+ const USER = resource("user", {
131
+ GET_MANY: ({ scope, search }, { signal }) =>
132
+ fetchJson(`/users?${new URLSearchParams({ scope, search })}`, { signal }),
133
+ });
134
+
135
+ const pageAction = USER.GET_MANY.bindParams({ scope: "seatable" });
136
+ const searchAction = USER.GET_MANY.bindParams({
137
+ scope: "seatable",
138
+ search: searchSignal,
139
+ });
140
+ ```
141
+
142
+ Not a resource of its own, and not a `createAction` sitting beside the resource.
143
+ Those answer with **detached copies**: the user a search found is a different
144
+ object from the same user everywhere else on screen — it does not follow an
145
+ update, and a mutation writes to the one it is not. The resource's own
146
+ `GET_MANY` upserts what comes back like any other read, so one object per user
147
+ answers both questions.
148
+
149
+ **Two `GET_MANY` on one resource do not compete.** Each bound instance keeps its
150
+ own array of ids and resolves it against the shared store, so answering the
151
+ search leaves the page's list as it was — clear the search box and every row it
152
+ had is still there. ["A `*_MANY` callback replaces the collection
153
+ wholesale"](#when-the-backend-answers-a-sub-route-with-the-whole-parent), below,
154
+ is about the rows the store keeps for one owner — not about what some other
155
+ action reading the same resource is showing.
156
+
157
+ It is `bindParams`, not `withParams`: a `withParams()` carves an autorerun scope
158
+ (a POST at the root does not reach into it), and a word someone typed is not a
159
+ scope — the search must refresh after a write like the list does
160
+ ([resource_with_params.md](./resource_with_params.md)).
161
+
162
+ The lifecycle a search wants comes with being an action: `loading` while the
163
+ word is in flight, the run before it aborted through its `signal`, typing
164
+ debounced by the effect driving it —
165
+ `actionRunEffect(searchAction, () => …, { debounce: 300 })` where the app owns
166
+ the request, a `stateSignal` when the word is in the address. A word already
167
+ asked is answered by the instance that asked it, with no second request
168
+ ([actions.md](./actions.md#reading-an-action)).
169
+
170
+ What stays the app's is what to KEEP of a search once the word changes — the
171
+ rows someone picked out of it and expects to still see under the next word.
172
+ Keep their **ids**, not the objects: an object is a snapshot, and it stops
173
+ following its row the moment anything writes to it. `RESOURCE.useAllByIds(ids)`
174
+ reads those rows as they are now — a kept id turned back into something that
175
+ stays fresh, and the ids the store no longer holds simply drop out.
176
+ `RESOURCE.useById(id)` is the singular, `RESOURCE.useArray()` the whole store;
177
+ all three subscribe the render that calls them, and none is a hook (a `.map()`
178
+ over them is fine).
179
+
122
180
  ## Relations: pick one of the four methods
123
181
 
124
182
  A backend sub-route (`/games/:id/candidates`, `/games/:id/candidates/:userId/seen`)
@@ -282,7 +340,9 @@ This is the common REST shape, and it is the reason `op` dispatch feels
282
340
  attractive: a full-parent response absorbs into a parent `PATCH` with no
283
341
  thinking. Model the relation anyway and absorb the response in the callback —
284
342
  `resource()` gives a plural callback for exactly this: **any `*_MANY` callback
285
- replaces the collection wholesale**.
343
+ replaces the collection wholesale** — the rows the store keeps for that owner,
344
+ not what some other action reading the same resource is showing (see
345
+ [Searching the same collection](#searching-the-same-collection)).
286
346
 
287
347
  ```js
288
348
  const GAME_CANDIDATES = GAME.scopedMany("candidates", {
@@ -340,33 +400,53 @@ graph: what used to be refreshed by the parent's own response is now refreshed
340
400
  only by these rules. When a parent field genuinely depends on a child mutation,
341
401
  say so with `dependencies` rather than relying on a rerun that will not happen.
342
402
 
343
- ## Binding params instead of wrapping in an arrow
403
+ ## A function calling the verb, or the instance
344
404
 
345
- Every action exposes `bindParams()`, which returns an action instance with its
346
- own state. Pass that instance to a component:
405
+ Both forms are real, and what separates them is narrower than it looks:
347
406
 
348
407
  ```jsx
349
- // ✓ loading, error and disabled states come for free
408
+ // a function that calls the verb — the nominal form
409
+ <Button action={() => GAME_CANDIDATES.POST({ id: game.id, user_id })}>
410
+ Accept
411
+ </Button>
412
+
413
+ // the instance, bound to its params
350
414
  <Button action={GAME_CANDIDATES.POST.bindParams({ id: game.id, user_id })}>
351
415
  Accept
352
416
  </Button>
353
417
  ```
354
418
 
419
+ The function gives up almost nothing. Either way the button is busy while the
420
+ write runs, shows the error if it fails and goes back to what it held; either way
421
+ what runs inside is the same resource run, so the store is updated and the
422
+ actions this mutation invalidates rerun the same. The failure travels too — the
423
+ call fails, so the action around it fails
424
+ ([error_handling.md](./error_handling.md#what-a-failing-action-does)).
425
+
426
+ What `bindParams` adds, and it adds only this, is **identity**: everyone binding
427
+ the same params holds one instance, so a run started in one place is visible from
428
+ another. Reach for it when something else on screen has to see this very run — a
429
+ user row in a side explorer going loading while the main page renames that user,
430
+ two affordances for the same delete that must both go busy at once. Nothing else
431
+ in the stack answers that question: the store holds the data, not who is
432
+ currently writing it.
433
+
434
+ Where it does not pay, and most call sites are here: one affordance, in one
435
+ place, that nobody else watches. A `bindParams({ id })` written per row builds an
436
+ identity nothing reads, and a plain function says the gesture more directly.
437
+
438
+ What genuinely loses what a resource gives is neither of those two, but a
439
+ callback that goes around the verb:
440
+
355
441
  ```jsx
356
- // ✗ an inline arrow throws away the per-params action state
442
+ // ✗ no resource run at all: nothing updates the store, nothing is invalidated
357
443
  <Button
358
- action={async () => {
359
- await acceptCandidate(game.id, user_id);
360
- }}
444
+ action={() => fetch(`/games/${game.id}/candidates`, { method: "POST" })}
361
445
  >
362
446
  Accept
363
447
  </Button>
364
448
  ```
365
449
 
366
- The arrow works, but nothing tracks it: no per-row spinner, no error surfaced on
367
- the button that caused it, no deduplication of concurrent runs, no autorerun of
368
- the actions this mutation should invalidate. See [actions.md](./actions.md).
369
-
370
450
  Away from a component, where the run is a gesture and not something to render,
371
451
  an action is callable: `GAME.DELETE({ id })` is
372
452
  `GAME.DELETE.bindParams({ id }).rerun()`.
@@ -60,7 +60,11 @@ Two recommendations that matter more than the individual choices:
60
60
  - **Keep reciprocity.** The way back being the same movement reversed is what
61
61
  makes the map hold together; it is the default, and breaking it (a relation
62
62
  written for the exact way travelled wins over being the reverse of another)
63
- should answer a real asymmetry in the app, not a styling whim.
63
+ should answer a real asymmetry in the app, not a styling whim. Write the way
64
+ back only to say something DIFFERENT — another movement, or `"none"`. Written
65
+ with the same one, both crossings find their own relation and both play
66
+ forward, and the pair can never say "back" again, the back button included;
67
+ navi warns when it sees that pair defined.
64
68
 
65
69
  ## A default transition — when
66
70
 
@@ -103,7 +107,7 @@ and no other**:
103
107
  <Link
104
108
  route={GAME_ROUTE}
105
109
  routeParams={{ id }}
106
- transition={{ direction: "back" }}
110
+ routeTransition={{ direction: "back" }}
107
111
  >
108
112
  {badge.gameName}
109
113
  </Link>
package/docs/testid.md CHANGED
@@ -94,10 +94,28 @@ found by nothing, while the host is what the user focuses and what
94
94
  </div>
95
95
  ```
96
96
 
97
- Which role that host answers to is the control's own: a picker's host is an
98
- `<input>` (`textbox`), a button's is a `<button>`. A picker trigger is not
99
- `getByRole("button")`, whatever it is drawn like — target its name, or give it
100
- a `data-testid`.
97
+ Which role that host answers to is what the control really is, not what it is
98
+ drawn like: a button's host is a `<button>`, and a picker's host is an
99
+ `<input>` — a `textbox`, because it holds text the user chose.
100
+
101
+ A picker that picks nothing is the exception, and answers to `button`: its
102
+ popup is a question (`type="confirm"`) or something to read
103
+ (`mode="callout"`), so it is a door, and a door is pressed. Its name is the
104
+ label it draws — the `ui` you gave it, or its placeholder — unless you name it
105
+ yourself:
106
+
107
+ ```jsx
108
+ <Picker type="confirm" ui="Delete" action={deleteFile} />
109
+ <Picker mode="callout" aria-label="Aide" ui={<Icon>…</Icon>}>…</Picker>
110
+ ```
111
+
112
+ ```js
113
+ await page.getByRole("button", { name: "Delete" }).click();
114
+ await page.getByRole("button", { name: "Aide" }).click();
115
+ ```
116
+
117
+ A trigger drawn as an icon draws no words, so `aria-label` is the only thing
118
+ that can name it — for the test and for a screen reader alike.
101
119
 
102
120
  When the wrapper IS what the test wants — a whole field with its label and its
103
121
  error message, a section, a row — put the testid on the surrounding component
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/navi",
3
- "version": "0.29.156",
3
+ "version": "0.29.158",
4
4
  "type": "module",
5
5
  "description": "Library of components including navigation to create frontend applications",
6
6
  "repository": {
@@ -29,7 +29,7 @@
29
29
  "prepublishOnly": "npm run build"
30
30
  },
31
31
  "dependencies": {
32
- "@jsenv/dom": "0.17.36",
32
+ "@jsenv/dom": "0.17.37",
33
33
  "@jsenv/humanize": "1.7.8",
34
34
  "@jsenv/validity": "0.4.3"
35
35
  },