@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.
- package/LICENSE +21 -0
- package/README.md +224 -2
- package/dist/core/cache.d.ts +30 -0
- package/dist/core/cache.js +47 -0
- package/dist/core/copy.d.ts +69 -0
- package/dist/core/copy.js +111 -0
- package/dist/core/edit.d.ts +27 -0
- package/dist/core/edit.js +51 -0
- package/dist/core/entity.d.ts +79 -0
- package/dist/core/entity.js +32 -0
- package/dist/core/history.d.ts +20 -0
- package/dist/core/history.js +40 -0
- package/dist/core/index.d.ts +19 -0
- package/dist/core/index.js +19 -0
- package/dist/core/lookup.d.ts +16 -0
- package/dist/core/lookup.js +10 -0
- package/dist/core/memory.d.ts +43 -0
- package/dist/core/memory.js +85 -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/rest.d.ts +30 -0
- package/dist/core/rest.js +50 -0
- package/dist/core/schema.d.ts +46 -0
- package/dist/core/schema.js +128 -0
- package/dist/core/transport.d.ts +59 -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 +27 -0
- package/dist/react/context.js +18 -0
- package/dist/react/index.d.ts +12 -0
- package/dist/react/index.js +12 -0
- package/dist/react/useCommand.d.ts +28 -0
- package/dist/react/useCommand.js +52 -0
- package/dist/react/useHistory.d.ts +5 -0
- package/dist/react/useHistory.js +41 -0
- package/dist/react/useListing.d.ts +75 -0
- package/dist/react/useListing.js +121 -0
- package/dist/react/useLookup.d.ts +22 -0
- package/dist/react/useLookup.js +15 -0
- package/dist/react/useRecord.d.ts +74 -0
- package/dist/react/useRecord.js +176 -0
- package/dist/react/useRelated.d.ts +21 -0
- package/dist/react/useRelated.js +32 -0
- package/dist/web/Refused.d.ts +9 -0
- package/dist/web/Refused.js +14 -0
- package/dist/web/index.d.ts +17 -0
- package/dist/web/index.js +17 -0
- package/dist/web/list/EntityList.d.ts +69 -0
- package/dist/web/list/EntityList.js +82 -0
- package/dist/web/list/EntitySearchBar.d.ts +26 -0
- package/dist/web/list/EntitySearchBar.js +43 -0
- package/dist/web/list/columns.d.ts +22 -0
- package/dist/web/list/columns.js +27 -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 +27 -0
- package/dist/web/record/EntityPicker.js +39 -0
- package/dist/web/record/RelatedList.d.ts +18 -0
- package/dist/web/record/RelatedList.js +16 -0
- package/dist/web/record/edit.d.ts +35 -0
- package/dist/web/record/edit.js +63 -0
- package/dist/web/record/record.d.ts +25 -0
- package/dist/web/record/record.js +56 -0
- package/dist/web/workspace/SincproApp.d.ts +37 -0
- package/dist/web/workspace/SincproApp.js +45 -0
- package/dist/web/workspace/composer.d.ts +33 -0
- package/dist/web/workspace/composer.js +37 -0
- package/dist/web/workspace/skin.d.ts +11 -0
- package/dist/web/workspace/skin.js +33 -0
- package/dist/web/workspace/workspace.d.ts +16 -0
- package/dist/web/workspace/workspace.js +29 -0
- 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
|
+
}
|