@jsenv/navi 0.29.156 → 0.29.157
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 +338 -162
- package/dist/jsenv_navi.js.map +27 -20
- package/docs/AI_INSTRUCTIONS.md +8 -3
- package/docs/actions.md +22 -0
- package/docs/resource.md +61 -1
- package/docs/route_transitions.md +6 -2
- package/docs/testid.md +22 -4
- package/package.json +1 -1
package/docs/AI_INSTRUCTIONS.md
CHANGED
|
@@ -65,8 +65,10 @@ consistency across the app, not from any single call site.
|
|
|
65
65
|
routing, actions, layout (`Box`), typography, icons, forms/validation,
|
|
66
66
|
`Table`, dialogs, popovers.
|
|
67
67
|
- `docs/resource.md` — REST state: `resource()`, relationships, callback return
|
|
68
|
-
contracts, autorerun rules
|
|
69
|
-
`
|
|
68
|
+
contracts, autorerun rules, and the second question a screen asks over one
|
|
69
|
+
collection (searching it is that collection's own `GET_MANY`). Companion
|
|
70
|
+
files: `docs/actions.md`, `docs/resource_with_params.md`,
|
|
71
|
+
`docs/resource_dependencies.md`.
|
|
70
72
|
- `docs/list_refresh.md` — what a write sends back to the network and what stays
|
|
71
73
|
on screen meanwhile: stale data returned by `useAsyncData({ loading: true })`,
|
|
72
74
|
what updates from a response without any request, `rerunOn` and its defaults,
|
|
@@ -342,7 +344,10 @@ consistency across the app, not from any single call site.
|
|
|
342
344
|
sub-resource (`/games/:id/candidates`) as an `op`/`type` discriminator inside
|
|
343
345
|
one verb's callback. Read `docs/resource.md` before writing a resource — the
|
|
344
346
|
callback return contracts differ per relationship method and are not
|
|
345
|
-
guessable.
|
|
347
|
+
guessable. Searching a collection is that collection's `GET_MANY` with one
|
|
348
|
+
more param, never a resource or an action of its own: two `GET_MANY` on one
|
|
349
|
+
resource do not compete (each holds its own ids, the store is shared), and
|
|
350
|
+
rows a search returns are then the same objects as everywhere else.
|
|
346
351
|
- **`Box`** is the layout primitive (Flexbox wrapper: `flex`, `flex="y"` for
|
|
347
352
|
column, `grid`, `alignX`/`alignY`). Prefer it over raw CSS for layout inside
|
|
348
353
|
Navi apps.
|
package/docs/actions.md
CHANGED
|
@@ -63,6 +63,28 @@ const userAction = getUser.bindParams({ id: userIdSignal });
|
|
|
63
63
|
// a new params value reruns it
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
+
`{ debounce }` puts a delay between the signal and the instance, for params that
|
|
67
|
+
move faster than a request should — a search box, a wheel, a slider:
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
const searchAction = USER.GET_MANY.bindParams(questionSignal, {
|
|
71
|
+
debounce: 300,
|
|
72
|
+
});
|
|
73
|
+
const [found, searching] = useAsyncData(searchAction, {
|
|
74
|
+
run: true,
|
|
75
|
+
loading: true,
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
There is no effect here and nothing to own: the delay lives in the binding, and
|
|
80
|
+
two call sites passing the same signal and the same delay get the same instance,
|
|
81
|
+
so the four components of one screen reading the same question ask it once. That
|
|
82
|
+
is the shape to reach for whenever the screen owns its params signal.
|
|
83
|
+
`actionRunEffect` is the other one, and what it adds is the run: use it when the request must go out whether or not something
|
|
84
|
+
is drawing it. During the delay the instance is still the previous one, holding
|
|
85
|
+
the previous answer — `loading` is what says a newer one is coming, don't
|
|
86
|
+
compare params by hand to find out.
|
|
87
|
+
|
|
66
88
|
## Running: `run`, `rerun`, `prerun`, `reset`
|
|
67
89
|
|
|
68
90
|
| Method | Does |
|
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", {
|
|
@@ -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
|