@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/dist/jsenv_navi.js +852 -342
- package/dist/jsenv_navi.js.map +98 -64
- package/dist/jsenv_navi_side_effects.js +1 -1
- package/dist/jsenv_navi_side_effects.js.map +1 -1
- package/docs/AI_INSTRUCTIONS.md +19 -6
- package/docs/actions.md +87 -3
- package/docs/control_object.md +13 -6
- package/docs/error_handling.md +39 -7
- package/docs/form_changed.md +24 -8
- package/docs/interactions.md +32 -10
- package/docs/popup_open.md +1 -1
- package/docs/resource.md +93 -13
- package/docs/route_transitions.md +6 -2
- package/docs/testid.md +22 -4
- package/package.json +2 -2
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
|
-
##
|
|
403
|
+
## A function calling the verb, or the instance
|
|
344
404
|
|
|
345
|
-
|
|
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
|
-
//
|
|
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
|
-
// ✗
|
|
442
|
+
// ✗ no resource run at all: nothing updates the store, nothing is invalidated
|
|
357
443
|
<Button
|
|
358
|
-
action={
|
|
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
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
`
|
|
100
|
-
|
|
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.
|
|
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.
|
|
32
|
+
"@jsenv/dom": "0.17.37",
|
|
33
33
|
"@jsenv/humanize": "1.7.8",
|
|
34
34
|
"@jsenv/validity": "0.4.3"
|
|
35
35
|
},
|