@sincpro/web-framework 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +261 -40
  2. package/dist/core/cache.js +2 -2
  3. package/dist/core/copy.d.ts +24 -0
  4. package/dist/core/copy.js +30 -0
  5. package/dist/core/edit.d.ts +13 -0
  6. package/dist/core/edit.js +30 -0
  7. package/dist/core/entity.d.ts +34 -0
  8. package/dist/core/entity.js +25 -0
  9. package/dist/core/form.d.ts +21 -0
  10. package/dist/core/form.js +30 -0
  11. package/dist/core/index.d.ts +10 -3
  12. package/dist/core/index.js +9 -2
  13. package/dist/core/list.d.ts +64 -0
  14. package/dist/core/list.js +22 -0
  15. package/dist/core/lookup.d.ts +5 -2
  16. package/dist/core/lookup.js +6 -2
  17. package/dist/core/measure.d.ts +16 -0
  18. package/dist/core/measure.js +38 -0
  19. package/dist/core/memory.js +18 -4
  20. package/dist/core/relation.d.ts +28 -0
  21. package/dist/core/relation.js +30 -0
  22. package/dist/core/rest.js +13 -3
  23. package/dist/core/retry.d.ts +24 -0
  24. package/dist/core/retry.js +25 -0
  25. package/dist/core/summary.d.ts +50 -0
  26. package/dist/core/summary.js +54 -0
  27. package/dist/core/trail.d.ts +44 -0
  28. package/dist/core/trail.js +70 -0
  29. package/dist/core/transport.d.ts +13 -1
  30. package/dist/react/context.d.ts +12 -1
  31. package/dist/react/context.js +10 -2
  32. package/dist/react/index.d.ts +3 -1
  33. package/dist/react/index.js +3 -1
  34. package/dist/react/useCommand.d.ts +3 -1
  35. package/dist/react/useCommand.js +12 -4
  36. package/dist/react/useGroups.d.ts +40 -0
  37. package/dist/react/useGroups.js +69 -0
  38. package/dist/react/useListing.d.ts +21 -3
  39. package/dist/react/useListing.js +38 -3
  40. package/dist/react/useLookup.d.ts +30 -5
  41. package/dist/react/useLookup.js +56 -3
  42. package/dist/react/useRecord.d.ts +17 -2
  43. package/dist/react/useRecord.js +105 -9
  44. package/dist/react/useSummary.d.ts +15 -0
  45. package/dist/react/useSummary.js +22 -0
  46. package/dist/web/index.d.ts +9 -3
  47. package/dist/web/index.js +9 -3
  48. package/dist/web/list/EntityList.d.ts +27 -4
  49. package/dist/web/list/EntityList.js +162 -15
  50. package/dist/web/list/EntitySearchBar.d.ts +16 -8
  51. package/dist/web/list/EntitySearchBar.js +34 -4
  52. package/dist/web/list/groups.d.ts +15 -0
  53. package/dist/web/list/groups.js +73 -0
  54. package/dist/web/list/rowCommands.d.ts +41 -0
  55. package/dist/web/list/rowCommands.js +56 -0
  56. package/dist/web/record/EntityPicker.d.ts +42 -13
  57. package/dist/web/record/EntityPicker.js +53 -19
  58. package/dist/web/record/EntityTags.d.ts +38 -0
  59. package/dist/web/record/EntityTags.js +41 -0
  60. package/dist/web/record/RelatedList.d.ts +2 -1
  61. package/dist/web/record/RelatedList.js +12 -2
  62. package/dist/web/record/edit.d.ts +12 -5
  63. package/dist/web/record/edit.js +67 -8
  64. package/dist/web/record/opening.d.ts +26 -0
  65. package/dist/web/record/opening.js +10 -0
  66. package/dist/web/record/summary.d.ts +22 -0
  67. package/dist/web/record/summary.js +55 -0
  68. package/dist/web/workspace/SincproApp.d.ts +4 -1
  69. package/dist/web/workspace/SincproApp.js +2 -2
  70. package/dist/web/workspace/composer.d.ts +26 -4
  71. package/dist/web/workspace/composer.js +59 -2
  72. package/dist/web/workspace/open.d.ts +61 -0
  73. package/dist/web/workspace/open.js +62 -0
  74. package/dist/web/workspace/record.d.ts +30 -0
  75. package/dist/web/workspace/record.js +80 -0
  76. package/dist/web/workspace/skin.d.ts +0 -9
  77. package/dist/web/workspace/skin.js +25 -13
  78. package/dist/web/workspace/workspace.d.ts +31 -3
  79. package/dist/web/workspace/workspace.js +57 -4
  80. package/package.json +5 -5
@@ -0,0 +1,70 @@
1
+ import { resumingFrom } from "@sincpro/criteria";
2
+ /** Where a record stands in its trail: «3 of 64», 1-based. */
3
+ export function placeOf(trail, id) {
4
+ const at = trail.ids.indexOf(id);
5
+ if (at < 0)
6
+ return null;
7
+ const index = trail.offset + at + 1;
8
+ return { index, total: Math.max(trail.total ?? 0, trail.offset + trail.ids.length, index) };
9
+ }
10
+ async function page(transport, trail, cursor) {
11
+ const sized = {
12
+ ...trail.question,
13
+ pagination: { ...trail.question.pagination, limit: trail.size },
14
+ };
15
+ const answer = await transport.list(trail.route, {
16
+ ...resumingFrom(sized, cursor),
17
+ meta: false,
18
+ });
19
+ return {
20
+ ids: answer.rows.map((row) => String(row[trail.key] ?? "")),
21
+ after: answer.cursor,
22
+ };
23
+ }
24
+ /**
25
+ * The record next to `id` (`step` 1) or before it (`step` -1), with the trail moved to its page
26
+ * when it lies on the neighbouring one. `null` at either end of the set.
27
+ */
28
+ export async function stepIn(transport, trail, id, step) {
29
+ const at = trail.ids.indexOf(id);
30
+ if (at < 0)
31
+ return null;
32
+ const there = trail.ids[at + step];
33
+ if (there !== undefined)
34
+ return { id: there, trail };
35
+ if (step === 1) {
36
+ if (trail.after === null)
37
+ return null;
38
+ const next = await page(transport, trail, trail.after);
39
+ const [first] = next.ids;
40
+ if (first === undefined)
41
+ return null;
42
+ return {
43
+ id: first,
44
+ trail: {
45
+ ...trail,
46
+ ids: next.ids,
47
+ offset: trail.offset + trail.ids.length,
48
+ cursors: [...trail.cursors, trail.after],
49
+ after: next.after,
50
+ },
51
+ };
52
+ }
53
+ if (trail.cursors.length < 2)
54
+ return null;
55
+ const cursors = trail.cursors.slice(0, -1);
56
+ const before = await page(transport, trail, cursors[cursors.length - 1] ?? null);
57
+ const last = before.ids[before.ids.length - 1];
58
+ if (last === undefined)
59
+ return null;
60
+ return {
61
+ id: last,
62
+ trail: {
63
+ ...trail,
64
+ ids: before.ids,
65
+ offset: Math.max(0, trail.offset - before.ids.length),
66
+ cursors,
67
+ after: trail.cursors[trail.cursors.length - 1] ?? null,
68
+ },
69
+ };
70
+ }
@@ -17,9 +17,21 @@ export interface Transport {
17
17
  /** One record, with the definition of its model. */
18
18
  one<T>(route: string, signal?: AbortSignal): Promise<One<T>>;
19
19
  /** A Command — a write, an action, a preview: `method` and a JSON body, its answer back. */
20
- send<T>(method: Method, route: string, body: unknown, signal?: AbortSignal): Promise<T>;
20
+ send<T>(method: Method, route: string, body: unknown, signal?: AbortSignal, options?: SendOptions): Promise<T>;
21
21
  }
22
22
  export type Method = "POST" | "PUT" | "PATCH" | "DELETE";
23
+ /**
24
+ * How a Command travels besides its body.
25
+ *
26
+ * Context: sincpro_framework runs a Command declared `@idempotency` once per key (REST: the
27
+ * `Idempotency-Key` header; 400 `IDEMPOTENCY_KEY_MISSING` without it) and answers a repeat with
28
+ * the first answer. That is what makes «retry» safe after a request that got no answer: the
29
+ * server may have created the invoice, and the same key cannot create it twice.
30
+ */
31
+ export type SendOptions = {
32
+ /** One per gesture, kept while it is retried — `retryKeys()`. */
33
+ idempotencyKey?: string;
34
+ };
23
35
  /** One record as sincpro_framework answers it (`ResponseRecord`): the record, its model. */
24
36
  export type One<T> = {
25
37
  record: T;
@@ -1,5 +1,7 @@
1
1
  import type { Meta } from "@sincpro/criteria";
2
+ import type { Entity } from "../core/entity.js";
2
3
  import { type MetaStore } from "../core/metas.js";
4
+ import type { Trail } from "../core/trail.js";
3
5
  import type { Transport } from "../core/transport.js";
4
6
  import { type ReactNode } from "react";
5
7
  /**
@@ -14,14 +16,23 @@ type FrameworkContextValue = {
14
16
  transport: Transport;
15
17
  locale: string | undefined;
16
18
  metas: MetaStore;
19
+ /** The application's entities: what a relation of a model is resolved to. */
20
+ entities: readonly Entity[];
21
+ /** Where each open record came from (`Trail`), by the key its reference carries. */
22
+ trails: Map<string, Trail>;
17
23
  };
18
- export declare function FrameworkProvider({ transport, locale, metas: seed, children, }: {
24
+ export declare function FrameworkProvider({ transport, locale, metas: seed, entities, children, }: {
19
25
  transport: Transport;
20
26
  /** The language labels and values are read in (`"es"`); the model's `default` without it. */
21
27
  locale?: string;
22
28
  /** Definitions known before any request, by entity key: the preload. */
23
29
  metas?: Readonly<Record<string, Meta>>;
30
+ /** The entities of the application: a form draws a many2one as a picker of the one its
31
+ * model points at (`relation: "Cliente"`) without being told. */
32
+ entities?: readonly Entity[];
24
33
  children: ReactNode;
25
34
  }): import("react").JSX.Element;
26
35
  export declare function useFramework(): FrameworkContextValue;
36
+ /** The declared entity a relation points at (`"Cliente"`), or `undefined`. */
37
+ export declare function useEntityFor(target: string | undefined): Entity | undefined;
27
38
  export {};
@@ -1,18 +1,26 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { metaStore } from "../core/metas.js";
4
+ import { entityFor } from "../core/relation.js";
4
5
  import { createContext, useContext, useMemo, useState } from "react";
5
6
  const FrameworkContext = createContext(null);
6
- export function FrameworkProvider({ transport, locale, metas: seed, children, }) {
7
+ export function FrameworkProvider({ transport, locale, metas: seed, entities = NO_ENTITIES, children, }) {
7
8
  // One store for the provider's life: a new seed object on a re-render must not forget what
8
9
  // the screens already learned.
9
10
  const [metas] = useState(() => metaStore(seed));
10
- const value = useMemo(() => ({ transport, locale, metas }), [transport, locale, metas]);
11
+ const [trails] = useState(() => new Map());
12
+ const value = useMemo(() => ({ transport, locale, metas, entities, trails }), [transport, locale, metas, entities, trails]);
11
13
  return _jsx(FrameworkContext.Provider, { value: value, children: children });
12
14
  }
15
+ const NO_ENTITIES = [];
13
16
  export function useFramework() {
14
17
  const value = useContext(FrameworkContext);
15
18
  if (value === null)
16
19
  throw new Error("useFramework outside a FrameworkProvider");
17
20
  return value;
18
21
  }
22
+ /** The declared entity a relation points at (`"Cliente"`), or `undefined`. */
23
+ export function useEntityFor(target) {
24
+ const { entities, metas } = useFramework();
25
+ return target === undefined ? undefined : entityFor(entities, target, metas.get);
26
+ }
@@ -3,10 +3,12 @@
3
3
  * DOM, no design system — so a screen can drop `EntityList` and still keep it. `make doctor`
4
4
  * keeps it that way.
5
5
  */
6
- export { FrameworkProvider, useFramework } from "./context.js";
6
+ export { FrameworkProvider, useEntityFor, useFramework, } from "./context.js";
7
7
  export { type CommandState, type Rehearsal, useCommand, } from "./useCommand.js";
8
+ export { type Groups, useGroups } from "./useGroups.js";
8
9
  export { useHistory } from "./useHistory.js";
9
10
  export { type Listing, type ListingOptions, type Paging, useListing, } from "./useListing.js";
10
11
  export { type Lookup, useLookup } from "./useLookup.js";
11
12
  export { type Advice, type Editing, type RecordReading, type RecordState, useRecord, } from "./useRecord.js";
12
13
  export { useRelated } from "./useRelated.js";
14
+ export { useSummaryOf } from "./useSummary.js";
@@ -3,10 +3,12 @@
3
3
  * DOM, no design system — so a screen can drop `EntityList` and still keep it. `make doctor`
4
4
  * keeps it that way.
5
5
  */
6
- export { FrameworkProvider, useFramework } from "./context.js";
6
+ export { FrameworkProvider, useEntityFor, useFramework, } from "./context.js";
7
7
  export { useCommand, } from "./useCommand.js";
8
+ export { useGroups } from "./useGroups.js";
8
9
  export { useHistory } from "./useHistory.js";
9
10
  export { useListing, } from "./useListing.js";
10
11
  export { useLookup } from "./useLookup.js";
11
12
  export { useRecord, } from "./useRecord.js";
12
13
  export { useRelated } from "./useRelated.js";
14
+ export { useSummaryOf } from "./useSummary.js";
@@ -9,7 +9,9 @@ import { type Entity } from "../core/entity.js";
9
9
  * destructive action deserves: not «are you sure?», but what is about to happen. forge's
10
10
  * console had the backend for it and no screen that asked.
11
11
  *
12
- * One at a time: `busy` turns the button off, because a payment sent twice is two payments.
12
+ * One at a time: `busy` turns the button off, because a payment sent twice is two payments. And
13
+ * once sent, the same gesture keeps its `Idempotency-Key` until the server answers: a retry
14
+ * after a dropped request is the same payment, never a second one (`retryKeys`).
13
15
  */
14
16
  export type Rehearsal = {
15
17
  text: string;
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { pathFor } from "../core/entity.js";
3
+ import { retryKeys } from "../core/retry.js";
3
4
  import { useFramework } from "./context.js";
4
5
  import { useCallback, useEffect, useRef, useState } from "react";
5
6
  export function useCommand(entity, name) {
@@ -17,13 +18,20 @@ export function useCommand(entity, name) {
17
18
  mounted.current = true;
18
19
  return () => void (mounted.current = false);
19
20
  }, []);
20
- const send = useCallback(async (id, body) => {
21
+ // One key per gesture, kept while it goes unanswered: «retry» after a dropped request must
22
+ // not run it twice. A dry run touches nothing, so each one gets a key of its own.
23
+ const keys = useRef(retryKeys());
24
+ const send = useCallback(async (id, body, gesture) => {
21
25
  setBusy(true);
22
26
  setError(null);
27
+ const idempotencyKey = gesture.keyFor({ id, body });
23
28
  try {
24
- return await transport.send(route.method, pathFor(route, id), body);
29
+ const answer = await transport.send(route.method, pathFor(route, id), body, undefined, { idempotencyKey });
30
+ gesture.settle(null);
31
+ return answer;
25
32
  }
26
33
  catch (failure) {
34
+ gesture.settle(failure);
27
35
  if (mounted.current)
28
36
  setError(failure);
29
37
  return undefined;
@@ -34,9 +42,9 @@ export function useCommand(entity, name) {
34
42
  }
35
43
  }, [transport, route]);
36
44
  return {
37
- run: (id, body = {}) => send(id, body),
45
+ run: (id, body = {}) => send(id, body, keys.current),
38
46
  rehearse: async (id, body = {}) => {
39
- const answer = await send(id, { ...body, dry_run: true });
47
+ const answer = await send(id, { ...body, dry_run: true }, retryKeys());
40
48
  if (answer !== undefined && mounted.current)
41
49
  setRehearsal(answer);
42
50
  return answer;
@@ -0,0 +1,40 @@
1
+ import type { AnyRecord, Bucket } from "@sincpro/criteria";
2
+ import type { GroupLevel } from "../core/list.js";
3
+ import type { Listing } from "./useListing.js";
4
+ /**
5
+ * The set of a list, split into groups — counted by the SERVER over every row that matches.
6
+ *
7
+ * Context: grouping is the same question as filtering («which part of this am I looking at»),
8
+ * and it obeys the same rule: never on the page. Twelve rows on screen cannot say «187 paid»;
9
+ * whoever keeps the set can. So the listing's own question (scope, search, filter) goes to the
10
+ * server with `group_by`, and what comes back is the groups: their value, their real count, the
11
+ * level below, and the criteria that opens each one (sincpro_framework's aggregation; the
12
+ * in-memory server answers the same with criteria's engine). A group's rows are read when it is
13
+ * opened, with that criteria — nobody rebuilds a filter here.
14
+ *
15
+ * const groups = useGroups(listing);
16
+ * groups.group([{ field: "state" }, { field: "invoice_date", grain: "month" }]);
17
+ * groups.buckets // [{ field: "state", value: "paid", count: 187, groups: [...] }, ...]
18
+ * groups.open(bucket); groups.rowsOf(bucket) // its first page of rows
19
+ */
20
+ export type { GroupLevel };
21
+ export type Groups<T> = {
22
+ levels: readonly GroupLevel[];
23
+ /** Split by these levels, outermost first; none is no grouping. */
24
+ group: (levels: readonly GroupLevel[]) => void;
25
+ status: "idle" | "loading" | "ready" | "failed";
26
+ buckets: readonly Bucket<T>[];
27
+ error: unknown;
28
+ /** More groups exist than the ceiling shows: narrow the set instead. */
29
+ capped: boolean;
30
+ /** Read the rows of a group — its first page. */
31
+ open: (bucket: Bucket<T>) => void;
32
+ /** The rows of a group once read; `undefined` while it was never opened or is loading. */
33
+ rowsOf: (bucket: Bucket<T>) => readonly T[] | undefined;
34
+ };
35
+ export declare function useGroups<T extends AnyRecord>(listing: Listing<T>, options?: {
36
+ pageSize?: number;
37
+ /** The fields each group adds up, over every row of it (`detailOf` reads them). Without
38
+ * it, the figures the model computes (`sumsOf`); `[]` adds nothing up. */
39
+ sums?: readonly string[];
40
+ }): Groups<T>;
@@ -0,0 +1,69 @@
1
+ "use client";
2
+ import { measuresOf, sumsOf } from "../core/measure.js";
3
+ import { useFramework } from "./context.js";
4
+ import { useCallback, useEffect, useState } from "react";
5
+ /** The most groups a level answers: a split into ten thousand groups is a list, not a split. */
6
+ const MAX_GROUPS = 100;
7
+ /** A group is known by its criteria: unique per group, and stable across renders. */
8
+ const keyOf = (bucket) => JSON.stringify(bucket.criteria);
9
+ export function useGroups(listing, options = {}) {
10
+ const { transport } = useFramework();
11
+ // A named list may open grouped (`groupBy`); a person regroups from there.
12
+ const [levels, setLevels] = useState(() => listing.definition?.groupBy ?? []);
13
+ const [state, setState] = useState({ status: "idle", buckets: [], error: null });
14
+ const [rows, setRows] = useState(new Map());
15
+ const pageSize = options.pageSize ?? 50;
16
+ // The question without what pages it: a cursor or a limit of rows has no meaning for groups.
17
+ const { pagination: _page, ...asked } = listing.question;
18
+ const question = JSON.stringify(asked);
19
+ const route = listing.route;
20
+ const sums = (options.sums ?? sumsOf(listing.meta)).join(",");
21
+ // What adds up by default is read from the model: without it yet, asking now and again when
22
+ // it arrives would be two questions — and the groups on screen replaced under the pointer.
23
+ const waiting = options.sums === undefined && listing.meta === null;
24
+ useEffect(() => {
25
+ setRows(new Map());
26
+ if (levels.length === 0) {
27
+ setState({ status: "idle", buckets: [], error: null });
28
+ return;
29
+ }
30
+ const aborter = new AbortController();
31
+ setState((now) => ({ ...now, status: "loading", error: null }));
32
+ if (waiting)
33
+ return () => aborter.abort();
34
+ const criteria = {
35
+ ...JSON.parse(question),
36
+ meta: false,
37
+ grouping: {
38
+ group_by: levels.map((level) => ({ ...level })),
39
+ ...(sums === "" ? {} : { measures: measuresOf(sums.split(",")) }),
40
+ pagination: { limit: MAX_GROUPS },
41
+ },
42
+ // One id per group is the least a grouped answer carries; the rows come on opening.
43
+ pagination: { limit: 1 },
44
+ };
45
+ transport.list(route, criteria, aborter.signal).then((page) => {
46
+ if (!aborter.signal.aborted)
47
+ setState({ status: "ready", buckets: page.rows, error: null });
48
+ }, (error) => {
49
+ if (!aborter.signal.aborted)
50
+ setState({ status: "failed", buckets: [], error });
51
+ });
52
+ return () => aborter.abort();
53
+ }, [transport, route, question, levels, sums, waiting]);
54
+ const open = useCallback((bucket) => {
55
+ const key = keyOf(bucket);
56
+ const { grouping: _grouping, pagination: _pagination, ...criteria } = bucket.criteria;
57
+ void transport
58
+ .list(route, { ...criteria, meta: false, pagination: { limit: pageSize } })
59
+ .then((page) => setRows((now) => new Map(now).set(key, page.rows)), (error) => setState((now) => ({ ...now, error })));
60
+ }, [transport, route, pageSize]);
61
+ return {
62
+ levels,
63
+ group: setLevels,
64
+ ...state,
65
+ capped: state.buckets.length >= MAX_GROUPS,
66
+ open,
67
+ rowsOf: (bucket) => rows.get(keyOf(bucket)),
68
+ };
69
+ }
@@ -1,5 +1,6 @@
1
1
  import { type AnyRecord, type Count, type Criteria, type Dropped, type Expression, type Meta, type ReadingStatus, type Sort } from "@sincpro/criteria";
2
2
  import type { Entity } from "../core/entity.js";
3
+ import { type ListDefinition } from "../core/list.js";
3
4
  /**
4
5
  * N records of an entity, live: what a list draws and the gestures that drive it.
5
6
  *
@@ -17,6 +18,16 @@ import type { Entity } from "../core/entity.js";
17
18
  */
18
19
  export type Listing<T> = {
19
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>;
20
31
  rows: T[];
21
32
  meta: Meta | null;
22
33
  count: Count | null;
@@ -29,8 +40,11 @@ export type Listing<T> = {
29
40
  reload: () => void;
30
41
  /** What was typed in the search box. */
31
42
  text: string;
32
- /** Debounced: a request per pause, not per keystroke. */
33
- search: (text: string) => void;
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;
34
48
  /** What the search bar put; `undefined` is nothing. */
35
49
  where: Expression<T> | undefined;
36
50
  filter: (where: Expression<T> | undefined) => void;
@@ -47,6 +61,10 @@ export type Listing<T> = {
47
61
  to: number;
48
62
  hasPrev: boolean;
49
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;
50
68
  };
51
69
  next: () => void;
52
70
  prev: () => void;
@@ -72,4 +90,4 @@ export type ListingOptions<T> = {
72
90
  /** `more` mode: the most rows ever on screen. 500 by default. */
73
91
  maxRows?: number;
74
92
  };
75
- export declare function useListing<T extends AnyRecord>(entity: Entity<T>, options?: ListingOptions<T>): Listing<T>;
93
+ export declare function useListing<T extends AnyRecord>(given: Entity<T> | ListDefinition<T>, own?: ListingOptions<T>): Listing<T>;
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { merge, resource, resumingFrom, searchOver, } from "@sincpro/criteria";
3
3
  import { useReading } from "@sincpro/criteria-react";
4
+ import { isList, scopeOf } from "../core/list.js";
4
5
  import { useFramework } from "./context.js";
5
6
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
7
  /** How long typing has to pause before the search goes out. */
@@ -11,8 +12,19 @@ function routeOf(list, params) {
11
12
  return list;
12
13
  return `${list}${list.includes("?") ? "&" : "?"}${query}`;
13
14
  }
14
- export function useListing(entity, options = {}) {
15
+ export function useListing(given, own = {}) {
15
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
+ };
16
28
  const route = routeOf(entity.list, options.params);
17
29
  const source = useMemo(() => resource((criteria, signal) => transport.list(route, criteria, signal)), [transport, route]);
18
30
  // Decided once, at mount: a definition already known means the server is not asked for it
@@ -62,6 +74,22 @@ export function useListing(entity, options = {}) {
62
74
  // `base` is the caller's literal: its identity changes every render, its content does not.
63
75
  // eslint-disable-next-line react-hooks/exhaustive-deps
64
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]);
65
93
  const index = cursors.length - 1;
66
94
  const turn = (to) => {
67
95
  setCursors(to);
@@ -73,13 +101,18 @@ export function useListing(entity, options = {}) {
73
101
  setNarrowing(next);
74
102
  if (typing.current)
75
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).
76
106
  if (debounce)
77
- typing.current = setTimeout(() => refine(next), TYPING_PAUSE);
107
+ typing.current = setTimeout(() => latest.current(next), TYPING_PAUSE);
78
108
  else
79
109
  refine(next);
80
110
  };
81
111
  return {
82
112
  entity,
113
+ ...(definition === undefined ? {} : { definition }),
114
+ route,
115
+ question: narrowed(narrowing),
83
116
  rows: reading.rows,
84
117
  meta,
85
118
  count: reading.count,
@@ -94,7 +127,7 @@ export function useListing(entity, options = {}) {
94
127
  },
95
128
  reload: () => void reading.reload(),
96
129
  text: narrowing.text,
97
- search: (text) => narrow({ text }, true),
130
+ search: (text, options) => narrow({ text }, options?.now !== true),
98
131
  where: narrowing.where,
99
132
  filter: (where) => narrow({ where }, false),
100
133
  order: narrowing.order,
@@ -107,6 +140,8 @@ export function useListing(entity, options = {}) {
107
140
  to: index * size + reading.rows.length,
108
141
  hasPrev: paging === "pages" && index > 0,
109
142
  hasNext: paging === "pages" && reading.cursor !== null,
143
+ cursors,
144
+ after: reading.cursor,
110
145
  },
111
146
  next: () => {
112
147
  if (paging === "pages" && reading.cursor !== null)
@@ -1,22 +1,47 @@
1
- import type { AnyRecord } from "@sincpro/criteria";
1
+ import type { AnyRecord, Criteria } from "@sincpro/criteria";
2
2
  import type { Entity } from "../core/entity.js";
3
+ import { type ListDefinition } from "../core/list.js";
3
4
  import { type LookupOption } from "../core/lookup.js";
4
5
  /**
5
6
  * The records of an entity as choices — the literal search of a many2one.
6
7
  *
7
8
  * 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.
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`).
11
17
  */
12
18
  export type Lookup = {
13
19
  options: LookupOption[];
14
20
  text: string;
15
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;
16
25
  loading: boolean;
17
26
  hasMore: boolean;
18
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[];
19
33
  };
20
- export declare function useLookup<T extends AnyRecord>(entity: Entity<T>, options?: {
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?: {
21
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[];
22
47
  }): Lookup;
@@ -1,15 +1,68 @@
1
1
  "use client";
2
+ import { isList } from "../core/list.js";
2
3
  import { lookupOf } from "../core/lookup.js";
4
+ import { useFramework } from "./context.js";
3
5
  import { useListing } from "./useListing.js";
4
- export function useLookup(entity, options = {}) {
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();
5
13
  // «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 });
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 });
7
57
  return {
8
- options: lookupOf(listing.meta, listing.rows),
58
+ options: choices,
9
59
  text: listing.text,
10
60
  search: listing.search,
61
+ searchable: listing.meta?.search != null,
11
62
  loading: listing.status === "loading",
12
63
  hasMore: listing.hasMore,
13
64
  more: listing.more,
65
+ chosen: known,
66
+ chosenAll,
14
67
  };
15
68
  }
@@ -18,6 +18,14 @@ import { type Draft } from "../core/transport.js";
18
18
  * in between, the server answers 409 and the screen says so instead of overwriting.
19
19
  * - **drafts** — an edit kept between requests (sincpro_framework's `Drafts`) comes back with
20
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
21
29
  */
22
30
  export type RecordState<T> = {
23
31
  status: "loading";
@@ -62,13 +70,20 @@ export type Editing = {
62
70
  draft: Draft | null;
63
71
  save: () => Promise<boolean>;
64
72
  discard: () => void;
65
- /** Store the current edit as a draft — only when the entity declares where. */
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. */
66
75
  keep?: () => Promise<void>;
76
+ /** What the last create answered, when it did not say the new record's identity. */
77
+ created: unknown;
67
78
  };
68
79
  export type RecordReading<T> = RecordState<T> & {
69
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;
70
85
  locale: string | undefined;
71
86
  reload: () => void;
72
87
  edit: Editing;
73
88
  };
74
- export declare function useRecord<T extends AnyRecord>(entity: Entity<T>, id: string): RecordReading<T>;
89
+ export declare function useRecord<T extends AnyRecord>(entity: Entity<T>, given: string): RecordReading<T>;