@sincpro/web-framework 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +445 -2
  3. package/dist/core/cache.d.ts +30 -0
  4. package/dist/core/cache.js +47 -0
  5. package/dist/core/copy.d.ts +93 -0
  6. package/dist/core/copy.js +141 -0
  7. package/dist/core/edit.d.ts +40 -0
  8. package/dist/core/edit.js +81 -0
  9. package/dist/core/entity.d.ts +113 -0
  10. package/dist/core/entity.js +57 -0
  11. package/dist/core/form.d.ts +21 -0
  12. package/dist/core/form.js +30 -0
  13. package/dist/core/history.d.ts +20 -0
  14. package/dist/core/history.js +40 -0
  15. package/dist/core/index.d.ts +26 -0
  16. package/dist/core/index.js +26 -0
  17. package/dist/core/list.d.ts +64 -0
  18. package/dist/core/list.js +22 -0
  19. package/dist/core/lookup.d.ts +19 -0
  20. package/dist/core/lookup.js +14 -0
  21. package/dist/core/measure.d.ts +16 -0
  22. package/dist/core/measure.js +38 -0
  23. package/dist/core/memory.d.ts +43 -0
  24. package/dist/core/memory.js +99 -0
  25. package/dist/core/meta.d.ts +114 -0
  26. package/dist/core/meta.js +130 -0
  27. package/dist/core/metas.d.ts +18 -0
  28. package/dist/core/metas.js +7 -0
  29. package/dist/core/present.d.ts +48 -0
  30. package/dist/core/present.js +165 -0
  31. package/dist/core/related.d.ts +22 -0
  32. package/dist/core/related.js +21 -0
  33. package/dist/core/relation.d.ts +28 -0
  34. package/dist/core/relation.js +30 -0
  35. package/dist/core/rest.d.ts +30 -0
  36. package/dist/core/rest.js +60 -0
  37. package/dist/core/retry.d.ts +24 -0
  38. package/dist/core/retry.js +25 -0
  39. package/dist/core/schema.d.ts +46 -0
  40. package/dist/core/schema.js +128 -0
  41. package/dist/core/summary.d.ts +50 -0
  42. package/dist/core/summary.js +54 -0
  43. package/dist/core/trail.d.ts +44 -0
  44. package/dist/core/trail.js +70 -0
  45. package/dist/core/transport.d.ts +71 -0
  46. package/dist/core/transport.js +43 -0
  47. package/dist/index.d.ts +10 -0
  48. package/dist/index.js +10 -0
  49. package/dist/react/context.d.ts +38 -0
  50. package/dist/react/context.js +26 -0
  51. package/dist/react/index.d.ts +14 -0
  52. package/dist/react/index.js +14 -0
  53. package/dist/react/useCommand.d.ts +30 -0
  54. package/dist/react/useCommand.js +60 -0
  55. package/dist/react/useGroups.d.ts +40 -0
  56. package/dist/react/useGroups.js +69 -0
  57. package/dist/react/useHistory.d.ts +5 -0
  58. package/dist/react/useHistory.js +41 -0
  59. package/dist/react/useListing.d.ts +93 -0
  60. package/dist/react/useListing.js +156 -0
  61. package/dist/react/useLookup.d.ts +47 -0
  62. package/dist/react/useLookup.js +68 -0
  63. package/dist/react/useRecord.d.ts +89 -0
  64. package/dist/react/useRecord.js +272 -0
  65. package/dist/react/useRelated.d.ts +21 -0
  66. package/dist/react/useRelated.js +32 -0
  67. package/dist/react/useSummary.d.ts +15 -0
  68. package/dist/react/useSummary.js +22 -0
  69. package/dist/web/Refused.d.ts +9 -0
  70. package/dist/web/Refused.js +14 -0
  71. package/dist/web/index.d.ts +23 -0
  72. package/dist/web/index.js +23 -0
  73. package/dist/web/list/EntityList.d.ts +92 -0
  74. package/dist/web/list/EntityList.js +229 -0
  75. package/dist/web/list/EntitySearchBar.d.ts +34 -0
  76. package/dist/web/list/EntitySearchBar.js +73 -0
  77. package/dist/web/list/columns.d.ts +22 -0
  78. package/dist/web/list/columns.js +27 -0
  79. package/dist/web/list/groups.d.ts +15 -0
  80. package/dist/web/list/groups.js +73 -0
  81. package/dist/web/list/rowCommands.d.ts +41 -0
  82. package/dist/web/list/rowCommands.js +56 -0
  83. package/dist/web/list/search.d.ts +21 -0
  84. package/dist/web/list/search.js +72 -0
  85. package/dist/web/record/CommandButton.d.ts +22 -0
  86. package/dist/web/record/CommandButton.js +40 -0
  87. package/dist/web/record/EntityPicker.d.ts +56 -0
  88. package/dist/web/record/EntityPicker.js +73 -0
  89. package/dist/web/record/EntityTags.d.ts +38 -0
  90. package/dist/web/record/EntityTags.js +41 -0
  91. package/dist/web/record/RelatedList.d.ts +19 -0
  92. package/dist/web/record/RelatedList.js +26 -0
  93. package/dist/web/record/edit.d.ts +42 -0
  94. package/dist/web/record/edit.js +122 -0
  95. package/dist/web/record/opening.d.ts +26 -0
  96. package/dist/web/record/opening.js +10 -0
  97. package/dist/web/record/record.d.ts +25 -0
  98. package/dist/web/record/record.js +56 -0
  99. package/dist/web/record/summary.d.ts +22 -0
  100. package/dist/web/record/summary.js +55 -0
  101. package/dist/web/workspace/SincproApp.d.ts +40 -0
  102. package/dist/web/workspace/SincproApp.js +45 -0
  103. package/dist/web/workspace/composer.d.ts +55 -0
  104. package/dist/web/workspace/composer.js +94 -0
  105. package/dist/web/workspace/open.d.ts +61 -0
  106. package/dist/web/workspace/open.js +62 -0
  107. package/dist/web/workspace/record.d.ts +30 -0
  108. package/dist/web/workspace/record.js +80 -0
  109. package/dist/web/workspace/skin.d.ts +2 -0
  110. package/dist/web/workspace/skin.js +45 -0
  111. package/dist/web/workspace/workspace.d.ts +44 -0
  112. package/dist/web/workspace/workspace.js +82 -0
  113. package/package.json +86 -4
@@ -0,0 +1,229 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { nameOf } from "@sincpro/criteria";
4
+ import { RecordLink, useCrumbs } from "@sincpro/web-composer";
5
+ import { frameworkCopy } from "../../core/copy.js";
6
+ import { PICK_PARAM, QUERY_PARAM, TRAIL_PARAM } from "../../core/entity.js";
7
+ import { textOf, viewOf } from "../../core/present.js";
8
+ import { explain } from "../../core/transport.js";
9
+ import { useFramework } from "../../react/context.js";
10
+ import { useGroups } from "../../react/useGroups.js";
11
+ import { columnsOf } from "./columns.js";
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;
36
+ const copy = frameworkCopy().list;
37
+ const { meta, locale, count, page } = listing;
38
+ const identity = meta?.identity ?? "id";
39
+ const idOf = (row) => String(row[identity]);
40
+ const failed = listing.status === "failed" ? explain(listing.error) : null;
41
+ const dropped = listing.dropped.map((d) => viewOf(meta, d.field, locale).label);
42
+ const [present, setPresent] = useState(views[0] ?? "table");
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;
112
+ // A row click opens the page — the composer's one predictable gesture. web-ui's table turns a
113
+ // row click into a PEEK whenever `onPeek` is given, so the list never passes one by itself:
114
+ // the peek, the side panel and the window are the composer's `RecordLink` on the identity.
115
+ const linked = api === undefined || choosing || cells?.[identity] !== undefined
116
+ ? cells
117
+ : {
118
+ ...cells,
119
+ [identity]: (row) => (_jsx(RecordLink, { api: api, resource: refOf(row), children: idOf(row) })),
120
+ };
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);
125
+ const [ticked, setTicked] = useState(() => new Set());
126
+ const tick = (next) => {
127
+ setTicked(next);
128
+ api?.select([...next].map((id) => listing.entity.ref(id)));
129
+ };
130
+ const selection = selectable
131
+ ? {
132
+ has: (id) => ticked.has(id),
133
+ toggle: (id) => {
134
+ const next = new Set(ticked);
135
+ if (next.has(id))
136
+ next.delete(id);
137
+ else
138
+ next.add(id);
139
+ tick(next);
140
+ },
141
+ count: ticked.size,
142
+ clear: () => tick(new Set()),
143
+ ...(selectionActions === undefined ? {} : { actions: selectionActions }),
144
+ }
145
+ : undefined;
146
+ // Rebuilt only when what it reads changes: a new set of columns on every render redraws
147
+ // every cell of the table. `columns` is compared by content (it is usually a literal);
148
+ // `cells` by identity, since its functions may close over state — memoise it to keep this.
149
+ const names = columns?.join("\u0000");
150
+ const shown = useMemo(() => columnsOf(meta, columns, { locale, ...(linked ? { cells: linked } : {}) }),
151
+ // eslint-disable-next-line react-hooks/exhaustive-deps
152
+ [meta, locale, names, cells, api]);
153
+ // A row and a card read the record by its display and the first fields shown beside it.
154
+ const display = meta?.display || identity;
155
+ const facts = shown
156
+ .filter((c) => c.id !== display && c.id !== identity && !c.defaultHidden)
157
+ .slice(0, 3);
158
+ const rowOf = (row) => ({
159
+ name: textOf(meta, display, row[display], locale) ?? idOf(row),
160
+ meta: [
161
+ ...(display === identity ? [] : [idOf(row)]),
162
+ ...facts.map((c) => textOf(meta, c.id, row[c.id], locale)).filter(Boolean),
163
+ ].join(" · "),
164
+ });
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
207
+ ? null
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: {
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
216
+ ? null
217
+ : [failure.heading, failure.sentence].filter(Boolean).join(". "),
218
+ }, filtered: listing.where !== undefined || listing.text !== "", empty: {
219
+ sentence: empty ?? copy.empty,
220
+ whenFiltered: { sentence: copy.emptyFiltered },
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 }))] }));
229
+ }
@@ -0,0 +1,34 @@
1
+ import { type AnyRecord } from "@sincpro/criteria";
2
+ import type { NamedFilter } from "../../core/list.js";
3
+ import type { Groups } from "../../react/useGroups.js";
4
+ import type { Listing } from "../../react/useListing.js";
5
+ /**
6
+ * web-ui's search bar and criteria builder, bound to a `Listing`.
7
+ *
8
+ * Context: this is the «search over a set» every collection screen needs — a list, the lines
9
+ * of a record, a chooser — so it is a piece of its own, not something only `EntityList` can do.
10
+ * A screen that writes its own `List` drops it in `List.Search` and gets the same behaviour:
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;
15
+ * - «search by name»: facets are the fields the entity's search template names first;
16
+ * - a typed question («total > 5000») is checked against each field's operators;
17
+ * - a named filter is one click; the builder writes what nobody foresaw (all/any/not);
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`).
21
+ *
22
+ * <List.Search><EntitySearchBar listing={listing} filters={…} /></List.Search>
23
+ */
24
+ export type { NamedFilter };
25
+ export declare function EntitySearchBar<T extends AnyRecord>({ listing, filters, groups, seed, }: {
26
+ listing: Listing<T>;
27
+ /** The cuts this set offers by name: «sólo pagadas», «vencidas». */
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;
34
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,73 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { matching } from "@sincpro/criteria";
4
+ import { frameworkCopy } from "../../core/copy.js";
5
+ import { groupableOf } from "./groups.js";
6
+ import { facetsOf, modelOf } from "./search.js";
7
+ import { CriteriaBuilder, describeFilter, Dialog, fillCopy, filterCriteria, GroupingLevels, hasNamed, SearchBar, toggleNamed, useGroupingControl, useUiCopy, whereOf, } from "@sincpro/web-ui";
8
+ import { useEffect, useMemo, useRef, useState } from "react";
9
+ /** No grouping: one constant, so the control is not handed a new array on every render. */
10
+ const NO_LEVELS = [];
11
+ export function EntitySearchBar({ listing, filters = [], groups, seed, }) {
12
+ const copy = frameworkCopy().list;
13
+ const { meta, locale } = listing;
14
+ const [put, setPut] = useState([]);
15
+ const [building, setBuilding] = useState(false);
16
+ const model = useMemo(() => (meta ? modelOf(meta, locale) : undefined), [meta, locale]);
17
+ const facets = useMemo(() => (meta ? facetsOf(meta, locale) : []), [meta, locale]);
18
+ const groupable = useMemo(() => groupableOf(meta, locale), [meta, locale]);
19
+ const ui = useUiCopy();
20
+ const seeded = useRef(false);
21
+ useEffect(() => {
22
+ if (seeded.current || seed === undefined || seed === "" || meta?.search == null)
23
+ return;
24
+ seeded.current = true;
25
+ const where = matching(meta, seed).where;
26
+ if (where === undefined)
27
+ return;
28
+ const chip = filterCriteria(where, fillCopy(ui.search.literalChip, { text: seed }));
29
+ setPut([chip]);
30
+ listing.filter(whereOf([chip]));
31
+ // Once the definition is known; what the person changes afterwards is theirs.
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
33
+ }, [meta, seed]);
34
+ const control = useGroupingControl({
35
+ levels: groups?.levels ?? NO_LEVELS,
36
+ onLevelsChange: (next) => groups?.group(next),
37
+ });
38
+ if (model === undefined)
39
+ return null;
40
+ const place = (next) => {
41
+ setPut(next);
42
+ listing.filter(whereOf(next));
43
+ };
44
+ return (_jsxs(_Fragment, { children: [_jsx(SearchBar, { facets: facets, model: model, criteria: put, onCriteriaChange: place, placeholder: copy.searchBar, ...(filters.length === 0
45
+ ? {}
46
+ : {
47
+ filters: [
48
+ {
49
+ options: filters.map((one) => ({
50
+ id: one.id,
51
+ label: one.label,
52
+ checked: hasNamed(put, one.id),
53
+ })),
54
+ },
55
+ ],
56
+ onFilterSelect: (id) => {
57
+ const named = filters.find((one) => one.id === id);
58
+ if (named !== undefined)
59
+ place(toggleNamed(put, { ...named, where: named.where }));
60
+ },
61
+ }), ...(groups === undefined || groupable.length === 0
62
+ ? {}
63
+ : { grouping: _jsx(GroupingLevels, { fields: groupable, control: control }) }), onOpenBuilder: () => setBuilding(true), ...(meta?.search == null
64
+ ? {}
65
+ : {
66
+ literal: (text) => matching(meta, text).where ?? null,
67
+ }) }), building ? (_jsx(Dialog, { open: true, width: "lg", title: copy.builder, onClose: () => setBuilding(false), children: _jsx(CriteriaBuilder, { model: model, ...(whereOf(put) === undefined ? {} : { initial: { where: whereOf(put) } }), onApply: (built) => {
68
+ place(built?.where === undefined
69
+ ? []
70
+ : [filterCriteria(built.where, describeFilter(built.where, model))]);
71
+ setBuilding(false);
72
+ }, onCancel: () => setBuilding(false) }) })) : null] }));
73
+ }
@@ -0,0 +1,22 @@
1
+ import type { AnyRecord, Meta } from "@sincpro/criteria";
2
+ import type { DataColumn } from "@sincpro/web-ui";
3
+ import type { ReactNode } from "react";
4
+ /**
5
+ * The columns of a table, from the model: name the fields, the rest follows.
6
+ *
7
+ * Context: forge's lists wrote every column by hand — `header: "nombre"`, `value: (p) =>
8
+ * p.display_name`, a date formatter per screen — while the server already said what each field
9
+ * is called and what type it holds. Here a column is a NAME: its header is the model's label,
10
+ * its cell the text the value reads as (`textOf`), its sort key the raw value, and whether it
11
+ * can be ordered is what the server said (`sortable`). A cell that needs more than text — a
12
+ * link, a tag — is the one thing a screen writes (`cells`).
13
+ *
14
+ * The fields NOT named still exist: they come after, `optional` and hidden, so the columns
15
+ * menu offers them and nobody edits the screen to see a field the server already sends.
16
+ */
17
+ export type ColumnOptions<T> = {
18
+ locale?: string | undefined;
19
+ /** The cells that are not plain text, by field name. */
20
+ cells?: Readonly<Partial<Record<string, (row: T) => ReactNode>>>;
21
+ };
22
+ export declare function columnsOf<T extends AnyRecord>(meta: Meta | null | undefined, names?: readonly string[], options?: ColumnOptions<T>): DataColumn<T>[];
@@ -0,0 +1,27 @@
1
+ import { BOOKKEEPING, defaultFields, sortKeyOf, textOf, viewOf, } from "../../core/present.js";
2
+ function columnOf(meta, name, options, hidden) {
3
+ const view = viewOf(meta, name, options.locale);
4
+ const text = (row) => textOf(meta, name, row[name], options.locale) ?? "";
5
+ const own = options.cells?.[name];
6
+ return {
7
+ id: name,
8
+ header: view.label,
9
+ field: name,
10
+ value: (row) => sortKeyOf(meta, name, row[name]),
11
+ cell: own ?? text,
12
+ text,
13
+ numeric: view.numeric,
14
+ sortable: view.sortable,
15
+ ...(view.name === meta?.identity ? { mono: true } : {}),
16
+ ...(hidden ? { optional: true, defaultHidden: true } : {}),
17
+ };
18
+ }
19
+ export function columnsOf(meta, names, options = {}) {
20
+ const all = defaultFields(meta);
21
+ const shown = names ?? all.filter((name) => !BOOKKEEPING.has(name));
22
+ const rest = all.filter((name) => !shown.includes(name));
23
+ return [
24
+ ...shown.map((name) => columnOf(meta, name, options, false)),
25
+ ...rest.map((name) => columnOf(meta, name, options, true)),
26
+ ];
27
+ }
@@ -0,0 +1,15 @@
1
+ import { type AnyRecord, type Bucket, type Meta } from "@sincpro/criteria";
2
+ import type { GroupLevel } from "../../react/useGroups.js";
3
+ import { type GroupableField, type Pile } from "@sincpro/web-ui";
4
+ /** The fields the model says may split a set (`groupable`), in its words. The identity splits
5
+ * into groups of one, and the version means nothing to a person: neither is offered. */
6
+ export declare function groupableOf(meta: Meta | null, locale?: string): GroupableField[];
7
+ /**
8
+ * The server's groups as piles, with the words a person reads, and each pile's group so that
9
+ * opening one reads its rows. Rows sit only on the deepest level, once read.
10
+ */
11
+ export declare function pilesOf<T extends AnyRecord>(buckets: readonly Bucket<T>[], levels: readonly GroupLevel[], meta: Meta | null, locale: string | undefined, rowsOf: (bucket: Bucket<T>) => readonly T[] | undefined): {
12
+ piles: Pile<T>[];
13
+ bucketAt: ReadonlyMap<string, Bucket<T>>;
14
+ deepest: number;
15
+ };
@@ -0,0 +1,73 @@
1
+ import { labelOf } from "@sincpro/criteria";
2
+ import { frameworkCopy } from "../../core/copy.js";
3
+ import { detailOf } from "../../core/measure.js";
4
+ import { textOf } from "../../core/present.js";
5
+ import { dateLabel, pathKey, } from "@sincpro/web-ui";
6
+ /**
7
+ * Grouping, as web-ui draws it: which fields may split a set, and the server's groups as the
8
+ * piles a `List` shows.
9
+ *
10
+ * Context: web-ui keeps «what was asked» (`GroupLevel`) apart from «what is drawn» (`Pile`)
11
+ * precisely so that piles can come counted from whoever keeps the data. These are the two
12
+ * translations from the model and from criteria's `Bucket`.
13
+ */
14
+ /** The cuts a date offers, coarse to fine — web-ui orders the levels by them. */
15
+ const DATE_GRAINS = ["year", "quarter", "month", "week", "day"];
16
+ /** The fields the model says may split a set (`groupable`), in its words. The identity splits
17
+ * into groups of one, and the version means nothing to a person: neither is offered. */
18
+ export function groupableOf(meta, locale) {
19
+ if (meta === null)
20
+ return [];
21
+ return Object.entries(meta.fields)
22
+ .filter(([name, field]) => field.groupable === true &&
23
+ field.kind === "scalar" &&
24
+ name !== meta.identity &&
25
+ name !== "version")
26
+ .map(([name, field]) => ({
27
+ field: name,
28
+ label: labelOf(meta, name, locale),
29
+ ...(field.type === "date" || field.type === "datetime" ? { grains: DATE_GRAINS } : {}),
30
+ }));
31
+ }
32
+ /**
33
+ * The server's groups as piles, with the words a person reads, and each pile's group so that
34
+ * opening one reads its rows. Rows sit only on the deepest level, once read.
35
+ */
36
+ export function pilesOf(buckets, levels, meta, locale, rowsOf) {
37
+ const bucketAt = new Map();
38
+ const none = frameworkCopy().list.noValue;
39
+ const labelFor = (bucket, depth, key) => {
40
+ const level = levels[depth];
41
+ if (bucket.value === null || bucket.value === undefined || bucket.value === "")
42
+ return none;
43
+ if (level?.grain) {
44
+ const within = levels
45
+ .slice(0, depth)
46
+ .flatMap((one) => (one.field === level.field && one.grain ? [one.grain] : []));
47
+ return dateLabel(key, level.grain, within);
48
+ }
49
+ return textOf(meta, bucket.field, bucket.value, locale) || key;
50
+ };
51
+ const detailed = (bucket) => {
52
+ const detail = detailOf(meta, bucket.measures, locale);
53
+ return detail === undefined ? {} : { detail };
54
+ };
55
+ const walk = (level, above, depth) => level.map((bucket) => {
56
+ const key = bucket.value === null || bucket.value === undefined ? "" : String(bucket.value);
57
+ const path = [...above, key];
58
+ bucketAt.set(pathKey(path), bucket);
59
+ const below = bucket.groups ?? [];
60
+ return {
61
+ path,
62
+ depth,
63
+ field: bucket.field,
64
+ key,
65
+ label: labelFor(bucket, depth, key),
66
+ count: bucket.count,
67
+ ...detailed(bucket),
68
+ items: below.length === 0 ? [...(rowsOf(bucket) ?? [])] : [],
69
+ piles: walk(below, path, depth + 1),
70
+ };
71
+ });
72
+ return { piles: walk(buckets, [], 0), bucketAt, deepest: levels.length - 1 };
73
+ }
@@ -0,0 +1,41 @@
1
+ import { type AnyRecord, type Expression } from "@sincpro/criteria";
2
+ import type { Listing } from "../../react/useListing.js";
3
+ import type { RowAction } from "@sincpro/web-ui";
4
+ import type { LucideIcon } from "lucide-react";
5
+ /**
6
+ * An entity's Commands as the actions of a row: «Confirmar» on each invoice, without opening it.
7
+ *
8
+ * Context: web-ui draws a row's actions (`rowActions`) and knows nothing of what they do; the
9
+ * entity knows its Commands and their routes. This joins them, with the rules a Command keeps
10
+ * everywhere: sent once per gesture with its `Idempotency-Key` (a retry is the same Command),
11
+ * the row's action disabled while it runs, and the list read again when it is done — the server
12
+ * says what the row became. `when` is a condition over the row, evaluated the way the server's
13
+ * hints are (`matches`): «Confirmar» only on drafts.
14
+ *
15
+ * Only Commands that can be undone belong on a row. One that destroys is confirmed by its dry
16
+ * run, and that is the record's `CommandButton`, where the consequence has room to be read.
17
+ *
18
+ * const commands = useRowCommands(listing, [
19
+ * { command: "confirmar", label: "Confirmar", icon: Check,
20
+ * when: { field: "state", operator: "=", value: "draft" } },
21
+ * ]);
22
+ * <EntityList listing={listing} rowActions={commands.rowActions} />
23
+ */
24
+ export type RowCommand<T> = {
25
+ /** A Command the entity declares (`entity({ commands })`). */
26
+ command: string;
27
+ label: string;
28
+ icon: LucideIcon;
29
+ tone?: RowAction["tone"];
30
+ /** Offered only on rows that hold it — a hint, not protection: the server decides. */
31
+ when?: Expression<T>;
32
+ /** What the Command is sent with. Without it, an empty body: the route names the row. */
33
+ body?: (row: T) => AnyRecord;
34
+ };
35
+ export type RowCommands<T> = {
36
+ rowActions: (row: T) => readonly RowAction[];
37
+ /** The last refusal, until `dismiss`. */
38
+ error: unknown;
39
+ dismiss: () => void;
40
+ };
41
+ export declare function useRowCommands<T extends AnyRecord>(listing: Listing<T>, commands: readonly RowCommand<T>[]): RowCommands<T>;
@@ -0,0 +1,56 @@
1
+ "use client";
2
+ import { matches } from "@sincpro/criteria";
3
+ import { pathFor } from "../../core/entity.js";
4
+ import { retryKeys } from "../../core/retry.js";
5
+ import { useFramework } from "../../react/context.js";
6
+ import { useCallback, useState } from "react";
7
+ export function useRowCommands(listing, commands) {
8
+ const { transport } = useFramework();
9
+ const [running, setRunning] = useState(new Set());
10
+ const [error, setError] = useState(null);
11
+ // One key per row and Command, kept while it goes unanswered: retrying is the same gesture.
12
+ const [keys] = useState(() => new Map());
13
+ const { entity, meta } = listing;
14
+ const key = meta?.get_id || meta?.identity || "id";
15
+ const run = useCallback(async (one, row) => {
16
+ const route = entity.commands[one.command];
17
+ if (route === undefined)
18
+ throw new Error(`${entity.key} declares no command "${one.command}"`);
19
+ const id = String(row[key]);
20
+ const gesture = `${one.command}\u0000${id}`;
21
+ const retry = keys.get(gesture) ?? retryKeys();
22
+ keys.set(gesture, retry);
23
+ const body = one.body?.(row) ?? {};
24
+ setRunning((now) => new Set(now).add(gesture));
25
+ setError(null);
26
+ try {
27
+ await transport.send(route.method, pathFor(route, id), body, undefined, {
28
+ idempotencyKey: retry.keyFor(body),
29
+ });
30
+ retry.settle(null);
31
+ listing.reload();
32
+ }
33
+ catch (failure) {
34
+ retry.settle(failure);
35
+ setError(failure);
36
+ }
37
+ finally {
38
+ setRunning((now) => {
39
+ const next = new Set(now);
40
+ next.delete(gesture);
41
+ return next;
42
+ });
43
+ }
44
+ }, [transport, entity, key, keys, listing]);
45
+ const rowActions = (row) => commands
46
+ .filter((one) => meta === null || one.when === undefined || matches(row, one.when, meta))
47
+ .map((one) => ({
48
+ id: one.command,
49
+ label: one.label,
50
+ icon: one.icon,
51
+ ...(one.tone === undefined ? {} : { tone: one.tone }),
52
+ disabled: running.has(`${one.command}\u0000${String(row[key])}`),
53
+ onSelect: () => void run(one, row),
54
+ }));
55
+ return { rowActions, error, dismiss: () => setError(null) };
56
+ }
@@ -0,0 +1,21 @@
1
+ import { type Meta } from "@sincpro/criteria";
2
+ import type { ModelDefinition, SearchFacet } from "@sincpro/web-ui";
3
+ /**
4
+ * The model, as web-ui's search bar and criteria builder read it.
5
+ *
6
+ * Context: web-ui keeps its OWN vocabulary of a model (`ModelDefinition`), written before the
7
+ * framework settled its wire, and the two drifted: `identifiedBy` against `identified_by`, one
8
+ * `translations` object against a `label` on each field. web-ui is right to own it — it is a
9
+ * design system and must not depend on one backend — so the translation lives here, the one
10
+ * place that knows both.
11
+ *
12
+ * Known gap: web-ui's field has `choices` but no word for each choice, so the bar offers an
13
+ * enum's raw values («paid») where a cell says «Pagada». Fixing it is web-ui's.
14
+ */
15
+ export declare function modelOf(meta: Meta, locale?: string): ModelDefinition;
16
+ /**
17
+ * Where a typed text can be looked for: the fields the entity's own search template names
18
+ * first (what «search by name» means for THIS entity), then every other text field it can
19
+ * filter on.
20
+ */
21
+ export declare function facetsOf(meta: Meta, locale?: string): SearchFacet[];