@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,272 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { changesOf, creationOf, defaultsOf, errorsByField, fieldStateOf, missingOf, } from "../core/edit.js";
|
|
3
|
+
import { pathFor } from "../core/entity.js";
|
|
4
|
+
import { retryKeys } from "../core/retry.js";
|
|
5
|
+
import { TransportError } from "../core/transport.js";
|
|
6
|
+
import { useFramework } from "./context.js";
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
8
|
+
/** How long typing has to pause before the server is asked what the record becomes. */
|
|
9
|
+
const PREVIEW_PAUSE = 300;
|
|
10
|
+
export function useRecord(entity, given) {
|
|
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());
|
|
19
|
+
const [state, setState] = useState({
|
|
20
|
+
status: "loading",
|
|
21
|
+
record: null,
|
|
22
|
+
meta: null,
|
|
23
|
+
error: null,
|
|
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);
|
|
28
|
+
const [round, setRound] = useState(0);
|
|
29
|
+
const [typed, setTyped] = useState({});
|
|
30
|
+
const [computed, setComputed] = useState({});
|
|
31
|
+
const [fromServer, setFromServer] = useState({});
|
|
32
|
+
const [advice, setAdvice] = useState([]);
|
|
33
|
+
const [saving, setSaving] = useState(false);
|
|
34
|
+
const [failure, setFailure] = useState(null);
|
|
35
|
+
const [conflict, setConflict] = useState(false);
|
|
36
|
+
const [draft, setDraft] = useState(null);
|
|
37
|
+
const pause = useRef(null);
|
|
38
|
+
// The preview in flight. A newer one cancels it, and so do a save, a discard and leaving:
|
|
39
|
+
// an answer about values that are no longer on screen must never land on them.
|
|
40
|
+
const asking = useRef(null);
|
|
41
|
+
const mounted = useRef(true);
|
|
42
|
+
const cancelPreview = () => {
|
|
43
|
+
if (pause.current)
|
|
44
|
+
clearTimeout(pause.current);
|
|
45
|
+
asking.current?.abort();
|
|
46
|
+
asking.current = null;
|
|
47
|
+
};
|
|
48
|
+
const forget = () => {
|
|
49
|
+
cancelPreview();
|
|
50
|
+
setTyped({});
|
|
51
|
+
setComputed({});
|
|
52
|
+
setFromServer({});
|
|
53
|
+
setAdvice([]);
|
|
54
|
+
setFailure(null);
|
|
55
|
+
setConflict(false);
|
|
56
|
+
};
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const aborter = new AbortController();
|
|
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
|
+
}
|
|
95
|
+
transport.one(entity.one(id), aborter.signal).then((answer) => {
|
|
96
|
+
if (answer.meta)
|
|
97
|
+
metas.set(entity.key, answer.meta);
|
|
98
|
+
if (aborter.signal.aborted)
|
|
99
|
+
return;
|
|
100
|
+
forget();
|
|
101
|
+
setReadFor(id);
|
|
102
|
+
setDraft(answer.draft);
|
|
103
|
+
if (answer.draft)
|
|
104
|
+
setTyped(answer.draft.values);
|
|
105
|
+
setState({
|
|
106
|
+
status: "ready",
|
|
107
|
+
record: answer.record,
|
|
108
|
+
meta: answer.meta ?? metas.get(entity.key) ?? null,
|
|
109
|
+
error: null,
|
|
110
|
+
});
|
|
111
|
+
}, (error) => {
|
|
112
|
+
if (aborter.signal.aborted)
|
|
113
|
+
return;
|
|
114
|
+
setReadFor(id);
|
|
115
|
+
setState((now) => ({ status: "failed", record: null, meta: now.meta, error }));
|
|
116
|
+
});
|
|
117
|
+
return () => aborter.abort();
|
|
118
|
+
// `forget` only resets this hook's own state; listing it would reload the record on every
|
|
119
|
+
// render. What reloads it is the transport, the entity, the id or an explicit `reload()`.
|
|
120
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
121
|
+
}, [transport, metas, entity, id, isNew, round]);
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
mounted.current = true;
|
|
124
|
+
return () => {
|
|
125
|
+
mounted.current = false;
|
|
126
|
+
cancelPreview();
|
|
127
|
+
};
|
|
128
|
+
}, []);
|
|
129
|
+
const reload = useCallback(() => setRound((n) => n + 1), []);
|
|
130
|
+
const record = useMemo(() => (state.record ?? {}), [state.record]);
|
|
131
|
+
const values = useMemo(() => ({ ...record, ...computed, ...typed }), [record, computed, typed]);
|
|
132
|
+
const changed = useMemo(() => changesOf(record, typed), [record, typed]);
|
|
133
|
+
const version = record["version"];
|
|
134
|
+
const ask = (next, name) => {
|
|
135
|
+
const preview = entity.preview;
|
|
136
|
+
if (preview === undefined)
|
|
137
|
+
return;
|
|
138
|
+
cancelPreview();
|
|
139
|
+
pause.current = setTimeout(() => {
|
|
140
|
+
const aborter = new AbortController();
|
|
141
|
+
asking.current = aborter;
|
|
142
|
+
transport
|
|
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)
|
|
148
|
+
.then((answer) => {
|
|
149
|
+
if (aborter.signal.aborted || !mounted.current)
|
|
150
|
+
return;
|
|
151
|
+
setComputed(answer.values ?? {});
|
|
152
|
+
setFromServer(answer.fields ?? {});
|
|
153
|
+
setAdvice(answer.advice ?? []);
|
|
154
|
+
}, () => undefined);
|
|
155
|
+
}, PREVIEW_PAUSE);
|
|
156
|
+
};
|
|
157
|
+
const refused = (error) => {
|
|
158
|
+
if (error instanceof TransportError && error.problem?.kind === "conflict")
|
|
159
|
+
setConflict(true);
|
|
160
|
+
else
|
|
161
|
+
setFailure(error);
|
|
162
|
+
};
|
|
163
|
+
const edit = {
|
|
164
|
+
values,
|
|
165
|
+
changed,
|
|
166
|
+
dirty: Object.keys(changed).length > 0,
|
|
167
|
+
set: (name, value) => {
|
|
168
|
+
const next = { ...typed, [name]: value };
|
|
169
|
+
setTyped(next);
|
|
170
|
+
ask(next, name);
|
|
171
|
+
},
|
|
172
|
+
state: (name) => fieldStateOf(state.meta, name, values, fromServer),
|
|
173
|
+
errors: failure instanceof TransportError ? errorsByField(failure.problem?.errors) : {},
|
|
174
|
+
advice,
|
|
175
|
+
missing: missingOf(state.meta, values, fromServer),
|
|
176
|
+
saving,
|
|
177
|
+
failure,
|
|
178
|
+
conflict,
|
|
179
|
+
restored: draft !== null && Object.keys(changed).length > 0,
|
|
180
|
+
draft,
|
|
181
|
+
save: async () => {
|
|
182
|
+
if (isNew)
|
|
183
|
+
return create();
|
|
184
|
+
const route = entity.save;
|
|
185
|
+
if (route === undefined)
|
|
186
|
+
throw new Error(`${entity.key} declares no save route`);
|
|
187
|
+
cancelPreview();
|
|
188
|
+
setSaving(true);
|
|
189
|
+
setFailure(null);
|
|
190
|
+
try {
|
|
191
|
+
await transport.send(route.method, pathFor(route, id), {
|
|
192
|
+
...changed,
|
|
193
|
+
...(version === undefined ? {} : { version }),
|
|
194
|
+
});
|
|
195
|
+
setDraft(null);
|
|
196
|
+
reload();
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
// A write is never aborted — the server may have done it — but an answer that comes
|
|
201
|
+
// back after the screen left lands nowhere.
|
|
202
|
+
if (mounted.current)
|
|
203
|
+
refused(error);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
if (mounted.current)
|
|
208
|
+
setSaving(false);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
discard: forget,
|
|
212
|
+
created,
|
|
213
|
+
...(entity.draft === undefined || isNew
|
|
214
|
+
? {}
|
|
215
|
+
: {
|
|
216
|
+
keep: async () => {
|
|
217
|
+
const route = entity.draft;
|
|
218
|
+
try {
|
|
219
|
+
const kept = await transport.send(route.method, pathFor(route, id), {
|
|
220
|
+
values: changed,
|
|
221
|
+
expected: draft?.version ?? 0,
|
|
222
|
+
});
|
|
223
|
+
setDraft(kept);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
refused(error);
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
};
|
|
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 };
|
|
272
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AnyRecord, Criteria } from "@sincpro/criteria";
|
|
2
|
+
import type { Entity } from "../core/entity.js";
|
|
3
|
+
import type { Listing, ListingOptions } from "./useListing.js";
|
|
4
|
+
import type { RecordReading } from "./useRecord.js";
|
|
5
|
+
/**
|
|
6
|
+
* The records a relation of ONE record points to, as a live listing of the related entity.
|
|
7
|
+
*
|
|
8
|
+
* Context: sincpro_framework sends a one2many inside its record as ONE page (`RecordLines` draws
|
|
9
|
+
* it). A customer with forty thousand invoices, a tenant with a thousand members, the lines of a
|
|
10
|
+
* big order need more than that page: search in them, order them, walk them. The model says how
|
|
11
|
+
* to ask — the related aggregate's key that points back (`identified_by`) — so the relation is
|
|
12
|
+
* a criteria over the related entity, `where <that key> = <this record's key>`, answered by the
|
|
13
|
+
* server like any list. The relation's scope is AND-ed with everything else, so no search, filter
|
|
14
|
+
* or page can ever leave it.
|
|
15
|
+
*
|
|
16
|
+
* const facturas = useRelated(cliente, "invoices", invoicesEntity);
|
|
17
|
+
* <EntityList listing={facturas} embedded />
|
|
18
|
+
*/
|
|
19
|
+
export declare function useRelated<P extends AnyRecord, T extends AnyRecord>(record: RecordReading<P>, name: string, related: Entity<T>, options?: Omit<ListingOptions<T>, "criteria"> & {
|
|
20
|
+
criteria?: Criteria<T>;
|
|
21
|
+
}): Listing<T>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useListing } from "./useListing.js";
|
|
3
|
+
/**
|
|
4
|
+
* The records a relation of ONE record points to, as a live listing of the related entity.
|
|
5
|
+
*
|
|
6
|
+
* Context: sincpro_framework sends a one2many inside its record as ONE page (`RecordLines` draws
|
|
7
|
+
* it). A customer with forty thousand invoices, a tenant with a thousand members, the lines of a
|
|
8
|
+
* big order need more than that page: search in them, order them, walk them. The model says how
|
|
9
|
+
* to ask — the related aggregate's key that points back (`identified_by`) — so the relation is
|
|
10
|
+
* a criteria over the related entity, `where <that key> = <this record's key>`, answered by the
|
|
11
|
+
* server like any list. The relation's scope is AND-ed with everything else, so no search, filter
|
|
12
|
+
* or page can ever leave it.
|
|
13
|
+
*
|
|
14
|
+
* const facturas = useRelated(cliente, "invoices", invoicesEntity);
|
|
15
|
+
* <EntityList listing={facturas} embedded />
|
|
16
|
+
*/
|
|
17
|
+
export function useRelated(record, name, related, options = {}) {
|
|
18
|
+
const field = record.meta?.fields[name];
|
|
19
|
+
const key = field?.identified_by ?? "";
|
|
20
|
+
const own = record.meta?.get_id || record.meta?.identity || "id";
|
|
21
|
+
const value = record.record === null ? null : record.record[own];
|
|
22
|
+
const scope = {
|
|
23
|
+
...options.criteria,
|
|
24
|
+
where: {
|
|
25
|
+
all: [
|
|
26
|
+
...(options.criteria?.where ? [options.criteria.where] : []),
|
|
27
|
+
{ field: key, operator: "=", value: value },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
return useListing(related, { pageSize: 10, ...options, criteria: scope });
|
|
32
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A failure, said the way the server said it: its title, what happened and what to do —
|
|
3
|
+
* `guidance` included, which is the part a person acts on. Retry only when retrying can help:
|
|
4
|
+
* a «you have no access» with a retry button is a button that lies.
|
|
5
|
+
*/
|
|
6
|
+
export declare function Refused({ error, onRetry }: {
|
|
7
|
+
error: unknown;
|
|
8
|
+
onRetry?: () => void;
|
|
9
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { frameworkCopy } from "../core/copy.js";
|
|
4
|
+
import { explain } from "../core/transport.js";
|
|
5
|
+
import { Button, Failed } from "@sincpro/web-ui";
|
|
6
|
+
/**
|
|
7
|
+
* A failure, said the way the server said it: its title, what happened and what to do —
|
|
8
|
+
* `guidance` included, which is the part a person acts on. Retry only when retrying can help:
|
|
9
|
+
* a «you have no access» with a retry button is a button that lies.
|
|
10
|
+
*/
|
|
11
|
+
export function Refused({ error, onRetry }) {
|
|
12
|
+
const { heading, sentence, retryable } = explain(error);
|
|
13
|
+
return (_jsx(Failed, { heading: heading, sentence: sentence, action: onRetry !== undefined && retryable ? (_jsx(Button, { size: "sm", role: "secondary", onClick: onRetry, children: frameworkCopy().failure.retry })) : undefined }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web bindings: what draws a `Listing` and a record with `@sincpro/web-ui`.
|
|
3
|
+
*/
|
|
4
|
+
export { type ColumnOptions, columnsOf } from "./list/columns.js";
|
|
5
|
+
export { EntityList, type EntityListProps } from "./list/EntityList.js";
|
|
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";
|
|
9
|
+
export { facetsOf, modelOf } from "./list/search.js";
|
|
10
|
+
export { CommandButton } from "./record/CommandButton.js";
|
|
11
|
+
export { dirtyOf, type FieldControl, RecordField, } from "./record/edit.js";
|
|
12
|
+
export { EntityPicker } from "./record/EntityPicker.js";
|
|
13
|
+
export { EntityTags } from "./record/EntityTags.js";
|
|
14
|
+
export { countOf, factsOf, RecordLines } from "./record/record.js";
|
|
15
|
+
export { RelatedList } from "./record/RelatedList.js";
|
|
16
|
+
export { cardOf, relationOf } from "./record/summary.js";
|
|
17
|
+
export { Refused } from "./Refused.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";
|
|
21
|
+
export { SincproApp } from "./workspace/SincproApp.js";
|
|
22
|
+
export { webUiSkin } from "./workspace/skin.js";
|
|
23
|
+
export { chooserIn, useFollowed, useRecordInWorkspace, } from "./workspace/workspace.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web bindings: what draws a `Listing` and a record with `@sincpro/web-ui`.
|
|
3
|
+
*/
|
|
4
|
+
export { columnsOf } from "./list/columns.js";
|
|
5
|
+
export { EntityList } from "./list/EntityList.js";
|
|
6
|
+
export { EntitySearchBar } from "./list/EntitySearchBar.js";
|
|
7
|
+
export { groupableOf, pilesOf } from "./list/groups.js";
|
|
8
|
+
export { useRowCommands, } from "./list/rowCommands.js";
|
|
9
|
+
export { facetsOf, modelOf } from "./list/search.js";
|
|
10
|
+
export { CommandButton } from "./record/CommandButton.js";
|
|
11
|
+
export { dirtyOf, RecordField, } from "./record/edit.js";
|
|
12
|
+
export { EntityPicker } from "./record/EntityPicker.js";
|
|
13
|
+
export { EntityTags } from "./record/EntityTags.js";
|
|
14
|
+
export { countOf, factsOf, RecordLines } from "./record/record.js";
|
|
15
|
+
export { RelatedList } from "./record/RelatedList.js";
|
|
16
|
+
export { cardOf, relationOf } from "./record/summary.js";
|
|
17
|
+
export { Refused } from "./Refused.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";
|
|
21
|
+
export { SincproApp } from "./workspace/SincproApp.js";
|
|
22
|
+
export { webUiSkin } from "./workspace/skin.js";
|
|
23
|
+
export { chooserIn, useFollowed, useRecordInWorkspace, } from "./workspace/workspace.js";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type AnyRecord } from "@sincpro/criteria";
|
|
2
|
+
import { type WorkspaceApi } from "@sincpro/web-composer";
|
|
3
|
+
import type { NamedFilter } from "../../core/list.js";
|
|
4
|
+
import type { Listing } from "../../react/useListing.js";
|
|
5
|
+
import { type RowCommand } from "./rowCommands.js";
|
|
6
|
+
import { type ListCrumb, type RowAction } from "@sincpro/web-ui";
|
|
7
|
+
import { type ReactNode } from "react";
|
|
8
|
+
/**
|
|
9
|
+
* A whole collection from a `Listing`: the bar, the search, the set in the views it offers,
|
|
10
|
+
* and the way through it — the screen forge wrote five times by hand, written once.
|
|
11
|
+
*
|
|
12
|
+
* Context: this is the ready-made composition, not a cage. Everything it does is reachable on
|
|
13
|
+
* its own — `columnsOf` for the columns, `EntitySearchBar` for the search, `useListing` for the
|
|
14
|
+
* state — so a screen that needs its own bar or a second table writes `List` with those.
|
|
15
|
+
*
|
|
16
|
+
* Built for sets of any size: in `pages` mode (the default) the screen holds ONE page and walks
|
|
17
|
+
* the server's cursor, so a million rows cost what fifty do; in `more` mode it stops growing at
|
|
18
|
+
* its ceiling and asks to narrow the search. Nothing is filtered, ordered or counted on the
|
|
19
|
+
* page: every gesture is a question to the server.
|
|
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
|
+
*
|
|
25
|
+
* Where a record opens is the composer's question: with `api`, a row opens the page and the
|
|
26
|
+
* identity column is the composer's `RecordLink` (peek, side panel, window); without it, the
|
|
27
|
+
* screen passes `onOpen`.
|
|
28
|
+
*/
|
|
29
|
+
export type { NamedFilter };
|
|
30
|
+
export type EntityListProps<T extends AnyRecord> = {
|
|
31
|
+
listing: Listing<T>;
|
|
32
|
+
/**
|
|
33
|
+
* The workspace this list is a view of. With it, a row opens its record as a page, the
|
|
34
|
+
* identity column is the composer's `RecordLink` (peek, side panel, window on hover) and the
|
|
35
|
+
* breadcrumb is the composer's — nothing for the screen to write.
|
|
36
|
+
*/
|
|
37
|
+
api?: WorkspaceApi;
|
|
38
|
+
/** The fields shown, in order. Without it, the named list's, or every scalar field. */
|
|
39
|
+
columns?: readonly string[];
|
|
40
|
+
/** The cells that are not plain text: a link, a tag. Memoise it: compared by identity. */
|
|
41
|
+
cells?: Readonly<Partial<Record<string, (row: T) => ReactNode>>>;
|
|
42
|
+
/**
|
|
43
|
+
* The ways to look at the set, the first one shown: `table` (columns), `rows` (a name and
|
|
44
|
+
* its facts, what a narrow panel wants) and `board` (cards). The same set, the same search,
|
|
45
|
+
* the same page in each.
|
|
46
|
+
*/
|
|
47
|
+
views?: readonly ("table" | "rows" | "board")[];
|
|
48
|
+
/** Without it, the model's own name. */
|
|
49
|
+
title?: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
crumbs?: readonly ListCrumb[];
|
|
52
|
+
actions?: ReactNode;
|
|
53
|
+
onOpen?: (row: T) => void;
|
|
54
|
+
onPeek?: (row: T) => void;
|
|
55
|
+
/** What an empty list says. */
|
|
56
|
+
empty?: string;
|
|
57
|
+
/**
|
|
58
|
+
* `"bar"` (the default) is the full search bar with the criteria builder; `"box"` is one
|
|
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.
|
|
61
|
+
*/
|
|
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;
|
|
67
|
+
/** The cuts this list offers by name: «sólo pagadas», «vencidas». */
|
|
68
|
+
filters?: readonly NamedFilter<T>[];
|
|
69
|
+
/**
|
|
70
|
+
* Rows can be ticked. With `api`, what is ticked is PUBLISHED to the workspace
|
|
71
|
+
* (`api.select`): a panel beside it follows the selection without knowing this list.
|
|
72
|
+
*/
|
|
73
|
+
selectable?: boolean;
|
|
74
|
+
/** What to do with what is ticked: drawn in the bar while something is. */
|
|
75
|
+
selectionActions?: ReactNode;
|
|
76
|
+
/** The list is a FIELD of a record — its lines, its members — and not a screen. */
|
|
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[];
|
|
91
|
+
};
|
|
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;
|