@sincpro/web-framework 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +445 -2
- package/dist/core/cache.d.ts +30 -0
- package/dist/core/cache.js +47 -0
- package/dist/core/copy.d.ts +93 -0
- package/dist/core/copy.js +141 -0
- package/dist/core/edit.d.ts +40 -0
- package/dist/core/edit.js +81 -0
- package/dist/core/entity.d.ts +113 -0
- package/dist/core/entity.js +57 -0
- package/dist/core/form.d.ts +21 -0
- package/dist/core/form.js +30 -0
- package/dist/core/history.d.ts +20 -0
- package/dist/core/history.js +40 -0
- package/dist/core/index.d.ts +26 -0
- package/dist/core/index.js +26 -0
- package/dist/core/list.d.ts +64 -0
- package/dist/core/list.js +22 -0
- package/dist/core/lookup.d.ts +19 -0
- package/dist/core/lookup.js +14 -0
- package/dist/core/measure.d.ts +16 -0
- package/dist/core/measure.js +38 -0
- package/dist/core/memory.d.ts +43 -0
- package/dist/core/memory.js +99 -0
- package/dist/core/meta.d.ts +114 -0
- package/dist/core/meta.js +130 -0
- package/dist/core/metas.d.ts +18 -0
- package/dist/core/metas.js +7 -0
- package/dist/core/present.d.ts +48 -0
- package/dist/core/present.js +165 -0
- package/dist/core/related.d.ts +22 -0
- package/dist/core/related.js +21 -0
- package/dist/core/relation.d.ts +28 -0
- package/dist/core/relation.js +30 -0
- package/dist/core/rest.d.ts +30 -0
- package/dist/core/rest.js +60 -0
- package/dist/core/retry.d.ts +24 -0
- package/dist/core/retry.js +25 -0
- package/dist/core/schema.d.ts +46 -0
- package/dist/core/schema.js +128 -0
- package/dist/core/summary.d.ts +50 -0
- package/dist/core/summary.js +54 -0
- package/dist/core/trail.d.ts +44 -0
- package/dist/core/trail.js +70 -0
- package/dist/core/transport.d.ts +71 -0
- package/dist/core/transport.js +43 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/react/context.d.ts +38 -0
- package/dist/react/context.js +26 -0
- package/dist/react/index.d.ts +14 -0
- package/dist/react/index.js +14 -0
- package/dist/react/useCommand.d.ts +30 -0
- package/dist/react/useCommand.js +60 -0
- package/dist/react/useGroups.d.ts +40 -0
- package/dist/react/useGroups.js +69 -0
- package/dist/react/useHistory.d.ts +5 -0
- package/dist/react/useHistory.js +41 -0
- package/dist/react/useListing.d.ts +93 -0
- package/dist/react/useListing.js +156 -0
- package/dist/react/useLookup.d.ts +47 -0
- package/dist/react/useLookup.js +68 -0
- package/dist/react/useRecord.d.ts +89 -0
- package/dist/react/useRecord.js +272 -0
- package/dist/react/useRelated.d.ts +21 -0
- package/dist/react/useRelated.js +32 -0
- package/dist/react/useSummary.d.ts +15 -0
- package/dist/react/useSummary.js +22 -0
- package/dist/web/Refused.d.ts +9 -0
- package/dist/web/Refused.js +14 -0
- package/dist/web/index.d.ts +23 -0
- package/dist/web/index.js +23 -0
- package/dist/web/list/EntityList.d.ts +92 -0
- package/dist/web/list/EntityList.js +229 -0
- package/dist/web/list/EntitySearchBar.d.ts +34 -0
- package/dist/web/list/EntitySearchBar.js +73 -0
- package/dist/web/list/columns.d.ts +22 -0
- package/dist/web/list/columns.js +27 -0
- package/dist/web/list/groups.d.ts +15 -0
- package/dist/web/list/groups.js +73 -0
- package/dist/web/list/rowCommands.d.ts +41 -0
- package/dist/web/list/rowCommands.js +56 -0
- package/dist/web/list/search.d.ts +21 -0
- package/dist/web/list/search.js +72 -0
- package/dist/web/record/CommandButton.d.ts +22 -0
- package/dist/web/record/CommandButton.js +40 -0
- package/dist/web/record/EntityPicker.d.ts +56 -0
- package/dist/web/record/EntityPicker.js +73 -0
- package/dist/web/record/EntityTags.d.ts +38 -0
- package/dist/web/record/EntityTags.js +41 -0
- package/dist/web/record/RelatedList.d.ts +19 -0
- package/dist/web/record/RelatedList.js +26 -0
- package/dist/web/record/edit.d.ts +42 -0
- package/dist/web/record/edit.js +122 -0
- package/dist/web/record/opening.d.ts +26 -0
- package/dist/web/record/opening.js +10 -0
- package/dist/web/record/record.d.ts +25 -0
- package/dist/web/record/record.js +56 -0
- package/dist/web/record/summary.d.ts +22 -0
- package/dist/web/record/summary.js +55 -0
- package/dist/web/workspace/SincproApp.d.ts +40 -0
- package/dist/web/workspace/SincproApp.js +45 -0
- package/dist/web/workspace/composer.d.ts +55 -0
- package/dist/web/workspace/composer.js +94 -0
- package/dist/web/workspace/open.d.ts +61 -0
- package/dist/web/workspace/open.js +62 -0
- package/dist/web/workspace/record.d.ts +30 -0
- package/dist/web/workspace/record.js +80 -0
- package/dist/web/workspace/skin.d.ts +2 -0
- package/dist/web/workspace/skin.js +45 -0
- package/dist/web/workspace/workspace.d.ts +44 -0
- package/dist/web/workspace/workspace.js +82 -0
- package/package.json +86 -4
|
@@ -0,0 +1,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[];
|