@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,141 @@
|
|
|
1
|
+
export const ENGLISH_FRAMEWORK_COPY = {
|
|
2
|
+
value: {
|
|
3
|
+
yes: "yes",
|
|
4
|
+
no: "no",
|
|
5
|
+
items: (count, exact) => `${count}${exact ? "" : "+"} ${count === 1 ? "item" : "items"}`,
|
|
6
|
+
},
|
|
7
|
+
list: {
|
|
8
|
+
loadMore: "Load more",
|
|
9
|
+
loading: "Loading…",
|
|
10
|
+
shown: (shown, total, exact) => `${shown} of ${total}${exact ? "" : "+"}`,
|
|
11
|
+
empty: "Nothing here yet.",
|
|
12
|
+
emptyFiltered: "Nothing matches.",
|
|
13
|
+
search: "Search",
|
|
14
|
+
searchBar: "Search, or # to pick a field…",
|
|
15
|
+
builder: "Criteria builder",
|
|
16
|
+
capped: (shown) => `Showing the first ${shown}. Narrow the search to see the rest.`,
|
|
17
|
+
dropped: (fields) => `The server ignored a condition on ${fields.join(", ")}: the list shows more than asked.`,
|
|
18
|
+
noValue: "No value",
|
|
19
|
+
groupsCapped: (shown) => `The first ${shown} groups. Narrow the search to see the rest.`,
|
|
20
|
+
groups: (groups, records) => `${groups} ${groups === 1 ? "group" : "groups"} · ${records} ${records === 1 ? "record" : "records"}`,
|
|
21
|
+
},
|
|
22
|
+
record: {
|
|
23
|
+
restored: "A draft you kept is back. Save it or discard it.",
|
|
24
|
+
conflict: "Someone saved this record after you opened it. Nothing was overwritten: read it again.",
|
|
25
|
+
reload: "Read it again",
|
|
26
|
+
missing: (fields) => `Missing: ${fields.join(", ")}.`,
|
|
27
|
+
keep: "Keep draft",
|
|
28
|
+
loading: "Loading the record",
|
|
29
|
+
partial: (shown, total) => `Showing ${shown} of ${total}.`,
|
|
30
|
+
creating: (name) => (name ? `${name} · new` : "New"),
|
|
31
|
+
create: "New",
|
|
32
|
+
},
|
|
33
|
+
picker: {
|
|
34
|
+
browse: "Search all",
|
|
35
|
+
},
|
|
36
|
+
summary: {
|
|
37
|
+
created: "Created",
|
|
38
|
+
updated: "Updated",
|
|
39
|
+
when: (at, by) => (by === null ? at : `${at} · ${by}`),
|
|
40
|
+
updatedLine: (at, by) => (by === null ? `Updated ${at}` : `Updated ${at} by ${by}`),
|
|
41
|
+
createdLine: (at, by) => (by === null ? `Created ${at}` : `Created ${at} by ${by}`),
|
|
42
|
+
},
|
|
43
|
+
command: {
|
|
44
|
+
confirm: "Do it",
|
|
45
|
+
cancel: "Cancel",
|
|
46
|
+
rehearsing: "Checking what it would do…",
|
|
47
|
+
},
|
|
48
|
+
failure: {
|
|
49
|
+
unreachable: "The server could not be reached",
|
|
50
|
+
status: (status) => `The server answered ${status}`,
|
|
51
|
+
retry: "Retry",
|
|
52
|
+
kind: {
|
|
53
|
+
invalid: "The request is not valid",
|
|
54
|
+
unauthenticated: "Sign in again",
|
|
55
|
+
permission_denied: "You do not have access",
|
|
56
|
+
not_found: "It does not exist",
|
|
57
|
+
conflict: "Someone changed it first",
|
|
58
|
+
in_progress: "It is still in progress",
|
|
59
|
+
key_reused: "That request was already sent",
|
|
60
|
+
domain: "The operation was refused",
|
|
61
|
+
exhausted: "Too many requests",
|
|
62
|
+
unavailable: "The service is not available",
|
|
63
|
+
unknown_outcome: "It is not known whether it was done",
|
|
64
|
+
internal: "Something failed on the server",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
export const SPANISH_FRAMEWORK_COPY = {
|
|
69
|
+
value: {
|
|
70
|
+
yes: "sí",
|
|
71
|
+
no: "no",
|
|
72
|
+
items: (count, exact) => `${count}${exact ? "" : "+"} ${count === 1 ? "elemento" : "elementos"}`,
|
|
73
|
+
},
|
|
74
|
+
list: {
|
|
75
|
+
loadMore: "Cargar más",
|
|
76
|
+
loading: "Cargando…",
|
|
77
|
+
shown: (shown, total, exact) => `${shown} de ${total}${exact ? "" : "+"}`,
|
|
78
|
+
empty: "Todavía no hay nada.",
|
|
79
|
+
emptyFiltered: "Nada coincide.",
|
|
80
|
+
search: "Buscar",
|
|
81
|
+
searchBar: "Buscar, o # para elegir el campo…",
|
|
82
|
+
builder: "Constructor de criteria",
|
|
83
|
+
capped: (shown) => `Se muestran los primeros ${shown}. Refiná la búsqueda para ver el resto.`,
|
|
84
|
+
dropped: (fields) => `El servidor ignoró una condición sobre ${fields.join(", ")}: la lista muestra más de lo pedido.`,
|
|
85
|
+
noValue: "Sin valor",
|
|
86
|
+
groupsCapped: (shown) => `Los primeros ${shown} grupos. Acotá la búsqueda para ver el resto.`,
|
|
87
|
+
groups: (groups, records) => `${groups} ${groups === 1 ? "grupo" : "grupos"} · ${records} ${records === 1 ? "registro" : "registros"}`,
|
|
88
|
+
},
|
|
89
|
+
record: {
|
|
90
|
+
restored: "Volvió un borrador que guardaste. Guardalo o descartalo.",
|
|
91
|
+
conflict: "Alguien guardó este registro después de que lo abriste. No se pisó nada: volvé a leerlo.",
|
|
92
|
+
reload: "Volver a leer",
|
|
93
|
+
missing: (fields) => `Falta: ${fields.join(", ")}.`,
|
|
94
|
+
keep: "Guardar borrador",
|
|
95
|
+
loading: "Cargando el registro",
|
|
96
|
+
partial: (shown, total) => `Se muestran ${shown} de ${total}.`,
|
|
97
|
+
creating: (name) => (name ? `${name} · nuevo` : "Nuevo"),
|
|
98
|
+
create: "Nuevo",
|
|
99
|
+
},
|
|
100
|
+
picker: {
|
|
101
|
+
browse: "Buscar en todos",
|
|
102
|
+
},
|
|
103
|
+
summary: {
|
|
104
|
+
created: "Creado",
|
|
105
|
+
updated: "Actualizado",
|
|
106
|
+
when: (at, by) => (by === null ? at : `${at} · ${by}`),
|
|
107
|
+
updatedLine: (at, by) => by === null ? `Actualizado el ${at}` : `Actualizado el ${at} por ${by}`,
|
|
108
|
+
createdLine: (at, by) => (by === null ? `Creado el ${at}` : `Creado el ${at} por ${by}`),
|
|
109
|
+
},
|
|
110
|
+
command: {
|
|
111
|
+
confirm: "Hacerlo",
|
|
112
|
+
cancel: "Cancelar",
|
|
113
|
+
rehearsing: "Revisando qué haría…",
|
|
114
|
+
},
|
|
115
|
+
failure: {
|
|
116
|
+
unreachable: "No se pudo hablar con el servidor",
|
|
117
|
+
status: (status) => `El servidor respondió ${status}`,
|
|
118
|
+
retry: "Reintentar",
|
|
119
|
+
kind: {
|
|
120
|
+
invalid: "El pedido no es válido",
|
|
121
|
+
unauthenticated: "Volvé a iniciar sesión",
|
|
122
|
+
permission_denied: "No tenés acceso",
|
|
123
|
+
not_found: "No existe",
|
|
124
|
+
conflict: "Alguien lo cambió antes",
|
|
125
|
+
in_progress: "Todavía está en curso",
|
|
126
|
+
key_reused: "Ese pedido ya se envió",
|
|
127
|
+
domain: "La operación fue rechazada",
|
|
128
|
+
exhausted: "Demasiados pedidos",
|
|
129
|
+
unavailable: "El servicio no está disponible",
|
|
130
|
+
unknown_outcome: "No se sabe si se hizo",
|
|
131
|
+
internal: "Algo falló en el servidor",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
let installed = ENGLISH_FRAMEWORK_COPY;
|
|
136
|
+
/** Install the framework's words once, where the application starts. */
|
|
137
|
+
export function installFrameworkCopy(copy) {
|
|
138
|
+
installed = copy;
|
|
139
|
+
}
|
|
140
|
+
/** The words in use. A plain read: they are installed before anything draws. */
|
|
141
|
+
export const frameworkCopy = () => installed;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type AnyRecord, type FieldError, type Meta } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* What an edit is, as plain functions: the state of each field and what changed.
|
|
4
|
+
*
|
|
5
|
+
* Context: sincpro_framework publishes form HINTS on each field — `readonly`, `required`, and
|
|
6
|
+
* `readonly_when` / `required_when` / `visible_when` as criteria conditions over the record —
|
|
7
|
+
* and says they are evaluated «with the same evaluator a client filters with». So a field's
|
|
8
|
+
* state is `matches(values, condition, meta)` over what is on screen NOW: typing «card» in the
|
|
9
|
+
* payment makes «card reference» required at once, with no round trip. A `Preview` from the
|
|
10
|
+
* server, when the entity has one, refines it — the server's word wins.
|
|
11
|
+
*
|
|
12
|
+
* Hints are defaults for a screen, never protection: the server keeps whatever rule matters.
|
|
13
|
+
*/
|
|
14
|
+
export type FieldState = {
|
|
15
|
+
readonly: boolean;
|
|
16
|
+
required: boolean;
|
|
17
|
+
visible: boolean;
|
|
18
|
+
};
|
|
19
|
+
/** What a server preview said about a field. */
|
|
20
|
+
export type FieldStates = Readonly<Record<string, Partial<FieldState>>>;
|
|
21
|
+
export declare function fieldStateOf(meta: Meta | null | undefined, name: string, values: AnyRecord, fromServer?: FieldStates): FieldState;
|
|
22
|
+
/** The fields whose value differs from the record's — what a save or a draft sends. */
|
|
23
|
+
export declare function changesOf(record: AnyRecord, values: AnyRecord): AnyRecord;
|
|
24
|
+
/** What is missing before it can be saved: a required, visible, editable field left empty. */
|
|
25
|
+
export declare function missingOf(meta: Meta | null | undefined, values: AnyRecord, fromServer?: FieldStates): string[];
|
|
26
|
+
/** The server's word on a field that did not validate: `errors[].pointer` is a JSON pointer. */
|
|
27
|
+
export declare function errorsByField(errors: readonly FieldError[] | undefined): Record<string, string>;
|
|
28
|
+
/**
|
|
29
|
+
* A record that does not exist yet: the defaults the model publishes for each field
|
|
30
|
+
* (`FieldMeta.default`, sincpro_framework's `defaults_of`), and nothing else.
|
|
31
|
+
*
|
|
32
|
+
* defaultsOf(invoiceMeta) -> { state: "draft", payment: "cash", discount: "0" }
|
|
33
|
+
*/
|
|
34
|
+
export declare function defaultsOf(meta: Meta | null | undefined): AnyRecord;
|
|
35
|
+
/**
|
|
36
|
+
* What a create sends: every value on screen the caller may write. A field the model marks
|
|
37
|
+
* `readonly` is the server's to fill (the number, the totals), and an empty one is left for
|
|
38
|
+
* the server's own default — never sent as `""`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function creationOf(meta: Meta | null | undefined, values: AnyRecord): AnyRecord;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { matches, } from "@sincpro/criteria";
|
|
2
|
+
function holds(values, condition, meta) {
|
|
3
|
+
if (condition === null || condition === undefined)
|
|
4
|
+
return false;
|
|
5
|
+
return matches(values, condition, meta);
|
|
6
|
+
}
|
|
7
|
+
export function fieldStateOf(meta, name, values, fromServer) {
|
|
8
|
+
const field = meta?.fields[name];
|
|
9
|
+
const own = field === undefined || meta == null
|
|
10
|
+
? { readonly: false, required: false, visible: true }
|
|
11
|
+
: {
|
|
12
|
+
readonly: (field.readonly ?? false) || holds(values, field.readonly_when, meta),
|
|
13
|
+
required: (field.required ?? false) || holds(values, field.required_when, meta),
|
|
14
|
+
visible: field.visible_when === null || field.visible_when === undefined
|
|
15
|
+
? true
|
|
16
|
+
: holds(values, field.visible_when, meta),
|
|
17
|
+
};
|
|
18
|
+
return { ...own, ...fromServer?.[name] };
|
|
19
|
+
}
|
|
20
|
+
/** The fields whose value differs from the record's — what a save or a draft sends. */
|
|
21
|
+
export function changesOf(record, values) {
|
|
22
|
+
const changed = {};
|
|
23
|
+
for (const [name, value] of Object.entries(values))
|
|
24
|
+
if (JSON.stringify(value) !== JSON.stringify(record[name]))
|
|
25
|
+
changed[name] = value;
|
|
26
|
+
return changed;
|
|
27
|
+
}
|
|
28
|
+
/** What is missing before it can be saved: a required, visible, editable field left empty. */
|
|
29
|
+
export function missingOf(meta, values, fromServer) {
|
|
30
|
+
if (!meta)
|
|
31
|
+
return [];
|
|
32
|
+
return Object.keys(meta.fields).filter((name) => {
|
|
33
|
+
const state = fieldStateOf(meta, name, values, fromServer);
|
|
34
|
+
const value = values[name];
|
|
35
|
+
const empty = value === null ||
|
|
36
|
+
value === undefined ||
|
|
37
|
+
value === "" ||
|
|
38
|
+
(Array.isArray(value) && value.length === 0);
|
|
39
|
+
return state.required && state.visible && !state.readonly && empty;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** The server's word on a field that did not validate: `errors[].pointer` is a JSON pointer. */
|
|
43
|
+
export function errorsByField(errors) {
|
|
44
|
+
const found = {};
|
|
45
|
+
for (const error of errors ?? []) {
|
|
46
|
+
const name = error.pointer.replace(/^\/(values\/)?/, "").split("/")[0] ?? "";
|
|
47
|
+
if (name !== "" && found[name] === undefined)
|
|
48
|
+
found[name] = error.detail;
|
|
49
|
+
}
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A record that does not exist yet: the defaults the model publishes for each field
|
|
54
|
+
* (`FieldMeta.default`, sincpro_framework's `defaults_of`), and nothing else.
|
|
55
|
+
*
|
|
56
|
+
* defaultsOf(invoiceMeta) -> { state: "draft", payment: "cash", discount: "0" }
|
|
57
|
+
*/
|
|
58
|
+
export function defaultsOf(meta) {
|
|
59
|
+
const values = {};
|
|
60
|
+
for (const [name, field] of Object.entries(meta?.fields ?? {}))
|
|
61
|
+
if (field.default !== null && field.default !== undefined)
|
|
62
|
+
values[name] = field.default;
|
|
63
|
+
return values;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* What a create sends: every value on screen the caller may write. A field the model marks
|
|
67
|
+
* `readonly` is the server's to fill (the number, the totals), and an empty one is left for
|
|
68
|
+
* the server's own default — never sent as `""`.
|
|
69
|
+
*/
|
|
70
|
+
export function creationOf(meta, values) {
|
|
71
|
+
const sent = {};
|
|
72
|
+
for (const [name, value] of Object.entries(values)) {
|
|
73
|
+
const field = meta?.fields[name];
|
|
74
|
+
if (field?.readonly === true)
|
|
75
|
+
continue;
|
|
76
|
+
if (value === undefined || value === null || value === "")
|
|
77
|
+
continue;
|
|
78
|
+
sent[name] = value;
|
|
79
|
+
}
|
|
80
|
+
return sent;
|
|
81
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { AnyRecord } from "@sincpro/criteria";
|
|
2
|
+
import type { SummaryFields } from "./summary.js";
|
|
3
|
+
import type { Method } from "./transport.js";
|
|
4
|
+
/**
|
|
5
|
+
* Everything a screen needs to know about where an entity lives on the server, declared once.
|
|
6
|
+
*
|
|
7
|
+
* Context: a screen should not know a URL. It names the entity, and the entity says where its
|
|
8
|
+
* list is, where one record is, which Commands it takes, how it is saved and previewed, where a
|
|
9
|
+
* draft is kept and where its history is. `key` is how the workspace refers to it
|
|
10
|
+
* (`domain:kind`, the shape of a composer `ResourceRef`).
|
|
11
|
+
*
|
|
12
|
+
* The routes are declared, not guessed: sincpro_framework publishes a route per use case and
|
|
13
|
+
* its model definition does not list them (yet). A route is `"METHOD /path/{id}"` for a write.
|
|
14
|
+
*
|
|
15
|
+
* export const invoices = entity<Invoice>("billing:invoice", {
|
|
16
|
+
* list: "/invoices",
|
|
17
|
+
* create: "POST /invoices",
|
|
18
|
+
* save: "PUT /invoices/{id}",
|
|
19
|
+
* preview: "POST /invoices/preview",
|
|
20
|
+
* commands: { confirm: "POST /invoices/{id}/confirm", cancel: "POST /invoices/{id}/cancel" },
|
|
21
|
+
* history: { route: "/billing-events", type: "Invoice" },
|
|
22
|
+
* });
|
|
23
|
+
*/
|
|
24
|
+
export type Entity<T extends AnyRecord = AnyRecord> = {
|
|
25
|
+
/** `domain:kind`, e.g. `"billing:invoice"`. */
|
|
26
|
+
key: string;
|
|
27
|
+
domain: string;
|
|
28
|
+
kind: string;
|
|
29
|
+
/** Where the workspace finds it: the list without an id, one record with it — the shape of a
|
|
30
|
+
* composer `ResourceRef`, built here so no screen writes `{ domain, kind, id }` by hand. */
|
|
31
|
+
ref: (id?: string) => EntityRef;
|
|
32
|
+
/** A record that does not exist yet — the form that creates one. Its view is the record's. */
|
|
33
|
+
newRef: () => EntityRef;
|
|
34
|
+
/** Its list opened to CHOOSE one: a row answers whoever asked (composer's `api.pick`). With
|
|
35
|
+
* `query`, the list opens on that literal search — what was typed in the picker. */
|
|
36
|
+
chooserRef: (query?: string) => EntityRef;
|
|
37
|
+
/** The route of its list. */
|
|
38
|
+
list: string;
|
|
39
|
+
/** The route of one record. */
|
|
40
|
+
one: (id: string) => string;
|
|
41
|
+
/** Its Commands, by name. */
|
|
42
|
+
commands: Readonly<Record<string, WriteRoute>>;
|
|
43
|
+
/** How a new record is created: the list's own route, usually. */
|
|
44
|
+
create?: WriteRoute;
|
|
45
|
+
/** How an edit is saved: the record's own route with `{id}`. */
|
|
46
|
+
save?: WriteRoute;
|
|
47
|
+
/** «What does this record become?» before saving — sincpro_framework's `Preview`. */
|
|
48
|
+
preview?: WriteRoute;
|
|
49
|
+
/** Where this caller keeps an edit between requests — sincpro_framework's `Drafts`. */
|
|
50
|
+
draft?: WriteRoute;
|
|
51
|
+
/** Its business events: the context's events route, and the `entity_type` they carry. */
|
|
52
|
+
history?: {
|
|
53
|
+
route: string;
|
|
54
|
+
type: string;
|
|
55
|
+
};
|
|
56
|
+
/** Which fields are its description, state, picture… when they are not the conventional
|
|
57
|
+
* ones (`summaryOf`). Empty: the convention decides. */
|
|
58
|
+
summary: SummaryFields;
|
|
59
|
+
/** The aggregate it is, as a relation of another model names it (`relation: "Cliente"`).
|
|
60
|
+
* Without it, the `aggregate` of its model definition, once known. */
|
|
61
|
+
aggregate?: string;
|
|
62
|
+
/** Only carries the record type; never read. */
|
|
63
|
+
readonly _record?: T;
|
|
64
|
+
};
|
|
65
|
+
/** A composer `ResourceRef`, structurally: `core/` does not import the composer. */
|
|
66
|
+
export type EntityRef = {
|
|
67
|
+
domain: string;
|
|
68
|
+
kind: string;
|
|
69
|
+
id?: string;
|
|
70
|
+
params?: Readonly<Record<string, string>>;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* The params that say what a reference without an id is for: `new` is the form of a record
|
|
74
|
+
* that does not exist yet, `pick` is the list opened to choose one. Params, because they are
|
|
75
|
+
* what a composer reference carries and survives a reload with.
|
|
76
|
+
*/
|
|
77
|
+
export declare const NEW_PARAM = "new";
|
|
78
|
+
export declare const PICK_PARAM = "pick";
|
|
79
|
+
/** What was typed where the chooser was asked for: its list opens on that search. */
|
|
80
|
+
export declare const QUERY_PARAM = "q";
|
|
81
|
+
/** The list a record was opened from (its `Trail`): «3 / 64» and the arrows to its neighbours. */
|
|
82
|
+
export declare const TRAIL_PARAM = "from";
|
|
83
|
+
/** A write: its method and its path, `{id}` where the record's key goes. */
|
|
84
|
+
export type WriteRoute = {
|
|
85
|
+
method: Method;
|
|
86
|
+
path: string;
|
|
87
|
+
};
|
|
88
|
+
export type EntityRoutes = {
|
|
89
|
+
list: string;
|
|
90
|
+
/** With `{id}` where the key goes. Without it, `<list>/<id>` — sincpro_framework's convention. */
|
|
91
|
+
one?: string;
|
|
92
|
+
/** `"POST /invoices/{id}/confirm"`, by name. */
|
|
93
|
+
commands?: Readonly<Record<string, string>>;
|
|
94
|
+
/** `"POST /invoices"`: where a new record goes. */
|
|
95
|
+
create?: string;
|
|
96
|
+
save?: string;
|
|
97
|
+
preview?: string;
|
|
98
|
+
draft?: string;
|
|
99
|
+
history?: {
|
|
100
|
+
route: string;
|
|
101
|
+
type: string;
|
|
102
|
+
};
|
|
103
|
+
/** `{ image: "logo", state: "estado_pago", person: true }` — not a route, but declared once
|
|
104
|
+
* with the rest of what a screen should not have to know. */
|
|
105
|
+
summary?: SummaryFields;
|
|
106
|
+
/** `"Cliente"`: what a many2one of another model calls it, so a form resolves it alone. */
|
|
107
|
+
aggregate?: string;
|
|
108
|
+
};
|
|
109
|
+
/** `"POST /invoices/{id}/confirm"` → its method and path. A write without a method is a bug. */
|
|
110
|
+
export declare function writeRoute(text: string): WriteRoute;
|
|
111
|
+
/** The path with the record's key in place of `{id}`. */
|
|
112
|
+
export declare function pathFor(route: WriteRoute, id?: string): string;
|
|
113
|
+
export declare function entity<T extends AnyRecord = AnyRecord>(key: string, routes: EntityRoutes): Entity<T>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The params that say what a reference without an id is for: `new` is the form of a record
|
|
3
|
+
* that does not exist yet, `pick` is the list opened to choose one. Params, because they are
|
|
4
|
+
* what a composer reference carries and survives a reload with.
|
|
5
|
+
*/
|
|
6
|
+
export const NEW_PARAM = "new";
|
|
7
|
+
export const PICK_PARAM = "pick";
|
|
8
|
+
/** What was typed where the chooser was asked for: its list opens on that search. */
|
|
9
|
+
export const QUERY_PARAM = "q";
|
|
10
|
+
/** The list a record was opened from (its `Trail`): «3 / 64» and the arrows to its neighbours. */
|
|
11
|
+
export const TRAIL_PARAM = "from";
|
|
12
|
+
const METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
13
|
+
/** `"POST /invoices/{id}/confirm"` → its method and path. A write without a method is a bug. */
|
|
14
|
+
export function writeRoute(text) {
|
|
15
|
+
const [method, path] = text.trim().split(/\s+/, 2);
|
|
16
|
+
if (!METHODS.has(method) || path === undefined)
|
|
17
|
+
throw new Error(`"${text}" is not "METHOD /path": a write says how it is sent`);
|
|
18
|
+
return { method: method, path };
|
|
19
|
+
}
|
|
20
|
+
/** The path with the record's key in place of `{id}`. */
|
|
21
|
+
export function pathFor(route, id) {
|
|
22
|
+
return id === undefined ? route.path : route.path.replace("{id}", encodeURIComponent(id));
|
|
23
|
+
}
|
|
24
|
+
export function entity(key, routes) {
|
|
25
|
+
if (!/^[^:\s]+:[^:\s]+$/.test(key))
|
|
26
|
+
throw new Error(`entity key "${key}" is not domain:kind`);
|
|
27
|
+
const list = routes.list.split("?")[0] ?? routes.list;
|
|
28
|
+
const template = routes.one ?? `${list}/{id}`;
|
|
29
|
+
const [domain = "", kind = ""] = key.split(":");
|
|
30
|
+
return {
|
|
31
|
+
key,
|
|
32
|
+
domain,
|
|
33
|
+
kind,
|
|
34
|
+
ref: (id) => ({ domain, kind, ...(id === undefined || id === "" ? {} : { id }) }),
|
|
35
|
+
newRef: () => ({ domain, kind, params: { [NEW_PARAM]: "1" } }),
|
|
36
|
+
chooserRef: (query) => ({
|
|
37
|
+
domain,
|
|
38
|
+
kind,
|
|
39
|
+
params: {
|
|
40
|
+
[PICK_PARAM]: "1",
|
|
41
|
+
...(query === undefined || query.trim() === ""
|
|
42
|
+
? {}
|
|
43
|
+
: { [QUERY_PARAM]: query.trim() }),
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
list: routes.list,
|
|
47
|
+
one: (id) => template.replace("{id}", encodeURIComponent(id)),
|
|
48
|
+
commands: Object.fromEntries(Object.entries(routes.commands ?? {}).map(([name, text]) => [name, writeRoute(text)])),
|
|
49
|
+
...(routes.create === undefined ? {} : { create: writeRoute(routes.create) }),
|
|
50
|
+
...(routes.save === undefined ? {} : { save: writeRoute(routes.save) }),
|
|
51
|
+
...(routes.preview === undefined ? {} : { preview: writeRoute(routes.preview) }),
|
|
52
|
+
...(routes.draft === undefined ? {} : { draft: writeRoute(routes.draft) }),
|
|
53
|
+
...(routes.history === undefined ? {} : { history: routes.history }),
|
|
54
|
+
summary: routes.summary ?? {},
|
|
55
|
+
...(routes.aggregate === undefined ? {} : { aggregate: routes.aggregate }),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Meta } from "@sincpro/criteria";
|
|
2
|
+
import { type SummaryFields } from "./summary.js";
|
|
3
|
+
/**
|
|
4
|
+
* What a record's form shows when the screen names nothing: its fields, and the pages beside
|
|
5
|
+
* them — read from the model, in the order it declares.
|
|
6
|
+
*
|
|
7
|
+
* Context: the default form must be useful on any entity, not a dump of every key. So:
|
|
8
|
+
*
|
|
9
|
+
* - the bookkeeping (`version`, `updated_at`) and who made and changed it are not fields: the
|
|
10
|
+
* paper says them on its last line (`Record.Audit`); the picture is not a text box;
|
|
11
|
+
* - a read-only identity is the record's name, not a field to look at;
|
|
12
|
+
* - a many2one is written by its KEY (`customer_id`, a picker of customers): the expanded
|
|
13
|
+
* relation beside it (`customer`) would be the same thing twice;
|
|
14
|
+
* - a one2many is a page of its own — a list, not a field;
|
|
15
|
+
* - a value object, and a many2many the backend cannot write yet, are left to the screen.
|
|
16
|
+
*/
|
|
17
|
+
export type FormLayout = {
|
|
18
|
+
fields: string[];
|
|
19
|
+
pages: string[];
|
|
20
|
+
};
|
|
21
|
+
export declare function formOf(meta: Meta | null | undefined, summary?: SummaryFields): FormLayout;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { BOOKKEEPING } from "./present.js";
|
|
2
|
+
import { summaryFieldsOf } from "./summary.js";
|
|
3
|
+
export function formOf(meta, summary = {}) {
|
|
4
|
+
if (!meta)
|
|
5
|
+
return { fields: [], pages: [] };
|
|
6
|
+
const parts = summaryFieldsOf(meta, {}, summary);
|
|
7
|
+
const told = new Set([parts.createdAt, parts.createdBy, parts.updatedAt, parts.updatedBy, parts.image].filter((name) => name !== undefined));
|
|
8
|
+
const keys = new Set(Object.values(meta.fields)
|
|
9
|
+
.map((field) => (field.kind === "relation" ? field.identified_by : undefined))
|
|
10
|
+
.filter((name) => typeof name === "string" && name in meta.fields));
|
|
11
|
+
const fields = [];
|
|
12
|
+
const pages = [];
|
|
13
|
+
for (const [name, field] of Object.entries(meta.fields)) {
|
|
14
|
+
if (BOOKKEEPING.has(name) || told.has(name))
|
|
15
|
+
continue;
|
|
16
|
+
if (name === meta.identity && field.readonly)
|
|
17
|
+
continue;
|
|
18
|
+
if (field.kind === "embedded")
|
|
19
|
+
continue;
|
|
20
|
+
if (field.kind === "relation") {
|
|
21
|
+
if (field.type === "one2many")
|
|
22
|
+
pages.push(name);
|
|
23
|
+
else if (!field.many && !(field.identified_by && keys.has(field.identified_by)))
|
|
24
|
+
fields.push(name);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
fields.push(name);
|
|
28
|
+
}
|
|
29
|
+
return { fields, pages };
|
|
30
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type AnyRecord, type Meta } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* A record's history as people read it — its business events, newest first.
|
|
4
|
+
*
|
|
5
|
+
* Context: sincpro_framework keeps every context's facts in one event table, read over
|
|
6
|
+
* `GET /<context>-events`, each fact naming the record it is about (`entity_type`,
|
|
7
|
+
* `entity_id`) and carrying a `label` in every language — the framework's `history_of`.
|
|
8
|
+
* forge's console read it with a dictionary of field names written by hand; here the words for
|
|
9
|
+
* the rest of a fact come from the events' own model definition, like any other field.
|
|
10
|
+
*
|
|
11
|
+
* These are business facts («Factura confirmada»), never technical logs: those belong to the
|
|
12
|
+
* observability stack, not to a record's screen.
|
|
13
|
+
*/
|
|
14
|
+
export type HistoryEntry = {
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
at: string;
|
|
18
|
+
detail?: string;
|
|
19
|
+
};
|
|
20
|
+
export declare function historyOf(rows: readonly AnyRecord[], meta: Meta | null | undefined, locale?: string): HistoryEntry[];
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { labelOf } from "@sincpro/criteria";
|
|
2
|
+
/** What every fact carries: what remains is what this fact says. */
|
|
3
|
+
const ENVELOPE = new Set([
|
|
4
|
+
"id",
|
|
5
|
+
"created_at",
|
|
6
|
+
"updated_at",
|
|
7
|
+
"version",
|
|
8
|
+
"label",
|
|
9
|
+
"entity_type",
|
|
10
|
+
"entity_id",
|
|
11
|
+
"entity_version",
|
|
12
|
+
"correlation_id",
|
|
13
|
+
"causation_id",
|
|
14
|
+
]);
|
|
15
|
+
function said(value) {
|
|
16
|
+
if (value === null || value === undefined || value === "")
|
|
17
|
+
return null;
|
|
18
|
+
if (Array.isArray(value))
|
|
19
|
+
return value.length === 0 ? null : value.map(String).join(", ");
|
|
20
|
+
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
21
|
+
}
|
|
22
|
+
export function historyOf(rows, meta, locale) {
|
|
23
|
+
return rows.map((row) => {
|
|
24
|
+
const label = row["label"];
|
|
25
|
+
const word = typeof label === "string"
|
|
26
|
+
? label
|
|
27
|
+
: ((locale ? label?.[locale] : undefined) ?? label?.["default"] ?? String(row["id"]));
|
|
28
|
+
const parts = Object.entries(row)
|
|
29
|
+
.filter(([key]) => !ENVELOPE.has(key))
|
|
30
|
+
.map(([key, value]) => [key, said(value)])
|
|
31
|
+
.filter(([, value]) => value !== null && value !== row["entity_id"])
|
|
32
|
+
.map(([key, value]) => `${labelOf(meta, key, locale)}: ${value}`);
|
|
33
|
+
return {
|
|
34
|
+
id: String(row["id"]),
|
|
35
|
+
label: word,
|
|
36
|
+
at: String(row["created_at"] ?? ""),
|
|
37
|
+
...(parts.length === 0 ? {} : { detail: parts.join(" · ") }),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain logic: no React, no DOM, no design system — so it is tested in Node, fast, with
|
|
3
|
+
* no browser. The transport, the entity, the model definition and what a value reads as.
|
|
4
|
+
* `make doctor` keeps it that way.
|
|
5
|
+
*/
|
|
6
|
+
export { type CachedTransport, cachedTransport, type CacheOptions, } from "./cache.js";
|
|
7
|
+
export { ENGLISH_FRAMEWORK_COPY, type FrameworkCopy, frameworkCopy, installFrameworkCopy, SPANISH_FRAMEWORK_COPY, } from "./copy.js";
|
|
8
|
+
export { changesOf, creationOf, defaultsOf, errorsByField, type FieldState, fieldStateOf, type FieldStates, missingOf, } from "./edit.js";
|
|
9
|
+
export { type Entity, entity, type EntityRef, type EntityRoutes, NEW_PARAM, pathFor, PICK_PARAM, QUERY_PARAM, TRAIL_PARAM, type WriteRoute, writeRoute, } from "./entity.js";
|
|
10
|
+
export { type FormLayout, formOf } from "./form.js";
|
|
11
|
+
export { type HistoryEntry, historyOf } from "./history.js";
|
|
12
|
+
export { defineList, type GroupLevel, isList, type ListDefinition, type ListSpec, type NamedFilter, scopeOf, } from "./list.js";
|
|
13
|
+
export { lookupOf, type LookupOption } from "./lookup.js";
|
|
14
|
+
export { detailOf, measuresOf, sumsOf } from "./measure.js";
|
|
15
|
+
export { type MemoryOptions, type MemoryTable, memoryTransport, } from "./memory.js";
|
|
16
|
+
export { defineMeta, field, type FieldOptions, type MetaSpec, type Words, } from "./meta.js";
|
|
17
|
+
export { type MetaStore, metaStore } from "./metas.js";
|
|
18
|
+
export { BOOKKEEPING, defaultFields, type FieldView, sortKeyOf, textOf, viewOf, } from "./present.js";
|
|
19
|
+
export { type Related, relatedOf } from "./related.js";
|
|
20
|
+
export { entityFor, type RelationField, relationFieldOf, } from "./relation.js";
|
|
21
|
+
export { type RestOptions, restTransport, urlOf } from "./rest.js";
|
|
22
|
+
export { type RetryKeys, retryKeys, unanswered } from "./retry.js";
|
|
23
|
+
export { completeMeta, type JsonSchema, metaFromSchema, type SchemaComponents, } from "./schema.js";
|
|
24
|
+
export { type RecordSummary, type SummaryFields, summaryFieldsOf, summaryOf, type SummaryPart, } from "./summary.js";
|
|
25
|
+
export { placeOf, stepIn, type Trail } from "./trail.js";
|
|
26
|
+
export { type Draft, explain, type Method, type One, type SendOptions, type Transport, TransportError, } from "./transport.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain logic: no React, no DOM, no design system — so it is tested in Node, fast, with
|
|
3
|
+
* no browser. The transport, the entity, the model definition and what a value reads as.
|
|
4
|
+
* `make doctor` keeps it that way.
|
|
5
|
+
*/
|
|
6
|
+
export { cachedTransport, } from "./cache.js";
|
|
7
|
+
export { ENGLISH_FRAMEWORK_COPY, frameworkCopy, installFrameworkCopy, SPANISH_FRAMEWORK_COPY, } from "./copy.js";
|
|
8
|
+
export { changesOf, creationOf, defaultsOf, errorsByField, fieldStateOf, missingOf, } from "./edit.js";
|
|
9
|
+
export { entity, NEW_PARAM, pathFor, PICK_PARAM, QUERY_PARAM, TRAIL_PARAM, writeRoute, } from "./entity.js";
|
|
10
|
+
export { formOf } from "./form.js";
|
|
11
|
+
export { historyOf } from "./history.js";
|
|
12
|
+
export { defineList, isList, scopeOf, } from "./list.js";
|
|
13
|
+
export { lookupOf } from "./lookup.js";
|
|
14
|
+
export { detailOf, measuresOf, sumsOf } from "./measure.js";
|
|
15
|
+
export { memoryTransport, } from "./memory.js";
|
|
16
|
+
export { defineMeta, field, } from "./meta.js";
|
|
17
|
+
export { metaStore } from "./metas.js";
|
|
18
|
+
export { BOOKKEEPING, defaultFields, sortKeyOf, textOf, viewOf, } from "./present.js";
|
|
19
|
+
export { relatedOf } from "./related.js";
|
|
20
|
+
export { entityFor, relationFieldOf, } from "./relation.js";
|
|
21
|
+
export { restTransport, urlOf } from "./rest.js";
|
|
22
|
+
export { retryKeys, unanswered } from "./retry.js";
|
|
23
|
+
export { completeMeta, metaFromSchema, } from "./schema.js";
|
|
24
|
+
export { summaryFieldsOf, summaryOf, } from "./summary.js";
|
|
25
|
+
export { placeOf, stepIn } from "./trail.js";
|
|
26
|
+
export { explain, TransportError, } from "./transport.js";
|