@uniweb/api 1.2.0 → 1.2.2
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/README.md +139 -2
- package/package.json +1 -1
- package/src/client.js +232 -5
- package/src/hooks/useChoices.js +102 -0
- package/src/hooks/useDefinition.js +78 -0
- package/src/index.js +5 -0
- package/src/mock/definition.js +119 -0
- package/src/mock/index.js +61 -7
- package/src/mock/schema-shape.js +151 -6
- package/src/mock/store.js +375 -41
- package/src/wire.js +149 -11
package/README.md
CHANGED
|
@@ -244,6 +244,54 @@ the item.
|
|
|
244
244
|
Its `absent` covers both not-found and not-permitted, on purpose: render your paywall
|
|
245
245
|
or sign-in prompt on it and never say "deleted".
|
|
246
246
|
|
|
247
|
+
### A Model's definition — what a form is built from
|
|
248
|
+
|
|
249
|
+
```jsx
|
|
250
|
+
import { useDefinition } from '@uniweb/api'
|
|
251
|
+
|
|
252
|
+
const { status, definition } = useDefinition('@acme/cv')
|
|
253
|
+
const brief = definition?.sections.find((s) => s.brief)
|
|
254
|
+
// brief.fields → [{ key: 'title', kind: 'string', required: true, label: 'Title', max_length: 250 }, …]
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Each field is its declaration as the backend stores it — `key`, `kind`, `required`,
|
|
258
|
+
`multi`, and what the schema declares: `label`, `description`, `localized`, `format`,
|
|
259
|
+
`max_length`, `min`, `max`, and `enum`. Each section is `{ name, path, kind, brief,
|
|
260
|
+
fields, rules }`, plus its `label` and `description`; `path` is the name a write takes,
|
|
261
|
+
`parent/child` for a nested section. ⭐ **These are the rules every write is checked
|
|
262
|
+
against**, so a form that reads them checks what the backend will. `readDefinition({
|
|
263
|
+
schema })` is the same outside React.
|
|
264
|
+
|
|
265
|
+
### A field's choices
|
|
266
|
+
|
|
267
|
+
A field that picks from a list — in a schema, `type: item_ref` with `options:` naming a
|
|
268
|
+
section of a lists type — stores the uuid of the item chosen. Its choices are that
|
|
269
|
+
section's items:
|
|
270
|
+
|
|
271
|
+
```jsx
|
|
272
|
+
import { useChoices } from '@uniweb/api'
|
|
273
|
+
|
|
274
|
+
const { items, hasMore } = useChoices({ schema: '@acme/cv', field: 'country', q, searchIn: 'label' })
|
|
275
|
+
// items[i].uuid — the value to store; items[i].data — the choice, in the site's language
|
|
276
|
+
|
|
277
|
+
// a dependent list: the provinces of the country chosen above
|
|
278
|
+
const provinces = useChoices(country ? { schema: '@acme/cv', field: 'province', parent: country } : null)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
- **A page at a time** — `limit`, 50 by default and at most 1000; pass `nextCursor` back as
|
|
282
|
+
`cursor`. ⚠️ A searched page can be short while more follow: `hasMore` is the end, never
|
|
283
|
+
the page's length.
|
|
284
|
+
- **A search** — `q` anywhere in the choices' field `searchIn`, ignoring case; a
|
|
285
|
+
localized field in the site's language.
|
|
286
|
+
- **One level at a time** — `parent` reads the items under one item: the next level of a
|
|
287
|
+
list whose items nest (each says `has_children`), or a dependent field's choices under
|
|
288
|
+
the item chosen in the field it depends on.
|
|
289
|
+
- **Whose lists** — the items of every entry of the lists type the viewer may read.
|
|
290
|
+
- **Read back**, the field answers `{ item, brief }`: the chosen item's uuid and its data.
|
|
291
|
+
|
|
292
|
+
`listChoices` is the same outside React, and `listSectionItems({ section })` reads a
|
|
293
|
+
section by its uuid.
|
|
294
|
+
|
|
247
295
|
## Writing
|
|
248
296
|
|
|
249
297
|
An entity's content is **items, each in a section** of its Model. A section that holds
|
|
@@ -297,6 +345,79 @@ Every failure is an `ApiError` with a `kind`: `auth` (sign in), `unverified`, `a
|
|
|
297
345
|
(`error.extensions.field` names a field the Model refused), `conflict`, `rule`,
|
|
298
346
|
`rate-limited`, `unavailable`, `disabled`. Branch on `kind`; `detail` is prose.
|
|
299
347
|
|
|
348
|
+
## Rules, lists, and what stays in your foundation
|
|
349
|
+
|
|
350
|
+
The backend checks every write against the Model's declaration, and refuses to register
|
|
351
|
+
a schema with a declaration it does not take. So where a rule is written decides who
|
|
352
|
+
keeps it.
|
|
353
|
+
|
|
354
|
+
### On a field — checked on every write
|
|
355
|
+
|
|
356
|
+
| declare | the backend |
|
|
357
|
+
|---|---|
|
|
358
|
+
| `required: true` | refuses a write without the field |
|
|
359
|
+
| `max_length` on `string`, `text` | counts characters — each language of a localized value on its own |
|
|
360
|
+
| `min`, `max` on `int`, `decimal` | refuses a value outside them |
|
|
361
|
+
| `format` — `email`, `url` on `string` and `text`; `currency` on `string`; `markdown`, `html` on `text` | checks `email`, `url` and `currency` (an ISO 4217 code); `markdown` and `html` mark what the text is |
|
|
362
|
+
| `localized: true` on text | holds one string for every language, or one per language |
|
|
363
|
+
| `format: year-month` or `format: year` on `date` | holds a month, `2024-05`, or a year, `2024` — ISO 8601's reduced precision, stored as written (a backend from before 2026-10-14 refuses the declaration). With no `format`, a `date` holds a day, `2024-05-14` |
|
|
364
|
+
| `enum: [...]` | keeps the field to those values — in the definition, as its section's `one_of` rule |
|
|
365
|
+
| `type: item_ref`, `options: '@acme/lists/countries'` | keeps the value to an item of that section — an item of another is `409`, `kind: 'rule'` |
|
|
366
|
+
|
|
367
|
+
A refused value is `kind: 'invalid'`, its `extensions` naming the field (`data.title`,
|
|
368
|
+
`data.label.fr` for one language), the `rule` it broke and the bound
|
|
369
|
+
(`{ rule: 'max_length', max_length: 250 }`, `{ rule: 'format', format: 'year-month' }`). An
|
|
370
|
+
`enum`'s refusal, `rule: 'one_of'`, names its field too — a backend from before 2026-10-14
|
|
371
|
+
names it only in `detail`.
|
|
372
|
+
|
|
373
|
+
⛔ **A declaration the backend does not take refuses the whole schema** when it reaches
|
|
374
|
+
the backend, naming the field and the key: a `format` its kind does not have (`month-day`
|
|
375
|
+
on a `date`, `year` on an `int`), a key it does not know (`help`), or one it does not check
|
|
376
|
+
yet (`pattern`, `min_length`).
|
|
377
|
+
|
|
378
|
+
### On a section — rules across fields, and across items
|
|
379
|
+
|
|
380
|
+
A section's `constraints` — a list of rules, in its definition as `rules`:
|
|
381
|
+
|
|
382
|
+
| rule | keeps |
|
|
383
|
+
|---|---|
|
|
384
|
+
| `{ kind: 'exclusive', fields: [...] }` | at most one of the fields set — "another organization" beside a picked one |
|
|
385
|
+
| `{ kind: 'required_one_of', fields: [...] }` | at least one of them set |
|
|
386
|
+
| `{ kind: 'required_when', field, when: { field, equals } }` | the field set whenever another field equals a value |
|
|
387
|
+
| `{ kind: 'field_order', before, after }` | one date no later than another |
|
|
388
|
+
| `{ kind: 'unique_field', field }`, `{ kind: 'unique_combination', fields }` | a value — or values together — once among an entry's items in the section |
|
|
389
|
+
| `{ kind: 'max_items', value }`, `{ kind: 'min_items', value }` | how many items the section holds — `min_items` refuses a delete below it, and does not make anyone add one |
|
|
390
|
+
| `{ kind: 'show_when', field, when: { field, equals } }` | nothing: when a form shows a field — read it from the definition |
|
|
391
|
+
|
|
392
|
+
### Shared lists
|
|
393
|
+
|
|
394
|
+
Put the lists your fields pick from in **one lists type, a section per list** — not a
|
|
395
|
+
type per list — and its entries where the people who should see them can read them.
|
|
396
|
+
Then:
|
|
397
|
+
|
|
398
|
+
- **A field that picks from one** is `type: item_ref` with `options:` naming its section
|
|
399
|
+
(`'@acme/lists/countries'`, a nested one by its path), and `useChoices` offers it.
|
|
400
|
+
- **A list too long for a dropdown** is searched: `q` with `searchIn`, a page at a time.
|
|
401
|
+
- **A dependent list** — a country, then one of its provinces — is a section nested in
|
|
402
|
+
the other: `countries` holding `subdivisions`, a field picking from each. The second
|
|
403
|
+
field's choices are `useChoices({ …, parent: country })`, and it holds only a province.
|
|
404
|
+
- **A list in levels** — a discipline, then its branches — is a section whose items nest
|
|
405
|
+
(`self_nesting: true`): read it a level at a time with `parent`.
|
|
406
|
+
- **A list people may add to** is one they may edit: who may write an entry's items is
|
|
407
|
+
who may edit the entry. They add a choice with `useEntityWriter` on the lists entry,
|
|
408
|
+
and `refresh()` reads the choices again.
|
|
409
|
+
|
|
410
|
+
### What stays in your foundation
|
|
411
|
+
|
|
412
|
+
The backend keeps content and checks it. How a field is edited is your foundation's — the
|
|
413
|
+
toolbar of a rich text field, a default for a new entry, a field shown read-only, an
|
|
414
|
+
autocomplete, a value computed from others (a currency converted, shares summed to 100
|
|
415
|
+
before an entry is complete). None of these is a declaration the backend keeps.
|
|
416
|
+
|
|
417
|
+
⭐ **Who may change a field is who may edit its entry** — the backend decides access per
|
|
418
|
+
entry, never per field. A field only some people may change belongs on an entry only they
|
|
419
|
+
edit.
|
|
420
|
+
|
|
300
421
|
## The service on your machine
|
|
301
422
|
|
|
302
423
|
Building against a live service is slow and puts a shared database behind your
|
|
@@ -343,8 +464,10 @@ production, and nothing in your site's configuration changes between the two.
|
|
|
343
464
|
|
|
344
465
|
⭐ **The mock answers what the backend answers** — the same statuses, bodies and
|
|
345
466
|
refusals, for every route this package uses. It enforces what your schemas declare —
|
|
346
|
-
which sections are insert-only, the fields a section takes
|
|
347
|
-
|
|
467
|
+
which sections are insert-only, the fields a section takes and their rules (`required`,
|
|
468
|
+
`max_length`, `min` and `max`, a date's form, an `enum`, the `email` and `url` formats, a
|
|
469
|
+
choice from a list) — and it is as strict as the backend about requests, so a mistake
|
|
470
|
+
fails here rather than in front of a user.
|
|
348
471
|
Everyone signed in reads and writes their own entities, and a member nothing of anyone
|
|
349
472
|
else's (`memberFloor: 'read'` in the seed models a service set up to let members read
|
|
350
473
|
each other's — or per Model, `memberFloor: { '@acme/course': 'read' }`, so the
|
|
@@ -361,6 +484,20 @@ from the start.
|
|
|
361
484
|
The operator's Admin link answers too, landing on a page of the mock's own — the backend's
|
|
362
485
|
administration is not part of it. State is in memory: restart to reset.
|
|
363
486
|
|
|
487
|
+
Write a seed's schemas as you write them for a backend — `type: 'int'`, `multiple: true`,
|
|
488
|
+
`options:` — and the mock's definition answers what a backend keeps of them, so a key no
|
|
489
|
+
backend takes is left out of it (a backend refuses it when you register). Seed a list's
|
|
490
|
+
choices as items of a lists entry, each item holding the items that sit under it:
|
|
491
|
+
|
|
492
|
+
```js
|
|
493
|
+
{ section: 'countries', data: { code: 'CA', label: { en: 'Canada' } }, items: [
|
|
494
|
+
{ section: 'countries/subdivisions', data: { name: { en: 'Ontario' } } },
|
|
495
|
+
] }
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
A field whose `options:` names no section of a seeded Model is reported when the mock
|
|
499
|
+
starts, and offers no choices — a backend refuses to register it.
|
|
500
|
+
|
|
364
501
|
There is also a standalone server, for a frontend that is not a Uniweb site — `--site`
|
|
365
502
|
names that frontend's origin, where the mail's links land:
|
|
366
503
|
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -15,7 +15,7 @@ import { getUniweb, deriveCacheKey } from '@uniweb/core'
|
|
|
15
15
|
import { resolveService } from '@uniweb/core'
|
|
16
16
|
import { ApiError } from './errors.js'
|
|
17
17
|
import { composeUrl, isCrossOrigin, readBody, UNSAFE } from './http.js'
|
|
18
|
-
import { AUTH, ROUTES, PARAM, FIELD, LIST, OP, GUARDED_OPS, CREATE, READ, SCHEMA, PAGE, TOTP, VIEWER } from './wire.js'
|
|
18
|
+
import { AUTH, ROUTES, PARAM, FIELD, LIST, OP, GUARDED_OPS, CREATE, READ, SCHEMA, SECTION_ITEMS, PAGE, TOTP, VIEWER } from './wire.js'
|
|
19
19
|
import { Ledger } from './ledger.js'
|
|
20
20
|
import { verifyEmailWith, claimOfferWith } from './mailed-links.js'
|
|
21
21
|
import { listInvitationsWith, acceptInvitationWith, declineInvitationWith } from './invitations.js'
|
|
@@ -141,6 +141,68 @@ function labelSections(items, sections) {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/** The values a `one_of` rule allows — `values`, or the `options: [{ value, label }]` form's values. */
|
|
145
|
+
function allowedValues(rule) {
|
|
146
|
+
if (Array.isArray(rule?.[SCHEMA.ruleValues])) return rule[SCHEMA.ruleValues]
|
|
147
|
+
if (Array.isArray(rule?.options)) return rule.options.map((o) => o?.value)
|
|
148
|
+
return []
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A definition as a form reads it (`ApiClient.readDefinition`): each field its own
|
|
153
|
+
* declaration, its `enum` folded back from its section's `one_of` rule; each section
|
|
154
|
+
* with what the schema declares of it, its `path`, and its rules.
|
|
155
|
+
*
|
|
156
|
+
* @param {object} body - `GET /models/@scope/name`, as the backend answers it (`SCHEMA`)
|
|
157
|
+
*/
|
|
158
|
+
function formOf(body) {
|
|
159
|
+
const rows = Array.isArray(body?.[SCHEMA.sections]) ? body[SCHEMA.sections] : []
|
|
160
|
+
const byId = new Map(rows.map((s) => [s[SCHEMA.id], s]))
|
|
161
|
+
const pathOf = (row) => {
|
|
162
|
+
const names = []
|
|
163
|
+
const seen = new Set()
|
|
164
|
+
for (let at = row; at && !seen.has(at[SCHEMA.id]); at = byId.get(at[SCHEMA.parent])) {
|
|
165
|
+
seen.add(at[SCHEMA.id])
|
|
166
|
+
names.unshift(at[SCHEMA.name])
|
|
167
|
+
}
|
|
168
|
+
return names.join('/')
|
|
169
|
+
}
|
|
170
|
+
const sections = rows.map((row) => {
|
|
171
|
+
const declared = row?.[SCHEMA.constraints]?.[SCHEMA.rules]
|
|
172
|
+
const rules = Array.isArray(declared) ? declared : []
|
|
173
|
+
const enums = new Map(
|
|
174
|
+
rules
|
|
175
|
+
.filter((r) => r?.kind === SCHEMA.oneOf && typeof r[SCHEMA.ruleField] === 'string')
|
|
176
|
+
.map((r) => [r[SCHEMA.ruleField], allowedValues(r)]),
|
|
177
|
+
)
|
|
178
|
+
const fields = (Array.isArray(row?.[SCHEMA.fields]) ? row[SCHEMA.fields] : []).map((f) => {
|
|
179
|
+
const declaration = f?.[SCHEMA.fieldType]?.[SCHEMA.fieldDecl] ?? {}
|
|
180
|
+
const field = {
|
|
181
|
+
...declaration,
|
|
182
|
+
key: f?.key ?? declaration.key,
|
|
183
|
+
kind: declaration.kind ?? f?.[SCHEMA.fieldType]?.kind,
|
|
184
|
+
required: f?.required === true || declaration.required === true,
|
|
185
|
+
multi: f?.multi === true || declaration.multi === true,
|
|
186
|
+
}
|
|
187
|
+
if (enums.has(field.key)) field.enum = enums.get(field.key)
|
|
188
|
+
return field
|
|
189
|
+
})
|
|
190
|
+
const other = row?.[SCHEMA.otherData]
|
|
191
|
+
return {
|
|
192
|
+
...(other && typeof other === 'object' ? other : {}),
|
|
193
|
+
id: row[SCHEMA.id],
|
|
194
|
+
name: row[SCHEMA.name],
|
|
195
|
+
path: pathOf(row),
|
|
196
|
+
kind: row[SCHEMA.kind],
|
|
197
|
+
brief: row[SCHEMA.isBrief] === true,
|
|
198
|
+
parent: row[SCHEMA.parent] ?? null,
|
|
199
|
+
rules,
|
|
200
|
+
fields,
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
return { model: body?.model ?? null, sections }
|
|
204
|
+
}
|
|
205
|
+
|
|
144
206
|
export class ApiClient {
|
|
145
207
|
/**
|
|
146
208
|
* @param {object} uniweb - the page's `Uniweb` singleton
|
|
@@ -770,23 +832,182 @@ export class ApiClient {
|
|
|
770
832
|
*/
|
|
771
833
|
sections(schema, signal) {
|
|
772
834
|
if (!schema) return Promise.reject(ApiError.invalid('No Model', 'sections needs a schema'))
|
|
773
|
-
|
|
774
|
-
if (held) return held
|
|
775
|
-
const pending = this.request('GET', ROUTES.schema(schema), { signal }).then((body) =>
|
|
835
|
+
return this._definition(schema, signal).then((body) =>
|
|
776
836
|
(Array.isArray(body?.[SCHEMA.sections]) ? body[SCHEMA.sections] : []).map((s) => ({
|
|
777
837
|
id: s[SCHEMA.id],
|
|
778
838
|
name: s[SCHEMA.name],
|
|
779
839
|
parent: s[SCHEMA.parent] ?? null,
|
|
780
840
|
})),
|
|
781
841
|
)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** Drop a Model's kept definition, so the next read asks the backend again. */
|
|
845
|
+
forgetDefinition(schema) {
|
|
846
|
+
this._schemas.delete(schema)
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** The definition as the backend answers it — read once per viewer, and kept. */
|
|
850
|
+
_definition(schema, signal) {
|
|
851
|
+
const held = this._schemas.get(schema)
|
|
852
|
+
if (held) return held
|
|
853
|
+
const pending = this.request('GET', ROUTES.schema(schema), { signal })
|
|
782
854
|
this._schemas.set(schema, pending)
|
|
783
|
-
// A failure is not kept: the next
|
|
855
|
+
// A failure is not kept: the next read asks again.
|
|
784
856
|
pending.catch(() => {
|
|
785
857
|
if (this._schemas.get(schema) === pending) this._schemas.delete(schema)
|
|
786
858
|
})
|
|
787
859
|
return pending
|
|
788
860
|
}
|
|
789
861
|
|
|
862
|
+
/**
|
|
863
|
+
* A Model's definition — what an editor builds a form from.
|
|
864
|
+
*
|
|
865
|
+
* ```js
|
|
866
|
+
* const { sections } = await readDefinition({ schema: '@acme/cv' })
|
|
867
|
+
* const brief = sections.find((s) => s.brief)
|
|
868
|
+
* // brief.fields → [{ key: 'title', kind: 'string', required: true, label: 'Title', max_length: 250 }, …]
|
|
869
|
+
* ```
|
|
870
|
+
*
|
|
871
|
+
* Each section is `{ id, name, path, kind, brief, parent, rules, fields }` plus what the
|
|
872
|
+
* schema declares of it — `label`, `description`, `append_only`, `allow_self_nesting`:
|
|
873
|
+
* `path` is the name a write takes (`parent/child` for a nested section), `parent` the
|
|
874
|
+
* parent section's id, `rules` its rules (`[]` with none). Each field is its declaration
|
|
875
|
+
* as the backend stores it — `key`, `kind`, `required`, `multi`, and what it declares:
|
|
876
|
+
* `label`, `description`, `localized`, `format`, `max_length`, `min`, `max` — with
|
|
877
|
+
* `enum`, the values its section's `one_of` rule allows, when it has one. A field of
|
|
878
|
+
* kind `item_ref` is a choice from a list: `section_uuid` names the section whose items
|
|
879
|
+
* are its choices (`listChoices`), and `entity_uuid` the entry that holds them, when the
|
|
880
|
+
* field pins one. `model` is the Model's row.
|
|
881
|
+
*
|
|
882
|
+
* ⭐ **These are the rules the backend applies to every write**, so a form that reads
|
|
883
|
+
* them checks what will be checked. What a form does with them — how a field is edited,
|
|
884
|
+
* what it shows — is the foundation's.
|
|
885
|
+
*
|
|
886
|
+
* Read once per viewer and kept. Signed out, the backend answers `401` (`auth`); an
|
|
887
|
+
* unknown Model is `absent`.
|
|
888
|
+
*
|
|
889
|
+
* @param {object} args
|
|
890
|
+
* @param {string} args.schema - the Model, e.g. `@acme/cv`
|
|
891
|
+
* @param {AbortSignal} [args.signal]
|
|
892
|
+
* @returns {Promise<{ model: object|null, sections: object[] }>}
|
|
893
|
+
*/
|
|
894
|
+
async readDefinition({ schema, signal } = {}) {
|
|
895
|
+
if (!schema) throw ApiError.invalid('No Model', 'readDefinition needs a schema')
|
|
896
|
+
return formOf(await this._definition(schema, signal))
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* One page of a section's items — the choices an `item_ref` field offers, read by the
|
|
901
|
+
* section the field names (`section_uuid` on its declaration). `listChoices` finds
|
|
902
|
+
* that section for you from a field.
|
|
903
|
+
*
|
|
904
|
+
* Each item is `{ uuid, entity, parent?, data, has_children? }`: `uuid` is the value the
|
|
905
|
+
* field stores; `data` the item's content, in the site's language (`locale`, as every
|
|
906
|
+
* read); `parent` the item it sits under; `has_children` — for a list whose items nest
|
|
907
|
+
* — whether items sit under it.
|
|
908
|
+
*
|
|
909
|
+
* - **Whose items:** every entry of the section's Model the viewer may read, or only
|
|
910
|
+
* `entity`'s.
|
|
911
|
+
* - **One level at a time:** without `parent`, the section's top level; with it, the
|
|
912
|
+
* items under that item — the next level of a nesting list, or a dependent list's
|
|
913
|
+
* choices under the item chosen in the field it depends on.
|
|
914
|
+
* - **A search:** `q` matched, ignoring case, anywhere in the section's field `field`
|
|
915
|
+
* (a `string` or `text` field), in the site's language for a localized one.
|
|
916
|
+
* - **Paging:** pass `nextCursor` back as `cursor`. ⚠️ A searched page can be short
|
|
917
|
+
* while more follow, so `hasMore` — a cursor came back — is the only end signal.
|
|
918
|
+
*
|
|
919
|
+
* @param {object} args
|
|
920
|
+
* @param {string} args.section - the section's uuid
|
|
921
|
+
* @param {string} [args.entity] - only this entry's items
|
|
922
|
+
* @param {string} [args.parent] - the uuid of the item whose children to read
|
|
923
|
+
* @param {string} [args.field] - the field `q` is matched in
|
|
924
|
+
* @param {string} [args.q] - the search term
|
|
925
|
+
* @param {string} [args.cursor] - the previous page's `nextCursor`
|
|
926
|
+
* @param {number} [args.limit] - default 50, at most 1000
|
|
927
|
+
* @param {AbortSignal} [args.signal]
|
|
928
|
+
* @returns {Promise<{ items: object[], nextCursor: string|null, hasMore: boolean }>}
|
|
929
|
+
*/
|
|
930
|
+
async listSectionItems({ section, entity, parent, field, q, cursor, limit, signal } = {}) {
|
|
931
|
+
if (!section) throw ApiError.invalid('No Section', 'listSectionItems needs a section uuid')
|
|
932
|
+
const query = {
|
|
933
|
+
[PARAM.entity]: entity || undefined,
|
|
934
|
+
[PARAM.parent]: parent || undefined,
|
|
935
|
+
[PARAM.field]: field || undefined,
|
|
936
|
+
[PARAM.q]: q || undefined,
|
|
937
|
+
[PARAM.cursor]: cursor || undefined,
|
|
938
|
+
[PARAM.limit]: limit ?? PAGE.size,
|
|
939
|
+
...this._localeQuery(),
|
|
940
|
+
}
|
|
941
|
+
const body = await this.request('GET', ROUTES.sectionItems(section), { query, signal })
|
|
942
|
+
const items = Array.isArray(body?.[SECTION_ITEMS.items]) ? body[SECTION_ITEMS.items] : []
|
|
943
|
+
const nextCursor = typeof body?.[SECTION_ITEMS.next] === 'string' ? body[SECTION_ITEMS.next] : null
|
|
944
|
+
return { items, nextCursor, hasMore: nextCursor != null }
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* The choices a field offers — the field's own list, read from the Model's definition.
|
|
949
|
+
*
|
|
950
|
+
* ```js
|
|
951
|
+
* const { items } = await listChoices({ schema: '@acme/cv', field: 'country', q: 'can', searchIn: 'label' })
|
|
952
|
+
* // items[i].uuid is the value to store in `country`
|
|
953
|
+
* const provinces = await listChoices({ schema: '@acme/cv', field: 'province', parent: country })
|
|
954
|
+
* ```
|
|
955
|
+
*
|
|
956
|
+
* `field` is a field of kind `item_ref` — in a schema, `type: item_ref` with
|
|
957
|
+
* `options: '@scope/lists/countries'` — and `section` names its section when several
|
|
958
|
+
* declare a field of that name. Everything else is `listSectionItems`'s: `parent` reads
|
|
959
|
+
* the items under an item, which is how a dependent list follows the field it depends
|
|
960
|
+
* on; `q` searches the choices' field `searchIn`.
|
|
961
|
+
*
|
|
962
|
+
* @param {object} args
|
|
963
|
+
* @param {string} args.schema - the Model whose field offers the choices
|
|
964
|
+
* @param {string} args.field - the field's key
|
|
965
|
+
* @param {string} [args.section] - the field's section, by name or path
|
|
966
|
+
* @param {string} [args.parent]
|
|
967
|
+
* @param {string} [args.q]
|
|
968
|
+
* @param {string} [args.searchIn] - the field of the choices `q` is matched in
|
|
969
|
+
* @param {string} [args.cursor]
|
|
970
|
+
* @param {number} [args.limit]
|
|
971
|
+
* @param {AbortSignal} [args.signal]
|
|
972
|
+
* @returns {Promise<{ items: object[], nextCursor: string|null, hasMore: boolean }>}
|
|
973
|
+
*/
|
|
974
|
+
async listChoices({ schema, field, section, parent, q, searchIn, cursor, limit, signal } = {}) {
|
|
975
|
+
if (!schema) throw ApiError.invalid('No Model', 'listChoices needs the schema whose field offers the choices')
|
|
976
|
+
if (!field) throw ApiError.invalid('No Field', 'listChoices needs the field whose choices to list')
|
|
977
|
+
const { sections } = await this.readDefinition({ schema, signal })
|
|
978
|
+
const found = sections
|
|
979
|
+
.filter((s) => section == null || s.name === section || s.path === section)
|
|
980
|
+
.flatMap((s) => s.fields.filter((f) => f.key === field))
|
|
981
|
+
if (found.length !== 1) {
|
|
982
|
+
throw ApiError.invalid(
|
|
983
|
+
'Unknown Field',
|
|
984
|
+
found.length
|
|
985
|
+
? `${schema} declares '${field}' in several sections — name the section`
|
|
986
|
+
: `${schema} has no field '${field}'${section != null ? ` in section '${section}'` : ''}`,
|
|
987
|
+
)
|
|
988
|
+
}
|
|
989
|
+
const [declared] = found
|
|
990
|
+
const pinned = declared[SCHEMA.choicesSection]
|
|
991
|
+
if (!pinned) {
|
|
992
|
+
throw ApiError.invalid(
|
|
993
|
+
'No Choices',
|
|
994
|
+
declared.kind === 'item_ref'
|
|
995
|
+
? `'${field}' of ${schema} names no list its choices are in`
|
|
996
|
+
: `'${field}' of ${schema} is not a choice from a list — its kind is ${declared.kind}`,
|
|
997
|
+
)
|
|
998
|
+
}
|
|
999
|
+
return this.listSectionItems({
|
|
1000
|
+
section: pinned,
|
|
1001
|
+
entity: declared[SCHEMA.choicesEntity],
|
|
1002
|
+
parent,
|
|
1003
|
+
field: searchIn,
|
|
1004
|
+
q,
|
|
1005
|
+
cursor,
|
|
1006
|
+
limit,
|
|
1007
|
+
signal,
|
|
1008
|
+
})
|
|
1009
|
+
}
|
|
1010
|
+
|
|
790
1011
|
/**
|
|
791
1012
|
* The numeric id of a section, by name — or by `parent/child` path for a nested
|
|
792
1013
|
* one — which is how the item route names a section.
|
|
@@ -1082,6 +1303,12 @@ export const declineInvitation = (id) => declineInvitationWith(required(), id)
|
|
|
1082
1303
|
export const readEntity = (args) => required().readEntity(args)
|
|
1083
1304
|
/** @see ApiClient#listEntities */
|
|
1084
1305
|
export const listEntities = (args) => required().listEntities(args)
|
|
1306
|
+
|
|
1307
|
+
export const readDefinition = (args) => required().readDefinition(args)
|
|
1308
|
+
|
|
1309
|
+
export const listSectionItems = (args) => required().listSectionItems(args)
|
|
1310
|
+
|
|
1311
|
+
export const listChoices = (args) => required().listChoices(args)
|
|
1085
1312
|
/** @see ApiClient#writeItems */
|
|
1086
1313
|
export const writeItems = (args) => required().writeItems(args)
|
|
1087
1314
|
/** @see ApiClient#createEntity */
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import { getClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
const noSubscribe = () => () => {}
|
|
5
|
+
const noSnapshot = () => null
|
|
6
|
+
const NONE = Object.freeze([])
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The choices a field offers — a page of its list, for a dropdown or a type-ahead.
|
|
10
|
+
*
|
|
11
|
+
* ```jsx
|
|
12
|
+
* const [q, setQ] = useState('')
|
|
13
|
+
* const { status, items } = useChoices({ schema: '@acme/cv', field: 'country', q, searchIn: 'label' })
|
|
14
|
+
* // items[i].uuid is the value to store in `country`; items[i].data.label what to show
|
|
15
|
+
*
|
|
16
|
+
* // a dependent list: the provinces of the country chosen above
|
|
17
|
+
* const provinces = useChoices(country ? { schema: '@acme/cv', field: 'province', parent: country } : null)
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* `field` is a field of kind `item_ref` (`options:` in a schema); `listChoices` says
|
|
21
|
+
* what each parameter does. A page is `limit` items (50 by default): pass `nextCursor`
|
|
22
|
+
* back as `cursor` for the next. ⚠️ A searched page can be short while more follow —
|
|
23
|
+
* `hasMore` is the end signal, never the page's length.
|
|
24
|
+
*
|
|
25
|
+
* `absent` — no `backend` service, or nobody signed in. Cached under a key scoped to the
|
|
26
|
+
* viewer; a list edited elsewhere is read again by `refresh`.
|
|
27
|
+
*
|
|
28
|
+
* @param {{ schema: string, field: string, section?: string, parent?: string, q?: string, searchIn?: string, cursor?: string, limit?: number } | null} query
|
|
29
|
+
* pass null to skip
|
|
30
|
+
* @returns {{ status: string, items: object[], nextCursor: string|null, hasMore: boolean, error: Error|null, refresh: Function }}
|
|
31
|
+
*/
|
|
32
|
+
export function useChoices(query) {
|
|
33
|
+
const client = getClient()
|
|
34
|
+
const store = client?.website?.dataStore ?? null
|
|
35
|
+
|
|
36
|
+
const session = useSyncExternalStore(
|
|
37
|
+
client ? client.subscribe : noSubscribe,
|
|
38
|
+
client ? () => client.session : noSnapshot,
|
|
39
|
+
client ? () => client.session : noSnapshot,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const active = !!(
|
|
43
|
+
client &&
|
|
44
|
+
client.enabled &&
|
|
45
|
+
store &&
|
|
46
|
+
query &&
|
|
47
|
+
query.schema &&
|
|
48
|
+
query.field &&
|
|
49
|
+
session?.status !== 'anonymous'
|
|
50
|
+
)
|
|
51
|
+
const spec = active
|
|
52
|
+
? {
|
|
53
|
+
endpoint: '/sections',
|
|
54
|
+
schema: query.schema,
|
|
55
|
+
field: query.field,
|
|
56
|
+
section: query.section,
|
|
57
|
+
parent: query.parent,
|
|
58
|
+
q: query.q || undefined,
|
|
59
|
+
searchIn: query.searchIn,
|
|
60
|
+
cursor: query.cursor,
|
|
61
|
+
limit: query.limit,
|
|
62
|
+
}
|
|
63
|
+
: null
|
|
64
|
+
const key = spec ? client.cacheKey(spec) : null
|
|
65
|
+
|
|
66
|
+
const subscribe = useCallback((fn) => (key ? store.subscribe(key, fn) : noSubscribe()), [store, key])
|
|
67
|
+
const entry = useSyncExternalStore(
|
|
68
|
+
subscribe,
|
|
69
|
+
() => (key ? store.get(key) : null),
|
|
70
|
+
() => (key ? store.get(key) : null),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const [error, setError] = useState(null)
|
|
74
|
+
const [attempt, setAttempt] = useState(0)
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!key || entry) return undefined
|
|
78
|
+
let live = true
|
|
79
|
+
setError(null)
|
|
80
|
+
client.load(key, () => client.listChoices(query), spec).catch((err) => {
|
|
81
|
+
if (live) setError(err)
|
|
82
|
+
})
|
|
83
|
+
return () => {
|
|
84
|
+
live = false
|
|
85
|
+
}
|
|
86
|
+
// `query` is read through `key`, which encodes every part of it.
|
|
87
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
|
+
}, [client, key, entry, attempt])
|
|
89
|
+
|
|
90
|
+
const refresh = useCallback(() => {
|
|
91
|
+
if (key && store) store.delete(key)
|
|
92
|
+
setAttempt((n) => n + 1)
|
|
93
|
+
}, [store, key])
|
|
94
|
+
|
|
95
|
+
const empty = { items: NONE, nextCursor: null, hasMore: false }
|
|
96
|
+
if (!active) return { status: 'absent', ...empty, error: null, refresh }
|
|
97
|
+
if (entry) return { status: 'ready', ...entry.data, error: null, refresh }
|
|
98
|
+
if (error) return { status: 'error', ...empty, error, refresh }
|
|
99
|
+
return { status: 'loading', ...empty, error: null, refresh }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default useChoices
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
|
2
|
+
import { getClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
const noSubscribe = () => () => {}
|
|
5
|
+
const noSnapshot = () => null
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A Model's definition — the sections and fields a form is built from.
|
|
9
|
+
*
|
|
10
|
+
* ```jsx
|
|
11
|
+
* const { status, definition } = useDefinition('@acme/cv')
|
|
12
|
+
* if (status !== 'ready') return null
|
|
13
|
+
* const brief = definition.sections.find((s) => s.brief)
|
|
14
|
+
* // brief.fields → [{ key: 'title', kind: 'string', required: true, max_length: 250 }, …]
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Each field is its declaration as the backend stores it, with `enum` folded in from its
|
|
18
|
+
* section's rules (`readDefinition`). The rules a definition states are the rules every
|
|
19
|
+
* write is checked against.
|
|
20
|
+
*
|
|
21
|
+
* `absent` — no `backend` service, or nobody signed in: a definition is read by someone
|
|
22
|
+
* signed in. Cached under a key scoped to the viewer.
|
|
23
|
+
*
|
|
24
|
+
* @param {string|null} schema - the Model, e.g. `@acme/cv`; null to skip
|
|
25
|
+
* @returns {{ status: string, definition: object|null, error: Error|null, refresh: Function }}
|
|
26
|
+
*/
|
|
27
|
+
export function useDefinition(schema) {
|
|
28
|
+
const client = getClient()
|
|
29
|
+
const store = client?.website?.dataStore ?? null
|
|
30
|
+
|
|
31
|
+
const session = useSyncExternalStore(
|
|
32
|
+
client ? client.subscribe : noSubscribe,
|
|
33
|
+
client ? () => client.session : noSnapshot,
|
|
34
|
+
client ? () => client.session : noSnapshot,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const active = !!(client && client.enabled && store && schema && session?.status !== 'anonymous')
|
|
38
|
+
const spec = active ? { endpoint: '/models', schema } : null
|
|
39
|
+
const key = spec ? client.cacheKey(spec) : null
|
|
40
|
+
|
|
41
|
+
const subscribe = useCallback((fn) => (key ? store.subscribe(key, fn) : noSubscribe()), [store, key])
|
|
42
|
+
const entry = useSyncExternalStore(
|
|
43
|
+
subscribe,
|
|
44
|
+
() => (key ? store.get(key) : null),
|
|
45
|
+
() => (key ? store.get(key) : null),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const [error, setError] = useState(null)
|
|
49
|
+
const [attempt, setAttempt] = useState(0)
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!key || entry) return undefined
|
|
53
|
+
let live = true
|
|
54
|
+
setError(null)
|
|
55
|
+
client.load(key, () => client.readDefinition({ schema }), spec).catch((err) => {
|
|
56
|
+
if (live) setError(err)
|
|
57
|
+
})
|
|
58
|
+
return () => {
|
|
59
|
+
live = false
|
|
60
|
+
}
|
|
61
|
+
// `schema` is read through `key`, which encodes it.
|
|
62
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
63
|
+
}, [client, key, entry, attempt])
|
|
64
|
+
|
|
65
|
+
const refresh = useCallback(() => {
|
|
66
|
+
if (key && store) store.delete(key)
|
|
67
|
+
// The client keeps the definition too; a refresh reads it from the backend again.
|
|
68
|
+
if (schema) client?.forgetDefinition(schema)
|
|
69
|
+
setAttempt((n) => n + 1)
|
|
70
|
+
}, [store, key, client, schema])
|
|
71
|
+
|
|
72
|
+
if (!active) return { status: 'absent', definition: null, error: null, refresh }
|
|
73
|
+
if (entry) return { status: 'ready', definition: entry.data, error: null, refresh }
|
|
74
|
+
if (error) return { status: 'error', definition: null, error, refresh }
|
|
75
|
+
return { status: 'loading', definition: null, error: null, refresh }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default useDefinition
|
package/src/index.js
CHANGED
|
@@ -35,6 +35,9 @@ export {
|
|
|
35
35
|
forgetMailedLink,
|
|
36
36
|
readEntity,
|
|
37
37
|
listEntities,
|
|
38
|
+
readDefinition,
|
|
39
|
+
listSectionItems,
|
|
40
|
+
listChoices,
|
|
38
41
|
writeItems,
|
|
39
42
|
createEntity,
|
|
40
43
|
deleteEntity,
|
|
@@ -52,5 +55,7 @@ export { useMailedLink } from './hooks/useMailedLink.js'
|
|
|
52
55
|
export { useInvitations } from './hooks/useInvitations.js'
|
|
53
56
|
export { useEntity } from './hooks/useEntity.js'
|
|
54
57
|
export { useRecords } from './hooks/useRecords.js'
|
|
58
|
+
export { useDefinition } from './hooks/useDefinition.js'
|
|
59
|
+
export { useChoices } from './hooks/useChoices.js'
|
|
55
60
|
export { useEntityWriter } from './hooks/useEntityWriter.js'
|
|
56
61
|
export { SignedIn, SignedOut } from './components/gates.js'
|