@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
@@ -1,19 +1,30 @@
1
1
  "use client";
2
- import { changesOf, errorsByField, fieldStateOf, missingOf, } from "../core/edit.js";
2
+ import { changesOf, creationOf, defaultsOf, errorsByField, fieldStateOf, missingOf, } from "../core/edit.js";
3
3
  import { pathFor } from "../core/entity.js";
4
+ import { retryKeys } from "../core/retry.js";
4
5
  import { TransportError } from "../core/transport.js";
5
6
  import { useFramework } from "./context.js";
6
7
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
8
  /** How long typing has to pause before the server is asked what the record becomes. */
8
9
  const PREVIEW_PAUSE = 300;
9
- export function useRecord(entity, id) {
10
+ export function useRecord(entity, given) {
10
11
  const { transport, locale, metas } = useFramework();
12
+ // A create that answered its identity turns this into that record — until the screen asks
13
+ // for another one.
14
+ const [born, setBorn] = useState(null);
15
+ const id = born !== null && born.from === given ? born.id : given;
16
+ const isNew = id === "";
17
+ const [created, setCreated] = useState(null);
18
+ const creating = useRef(retryKeys());
11
19
  const [state, setState] = useState({
12
20
  status: "loading",
13
21
  record: null,
14
22
  meta: null,
15
23
  error: null,
16
24
  });
25
+ // Which record `state` was read for: the render after the id changed — a create that answered,
26
+ // another record asked — must not show the previous one under the new id.
27
+ const [readFor, setReadFor] = useState(null);
17
28
  const [round, setRound] = useState(0);
18
29
  const [typed, setTyped] = useState({});
19
30
  const [computed, setComputed] = useState({});
@@ -45,13 +56,49 @@ export function useRecord(entity, id) {
45
56
  };
46
57
  useEffect(() => {
47
58
  const aborter = new AbortController();
48
- setState((now) => ({ status: "loading", record: null, meta: now.meta, error: null }));
59
+ // Read again — after a save, a reload — the record on screen STAYS until the answer: a form
60
+ // that blinked through a skeleton lost its focus, its scroll and the click that landed then.
61
+ // Only a record not shown yet (another id, the first read) starts as «loading».
62
+ const again = !isNew && readFor === id;
63
+ if (!again)
64
+ setState((now) => ({ status: "loading", record: null, meta: now.meta, error: null }));
65
+ if (isNew) {
66
+ // Nothing to read but the model: known already, or asked of the list with no rows.
67
+ const known = metas.get(entity.key);
68
+ const asked = known
69
+ ? Promise.resolve(known)
70
+ : transport
71
+ .list(entity.list, { pagination: { limit: 1 } }, aborter.signal)
72
+ .then((page) => page.meta);
73
+ asked.then((meta) => {
74
+ if (meta)
75
+ metas.set(entity.key, meta);
76
+ if (aborter.signal.aborted)
77
+ return;
78
+ forget();
79
+ setDraft(null);
80
+ setReadFor(id);
81
+ setState({
82
+ status: "ready",
83
+ record: defaultsOf(meta),
84
+ meta: meta ?? null,
85
+ error: null,
86
+ });
87
+ }, (error) => {
88
+ if (aborter.signal.aborted)
89
+ return;
90
+ setReadFor(id);
91
+ setState((now) => ({ status: "failed", record: null, meta: now.meta, error }));
92
+ });
93
+ return () => aborter.abort();
94
+ }
49
95
  transport.one(entity.one(id), aborter.signal).then((answer) => {
50
96
  if (answer.meta)
51
97
  metas.set(entity.key, answer.meta);
52
98
  if (aborter.signal.aborted)
53
99
  return;
54
100
  forget();
101
+ setReadFor(id);
55
102
  setDraft(answer.draft);
56
103
  if (answer.draft)
57
104
  setTyped(answer.draft.values);
@@ -62,14 +109,16 @@ export function useRecord(entity, id) {
62
109
  error: null,
63
110
  });
64
111
  }, (error) => {
65
- if (!aborter.signal.aborted)
66
- setState((now) => ({ status: "failed", record: null, meta: now.meta, error }));
112
+ if (aborter.signal.aborted)
113
+ return;
114
+ setReadFor(id);
115
+ setState((now) => ({ status: "failed", record: null, meta: now.meta, error }));
67
116
  });
68
117
  return () => aborter.abort();
69
118
  // `forget` only resets this hook's own state; listing it would reload the record on every
70
119
  // render. What reloads it is the transport, the entity, the id or an explicit `reload()`.
71
120
  // eslint-disable-next-line react-hooks/exhaustive-deps
72
- }, [transport, metas, entity, id, round]);
121
+ }, [transport, metas, entity, id, isNew, round]);
73
122
  useEffect(() => {
74
123
  mounted.current = true;
75
124
  return () => {
@@ -91,7 +140,11 @@ export function useRecord(entity, id) {
91
140
  const aborter = new AbortController();
92
141
  asking.current = aborter;
93
142
  transport
94
- .send(preview.method, pathFor(preview, id), { id, values: changesOf(record, next), changed: [name] }, aborter.signal)
143
+ .send(preview.method, pathFor(preview, isNew ? undefined : id), {
144
+ id: isNew ? null : id,
145
+ values: isNew ? { ...record, ...next } : changesOf(record, next),
146
+ changed: [name],
147
+ }, aborter.signal)
95
148
  .then((answer) => {
96
149
  if (aborter.signal.aborted || !mounted.current)
97
150
  return;
@@ -126,6 +179,8 @@ export function useRecord(entity, id) {
126
179
  restored: draft !== null && Object.keys(changed).length > 0,
127
180
  draft,
128
181
  save: async () => {
182
+ if (isNew)
183
+ return create();
129
184
  const route = entity.save;
130
185
  if (route === undefined)
131
186
  throw new Error(`${entity.key} declares no save route`);
@@ -154,7 +209,8 @@ export function useRecord(entity, id) {
154
209
  }
155
210
  },
156
211
  discard: forget,
157
- ...(entity.draft === undefined
212
+ created,
213
+ ...(entity.draft === undefined || isNew
158
214
  ? {}
159
215
  : {
160
216
  keep: async () => {
@@ -172,5 +228,45 @@ export function useRecord(entity, id) {
172
228
  },
173
229
  }),
174
230
  };
175
- return { ...state, entity, locale, reload, edit };
231
+ async function create() {
232
+ const route = entity.create;
233
+ if (route === undefined)
234
+ throw new Error(`${entity.key} declares no create route`);
235
+ cancelPreview();
236
+ setSaving(true);
237
+ setFailure(null);
238
+ setCreated(null);
239
+ const body = creationOf(state.meta, values);
240
+ const idempotencyKey = creating.current.keyFor(body);
241
+ try {
242
+ const answer = await transport.send(route.method, pathFor(route), body, undefined, { idempotencyKey });
243
+ creating.current.settle(null);
244
+ if (!mounted.current)
245
+ return true;
246
+ const key = state.meta?.get_id || state.meta?.identity || "id";
247
+ const made = typeof answer === "object" && answer !== null
248
+ ? answer[key]
249
+ : undefined;
250
+ forget();
251
+ if (typeof made === "string" || typeof made === "number")
252
+ setBorn({ from: given, id: String(made) });
253
+ else
254
+ setCreated(answer);
255
+ return true;
256
+ }
257
+ catch (error) {
258
+ creating.current.settle(error);
259
+ if (mounted.current)
260
+ refused(error);
261
+ return false;
262
+ }
263
+ finally {
264
+ if (mounted.current)
265
+ setSaving(false);
266
+ }
267
+ }
268
+ const shown = readFor === id
269
+ ? state
270
+ : { status: "loading", record: null, meta: state.meta, error: null };
271
+ return { ...shown, entity, id, isNew, locale, reload, edit };
176
272
  }
@@ -0,0 +1,15 @@
1
+ import type { AnyRecord, Meta } from "@sincpro/criteria";
2
+ import type { Entity } from "../core/entity.js";
3
+ import { type RecordSummary } from "../core/summary.js";
4
+ /**
5
+ * The summary of any record of an entity, read with the model this application already knows
6
+ * for it (a list or a record of it was read, or `metas` preloaded it) and in its language.
7
+ *
8
+ * Context: a function, not a summary, so a list of N rows asks once and summarizes each row.
9
+ * The model is looked up on every call: it may arrive after the first render (the first page
10
+ * of a list brings it), and the next render of whoever holds the rows reads it.
11
+ *
12
+ * const summarize = useSummaryOf(contactos);
13
+ * <RecordCard {...cardOf(summarize(row))} />
14
+ */
15
+ export declare function useSummaryOf<T extends AnyRecord>(entity: Entity<T>): (record: AnyRecord, meta?: Meta | null) => RecordSummary;
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ import { summaryOf } from "../core/summary.js";
3
+ import { useFramework } from "./context.js";
4
+ import { useCallback } from "react";
5
+ /**
6
+ * The summary of any record of an entity, read with the model this application already knows
7
+ * for it (a list or a record of it was read, or `metas` preloaded it) and in its language.
8
+ *
9
+ * Context: a function, not a summary, so a list of N rows asks once and summarizes each row.
10
+ * The model is looked up on every call: it may arrive after the first render (the first page
11
+ * of a list brings it), and the next render of whoever holds the rows reads it.
12
+ *
13
+ * const summarize = useSummaryOf(contactos);
14
+ * <RecordCard {...cardOf(summarize(row))} />
15
+ */
16
+ export function useSummaryOf(entity) {
17
+ const { metas, locale } = useFramework();
18
+ return useCallback((record, meta) => summaryOf(meta ?? metas.get(entity.key) ?? null, record, {
19
+ fields: entity.summary,
20
+ ...(locale === undefined ? {} : { locale }),
21
+ }), [metas, entity, locale]);
22
+ }
@@ -2,16 +2,22 @@
2
2
  * The web bindings: what draws a `Listing` and a record with `@sincpro/web-ui`.
3
3
  */
4
4
  export { type ColumnOptions, columnsOf } from "./list/columns.js";
5
- export { EntityList, type EntityListProps, type NamedFilter, } from "./list/EntityList.js";
5
+ export { EntityList, type EntityListProps } from "./list/EntityList.js";
6
6
  export { EntitySearchBar } from "./list/EntitySearchBar.js";
7
+ export { groupableOf, pilesOf } from "./list/groups.js";
8
+ export { type RowCommand, type RowCommands, useRowCommands, } from "./list/rowCommands.js";
7
9
  export { facetsOf, modelOf } from "./list/search.js";
8
10
  export { CommandButton } from "./record/CommandButton.js";
9
11
  export { dirtyOf, type FieldControl, RecordField, } from "./record/edit.js";
10
12
  export { EntityPicker } from "./record/EntityPicker.js";
13
+ export { EntityTags } from "./record/EntityTags.js";
11
14
  export { countOf, factsOf, RecordLines } from "./record/record.js";
12
15
  export { RelatedList } from "./record/RelatedList.js";
16
+ export { cardOf, relationOf } from "./record/summary.js";
13
17
  export { Refused } from "./Refused.js";
14
- export { entityView, type EntityViewOptions, } from "./workspace/composer.js";
18
+ export { entityView, type EntityViewOptions, listView, type ListViewOptions, } from "./workspace/composer.js";
19
+ export { openIn, openOptions, RECORD_SIZE, RelatedRecord, targetsFor, } from "./workspace/open.js";
20
+ export { EntityRecord } from "./workspace/record.js";
15
21
  export { SincproApp } from "./workspace/SincproApp.js";
16
22
  export { webUiSkin } from "./workspace/skin.js";
17
- export { useRecordInWorkspace } from "./workspace/workspace.js";
23
+ export { chooserIn, useFollowed, useRecordInWorkspace, } from "./workspace/workspace.js";
package/dist/web/index.js CHANGED
@@ -2,16 +2,22 @@
2
2
  * The web bindings: what draws a `Listing` and a record with `@sincpro/web-ui`.
3
3
  */
4
4
  export { columnsOf } from "./list/columns.js";
5
- export { EntityList, } from "./list/EntityList.js";
5
+ export { EntityList } from "./list/EntityList.js";
6
6
  export { EntitySearchBar } from "./list/EntitySearchBar.js";
7
+ export { groupableOf, pilesOf } from "./list/groups.js";
8
+ export { useRowCommands, } from "./list/rowCommands.js";
7
9
  export { facetsOf, modelOf } from "./list/search.js";
8
10
  export { CommandButton } from "./record/CommandButton.js";
9
11
  export { dirtyOf, RecordField, } from "./record/edit.js";
10
12
  export { EntityPicker } from "./record/EntityPicker.js";
13
+ export { EntityTags } from "./record/EntityTags.js";
11
14
  export { countOf, factsOf, RecordLines } from "./record/record.js";
12
15
  export { RelatedList } from "./record/RelatedList.js";
16
+ export { cardOf, relationOf } from "./record/summary.js";
13
17
  export { Refused } from "./Refused.js";
14
- export { entityView, } from "./workspace/composer.js";
18
+ export { entityView, listView, } from "./workspace/composer.js";
19
+ export { openIn, openOptions, RECORD_SIZE, RelatedRecord, targetsFor, } from "./workspace/open.js";
20
+ export { EntityRecord } from "./workspace/record.js";
15
21
  export { SincproApp } from "./workspace/SincproApp.js";
16
22
  export { webUiSkin } from "./workspace/skin.js";
17
- export { useRecordInWorkspace } from "./workspace/workspace.js";
23
+ export { chooserIn, useFollowed, useRecordInWorkspace, } from "./workspace/workspace.js";
@@ -1,8 +1,9 @@
1
1
  import { type AnyRecord } from "@sincpro/criteria";
2
2
  import { type WorkspaceApi } from "@sincpro/web-composer";
3
+ import type { NamedFilter } from "../../core/list.js";
3
4
  import type { Listing } from "../../react/useListing.js";
4
- import { type NamedFilter } from "./EntitySearchBar.js";
5
- import { type ListCrumb } from "@sincpro/web-ui";
5
+ import { type RowCommand } from "./rowCommands.js";
6
+ import { type ListCrumb, type RowAction } from "@sincpro/web-ui";
6
7
  import { type ReactNode } from "react";
7
8
  /**
8
9
  * A whole collection from a `Listing`: the bar, the search, the set in the views it offers,
@@ -17,6 +18,10 @@ import { type ReactNode } from "react";
17
18
  * its ceiling and asks to narrow the search. Nothing is filtered, ordered or counted on the
18
19
  * page: every gesture is a question to the server.
19
20
  *
21
+ * Grouping obeys the same rule: «Group by» in the search bar asks the SERVER for the groups of
22
+ * the whole set (`useGroups`), drawn as piles with their real count; a group's rows are read
23
+ * when it is opened. Grouped, the list shows rows: web-ui's table groups the page it holds.
24
+ *
20
25
  * Where a record opens is the composer's question: with `api`, a row opens the page and the
21
26
  * identity column is the composer's `RecordLink` (peek, side panel, window); without it, the
22
27
  * screen passes `onOpen`.
@@ -30,7 +35,7 @@ export type EntityListProps<T extends AnyRecord> = {
30
35
  * breadcrumb is the composer's — nothing for the screen to write.
31
36
  */
32
37
  api?: WorkspaceApi;
33
- /** The fields shown, in order. Without it, every scalar field of the model. */
38
+ /** The fields shown, in order. Without it, the named list's, or every scalar field. */
34
39
  columns?: readonly string[];
35
40
  /** The cells that are not plain text: a link, a tag. Memoise it: compared by identity. */
36
41
  cells?: Readonly<Partial<Record<string, (row: T) => ReactNode>>>;
@@ -52,8 +57,13 @@ export type EntityListProps<T extends AnyRecord> = {
52
57
  /**
53
58
  * `"bar"` (the default) is the full search bar with the criteria builder; `"box"` is one
54
59
  * text box over the entity's search template — a chooser, a narrow panel; `false` is none.
60
+ * Both ask the server on Enter, never per keystroke: a table is searched on purpose.
55
61
  */
56
62
  search?: "bar" | "box" | false;
63
+ /** What each group adds up when the list is grouped — «Total 1.204.933,00» in its header,
64
+ * summed by the server over every row of it. Without it, the figures the model computes
65
+ * (read-only exact numbers, `sumsOf`); `false` adds nothing up. */
66
+ sums?: readonly string[] | false;
57
67
  /** The cuts this list offers by name: «sólo pagadas», «vencidas». */
58
68
  filters?: readonly NamedFilter<T>[];
59
69
  /**
@@ -65,5 +75,18 @@ export type EntityListProps<T extends AnyRecord> = {
65
75
  selectionActions?: ReactNode;
66
76
  /** The list is a FIELD of a record — its lines, its members — and not a screen. */
67
77
  embedded?: boolean;
78
+ /**
79
+ * With `api` and an entity that declares `create`, the bar offers «New», which opens the
80
+ * record's form with no id (`entity.newRef()`). `false` hides it — a list of what only a
81
+ * Command produces, or a caller who may not create.
82
+ */
83
+ create?: boolean;
84
+ /**
85
+ * The entity's Commands offered on each row («Confirmar»), sent with their idempotency key;
86
+ * the list is read again when one is done (`useRowCommands`).
87
+ */
88
+ commands?: readonly RowCommand<T>[];
89
+ /** Any other action of a row — or the commands built by hand with `useRowCommands`. */
90
+ rowActions?: (row: T) => readonly RowAction[];
68
91
  };
69
- export declare function EntityList<T extends AnyRecord>({ listing, api, columns, cells, views, title, description, crumbs, actions, onOpen, onPeek, empty, search, filters, selectable, selectionActions, embedded, }: EntityListProps<T>): import("react").JSX.Element;
92
+ export declare function EntityList<T extends AnyRecord>({ listing, api, columns: askedColumns, cells, views: askedViews, title: askedTitle, description: askedDescription, crumbs, actions, onOpen, onPeek, empty, search, sums, filters: askedFilters, selectable, selectionActions, embedded, create, commands, rowActions: askedRowActions, }: EntityListProps<T>): import("react").JSX.Element;
@@ -1,15 +1,38 @@
1
1
  "use client";
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { nameOf } from "@sincpro/criteria";
4
- import { RecordLink } from "@sincpro/web-composer";
4
+ import { RecordLink, useCrumbs } from "@sincpro/web-composer";
5
5
  import { frameworkCopy } from "../../core/copy.js";
6
+ import { PICK_PARAM, QUERY_PARAM, TRAIL_PARAM } from "../../core/entity.js";
6
7
  import { textOf, viewOf } from "../../core/present.js";
7
8
  import { explain } from "../../core/transport.js";
9
+ import { useFramework } from "../../react/context.js";
10
+ import { useGroups } from "../../react/useGroups.js";
8
11
  import { columnsOf } from "./columns.js";
9
- import { EntitySearchBar, } from "./EntitySearchBar.js";
10
- import { Button, List, Pagination, SearchField, Tag } from "@sincpro/web-ui";
11
- import { useMemo, useState } from "react";
12
- export function EntityList({ listing, api, columns, cells, views = ["table"], title, description, crumbs, actions, onOpen, onPeek, empty, search = "bar", filters = [], selectable = false, selectionActions, embedded = false, }) {
12
+ import { EntitySearchBar } from "./EntitySearchBar.js";
13
+ import { pilesOf } from "./groups.js";
14
+ import { useRowCommands } from "./rowCommands.js";
15
+ import { Button, Callout, List, Pagination, SearchField, Tag, } from "@sincpro/web-ui";
16
+ import { useEffect, useId, useMemo, useState } from "react";
17
+ /** What `useCrumbs` reads when the list is not in a workspace: no trail, and nothing to follow. */
18
+ const NO_TRAIL = [];
19
+ const NO_WORKSPACE = {
20
+ crumbs: () => NO_TRAIL,
21
+ context: { subscribe: () => () => undefined },
22
+ };
23
+ /** How many lists' trails are kept: the records opened from the last fifty lists page on. */
24
+ const TRAILS = 50;
25
+ /** No commands: one constant, so the hook is not handed a new array on every render. */
26
+ const NO_COMMANDS = [];
27
+ export function EntityList({ listing, api, columns: askedColumns, cells, views: askedViews, title: askedTitle, description: askedDescription, crumbs, actions, onOpen, onPeek, empty, search = "bar", sums, filters: askedFilters, selectable = false, selectionActions, embedded = false, create = true, commands = NO_COMMANDS, rowActions: askedRowActions, }) {
28
+ // A named list (`defineList`) brings its own columns, filters, faces and words; what the
29
+ // screen passes wins.
30
+ const definition = listing.definition;
31
+ const columns = askedColumns ?? definition?.columns;
32
+ const views = askedViews ?? definition?.views ?? ["table"];
33
+ const filters = askedFilters ?? definition?.filters ?? [];
34
+ const title = askedTitle ?? definition?.title;
35
+ const description = askedDescription ?? definition?.description;
13
36
  const copy = frameworkCopy().list;
14
37
  const { meta, locale, count, page } = listing;
15
38
  const identity = meta?.identity ?? "id";
@@ -17,17 +40,88 @@ export function EntityList({ listing, api, columns, cells, views = ["table"], ti
17
40
  const failed = listing.status === "failed" ? explain(listing.error) : null;
18
41
  const dropped = listing.dropped.map((d) => viewOf(meta, d.field, locale).label);
19
42
  const [present, setPresent] = useState(views[0] ?? "table");
20
- const opening = onOpen ?? (api ? (row) => api.open(listing.entity.ref(idOf(row))) : undefined);
43
+ // Opened to choose on what was typed in a picker (`chooserRef(query)`): it starts searched.
44
+ const asked = api?.self.params?.[QUERY_PARAM] ?? "";
45
+ // The box's text is a draft until Enter: typing asks the server nothing.
46
+ const [draft, setDraft] = useState(asked === "" ? listing.text : asked);
47
+ useEffect(() => {
48
+ if (asked !== "" && search === "box")
49
+ listing.search(asked, { now: true });
50
+ // Once, as it opens: what the person types next is theirs.
51
+ // eslint-disable-next-line react-hooks/exhaustive-deps
52
+ }, []);
53
+ // Opened by `api.pick` (`entity.chooserRef()`): a row ANSWERS whoever asked, it does not open.
54
+ const choosing = api?.self.params?.[PICK_PARAM] !== undefined;
55
+ // The record opened from a row knows the page it came from: «3 / 64» and its neighbours.
56
+ const { trails } = useFramework();
57
+ const trailKey = `trail:${useId()}`;
58
+ // Kept current with the page on screen: the row and its name (`RecordLink`) open the same
59
+ // reference, and whichever opens it, the record finds where it came from.
60
+ useEffect(() => {
61
+ if (api === undefined)
62
+ return;
63
+ const pages = listing.paging === "pages";
64
+ trails.set(trailKey, {
65
+ route: listing.route,
66
+ question: listing.question,
67
+ key: identity,
68
+ ids: listing.rows.map(idOf),
69
+ offset: pages ? Math.max(0, listing.page.from - 1) : 0,
70
+ size: listing.page.size,
71
+ cursors: pages ? listing.page.cursors : [null],
72
+ after: pages ? listing.page.after : null,
73
+ total: pages ? (listing.count?.value ?? null) : listing.rows.length,
74
+ });
75
+ // Never forgotten when the list unmounts — the record it opened replaced it and still walks
76
+ // on it — so the oldest go past a ceiling instead: one per list ever shown, fifty at most.
77
+ for (const old of trails.keys()) {
78
+ if (trails.size <= TRAILS)
79
+ break;
80
+ if (old !== trailKey)
81
+ trails.delete(old);
82
+ }
83
+ // The page on screen is what the trail is: its rows, its cursors, its count.
84
+ // eslint-disable-next-line react-hooks/exhaustive-deps
85
+ }, [
86
+ api,
87
+ trails,
88
+ trailKey,
89
+ listing.rows,
90
+ listing.count,
91
+ listing.page.from,
92
+ listing.page.after,
93
+ ]);
94
+ const refOf = (row) => {
95
+ const ref = listing.entity.ref(idOf(row));
96
+ return { ...ref, params: { ...ref.params, [TRAIL_PARAM]: trailKey } };
97
+ };
98
+ const openWithTrail = (row) => api?.open(refOf(row));
99
+ const opening = onOpen ??
100
+ (api === undefined
101
+ ? undefined
102
+ : choosing
103
+ ? (row) => api.answer(listing.entity.ref(idOf(row)))
104
+ : openWithTrail);
105
+ const creating = create &&
106
+ api !== undefined &&
107
+ !choosing &&
108
+ !embedded &&
109
+ listing.entity.create !== undefined
110
+ ? () => api.open(listing.entity.newRef())
111
+ : undefined;
21
112
  // A row click opens the page — the composer's one predictable gesture. web-ui's table turns a
22
113
  // row click into a PEEK whenever `onPeek` is given, so the list never passes one by itself:
23
114
  // the peek, the side panel and the window are the composer's `RecordLink` on the identity.
24
- const linked = api === undefined || cells?.[identity] !== undefined
115
+ const linked = api === undefined || choosing || cells?.[identity] !== undefined
25
116
  ? cells
26
117
  : {
27
118
  ...cells,
28
- [identity]: (row) => (_jsx(RecordLink, { api: api, resource: listing.entity.ref(idOf(row)), children: idOf(row) })),
119
+ [identity]: (row) => (_jsx(RecordLink, { api: api, resource: refOf(row), children: idOf(row) })),
29
120
  };
30
- const trail = crumbs ?? (embedded ? undefined : api?.crumbs());
121
+ // `useCrumbs`, not `api.crumbs()`: the crumb must follow a name given later (`setTitle`).
122
+ // Without a workspace it reads a trail that never changes — a hook cannot be skipped.
123
+ const following = useCrumbs(api ?? NO_WORKSPACE);
124
+ const trail = crumbs ?? (embedded || api === undefined ? undefined : following);
31
125
  const [ticked, setTicked] = useState(() => new Set());
32
126
  const tick = (next) => {
33
127
  setTicked(next);
@@ -68,15 +162,68 @@ export function EntityList({ listing, api, columns, cells, views = ["table"], ti
68
162
  ...facts.map((c) => textOf(meta, c.id, row[c.id], locale)).filter(Boolean),
69
163
  ].join(" · "),
70
164
  });
71
- return (_jsxs(List, { items: listing.rows, keyOf: idOf, embedded: embedded, present: present, onPresentChange: (next) => setPresent(next), sort: listing.order === null
165
+ // Grouped: the SERVER counts the groups; a group's rows are read when it is opened, so every
166
+ // group starts closed. web-ui's table groups the rows it holds — the page — so a grouped list
167
+ // shows rows, the face that draws the piles it is given.
168
+ const groups = useGroups(listing, sums === undefined ? {} : { sums: sums || [] });
169
+ const grouped = groups.levels.length > 0;
170
+ const { piles, bucketAt } = pilesOf(groups.buckets, groups.levels, meta, locale, groups.rowsOf);
171
+ const [opened, setOpened] = useState(() => new Set());
172
+ useEffect(() => setOpened(new Set()), [groups.buckets]);
173
+ const collapsed = [...bucketAt.keys()].filter((key) => !opened.has(key));
174
+ const toggle = (key) => {
175
+ const next = new Set(opened);
176
+ if (next.has(key))
177
+ next.delete(key);
178
+ else {
179
+ next.add(key);
180
+ const bucket = bucketAt.get(key);
181
+ const deepest = bucket !== undefined && (bucket.groups ?? []).length === 0;
182
+ if (deepest && groups.rowsOf(bucket) === undefined)
183
+ groups.open(bucket);
184
+ }
185
+ setOpened(next);
186
+ };
187
+ const failure = grouped && groups.status === "failed" ? explain(groups.error) : grouped ? null : failed;
188
+ const grouping = grouped
189
+ ? {
190
+ group: {
191
+ levels: groups.levels,
192
+ of: (row, level) => String(row[level.field] ?? ""),
193
+ piles,
194
+ collapsed,
195
+ onToggle: toggle,
196
+ },
197
+ }
198
+ : {};
199
+ const records = groups.buckets.reduce((sum, bucket) => sum + bucket.count, 0);
200
+ // A row's actions: the entity's Commands, then whatever else the screen offers.
201
+ const rowCommands = useRowCommands(listing, commands);
202
+ const rowActions = commands.length === 0 && askedRowActions === undefined
203
+ ? undefined
204
+ : (row) => [...rowCommands.rowActions(row), ...(askedRowActions?.(row) ?? [])];
205
+ const refused = rowCommands.error === null ? null : explain(rowCommands.error);
206
+ return (_jsxs(List, { items: listing.rows, keyOf: idOf, embedded: embedded, present: grouped ? "rows" : present, onPresentChange: (next) => setPresent(next), ...grouping, sort: listing.order === null
72
207
  ? null
73
208
  : { key: listing.order.field, direction: listing.order.descending ? "desc" : "asc" }, onSortChange: (next) => listing.orderBy(next === null ? null : { field: next.key, descending: next.direction === "desc" }), state: {
74
- loading: listing.status === "loading",
75
- error: failed === null
209
+ // Only the FIRST reading shows the skeleton. Refining (typing, a filter, the next
210
+ // page) keeps the rows on screen until the server answers: web-ui swaps the body for
211
+ // a skeleton while `loading`, and a row that vanished under the pointer lost a click.
212
+ loading: grouped
213
+ ? groups.status === "loading"
214
+ : listing.status === "loading" && listing.rows.length === 0,
215
+ error: failure === null
76
216
  ? null
77
- : [failed.heading, failed.sentence].filter(Boolean).join(". "),
217
+ : [failure.heading, failure.sentence].filter(Boolean).join(". "),
78
218
  }, filtered: listing.where !== undefined || listing.text !== "", empty: {
79
219
  sentence: empty ?? copy.empty,
80
220
  whenFiltered: { sentence: copy.emptyFiltered },
81
- }, ...(selection === undefined ? {} : { selection }), ...(opening === undefined ? {} : { onOpen: opening }), ...(onPeek === undefined ? {} : { onPeek }), children: [_jsxs(List.Bar, { children: [trail === undefined || trail.length === 0 ? null : _jsx(List.Crumbs, { crumbs: trail }), embedded ? null : (_jsx(List.Identity, { title: title ?? nameOf(meta, locale), ...(description === undefined ? {} : { description }) })), search === "bar" ? (_jsx(List.Search, { children: _jsx(EntitySearchBar, { listing: listing, filters: filters }) })) : null, search === "box" && meta?.search != null ? (_jsx(List.Search, { children: _jsx(SearchField, { value: listing.text, onChange: listing.search, placeholder: copy.search, "aria-label": copy.search }) })) : null, actions === undefined ? null : _jsx(List.Actions, { children: actions }), _jsxs(List.Pagination, { children: [dropped.length === 0 ? null : (_jsx(Tag, { tone: "warn", title: copy.dropped(dropped), children: copy.dropped(dropped) })), listing.paging === "pages" ? (page.from === 0 ? null : (_jsx(Pagination, { from: page.from, to: page.to, total: count?.value ?? page.to, ...(page.hasPrev ? { onPrev: listing.prev } : {}), ...(page.hasNext ? { onNext: listing.next } : {}) }))) : (_jsxs(_Fragment, { children: [count === null ? null : (_jsx("span", { className: "text-meta text-muted", children: copy.shown(listing.rows.length, count.value, count.exact) })), listing.capped ? (_jsx("span", { className: "text-meta text-muted", children: copy.capped(listing.rows.length) })) : listing.hasMore ? (_jsx(Button, { size: "sm", role: "secondary", disabled: listing.status === "loadingMore", onClick: listing.more, children: listing.status === "loadingMore" ? copy.loading : copy.loadMore })) : null] }))] })] }), views.includes("table") ? _jsx(List.Table, { columns: shown }) : null, views.includes("rows") ? _jsx(List.Rows, { row: rowOf }) : null, views.includes("board") ? _jsx(List.Board, {}) : null] }));
221
+ }, ...(selection === undefined ? {} : { selection }), ...(opening === undefined ? {} : { onOpen: opening }), ...(onPeek === undefined ? {} : { onPeek }), children: [_jsxs(List.Bar, { children: [trail === undefined || trail.length === 0 ? null : _jsx(List.Crumbs, { crumbs: trail }), embedded ? null : (_jsx(List.Identity, { title: title ?? nameOf(meta, locale), ...(description === undefined ? {} : { description }) })), search === "bar" ? (_jsx(List.Search, { children: _jsx(EntitySearchBar, { listing: listing, filters: filters, groups: groups, ...(asked === "" ? {} : { seed: asked }) }) })) : null, search === "box" && meta?.search != null ? (_jsx(List.Search, { children: _jsx(SearchField, { value: draft, onChange: (next) => {
222
+ setDraft(next);
223
+ // Emptied (or cleared with ×): the whole set again, at once.
224
+ if (next === "" && listing.text !== "")
225
+ listing.search("", { now: true });
226
+ }, onSubmit: (text) => listing.search(text, { now: true }), placeholder: copy.search, "aria-label": copy.search }) })) : null, actions === undefined && creating === undefined ? null : (_jsxs(List.Actions, { children: [actions, creating === undefined ? null : (_jsx(Button, { role: "primary", size: "sm", onClick: creating, children: frameworkCopy().record.create }))] })), _jsxs(List.Pagination, { children: [dropped.length === 0 ? null : (_jsx(Tag, { tone: "warn", title: copy.dropped(dropped), children: copy.dropped(dropped) })), grouped ? (groups.status !== "ready" ? null : (_jsx("span", { className: "text-meta text-muted", children: groups.capped
227
+ ? copy.groupsCapped(groups.buckets.length)
228
+ : copy.groups(groups.buckets.length, records) }))) : listing.paging === "pages" ? (page.from === 0 ? null : (_jsx(Pagination, { from: page.from, to: page.to, total: count?.value ?? page.to, ...(page.hasPrev ? { onPrev: listing.prev } : {}), ...(page.hasNext ? { onNext: listing.next } : {}) }))) : (_jsxs(_Fragment, { children: [count === null ? null : (_jsx("span", { className: "text-meta text-muted", children: copy.shown(listing.rows.length, count.value, count.exact) })), listing.capped ? (_jsx("span", { className: "text-meta text-muted", children: copy.capped(listing.rows.length) })) : listing.hasMore ? (_jsx(Button, { size: "sm", role: "secondary", disabled: listing.status === "loadingMore", onClick: listing.more, children: listing.status === "loadingMore" ? copy.loading : copy.loadMore })) : null] }))] })] }), views.includes("table") ? (_jsx(List.Table, { columns: shown, ...(rowActions === undefined ? {} : { rowActions }) })) : null, _jsx(List.Rows, { row: rowOf }), views.includes("board") ? _jsx(List.Board, {}) : null, refused === null ? null : (_jsx(Callout, { tone: "bad", title: refused.heading, children: refused.sentence }))] }));
82
229
  }
@@ -1,4 +1,6 @@
1
- import type { AnyRecord, Expression } from "@sincpro/criteria";
1
+ import { type AnyRecord } from "@sincpro/criteria";
2
+ import type { NamedFilter } from "../../core/list.js";
3
+ import type { Groups } from "../../react/useGroups.js";
2
4
  import type { Listing } from "../../react/useListing.js";
3
5
  /**
4
6
  * web-ui's search bar and criteria builder, bound to a `Listing`.
@@ -7,20 +9,26 @@ import type { Listing } from "../../react/useListing.js";
7
9
  * of a record, a chooser — so it is a piece of its own, not something only `EntityList` can do.
8
10
  * A screen that writes its own `List` drops it in `List.Search` and gets the same behaviour:
9
11
  *
12
+ * - a free text is the entity's LITERAL search — its whole template, every field at once
13
+ * (sincpro_framework's `LiteralSearch`, the same question a many2one and ⌘K ask): it is
14
+ * what Enter puts, unless the model reads the text as a question of its own;
10
15
  * - «search by name»: facets are the fields the entity's search template names first;
11
16
  * - a typed question («total > 5000») is checked against each field's operators;
12
17
  * - a named filter is one click; the builder writes what nobody foresaw (all/any/not);
13
- * - everything put becomes ONE `where` the SERVER answers (`listing.filter`).
18
+ * - everything put becomes ONE `where` the SERVER answers (`listing.filter`);
19
+ * - with `groups`, «Group by» offers the fields the model says may split the set, and the
20
+ * SERVER counts the groups (`useGroups`).
14
21
  *
15
22
  * <List.Search><EntitySearchBar listing={listing} filters={…} /></List.Search>
16
23
  */
17
- export type NamedFilter<T> = {
18
- id: string;
19
- label: string;
20
- where: Expression<T>;
21
- };
22
- export declare function EntitySearchBar<T extends AnyRecord>({ listing, filters, }: {
24
+ export type { NamedFilter };
25
+ export declare function EntitySearchBar<T extends AnyRecord>({ listing, filters, groups, seed, }: {
23
26
  listing: Listing<T>;
24
27
  /** The cuts this set offers by name: «sólo pagadas», «vencidas». */
25
28
  filters?: readonly NamedFilter<T>[];
29
+ /** The grouping of the same set (`useGroups(listing)`): offers «Group by». */
30
+ groups?: Groups<T>;
31
+ /** A text to start on, put as the literal search chip — what was typed in a picker whose
32
+ * «Buscar más…» opened this list. */
33
+ seed?: string;
26
34
  }): import("react").JSX.Element | null;