@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.
- package/README.md +261 -40
- package/dist/core/cache.js +2 -2
- package/dist/core/copy.d.ts +24 -0
- package/dist/core/copy.js +30 -0
- package/dist/core/edit.d.ts +13 -0
- package/dist/core/edit.js +30 -0
- package/dist/core/entity.d.ts +34 -0
- package/dist/core/entity.js +25 -0
- package/dist/core/form.d.ts +21 -0
- package/dist/core/form.js +30 -0
- package/dist/core/index.d.ts +10 -3
- package/dist/core/index.js +9 -2
- package/dist/core/list.d.ts +64 -0
- package/dist/core/list.js +22 -0
- package/dist/core/lookup.d.ts +5 -2
- package/dist/core/lookup.js +6 -2
- package/dist/core/measure.d.ts +16 -0
- package/dist/core/measure.js +38 -0
- package/dist/core/memory.js +18 -4
- package/dist/core/relation.d.ts +28 -0
- package/dist/core/relation.js +30 -0
- package/dist/core/rest.js +13 -3
- package/dist/core/retry.d.ts +24 -0
- package/dist/core/retry.js +25 -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 +13 -1
- package/dist/react/context.d.ts +12 -1
- package/dist/react/context.js +10 -2
- package/dist/react/index.d.ts +3 -1
- package/dist/react/index.js +3 -1
- package/dist/react/useCommand.d.ts +3 -1
- package/dist/react/useCommand.js +12 -4
- package/dist/react/useGroups.d.ts +40 -0
- package/dist/react/useGroups.js +69 -0
- package/dist/react/useListing.d.ts +21 -3
- package/dist/react/useListing.js +38 -3
- package/dist/react/useLookup.d.ts +30 -5
- package/dist/react/useLookup.js +56 -3
- package/dist/react/useRecord.d.ts +17 -2
- package/dist/react/useRecord.js +105 -9
- package/dist/react/useSummary.d.ts +15 -0
- package/dist/react/useSummary.js +22 -0
- package/dist/web/index.d.ts +9 -3
- package/dist/web/index.js +9 -3
- package/dist/web/list/EntityList.d.ts +27 -4
- package/dist/web/list/EntityList.js +162 -15
- package/dist/web/list/EntitySearchBar.d.ts +16 -8
- package/dist/web/list/EntitySearchBar.js +34 -4
- 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/record/EntityPicker.d.ts +42 -13
- package/dist/web/record/EntityPicker.js +53 -19
- package/dist/web/record/EntityTags.d.ts +38 -0
- package/dist/web/record/EntityTags.js +41 -0
- package/dist/web/record/RelatedList.d.ts +2 -1
- package/dist/web/record/RelatedList.js +12 -2
- package/dist/web/record/edit.d.ts +12 -5
- package/dist/web/record/edit.js +67 -8
- package/dist/web/record/opening.d.ts +26 -0
- package/dist/web/record/opening.js +10 -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 +4 -1
- package/dist/web/workspace/SincproApp.js +2 -2
- package/dist/web/workspace/composer.d.ts +26 -4
- package/dist/web/workspace/composer.js +59 -2
- 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 +0 -9
- package/dist/web/workspace/skin.js +25 -13
- package/dist/web/workspace/workspace.d.ts +31 -3
- package/dist/web/workspace/workspace.js +57 -4
- package/package.json +5 -5
package/dist/react/useRecord.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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 (
|
|
66
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/web/index.d.ts
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 { type ColumnOptions, columnsOf } from "./list/columns.js";
|
|
5
|
-
export { EntityList, type EntityListProps
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
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
|
-
|
|
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:
|
|
119
|
+
[identity]: (row) => (_jsx(RecordLink, { api: api, resource: refOf(row), children: idOf(row) })),
|
|
29
120
|
};
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
: [
|
|
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
|
|
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
|
|
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
|
|
18
|
-
|
|
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;
|