@sincpro/web-framework 0.0.0-stage → 0.2.0

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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +445 -2
  3. package/dist/core/cache.d.ts +30 -0
  4. package/dist/core/cache.js +47 -0
  5. package/dist/core/copy.d.ts +93 -0
  6. package/dist/core/copy.js +141 -0
  7. package/dist/core/edit.d.ts +40 -0
  8. package/dist/core/edit.js +81 -0
  9. package/dist/core/entity.d.ts +113 -0
  10. package/dist/core/entity.js +57 -0
  11. package/dist/core/form.d.ts +21 -0
  12. package/dist/core/form.js +30 -0
  13. package/dist/core/history.d.ts +20 -0
  14. package/dist/core/history.js +40 -0
  15. package/dist/core/index.d.ts +26 -0
  16. package/dist/core/index.js +26 -0
  17. package/dist/core/list.d.ts +64 -0
  18. package/dist/core/list.js +22 -0
  19. package/dist/core/lookup.d.ts +19 -0
  20. package/dist/core/lookup.js +14 -0
  21. package/dist/core/measure.d.ts +16 -0
  22. package/dist/core/measure.js +38 -0
  23. package/dist/core/memory.d.ts +43 -0
  24. package/dist/core/memory.js +99 -0
  25. package/dist/core/meta.d.ts +114 -0
  26. package/dist/core/meta.js +130 -0
  27. package/dist/core/metas.d.ts +18 -0
  28. package/dist/core/metas.js +7 -0
  29. package/dist/core/present.d.ts +48 -0
  30. package/dist/core/present.js +165 -0
  31. package/dist/core/related.d.ts +22 -0
  32. package/dist/core/related.js +21 -0
  33. package/dist/core/relation.d.ts +28 -0
  34. package/dist/core/relation.js +30 -0
  35. package/dist/core/rest.d.ts +30 -0
  36. package/dist/core/rest.js +60 -0
  37. package/dist/core/retry.d.ts +24 -0
  38. package/dist/core/retry.js +25 -0
  39. package/dist/core/schema.d.ts +46 -0
  40. package/dist/core/schema.js +128 -0
  41. package/dist/core/summary.d.ts +50 -0
  42. package/dist/core/summary.js +54 -0
  43. package/dist/core/trail.d.ts +44 -0
  44. package/dist/core/trail.js +70 -0
  45. package/dist/core/transport.d.ts +71 -0
  46. package/dist/core/transport.js +43 -0
  47. package/dist/index.d.ts +10 -0
  48. package/dist/index.js +10 -0
  49. package/dist/react/context.d.ts +38 -0
  50. package/dist/react/context.js +26 -0
  51. package/dist/react/index.d.ts +14 -0
  52. package/dist/react/index.js +14 -0
  53. package/dist/react/useCommand.d.ts +30 -0
  54. package/dist/react/useCommand.js +60 -0
  55. package/dist/react/useGroups.d.ts +40 -0
  56. package/dist/react/useGroups.js +69 -0
  57. package/dist/react/useHistory.d.ts +5 -0
  58. package/dist/react/useHistory.js +41 -0
  59. package/dist/react/useListing.d.ts +93 -0
  60. package/dist/react/useListing.js +156 -0
  61. package/dist/react/useLookup.d.ts +47 -0
  62. package/dist/react/useLookup.js +68 -0
  63. package/dist/react/useRecord.d.ts +89 -0
  64. package/dist/react/useRecord.js +272 -0
  65. package/dist/react/useRelated.d.ts +21 -0
  66. package/dist/react/useRelated.js +32 -0
  67. package/dist/react/useSummary.d.ts +15 -0
  68. package/dist/react/useSummary.js +22 -0
  69. package/dist/web/Refused.d.ts +9 -0
  70. package/dist/web/Refused.js +14 -0
  71. package/dist/web/index.d.ts +23 -0
  72. package/dist/web/index.js +23 -0
  73. package/dist/web/list/EntityList.d.ts +92 -0
  74. package/dist/web/list/EntityList.js +229 -0
  75. package/dist/web/list/EntitySearchBar.d.ts +34 -0
  76. package/dist/web/list/EntitySearchBar.js +73 -0
  77. package/dist/web/list/columns.d.ts +22 -0
  78. package/dist/web/list/columns.js +27 -0
  79. package/dist/web/list/groups.d.ts +15 -0
  80. package/dist/web/list/groups.js +73 -0
  81. package/dist/web/list/rowCommands.d.ts +41 -0
  82. package/dist/web/list/rowCommands.js +56 -0
  83. package/dist/web/list/search.d.ts +21 -0
  84. package/dist/web/list/search.js +72 -0
  85. package/dist/web/record/CommandButton.d.ts +22 -0
  86. package/dist/web/record/CommandButton.js +40 -0
  87. package/dist/web/record/EntityPicker.d.ts +56 -0
  88. package/dist/web/record/EntityPicker.js +73 -0
  89. package/dist/web/record/EntityTags.d.ts +38 -0
  90. package/dist/web/record/EntityTags.js +41 -0
  91. package/dist/web/record/RelatedList.d.ts +19 -0
  92. package/dist/web/record/RelatedList.js +26 -0
  93. package/dist/web/record/edit.d.ts +42 -0
  94. package/dist/web/record/edit.js +122 -0
  95. package/dist/web/record/opening.d.ts +26 -0
  96. package/dist/web/record/opening.js +10 -0
  97. package/dist/web/record/record.d.ts +25 -0
  98. package/dist/web/record/record.js +56 -0
  99. package/dist/web/record/summary.d.ts +22 -0
  100. package/dist/web/record/summary.js +55 -0
  101. package/dist/web/workspace/SincproApp.d.ts +40 -0
  102. package/dist/web/workspace/SincproApp.js +45 -0
  103. package/dist/web/workspace/composer.d.ts +55 -0
  104. package/dist/web/workspace/composer.js +94 -0
  105. package/dist/web/workspace/open.d.ts +61 -0
  106. package/dist/web/workspace/open.js +62 -0
  107. package/dist/web/workspace/record.d.ts +30 -0
  108. package/dist/web/workspace/record.js +80 -0
  109. package/dist/web/workspace/skin.d.ts +2 -0
  110. package/dist/web/workspace/skin.js +45 -0
  111. package/dist/web/workspace/workspace.d.ts +44 -0
  112. package/dist/web/workspace/workspace.js +82 -0
  113. package/package.json +86 -4
@@ -0,0 +1,93 @@
1
+ import { type AnyRecord, type Count, type Criteria, type Dropped, type Expression, type Meta, type ReadingStatus, type Sort } from "@sincpro/criteria";
2
+ import type { Entity } from "../core/entity.js";
3
+ import { type ListDefinition } from "../core/list.js";
4
+ /**
5
+ * N records of an entity, live: what a list draws and the gestures that drive it.
6
+ *
7
+ * Context: `useReading` from `@sincpro/criteria-react` already owns the hard rules — the
8
+ * definition asked for once, the stale answer dropped, the cursor never seen. What a screen
9
+ * still wrote by hand around it (forge's `listState`, `More`, the search box, the order) is
10
+ * the same on every list, so it lives here. Three things narrow the reading, and they AND:
11
+ *
12
+ * search(text) the entity's own search template (`meta.search`), as the server fills it
13
+ * filter(where) what a search bar or a criteria builder put — any depth of all/any/negate
14
+ * orderBy(sort) the order, asked of the SERVER: a page sorted locally lies about page two
15
+ *
16
+ * All three refine the same reading instead of starting another. It holds state and draws
17
+ * nothing: `EntityList` draws it, and a screen that needs another shape draws it itself.
18
+ */
19
+ export type Listing<T> = {
20
+ entity: Entity<T & AnyRecord>;
21
+ /** The named list it reads, when it was handed one (`defineList`): what `EntityList` draws
22
+ * by default — its columns, filters, faces, title — and how `useGroups` starts. */
23
+ definition?: ListDefinition<T & AnyRecord>;
24
+ /** The route this list reads, with its own parameters (`/projects?tenant_id=acme`). */
25
+ route: string;
26
+ /**
27
+ * The question on screen — scope, search, filter and order — without a page: what a grouping
28
+ * of the same set asks with (`useGroups`), so groups and rows never disagree.
29
+ */
30
+ question: Criteria<T>;
31
+ rows: T[];
32
+ meta: Meta | null;
33
+ count: Count | null;
34
+ dropped: Dropped[];
35
+ status: ReadingStatus;
36
+ error: unknown;
37
+ hasMore: boolean;
38
+ locale: string | undefined;
39
+ more: () => void;
40
+ reload: () => void;
41
+ /** What was typed in the search box. */
42
+ text: string;
43
+ /** Debounced: a request per pause, not per keystroke. `now` asks at once — an explicit
44
+ * Enter does not wait for a pause. */
45
+ search: (text: string, options?: {
46
+ now?: boolean;
47
+ }) => void;
48
+ /** What the search bar put; `undefined` is nothing. */
49
+ where: Expression<T> | undefined;
50
+ filter: (where: Expression<T> | undefined) => void;
51
+ /** The order the server was asked for; `null` is the entity's own default. */
52
+ order: Sort | null;
53
+ orderBy: (order: Sort | null) => void;
54
+ /** How the set is walked: a page at a time, or «load more» up to a ceiling. */
55
+ paging: Paging;
56
+ /** Where the page on screen is — `pages` mode. `from`/`to` count from 1. */
57
+ page: {
58
+ index: number;
59
+ size: number;
60
+ from: number;
61
+ to: number;
62
+ hasPrev: boolean;
63
+ hasNext: boolean;
64
+ /** The server's cursors that start each page up to this one (`null` is the first) — and
65
+ * the one that starts the next: what a record opened from here walks on (`trailOf`). */
66
+ cursors: readonly (string | null)[];
67
+ after: string | null;
68
+ };
69
+ next: () => void;
70
+ prev: () => void;
71
+ /** `more` mode reached its ceiling: refine the search instead of loading on. */
72
+ capped: boolean;
73
+ };
74
+ /**
75
+ * - `pages`: one page on screen at a time, walked by the server's own cursor (keyset — no
76
+ * `offset`, so page 4 000 costs what page 1 costs). The DOM holds one page: a set of millions
77
+ * never freezes the screen. The default.
78
+ * - `more`: «load more» appends — natural for short sets, a feed — up to `maxRows`; past it the
79
+ * list asks to narrow the search instead of growing the page until it freezes.
80
+ */
81
+ export type Paging = "pages" | "more";
82
+ export type ListingOptions<T> = {
83
+ /** The route's own parameters: `{ tenant_id: "acme" }` → `/projects?tenant_id=acme`. */
84
+ params?: Readonly<Record<string, string>>;
85
+ /** What this list always asks — a scope. Search, filter and order AND it. */
86
+ criteria?: Criteria<T>;
87
+ paging?: Paging;
88
+ /** Rows per page (or per «load more»). 50 by default; the server's own default too. */
89
+ pageSize?: number;
90
+ /** `more` mode: the most rows ever on screen. 500 by default. */
91
+ maxRows?: number;
92
+ };
93
+ export declare function useListing<T extends AnyRecord>(given: Entity<T> | ListDefinition<T>, own?: ListingOptions<T>): Listing<T>;
@@ -0,0 +1,156 @@
1
+ "use client";
2
+ import { merge, resource, resumingFrom, searchOver, } from "@sincpro/criteria";
3
+ import { useReading } from "@sincpro/criteria-react";
4
+ import { isList, scopeOf } from "../core/list.js";
5
+ import { useFramework } from "./context.js";
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
+ /** How long typing has to pause before the search goes out. */
8
+ const TYPING_PAUSE = 200;
9
+ function routeOf(list, params) {
10
+ const query = new URLSearchParams(Object.entries(params ?? {}).filter(([, value]) => value !== "")).toString();
11
+ if (query === "")
12
+ return list;
13
+ return `${list}${list.includes("?") ? "&" : "?"}${query}`;
14
+ }
15
+ export function useListing(given, own = {}) {
16
+ const { transport, locale, metas } = useFramework();
17
+ // A named list is its entity under a fixed scope: the scope ANDs under the caller's own.
18
+ const definition = isList(given) ? given : undefined;
19
+ const entity = definition?.entity ?? given;
20
+ const options = definition === undefined
21
+ ? own
22
+ : {
23
+ ...own,
24
+ criteria: own.criteria === undefined
25
+ ? scopeOf(definition)
26
+ : merge(scopeOf(definition), own.criteria),
27
+ };
28
+ const route = routeOf(entity.list, options.params);
29
+ const source = useMemo(() => resource((criteria, signal) => transport.list(route, criteria, signal)), [transport, route]);
30
+ // Decided once, at mount: a definition already known means the server is not asked for it
31
+ // again. Deciding it later would change the criteria and restart the reading.
32
+ const [preloaded] = useState(() => metas.get(entity.key));
33
+ const paging = options.paging ?? "pages";
34
+ const size = options.pageSize ?? options.criteria?.pagination?.limit ?? 50;
35
+ const maxRows = options.maxRows ?? 500;
36
+ const asked = {
37
+ ...options.criteria,
38
+ pagination: { ...options.criteria?.pagination, limit: size },
39
+ };
40
+ const base = preloaded ? { ...asked, meta: false } : asked;
41
+ const reading = useReading(source, base);
42
+ // `pages` mode: the cursor that opens each page walked so far — page 0 opens with none.
43
+ const [cursors, setCursors] = useState([null]);
44
+ // The definition is kept across refinements: a refine in flight must not blank the columns.
45
+ const known = useRef(preloaded ?? null);
46
+ if (reading.meta && reading.meta !== known.current) {
47
+ known.current = reading.meta;
48
+ metas.set(entity.key, reading.meta);
49
+ }
50
+ const meta = reading.meta ?? known.current;
51
+ const [narrowing, setNarrowing] = useState({
52
+ text: "",
53
+ where: undefined,
54
+ order: null,
55
+ });
56
+ const typing = useRef(null);
57
+ useEffect(() => () => void (typing.current && clearTimeout(typing.current)), []);
58
+ const narrowed = (next) => {
59
+ let criteria = base;
60
+ const found = searchOver(meta, next.text);
61
+ if (found !== undefined)
62
+ criteria = merge(criteria, { where: found });
63
+ if (next.where !== undefined)
64
+ criteria = merge(criteria, { where: next.where });
65
+ if (next.order !== null)
66
+ criteria = merge(criteria, { order: [next.order] });
67
+ return criteria;
68
+ };
69
+ const refine = useCallback((next) => {
70
+ // Another question starts on its first page: a cursor belongs to one order and one filter.
71
+ setCursors([null]);
72
+ void reading.refine(narrowed(next));
73
+ },
74
+ // `base` is the caller's literal: its identity changes every render, its content does not.
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ [reading.refine, meta]);
77
+ const latest = useRef(refine);
78
+ latest.current = refine;
79
+ // Typed before the definition arrived: the text had no template to fill, so it asked nothing.
80
+ // The moment the definition is known, the question on screen is asked again — what a person
81
+ // typed is never lost to a slow first answer.
82
+ const typedBeforeModel = useRef(false);
83
+ if (meta === null && narrowing.text !== "")
84
+ typedBeforeModel.current = true;
85
+ useEffect(() => {
86
+ if (meta === null || !typedBeforeModel.current)
87
+ return;
88
+ typedBeforeModel.current = false;
89
+ refine(narrowing);
90
+ // Only the arrival of the definition re-asks; `narrowing` is read as it is then.
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, [meta]);
93
+ const index = cursors.length - 1;
94
+ const turn = (to) => {
95
+ setCursors(to);
96
+ void reading.refine(resumingFrom(narrowed(narrowing), to[to.length - 1] ?? null));
97
+ };
98
+ const capped = paging === "more" && reading.rows.length >= maxRows;
99
+ const narrow = (change, debounce) => {
100
+ const next = { ...narrowing, ...change };
101
+ setNarrowing(next);
102
+ if (typing.current)
103
+ clearTimeout(typing.current);
104
+ // The pause ends with the refine of THAT moment, not of the keystroke: a model that arrived
105
+ // meanwhile fills the search, instead of a stale refine asking unfiltered (CI found it).
106
+ if (debounce)
107
+ typing.current = setTimeout(() => latest.current(next), TYPING_PAUSE);
108
+ else
109
+ refine(next);
110
+ };
111
+ return {
112
+ entity,
113
+ ...(definition === undefined ? {} : { definition }),
114
+ route,
115
+ question: narrowed(narrowing),
116
+ rows: reading.rows,
117
+ meta,
118
+ count: reading.count,
119
+ dropped: reading.dropped,
120
+ status: reading.status,
121
+ error: reading.error,
122
+ hasMore: paging === "more" && reading.hasMore && !capped,
123
+ locale,
124
+ more: () => {
125
+ if (paging === "more" && !capped)
126
+ void reading.more();
127
+ },
128
+ reload: () => void reading.reload(),
129
+ text: narrowing.text,
130
+ search: (text, options) => narrow({ text }, options?.now !== true),
131
+ where: narrowing.where,
132
+ filter: (where) => narrow({ where }, false),
133
+ order: narrowing.order,
134
+ orderBy: (order) => narrow({ order }, false),
135
+ paging,
136
+ page: {
137
+ index,
138
+ size,
139
+ from: reading.rows.length === 0 ? 0 : index * size + 1,
140
+ to: index * size + reading.rows.length,
141
+ hasPrev: paging === "pages" && index > 0,
142
+ hasNext: paging === "pages" && reading.cursor !== null,
143
+ cursors,
144
+ after: reading.cursor,
145
+ },
146
+ next: () => {
147
+ if (paging === "pages" && reading.cursor !== null)
148
+ turn([...cursors, reading.cursor]);
149
+ },
150
+ prev: () => {
151
+ if (paging === "pages" && index > 0)
152
+ turn(cursors.slice(0, -1));
153
+ },
154
+ capped,
155
+ };
156
+ }
@@ -0,0 +1,47 @@
1
+ import type { AnyRecord, Criteria } from "@sincpro/criteria";
2
+ import type { Entity } from "../core/entity.js";
3
+ import { type ListDefinition } from "../core/list.js";
4
+ import { type LookupOption } from "../core/lookup.js";
5
+ /**
6
+ * The records of an entity as choices — the literal search of a many2one.
7
+ *
8
+ * Context: it is a listing like any other — the entity's own search template fills with what
9
+ * is typed, debounced, asked of the server — whose rows are read as choices (`lookupOf`). It
10
+ * opens on the first TEN in the entity's own order (what Odoo's many2one shows, and what fits
11
+ * a dropdown); typing asks the server, so the record 123 455 of 200 000 is found by its name
12
+ * though it was never among the ten. Never the table.
13
+ *
14
+ * The chosen record usually is NOT on the page on screen — a search narrowed it, or it lies
15
+ * past the first ten — so its name is kept: from the options it was seen among, or asked of
16
+ * the server once by its key (`chosen`).
17
+ */
18
+ export type Lookup = {
19
+ options: LookupOption[];
20
+ text: string;
21
+ search: (text: string) => void;
22
+ /** The entity has a literal search template: typing asks the server. Without one the
23
+ * options are filtered where they are. */
24
+ searchable: boolean;
25
+ loading: boolean;
26
+ hasMore: boolean;
27
+ more: () => void;
28
+ /** The chosen record as a choice, whether or not it is among `options`. */
29
+ chosen: LookupOption | undefined;
30
+ /** The chosen records of a many2many (`values`), in their order, each as a choice — those
31
+ * not known yet are asked of the server in one question. */
32
+ chosenAll: LookupOption[];
33
+ };
34
+ export declare function useLookup<T extends AnyRecord>(
35
+ /** The entity, or a named list of it: «active customers» chooses only those. */
36
+ entity: Entity<T> | ListDefinition<T>, options?: {
37
+ pageSize?: number;
38
+ /**
39
+ * Which records may be chosen at all — the contacts OF the chosen customer, the warehouses
40
+ * OF the branch. The server applies it under whatever is typed; a new scope starts over.
41
+ */
42
+ criteria?: Criteria<T>;
43
+ /** The key of the chosen record, to keep its name (`chosen`). */
44
+ value?: string;
45
+ /** The keys chosen in a many2many, to keep their names (`chosenAll`). */
46
+ values?: readonly string[];
47
+ }): Lookup;
@@ -0,0 +1,68 @@
1
+ "use client";
2
+ import { isList } from "../core/list.js";
3
+ import { lookupOf } from "../core/lookup.js";
4
+ import { useFramework } from "./context.js";
5
+ import { useListing } from "./useListing.js";
6
+ import { useEffect, useRef, useState } from "react";
7
+ /** Ten: what a dropdown shows without scrolling, and what Odoo's many2one asks for. */
8
+ const FIRST = 10;
9
+ export function useLookup(
10
+ /** The entity, or a named list of it: «active customers» chooses only those. */
11
+ entity, options = {}) {
12
+ const { transport } = useFramework();
13
+ // «More», not pages: a choice list grows downwards, and its ceiling keeps it light.
14
+ const listing = useListing(entity, {
15
+ paging: "more",
16
+ pageSize: options.pageSize ?? FIRST,
17
+ ...(options.criteria === undefined ? {} : { criteria: options.criteria }),
18
+ });
19
+ const base = isList(entity) ? entity.entity : entity;
20
+ const choices = lookupOf(listing.meta, listing.rows, base.summary);
21
+ // Every choice seen is remembered: the chosen are named even after the page moved on.
22
+ const seen = useRef(new Map());
23
+ for (const one of choices)
24
+ seen.current.set(one.id, one);
25
+ const [, setNamed] = useState(0);
26
+ const value = options.value ?? "";
27
+ const wanted = options.values ?? (value === "" ? [] : [value]);
28
+ const missing = wanted.filter((id) => !seen.current.has(id));
29
+ const missingKey = missing.join("\u0000");
30
+ // Chosen but never seen (a form opened on a saved record): their names, asked ONCE by key.
31
+ const route = base.list;
32
+ const meta = listing.meta;
33
+ useEffect(() => {
34
+ if (missingKey === "" || meta === null)
35
+ return;
36
+ const keys = missingKey.split("\u0000");
37
+ const key = meta.get_id || meta.identity || "id";
38
+ const aborter = new AbortController();
39
+ transport
40
+ .list(route, {
41
+ where: keys.length === 1
42
+ ? { field: key, operator: "=", value: keys[0] ?? "" }
43
+ : { field: key, operator: "in", value: keys },
44
+ pagination: { limit: keys.length },
45
+ meta: false,
46
+ }, aborter.signal)
47
+ .then((page) => {
48
+ for (const one of lookupOf(meta, page.rows, base.summary))
49
+ seen.current.set(one.id, one);
50
+ setNamed((n) => n + 1);
51
+ })
52
+ .catch(() => undefined); // unnamed is not an error: the field shows its key
53
+ return () => aborter.abort();
54
+ }, [transport, route, meta, missingKey, base.summary]);
55
+ const known = value === "" ? undefined : seen.current.get(value);
56
+ const chosenAll = wanted.map((id) => seen.current.get(id) ?? { id, label: id });
57
+ return {
58
+ options: choices,
59
+ text: listing.text,
60
+ search: listing.search,
61
+ searchable: listing.meta?.search != null,
62
+ loading: listing.status === "loading",
63
+ hasMore: listing.hasMore,
64
+ more: listing.more,
65
+ chosen: known,
66
+ chosenAll,
67
+ };
68
+ }
@@ -0,0 +1,89 @@
1
+ import type { AnyRecord, Meta } from "@sincpro/criteria";
2
+ import { type FieldState } from "../core/edit.js";
3
+ import { type Entity } from "../core/entity.js";
4
+ import { type Draft } from "../core/transport.js";
5
+ /**
6
+ * ONE record of an entity, with the definition of its model — read, and edited.
7
+ *
8
+ * Context: forge's console wrote the reading as `useAnswer` and never edited at all, though its
9
+ * backend had an update route: editing by hand (dirty state, save, discard, conflicts) cost more
10
+ * than the screen was worth. Here it is the same object:
11
+ *
12
+ * - **reading** — the record and its meta travel together (sincpro_framework's
13
+ * `ResponseRecord`); an answer that arrives after the view moved on is dropped.
14
+ * - **editing** — `set` keeps what is typed apart from the record; each field's state comes
15
+ * from the model's hints evaluated over what is on screen (`fieldStateOf`), refined by the
16
+ * server's `Preview` when the entity has one (computed values, states, advice).
17
+ * - **saving** — only what changed travels, with the version that was read: if someone saved
18
+ * in between, the server answers 409 and the screen says so instead of overwriting.
19
+ * - **drafts** — an edit kept between requests (sincpro_framework's `Drafts`) comes back with
20
+ * the record and is shown as restored; `keep()` stores the current one.
21
+ * - **creating** — with no id it is a record that does not exist yet: it starts from the
22
+ * defaults the model publishes, previews like any other, and `save()` sends it to the
23
+ * entity's `create` route with an `Idempotency-Key` (a retry never creates two). When the
24
+ * answer carries the new identity, the hook becomes that record; otherwise `edit.created`
25
+ * holds the answer and the screen decides where to go.
26
+ *
27
+ * const invoice = useRecord(invoices, ""); // a new one
28
+ * const invoice = useRecord(invoices, "F-2026-7"); // an existing one
29
+ */
30
+ export type RecordState<T> = {
31
+ status: "loading";
32
+ record: null;
33
+ meta: Meta | null;
34
+ error: null;
35
+ } | {
36
+ status: "ready";
37
+ record: T;
38
+ meta: Meta | null;
39
+ error: null;
40
+ } | {
41
+ status: "failed";
42
+ record: null;
43
+ meta: Meta | null;
44
+ error: unknown;
45
+ };
46
+ export type Advice = {
47
+ field?: string;
48
+ text: string;
49
+ };
50
+ export type Editing = {
51
+ /** The record as on screen: what was read, what the server computed, what was typed. */
52
+ values: AnyRecord;
53
+ changed: AnyRecord;
54
+ dirty: boolean;
55
+ set: (name: string, value: unknown) => void;
56
+ state: (name: string) => FieldState;
57
+ /** The server's word on fields that did not validate, by field. */
58
+ errors: Record<string, string>;
59
+ advice: Advice[];
60
+ /** Required, visible, editable and empty: what blocks a save. */
61
+ missing: string[];
62
+ saving: boolean;
63
+ /** The last save's refusal, when it was not a conflict. */
64
+ failure: unknown;
65
+ /** Someone saved after this was read: nothing was overwritten. */
66
+ conflict: boolean;
67
+ /** The values came back from a kept draft. */
68
+ restored: boolean;
69
+ /** The draft this caller keeps on the server, when there is one. */
70
+ draft: Draft | null;
71
+ save: () => Promise<boolean>;
72
+ discard: () => void;
73
+ /** Store the current edit as a draft — only when the entity declares where, and the record
74
+ * exists: a draft is kept against a record. */
75
+ keep?: () => Promise<void>;
76
+ /** What the last create answered, when it did not say the new record's identity. */
77
+ created: unknown;
78
+ };
79
+ export type RecordReading<T> = RecordState<T> & {
80
+ entity: Entity<T & AnyRecord>;
81
+ /** The record's key — `""` while it is new, its identity once created. */
82
+ id: string;
83
+ /** It does not exist on the server yet. */
84
+ isNew: boolean;
85
+ locale: string | undefined;
86
+ reload: () => void;
87
+ edit: Editing;
88
+ };
89
+ export declare function useRecord<T extends AnyRecord>(entity: Entity<T>, given: string): RecordReading<T>;