@uniweb/api 1.2.0 → 1.2.1

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 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,80 @@ 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
+ | `enum: [...]` | keeps the field to those values — in the definition, as its section's `one_of` rule |
364
+ | `type: item_ref`, `options: '@acme/lists/countries'` | keeps the value to an item of that section — an item of another is `409`, `kind: 'rule'` |
365
+
366
+ A refused value is `kind: 'invalid'`, its `extensions` naming the field (`data.title`,
367
+ `data.label.fr` for one language), the `rule` it broke and the bound
368
+ (`{ rule: 'max_length', max_length: 250 }`). ⚠️ An `enum`'s refusal, `rule: 'one_of'`,
369
+ names its field only in `detail`.
370
+
371
+ ⛔ **A declaration the backend does not take refuses the whole schema** when it reaches
372
+ the backend, naming the field and the key: a `format` its kind does not have
373
+ (`year-month` on a `date`, `year` on an `int`), a key it does not know (`help`), or one it
374
+ does not check yet (`pattern`, `min_length`).
375
+
376
+ ⚠️ **A `date` is a calendar day**, `YYYY-MM-DD`: `2024-05` is refused. A year alone is an
377
+ `int`, with `min` and `max`.
378
+
379
+ ### On a section — rules across fields, and across items
380
+
381
+ A section's `constraints` — a list of rules, in its definition as `rules`:
382
+
383
+ | rule | keeps |
384
+ |---|---|
385
+ | `{ kind: 'exclusive', fields: [...] }` | at most one of the fields set — "another organization" beside a picked one |
386
+ | `{ kind: 'required_one_of', fields: [...] }` | at least one of them set |
387
+ | `{ kind: 'required_when', field, when: { field, equals } }` | the field set whenever another field equals a value |
388
+ | `{ kind: 'field_order', before, after }` | one date no later than another |
389
+ | `{ kind: 'unique_field', field }`, `{ kind: 'unique_combination', fields }` | a value — or values together — once among an entry's items in the section |
390
+ | `{ 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 |
391
+ | `{ kind: 'show_when', field, when: { field, equals } }` | nothing: when a form shows a field — read it from the definition |
392
+
393
+ ### Shared lists
394
+
395
+ Put the lists your fields pick from in **one lists type, a section per list** — not a
396
+ type per list — and its entries where the people who should see them can read them.
397
+ Then:
398
+
399
+ - **A field that picks from one** is `type: item_ref` with `options:` naming its section
400
+ (`'@acme/lists/countries'`, a nested one by its path), and `useChoices` offers it.
401
+ - **A list too long for a dropdown** is searched: `q` with `searchIn`, a page at a time.
402
+ - **A dependent list** — a country, then one of its provinces — is a section nested in
403
+ the other: `countries` holding `subdivisions`, a field picking from each. The second
404
+ field's choices are `useChoices({ …, parent: country })`, and it holds only a province.
405
+ - **A list in levels** — a discipline, then its branches — is a section whose items nest
406
+ (`self_nesting: true`): read it a level at a time with `parent`.
407
+ - **A list people may add to** is one they may edit: who may write an entry's items is
408
+ who may edit the entry. They add a choice with `useEntityWriter` on the lists entry,
409
+ and `refresh()` reads the choices again.
410
+
411
+ ### What stays in your foundation
412
+
413
+ The backend keeps content and checks it. How a field is edited is your foundation's — the
414
+ toolbar of a rich text field, a default for a new entry, a field shown read-only, an
415
+ autocomplete, a value computed from others (a currency converted, shares summed to 100
416
+ before an entry is complete). None of these is a declaration the backend keeps.
417
+
418
+ ⭐ **Who may change a field is who may edit its entry** — the backend decides access per
419
+ entry, never per field. A field only some people may change belongs on an entry only they
420
+ edit.
421
+
300
422
  ## The service on your machine
301
423
 
302
424
  Building against a live service is slow and puts a shared database behind your
@@ -343,8 +465,10 @@ production, and nothing in your site's configuration changes between the two.
343
465
 
344
466
  ⭐ **The mock answers what the backend answers** — the same statuses, bodies and
345
467
  refusals, for every route this package uses. It enforces what your schemas declare —
346
- which sections are insert-only, the fields a section takes — and it is as strict as
347
- the backend about requests, so a mistake fails here rather than in front of a user.
468
+ which sections are insert-only, the fields a section takes and their rules (`required`,
469
+ `max_length`, `min` and `max`, a date's form, an `enum`, the `email` and `url` formats, a
470
+ choice from a list) — and it is as strict as the backend about requests, so a mistake
471
+ fails here rather than in front of a user.
348
472
  Everyone signed in reads and writes their own entities, and a member nothing of anyone
349
473
  else's (`memberFloor: 'read'` in the seed models a service set up to let members read
350
474
  each other's — or per Model, `memberFloor: { '@acme/course': 'read' }`, so the
@@ -361,6 +485,20 @@ from the start.
361
485
  The operator's Admin link answers too, landing on a page of the mock's own — the backend's
362
486
  administration is not part of it. State is in memory: restart to reset.
363
487
 
488
+ Write a seed's schemas as you write them for a backend — `type: 'int'`, `multiple: true`,
489
+ `options:` — and the mock's definition answers what a backend keeps of them, so a key no
490
+ backend takes is left out of it (a backend refuses it when you register). Seed a list's
491
+ choices as items of a lists entry, each item holding the items that sit under it:
492
+
493
+ ```js
494
+ { section: 'countries', data: { code: 'CA', label: { en: 'Canada' } }, items: [
495
+ { section: 'countries/subdivisions', data: { name: { en: 'Ontario' } } },
496
+ ] }
497
+ ```
498
+
499
+ A field whose `options:` names no section of a seeded Model is reported when the mock
500
+ starts, and offers no choices — a backend refuses to register it.
501
+
364
502
  There is also a standalone server, for a frontend that is not a Uniweb site — `--site`
365
503
  names that frontend's origin, where the mail's links land:
366
504
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/api",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "The client for a Uniweb site's own backend — session, records, entities — imported by a foundation the way it imports @uniweb/kit.",
5
5
  "type": "module",
6
6
  "exports": {
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
- const held = this._schemas.get(schema)
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 write asks again.
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'