@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.
- package/LICENSE +21 -0
- package/README.md +445 -2
- package/dist/core/cache.d.ts +30 -0
- package/dist/core/cache.js +47 -0
- package/dist/core/copy.d.ts +93 -0
- package/dist/core/copy.js +141 -0
- package/dist/core/edit.d.ts +40 -0
- package/dist/core/edit.js +81 -0
- package/dist/core/entity.d.ts +113 -0
- package/dist/core/entity.js +57 -0
- package/dist/core/form.d.ts +21 -0
- package/dist/core/form.js +30 -0
- package/dist/core/history.d.ts +20 -0
- package/dist/core/history.js +40 -0
- package/dist/core/index.d.ts +26 -0
- package/dist/core/index.js +26 -0
- package/dist/core/list.d.ts +64 -0
- package/dist/core/list.js +22 -0
- package/dist/core/lookup.d.ts +19 -0
- package/dist/core/lookup.js +14 -0
- package/dist/core/measure.d.ts +16 -0
- package/dist/core/measure.js +38 -0
- package/dist/core/memory.d.ts +43 -0
- package/dist/core/memory.js +99 -0
- package/dist/core/meta.d.ts +114 -0
- package/dist/core/meta.js +130 -0
- package/dist/core/metas.d.ts +18 -0
- package/dist/core/metas.js +7 -0
- package/dist/core/present.d.ts +48 -0
- package/dist/core/present.js +165 -0
- package/dist/core/related.d.ts +22 -0
- package/dist/core/related.js +21 -0
- package/dist/core/relation.d.ts +28 -0
- package/dist/core/relation.js +30 -0
- package/dist/core/rest.d.ts +30 -0
- package/dist/core/rest.js +60 -0
- package/dist/core/retry.d.ts +24 -0
- package/dist/core/retry.js +25 -0
- package/dist/core/schema.d.ts +46 -0
- package/dist/core/schema.js +128 -0
- package/dist/core/summary.d.ts +50 -0
- package/dist/core/summary.js +54 -0
- package/dist/core/trail.d.ts +44 -0
- package/dist/core/trail.js +70 -0
- package/dist/core/transport.d.ts +71 -0
- package/dist/core/transport.js +43 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/react/context.d.ts +38 -0
- package/dist/react/context.js +26 -0
- package/dist/react/index.d.ts +14 -0
- package/dist/react/index.js +14 -0
- package/dist/react/useCommand.d.ts +30 -0
- package/dist/react/useCommand.js +60 -0
- package/dist/react/useGroups.d.ts +40 -0
- package/dist/react/useGroups.js +69 -0
- package/dist/react/useHistory.d.ts +5 -0
- package/dist/react/useHistory.js +41 -0
- package/dist/react/useListing.d.ts +93 -0
- package/dist/react/useListing.js +156 -0
- package/dist/react/useLookup.d.ts +47 -0
- package/dist/react/useLookup.js +68 -0
- package/dist/react/useRecord.d.ts +89 -0
- package/dist/react/useRecord.js +272 -0
- package/dist/react/useRelated.d.ts +21 -0
- package/dist/react/useRelated.js +32 -0
- package/dist/react/useSummary.d.ts +15 -0
- package/dist/react/useSummary.js +22 -0
- package/dist/web/Refused.d.ts +9 -0
- package/dist/web/Refused.js +14 -0
- package/dist/web/index.d.ts +23 -0
- package/dist/web/index.js +23 -0
- package/dist/web/list/EntityList.d.ts +92 -0
- package/dist/web/list/EntityList.js +229 -0
- package/dist/web/list/EntitySearchBar.d.ts +34 -0
- package/dist/web/list/EntitySearchBar.js +73 -0
- package/dist/web/list/columns.d.ts +22 -0
- package/dist/web/list/columns.js +27 -0
- package/dist/web/list/groups.d.ts +15 -0
- package/dist/web/list/groups.js +73 -0
- package/dist/web/list/rowCommands.d.ts +41 -0
- package/dist/web/list/rowCommands.js +56 -0
- package/dist/web/list/search.d.ts +21 -0
- package/dist/web/list/search.js +72 -0
- package/dist/web/record/CommandButton.d.ts +22 -0
- package/dist/web/record/CommandButton.js +40 -0
- package/dist/web/record/EntityPicker.d.ts +56 -0
- package/dist/web/record/EntityPicker.js +73 -0
- package/dist/web/record/EntityTags.d.ts +38 -0
- package/dist/web/record/EntityTags.js +41 -0
- package/dist/web/record/RelatedList.d.ts +19 -0
- package/dist/web/record/RelatedList.js +26 -0
- package/dist/web/record/edit.d.ts +42 -0
- package/dist/web/record/edit.js +122 -0
- package/dist/web/record/opening.d.ts +26 -0
- package/dist/web/record/opening.js +10 -0
- package/dist/web/record/record.d.ts +25 -0
- package/dist/web/record/record.js +56 -0
- package/dist/web/record/summary.d.ts +22 -0
- package/dist/web/record/summary.js +55 -0
- package/dist/web/workspace/SincproApp.d.ts +40 -0
- package/dist/web/workspace/SincproApp.js +45 -0
- package/dist/web/workspace/composer.d.ts +55 -0
- package/dist/web/workspace/composer.js +94 -0
- package/dist/web/workspace/open.d.ts +61 -0
- package/dist/web/workspace/open.js +62 -0
- package/dist/web/workspace/record.d.ts +30 -0
- package/dist/web/workspace/record.js +80 -0
- package/dist/web/workspace/skin.d.ts +2 -0
- package/dist/web/workspace/skin.js +45 -0
- package/dist/web/workspace/workspace.d.ts +44 -0
- package/dist/web/workspace/workspace.js +82 -0
- 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>;
|