@sincpro/web-framework 0.0.0-stage → 0.1.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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -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 +69 -0
  6. package/dist/core/copy.js +111 -0
  7. package/dist/core/edit.d.ts +27 -0
  8. package/dist/core/edit.js +51 -0
  9. package/dist/core/entity.d.ts +79 -0
  10. package/dist/core/entity.js +32 -0
  11. package/dist/core/history.d.ts +20 -0
  12. package/dist/core/history.js +40 -0
  13. package/dist/core/index.d.ts +19 -0
  14. package/dist/core/index.js +19 -0
  15. package/dist/core/lookup.d.ts +16 -0
  16. package/dist/core/lookup.js +10 -0
  17. package/dist/core/memory.d.ts +43 -0
  18. package/dist/core/memory.js +85 -0
  19. package/dist/core/meta.d.ts +114 -0
  20. package/dist/core/meta.js +130 -0
  21. package/dist/core/metas.d.ts +18 -0
  22. package/dist/core/metas.js +7 -0
  23. package/dist/core/present.d.ts +48 -0
  24. package/dist/core/present.js +165 -0
  25. package/dist/core/related.d.ts +22 -0
  26. package/dist/core/related.js +21 -0
  27. package/dist/core/rest.d.ts +30 -0
  28. package/dist/core/rest.js +50 -0
  29. package/dist/core/schema.d.ts +46 -0
  30. package/dist/core/schema.js +128 -0
  31. package/dist/core/transport.d.ts +59 -0
  32. package/dist/core/transport.js +43 -0
  33. package/dist/index.d.ts +10 -0
  34. package/dist/index.js +10 -0
  35. package/dist/react/context.d.ts +27 -0
  36. package/dist/react/context.js +18 -0
  37. package/dist/react/index.d.ts +12 -0
  38. package/dist/react/index.js +12 -0
  39. package/dist/react/useCommand.d.ts +28 -0
  40. package/dist/react/useCommand.js +52 -0
  41. package/dist/react/useHistory.d.ts +5 -0
  42. package/dist/react/useHistory.js +41 -0
  43. package/dist/react/useListing.d.ts +75 -0
  44. package/dist/react/useListing.js +121 -0
  45. package/dist/react/useLookup.d.ts +22 -0
  46. package/dist/react/useLookup.js +15 -0
  47. package/dist/react/useRecord.d.ts +74 -0
  48. package/dist/react/useRecord.js +176 -0
  49. package/dist/react/useRelated.d.ts +21 -0
  50. package/dist/react/useRelated.js +32 -0
  51. package/dist/web/Refused.d.ts +9 -0
  52. package/dist/web/Refused.js +14 -0
  53. package/dist/web/index.d.ts +17 -0
  54. package/dist/web/index.js +17 -0
  55. package/dist/web/list/EntityList.d.ts +69 -0
  56. package/dist/web/list/EntityList.js +82 -0
  57. package/dist/web/list/EntitySearchBar.d.ts +26 -0
  58. package/dist/web/list/EntitySearchBar.js +43 -0
  59. package/dist/web/list/columns.d.ts +22 -0
  60. package/dist/web/list/columns.js +27 -0
  61. package/dist/web/list/search.d.ts +21 -0
  62. package/dist/web/list/search.js +72 -0
  63. package/dist/web/record/CommandButton.d.ts +22 -0
  64. package/dist/web/record/CommandButton.js +40 -0
  65. package/dist/web/record/EntityPicker.d.ts +27 -0
  66. package/dist/web/record/EntityPicker.js +39 -0
  67. package/dist/web/record/RelatedList.d.ts +18 -0
  68. package/dist/web/record/RelatedList.js +16 -0
  69. package/dist/web/record/edit.d.ts +35 -0
  70. package/dist/web/record/edit.js +63 -0
  71. package/dist/web/record/record.d.ts +25 -0
  72. package/dist/web/record/record.js +56 -0
  73. package/dist/web/workspace/SincproApp.d.ts +37 -0
  74. package/dist/web/workspace/SincproApp.js +45 -0
  75. package/dist/web/workspace/composer.d.ts +33 -0
  76. package/dist/web/workspace/composer.js +37 -0
  77. package/dist/web/workspace/skin.d.ts +11 -0
  78. package/dist/web/workspace/skin.js +33 -0
  79. package/dist/web/workspace/workspace.d.ts +16 -0
  80. package/dist/web/workspace/workspace.js +29 -0
  81. package/package.json +86 -4
@@ -0,0 +1,18 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { metaStore } from "../core/metas.js";
4
+ import { createContext, useContext, useMemo, useState } from "react";
5
+ const FrameworkContext = createContext(null);
6
+ export function FrameworkProvider({ transport, locale, metas: seed, children, }) {
7
+ // One store for the provider's life: a new seed object on a re-render must not forget what
8
+ // the screens already learned.
9
+ const [metas] = useState(() => metaStore(seed));
10
+ const value = useMemo(() => ({ transport, locale, metas }), [transport, locale, metas]);
11
+ return _jsx(FrameworkContext.Provider, { value: value, children: children });
12
+ }
13
+ export function useFramework() {
14
+ const value = useContext(FrameworkContext);
15
+ if (value === null)
16
+ throw new Error("useFramework outside a FrameworkProvider");
17
+ return value;
18
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The state of a list and a record and the gestures that drive them. It draws nothing — no
3
+ * DOM, no design system — so a screen can drop `EntityList` and still keep it. `make doctor`
4
+ * keeps it that way.
5
+ */
6
+ export { FrameworkProvider, useFramework } from "./context.js";
7
+ export { type CommandState, type Rehearsal, useCommand, } from "./useCommand.js";
8
+ export { useHistory } from "./useHistory.js";
9
+ export { type Listing, type ListingOptions, type Paging, useListing, } from "./useListing.js";
10
+ export { type Lookup, useLookup } from "./useLookup.js";
11
+ export { type Advice, type Editing, type RecordReading, type RecordState, useRecord, } from "./useRecord.js";
12
+ export { useRelated } from "./useRelated.js";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The state of a list and a record and the gestures that drive them. It draws nothing — no
3
+ * DOM, no design system — so a screen can drop `EntityList` and still keep it. `make doctor`
4
+ * keeps it that way.
5
+ */
6
+ export { FrameworkProvider, useFramework } from "./context.js";
7
+ export { useCommand, } from "./useCommand.js";
8
+ export { useHistory } from "./useHistory.js";
9
+ export { useListing, } from "./useListing.js";
10
+ export { useLookup } from "./useLookup.js";
11
+ export { useRecord, } from "./useRecord.js";
12
+ export { useRelated } from "./useRelated.js";
@@ -0,0 +1,28 @@
1
+ import type { AnyRecord } from "@sincpro/criteria";
2
+ import { type Entity } from "../core/entity.js";
3
+ /**
4
+ * One Command of an entity: run it, or ask first what it would do.
5
+ *
6
+ * Context: sincpro_framework runs a Command dry when it carries `dry_run: true` (a
7
+ * `DryRunnable`): the handler's `preview` answers what it WOULD do — «provider odoo deletes
8
+ * env-42; its record and history stay» — and nothing is touched. That is the confirmation a
9
+ * destructive action deserves: not «are you sure?», but what is about to happen. forge's
10
+ * console had the backend for it and no screen that asked.
11
+ *
12
+ * One at a time: `busy` turns the button off, because a payment sent twice is two payments.
13
+ */
14
+ export type Rehearsal = {
15
+ text: string;
16
+ data: Record<string, unknown>;
17
+ };
18
+ export type CommandState<R> = {
19
+ run: (id?: string, body?: AnyRecord) => Promise<R | undefined>;
20
+ /** The dry run: what it would do, nothing touched. */
21
+ rehearse: (id?: string, body?: AnyRecord) => Promise<Rehearsal | undefined>;
22
+ busy: boolean;
23
+ error: unknown;
24
+ /** The last dry run's answer, until `reset`. */
25
+ rehearsal: Rehearsal | null;
26
+ reset: () => void;
27
+ };
28
+ export declare function useCommand<T extends AnyRecord, R = unknown>(entity: Entity<T>, name: string): CommandState<R>;
@@ -0,0 +1,52 @@
1
+ "use client";
2
+ import { pathFor } from "../core/entity.js";
3
+ import { useFramework } from "./context.js";
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
+ export function useCommand(entity, name) {
6
+ const { transport } = useFramework();
7
+ const route = entity.commands[name];
8
+ if (route === undefined)
9
+ throw new Error(`${entity.key} declares no command "${name}"`);
10
+ const [busy, setBusy] = useState(false);
11
+ const [error, setError] = useState(null);
12
+ const [rehearsal, setRehearsal] = useState(null);
13
+ // A Command is never aborted once sent — the server may have done it — but its answer must
14
+ // not land on a screen that left.
15
+ const mounted = useRef(true);
16
+ useEffect(() => {
17
+ mounted.current = true;
18
+ return () => void (mounted.current = false);
19
+ }, []);
20
+ const send = useCallback(async (id, body) => {
21
+ setBusy(true);
22
+ setError(null);
23
+ try {
24
+ return await transport.send(route.method, pathFor(route, id), body);
25
+ }
26
+ catch (failure) {
27
+ if (mounted.current)
28
+ setError(failure);
29
+ return undefined;
30
+ }
31
+ finally {
32
+ if (mounted.current)
33
+ setBusy(false);
34
+ }
35
+ }, [transport, route]);
36
+ return {
37
+ run: (id, body = {}) => send(id, body),
38
+ rehearse: async (id, body = {}) => {
39
+ const answer = await send(id, { ...body, dry_run: true });
40
+ if (answer !== undefined && mounted.current)
41
+ setRehearsal(answer);
42
+ return answer;
43
+ },
44
+ busy,
45
+ error,
46
+ rehearsal,
47
+ reset: () => {
48
+ setRehearsal(null);
49
+ setError(null);
50
+ },
51
+ };
52
+ }
@@ -0,0 +1,5 @@
1
+ import type { AnyRecord } from "@sincpro/criteria";
2
+ import type { Entity } from "../core/entity.js";
3
+ import { type HistoryEntry } from "../core/history.js";
4
+ import { type DependencyList } from "react";
5
+ export declare function useHistory<T extends AnyRecord>(entity: Entity<T>, id: string, deps?: DependencyList): HistoryEntry[] | undefined;
@@ -0,0 +1,41 @@
1
+ "use client";
2
+ import { historyOf } from "../core/history.js";
3
+ import { useFramework } from "./context.js";
4
+ import { useEffect, useState } from "react";
5
+ /**
6
+ * A record's business events, newest first — what `Record.Activity` draws.
7
+ *
8
+ * Context: the history of a record is a criteria over its context's events — `entity_type`
9
+ * and `entity_id` — so it is read like any list, with the same transport. `undefined` while it
10
+ * is not read, or when it cannot be: an empty history says «nothing happened», which is a
11
+ * different claim from «you may not see it». Pass the record among `deps` so a write that
12
+ * reloads it reads its new facts too.
13
+ *
14
+ * Named after sincpro_framework's `history_of`; web-ui's `useActivity` is something else (the
15
+ * panel's open state).
16
+ */
17
+ const SHOWN = 100;
18
+ export function useHistory(entity, id, deps = []) {
19
+ const { transport, locale } = useFramework();
20
+ const [entries, setEntries] = useState(undefined);
21
+ const history = entity.history;
22
+ useEffect(() => {
23
+ if (history === undefined)
24
+ return;
25
+ const aborter = new AbortController();
26
+ const criteria = {
27
+ where: {
28
+ all: [
29
+ { field: "entity_type", operator: "=", value: history.type },
30
+ { field: "entity_id", operator: "=", value: id },
31
+ ],
32
+ },
33
+ order: [{ field: "created_at", descending: true }],
34
+ pagination: { limit: SHOWN },
35
+ };
36
+ transport.list(history.route, criteria, aborter.signal).then((page) => aborter.signal.aborted || setEntries(historyOf(page.rows, page.meta, locale)), () => aborter.signal.aborted || setEntries(undefined));
37
+ return () => aborter.abort();
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the caller names what reloads it
39
+ }, [transport, history, id, locale, ...deps]);
40
+ return entries;
41
+ }
@@ -0,0 +1,75 @@
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
+ /**
4
+ * N records of an entity, live: what a list draws and the gestures that drive it.
5
+ *
6
+ * Context: `useReading` from `@sincpro/criteria-react` already owns the hard rules — the
7
+ * definition asked for once, the stale answer dropped, the cursor never seen. What a screen
8
+ * still wrote by hand around it (forge's `listState`, `More`, the search box, the order) is
9
+ * the same on every list, so it lives here. Three things narrow the reading, and they AND:
10
+ *
11
+ * search(text) the entity's own search template (`meta.search`), as the server fills it
12
+ * filter(where) what a search bar or a criteria builder put — any depth of all/any/negate
13
+ * orderBy(sort) the order, asked of the SERVER: a page sorted locally lies about page two
14
+ *
15
+ * All three refine the same reading instead of starting another. It holds state and draws
16
+ * nothing: `EntityList` draws it, and a screen that needs another shape draws it itself.
17
+ */
18
+ export type Listing<T> = {
19
+ entity: Entity<T & AnyRecord>;
20
+ rows: T[];
21
+ meta: Meta | null;
22
+ count: Count | null;
23
+ dropped: Dropped[];
24
+ status: ReadingStatus;
25
+ error: unknown;
26
+ hasMore: boolean;
27
+ locale: string | undefined;
28
+ more: () => void;
29
+ reload: () => void;
30
+ /** What was typed in the search box. */
31
+ text: string;
32
+ /** Debounced: a request per pause, not per keystroke. */
33
+ search: (text: string) => void;
34
+ /** What the search bar put; `undefined` is nothing. */
35
+ where: Expression<T> | undefined;
36
+ filter: (where: Expression<T> | undefined) => void;
37
+ /** The order the server was asked for; `null` is the entity's own default. */
38
+ order: Sort | null;
39
+ orderBy: (order: Sort | null) => void;
40
+ /** How the set is walked: a page at a time, or «load more» up to a ceiling. */
41
+ paging: Paging;
42
+ /** Where the page on screen is — `pages` mode. `from`/`to` count from 1. */
43
+ page: {
44
+ index: number;
45
+ size: number;
46
+ from: number;
47
+ to: number;
48
+ hasPrev: boolean;
49
+ hasNext: boolean;
50
+ };
51
+ next: () => void;
52
+ prev: () => void;
53
+ /** `more` mode reached its ceiling: refine the search instead of loading on. */
54
+ capped: boolean;
55
+ };
56
+ /**
57
+ * - `pages`: one page on screen at a time, walked by the server's own cursor (keyset — no
58
+ * `offset`, so page 4 000 costs what page 1 costs). The DOM holds one page: a set of millions
59
+ * never freezes the screen. The default.
60
+ * - `more`: «load more» appends — natural for short sets, a feed — up to `maxRows`; past it the
61
+ * list asks to narrow the search instead of growing the page until it freezes.
62
+ */
63
+ export type Paging = "pages" | "more";
64
+ export type ListingOptions<T> = {
65
+ /** The route's own parameters: `{ tenant_id: "acme" }` → `/projects?tenant_id=acme`. */
66
+ params?: Readonly<Record<string, string>>;
67
+ /** What this list always asks — a scope. Search, filter and order AND it. */
68
+ criteria?: Criteria<T>;
69
+ paging?: Paging;
70
+ /** Rows per page (or per «load more»). 50 by default; the server's own default too. */
71
+ pageSize?: number;
72
+ /** `more` mode: the most rows ever on screen. 500 by default. */
73
+ maxRows?: number;
74
+ };
75
+ export declare function useListing<T extends AnyRecord>(entity: Entity<T>, options?: ListingOptions<T>): Listing<T>;
@@ -0,0 +1,121 @@
1
+ "use client";
2
+ import { merge, resource, resumingFrom, searchOver, } from "@sincpro/criteria";
3
+ import { useReading } from "@sincpro/criteria-react";
4
+ import { useFramework } from "./context.js";
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ /** How long typing has to pause before the search goes out. */
7
+ const TYPING_PAUSE = 200;
8
+ function routeOf(list, params) {
9
+ const query = new URLSearchParams(Object.entries(params ?? {}).filter(([, value]) => value !== "")).toString();
10
+ if (query === "")
11
+ return list;
12
+ return `${list}${list.includes("?") ? "&" : "?"}${query}`;
13
+ }
14
+ export function useListing(entity, options = {}) {
15
+ const { transport, locale, metas } = useFramework();
16
+ const route = routeOf(entity.list, options.params);
17
+ const source = useMemo(() => resource((criteria, signal) => transport.list(route, criteria, signal)), [transport, route]);
18
+ // Decided once, at mount: a definition already known means the server is not asked for it
19
+ // again. Deciding it later would change the criteria and restart the reading.
20
+ const [preloaded] = useState(() => metas.get(entity.key));
21
+ const paging = options.paging ?? "pages";
22
+ const size = options.pageSize ?? options.criteria?.pagination?.limit ?? 50;
23
+ const maxRows = options.maxRows ?? 500;
24
+ const asked = {
25
+ ...options.criteria,
26
+ pagination: { ...options.criteria?.pagination, limit: size },
27
+ };
28
+ const base = preloaded ? { ...asked, meta: false } : asked;
29
+ const reading = useReading(source, base);
30
+ // `pages` mode: the cursor that opens each page walked so far — page 0 opens with none.
31
+ const [cursors, setCursors] = useState([null]);
32
+ // The definition is kept across refinements: a refine in flight must not blank the columns.
33
+ const known = useRef(preloaded ?? null);
34
+ if (reading.meta && reading.meta !== known.current) {
35
+ known.current = reading.meta;
36
+ metas.set(entity.key, reading.meta);
37
+ }
38
+ const meta = reading.meta ?? known.current;
39
+ const [narrowing, setNarrowing] = useState({
40
+ text: "",
41
+ where: undefined,
42
+ order: null,
43
+ });
44
+ const typing = useRef(null);
45
+ useEffect(() => () => void (typing.current && clearTimeout(typing.current)), []);
46
+ const narrowed = (next) => {
47
+ let criteria = base;
48
+ const found = searchOver(meta, next.text);
49
+ if (found !== undefined)
50
+ criteria = merge(criteria, { where: found });
51
+ if (next.where !== undefined)
52
+ criteria = merge(criteria, { where: next.where });
53
+ if (next.order !== null)
54
+ criteria = merge(criteria, { order: [next.order] });
55
+ return criteria;
56
+ };
57
+ const refine = useCallback((next) => {
58
+ // Another question starts on its first page: a cursor belongs to one order and one filter.
59
+ setCursors([null]);
60
+ void reading.refine(narrowed(next));
61
+ },
62
+ // `base` is the caller's literal: its identity changes every render, its content does not.
63
+ // eslint-disable-next-line react-hooks/exhaustive-deps
64
+ [reading.refine, meta]);
65
+ const index = cursors.length - 1;
66
+ const turn = (to) => {
67
+ setCursors(to);
68
+ void reading.refine(resumingFrom(narrowed(narrowing), to[to.length - 1] ?? null));
69
+ };
70
+ const capped = paging === "more" && reading.rows.length >= maxRows;
71
+ const narrow = (change, debounce) => {
72
+ const next = { ...narrowing, ...change };
73
+ setNarrowing(next);
74
+ if (typing.current)
75
+ clearTimeout(typing.current);
76
+ if (debounce)
77
+ typing.current = setTimeout(() => refine(next), TYPING_PAUSE);
78
+ else
79
+ refine(next);
80
+ };
81
+ return {
82
+ entity,
83
+ rows: reading.rows,
84
+ meta,
85
+ count: reading.count,
86
+ dropped: reading.dropped,
87
+ status: reading.status,
88
+ error: reading.error,
89
+ hasMore: paging === "more" && reading.hasMore && !capped,
90
+ locale,
91
+ more: () => {
92
+ if (paging === "more" && !capped)
93
+ void reading.more();
94
+ },
95
+ reload: () => void reading.reload(),
96
+ text: narrowing.text,
97
+ search: (text) => narrow({ text }, true),
98
+ where: narrowing.where,
99
+ filter: (where) => narrow({ where }, false),
100
+ order: narrowing.order,
101
+ orderBy: (order) => narrow({ order }, false),
102
+ paging,
103
+ page: {
104
+ index,
105
+ size,
106
+ from: reading.rows.length === 0 ? 0 : index * size + 1,
107
+ to: index * size + reading.rows.length,
108
+ hasPrev: paging === "pages" && index > 0,
109
+ hasNext: paging === "pages" && reading.cursor !== null,
110
+ },
111
+ next: () => {
112
+ if (paging === "pages" && reading.cursor !== null)
113
+ turn([...cursors, reading.cursor]);
114
+ },
115
+ prev: () => {
116
+ if (paging === "pages" && index > 0)
117
+ turn(cursors.slice(0, -1));
118
+ },
119
+ capped,
120
+ };
121
+ }
@@ -0,0 +1,22 @@
1
+ import type { AnyRecord } from "@sincpro/criteria";
2
+ import type { Entity } from "../core/entity.js";
3
+ import { type LookupOption } from "../core/lookup.js";
4
+ /**
5
+ * The records of an entity as choices — the literal search of a many2one.
6
+ *
7
+ * Context: it is a listing like any other — the entity's own search template fills with what
8
+ * is typed, debounced, asked of the server — whose rows are read as choices (`lookupOf`). One
9
+ * page at a time: a select over ten thousand customers loads the first page and the next on
10
+ * demand, never the table.
11
+ */
12
+ export type Lookup = {
13
+ options: LookupOption[];
14
+ text: string;
15
+ search: (text: string) => void;
16
+ loading: boolean;
17
+ hasMore: boolean;
18
+ more: () => void;
19
+ };
20
+ export declare function useLookup<T extends AnyRecord>(entity: Entity<T>, options?: {
21
+ pageSize?: number;
22
+ }): Lookup;
@@ -0,0 +1,15 @@
1
+ "use client";
2
+ import { lookupOf } from "../core/lookup.js";
3
+ import { useListing } from "./useListing.js";
4
+ export function useLookup(entity, options = {}) {
5
+ // «More», not pages: a choice list grows downwards, and its ceiling keeps it light.
6
+ const listing = useListing(entity, { paging: "more", pageSize: options.pageSize ?? 50 });
7
+ return {
8
+ options: lookupOf(listing.meta, listing.rows),
9
+ text: listing.text,
10
+ search: listing.search,
11
+ loading: listing.status === "loading",
12
+ hasMore: listing.hasMore,
13
+ more: listing.more,
14
+ };
15
+ }
@@ -0,0 +1,74 @@
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
+ */
22
+ export type RecordState<T> = {
23
+ status: "loading";
24
+ record: null;
25
+ meta: Meta | null;
26
+ error: null;
27
+ } | {
28
+ status: "ready";
29
+ record: T;
30
+ meta: Meta | null;
31
+ error: null;
32
+ } | {
33
+ status: "failed";
34
+ record: null;
35
+ meta: Meta | null;
36
+ error: unknown;
37
+ };
38
+ export type Advice = {
39
+ field?: string;
40
+ text: string;
41
+ };
42
+ export type Editing = {
43
+ /** The record as on screen: what was read, what the server computed, what was typed. */
44
+ values: AnyRecord;
45
+ changed: AnyRecord;
46
+ dirty: boolean;
47
+ set: (name: string, value: unknown) => void;
48
+ state: (name: string) => FieldState;
49
+ /** The server's word on fields that did not validate, by field. */
50
+ errors: Record<string, string>;
51
+ advice: Advice[];
52
+ /** Required, visible, editable and empty: what blocks a save. */
53
+ missing: string[];
54
+ saving: boolean;
55
+ /** The last save's refusal, when it was not a conflict. */
56
+ failure: unknown;
57
+ /** Someone saved after this was read: nothing was overwritten. */
58
+ conflict: boolean;
59
+ /** The values came back from a kept draft. */
60
+ restored: boolean;
61
+ /** The draft this caller keeps on the server, when there is one. */
62
+ draft: Draft | null;
63
+ save: () => Promise<boolean>;
64
+ discard: () => void;
65
+ /** Store the current edit as a draft — only when the entity declares where. */
66
+ keep?: () => Promise<void>;
67
+ };
68
+ export type RecordReading<T> = RecordState<T> & {
69
+ entity: Entity<T & AnyRecord>;
70
+ locale: string | undefined;
71
+ reload: () => void;
72
+ edit: Editing;
73
+ };
74
+ export declare function useRecord<T extends AnyRecord>(entity: Entity<T>, id: string): RecordReading<T>;
@@ -0,0 +1,176 @@
1
+ "use client";
2
+ import { changesOf, errorsByField, fieldStateOf, missingOf, } from "../core/edit.js";
3
+ import { pathFor } from "../core/entity.js";
4
+ import { TransportError } from "../core/transport.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 server is asked what the record becomes. */
8
+ const PREVIEW_PAUSE = 300;
9
+ export function useRecord(entity, id) {
10
+ const { transport, locale, metas } = useFramework();
11
+ const [state, setState] = useState({
12
+ status: "loading",
13
+ record: null,
14
+ meta: null,
15
+ error: null,
16
+ });
17
+ const [round, setRound] = useState(0);
18
+ const [typed, setTyped] = useState({});
19
+ const [computed, setComputed] = useState({});
20
+ const [fromServer, setFromServer] = useState({});
21
+ const [advice, setAdvice] = useState([]);
22
+ const [saving, setSaving] = useState(false);
23
+ const [failure, setFailure] = useState(null);
24
+ const [conflict, setConflict] = useState(false);
25
+ const [draft, setDraft] = useState(null);
26
+ const pause = useRef(null);
27
+ // The preview in flight. A newer one cancels it, and so do a save, a discard and leaving:
28
+ // an answer about values that are no longer on screen must never land on them.
29
+ const asking = useRef(null);
30
+ const mounted = useRef(true);
31
+ const cancelPreview = () => {
32
+ if (pause.current)
33
+ clearTimeout(pause.current);
34
+ asking.current?.abort();
35
+ asking.current = null;
36
+ };
37
+ const forget = () => {
38
+ cancelPreview();
39
+ setTyped({});
40
+ setComputed({});
41
+ setFromServer({});
42
+ setAdvice([]);
43
+ setFailure(null);
44
+ setConflict(false);
45
+ };
46
+ useEffect(() => {
47
+ const aborter = new AbortController();
48
+ setState((now) => ({ status: "loading", record: null, meta: now.meta, error: null }));
49
+ transport.one(entity.one(id), aborter.signal).then((answer) => {
50
+ if (answer.meta)
51
+ metas.set(entity.key, answer.meta);
52
+ if (aborter.signal.aborted)
53
+ return;
54
+ forget();
55
+ setDraft(answer.draft);
56
+ if (answer.draft)
57
+ setTyped(answer.draft.values);
58
+ setState({
59
+ status: "ready",
60
+ record: answer.record,
61
+ meta: answer.meta ?? metas.get(entity.key) ?? null,
62
+ error: null,
63
+ });
64
+ }, (error) => {
65
+ if (!aborter.signal.aborted)
66
+ setState((now) => ({ status: "failed", record: null, meta: now.meta, error }));
67
+ });
68
+ return () => aborter.abort();
69
+ // `forget` only resets this hook's own state; listing it would reload the record on every
70
+ // render. What reloads it is the transport, the entity, the id or an explicit `reload()`.
71
+ // eslint-disable-next-line react-hooks/exhaustive-deps
72
+ }, [transport, metas, entity, id, round]);
73
+ useEffect(() => {
74
+ mounted.current = true;
75
+ return () => {
76
+ mounted.current = false;
77
+ cancelPreview();
78
+ };
79
+ }, []);
80
+ const reload = useCallback(() => setRound((n) => n + 1), []);
81
+ const record = useMemo(() => (state.record ?? {}), [state.record]);
82
+ const values = useMemo(() => ({ ...record, ...computed, ...typed }), [record, computed, typed]);
83
+ const changed = useMemo(() => changesOf(record, typed), [record, typed]);
84
+ const version = record["version"];
85
+ const ask = (next, name) => {
86
+ const preview = entity.preview;
87
+ if (preview === undefined)
88
+ return;
89
+ cancelPreview();
90
+ pause.current = setTimeout(() => {
91
+ const aborter = new AbortController();
92
+ asking.current = aborter;
93
+ transport
94
+ .send(preview.method, pathFor(preview, id), { id, values: changesOf(record, next), changed: [name] }, aborter.signal)
95
+ .then((answer) => {
96
+ if (aborter.signal.aborted || !mounted.current)
97
+ return;
98
+ setComputed(answer.values ?? {});
99
+ setFromServer(answer.fields ?? {});
100
+ setAdvice(answer.advice ?? []);
101
+ }, () => undefined);
102
+ }, PREVIEW_PAUSE);
103
+ };
104
+ const refused = (error) => {
105
+ if (error instanceof TransportError && error.problem?.kind === "conflict")
106
+ setConflict(true);
107
+ else
108
+ setFailure(error);
109
+ };
110
+ const edit = {
111
+ values,
112
+ changed,
113
+ dirty: Object.keys(changed).length > 0,
114
+ set: (name, value) => {
115
+ const next = { ...typed, [name]: value };
116
+ setTyped(next);
117
+ ask(next, name);
118
+ },
119
+ state: (name) => fieldStateOf(state.meta, name, values, fromServer),
120
+ errors: failure instanceof TransportError ? errorsByField(failure.problem?.errors) : {},
121
+ advice,
122
+ missing: missingOf(state.meta, values, fromServer),
123
+ saving,
124
+ failure,
125
+ conflict,
126
+ restored: draft !== null && Object.keys(changed).length > 0,
127
+ draft,
128
+ save: async () => {
129
+ const route = entity.save;
130
+ if (route === undefined)
131
+ throw new Error(`${entity.key} declares no save route`);
132
+ cancelPreview();
133
+ setSaving(true);
134
+ setFailure(null);
135
+ try {
136
+ await transport.send(route.method, pathFor(route, id), {
137
+ ...changed,
138
+ ...(version === undefined ? {} : { version }),
139
+ });
140
+ setDraft(null);
141
+ reload();
142
+ return true;
143
+ }
144
+ catch (error) {
145
+ // A write is never aborted — the server may have done it — but an answer that comes
146
+ // back after the screen left lands nowhere.
147
+ if (mounted.current)
148
+ refused(error);
149
+ return false;
150
+ }
151
+ finally {
152
+ if (mounted.current)
153
+ setSaving(false);
154
+ }
155
+ },
156
+ discard: forget,
157
+ ...(entity.draft === undefined
158
+ ? {}
159
+ : {
160
+ keep: async () => {
161
+ const route = entity.draft;
162
+ try {
163
+ const kept = await transport.send(route.method, pathFor(route, id), {
164
+ values: changed,
165
+ expected: draft?.version ?? 0,
166
+ });
167
+ setDraft(kept);
168
+ }
169
+ catch (error) {
170
+ refused(error);
171
+ }
172
+ },
173
+ }),
174
+ };
175
+ return { ...state, entity, locale, reload, edit };
176
+ }