@sincpro/web-framework 0.0.0-stage → 0.1.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 +224 -2
- package/dist/core/cache.d.ts +30 -0
- package/dist/core/cache.js +47 -0
- package/dist/core/copy.d.ts +69 -0
- package/dist/core/copy.js +111 -0
- package/dist/core/edit.d.ts +27 -0
- package/dist/core/edit.js +51 -0
- package/dist/core/entity.d.ts +79 -0
- package/dist/core/entity.js +32 -0
- package/dist/core/history.d.ts +20 -0
- package/dist/core/history.js +40 -0
- package/dist/core/index.d.ts +19 -0
- package/dist/core/index.js +19 -0
- package/dist/core/lookup.d.ts +16 -0
- package/dist/core/lookup.js +10 -0
- package/dist/core/memory.d.ts +43 -0
- package/dist/core/memory.js +85 -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/rest.d.ts +30 -0
- package/dist/core/rest.js +50 -0
- package/dist/core/schema.d.ts +46 -0
- package/dist/core/schema.js +128 -0
- package/dist/core/transport.d.ts +59 -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 +27 -0
- package/dist/react/context.js +18 -0
- package/dist/react/index.d.ts +12 -0
- package/dist/react/index.js +12 -0
- package/dist/react/useCommand.d.ts +28 -0
- package/dist/react/useCommand.js +52 -0
- package/dist/react/useHistory.d.ts +5 -0
- package/dist/react/useHistory.js +41 -0
- package/dist/react/useListing.d.ts +75 -0
- package/dist/react/useListing.js +121 -0
- package/dist/react/useLookup.d.ts +22 -0
- package/dist/react/useLookup.js +15 -0
- package/dist/react/useRecord.d.ts +74 -0
- package/dist/react/useRecord.js +176 -0
- package/dist/react/useRelated.d.ts +21 -0
- package/dist/react/useRelated.js +32 -0
- package/dist/web/Refused.d.ts +9 -0
- package/dist/web/Refused.js +14 -0
- package/dist/web/index.d.ts +17 -0
- package/dist/web/index.js +17 -0
- package/dist/web/list/EntityList.d.ts +69 -0
- package/dist/web/list/EntityList.js +82 -0
- package/dist/web/list/EntitySearchBar.d.ts +26 -0
- package/dist/web/list/EntitySearchBar.js +43 -0
- package/dist/web/list/columns.d.ts +22 -0
- package/dist/web/list/columns.js +27 -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 +27 -0
- package/dist/web/record/EntityPicker.js +39 -0
- package/dist/web/record/RelatedList.d.ts +18 -0
- package/dist/web/record/RelatedList.js +16 -0
- package/dist/web/record/edit.d.ts +35 -0
- package/dist/web/record/edit.js +63 -0
- package/dist/web/record/record.d.ts +25 -0
- package/dist/web/record/record.js +56 -0
- package/dist/web/workspace/SincproApp.d.ts +37 -0
- package/dist/web/workspace/SincproApp.js +45 -0
- package/dist/web/workspace/composer.d.ts +33 -0
- package/dist/web/workspace/composer.js +37 -0
- package/dist/web/workspace/skin.d.ts +11 -0
- package/dist/web/workspace/skin.js +33 -0
- package/dist/web/workspace/workspace.d.ts +16 -0
- package/dist/web/workspace/workspace.js +29 -0
- package/package.json +86 -4
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type FieldMeta, type Meta } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* How a field is SHOWN, decided once from the model and nowhere else.
|
|
4
|
+
*
|
|
5
|
+
* Context: this is the part of "render from the metadata" that is the same in every
|
|
6
|
+
* piece. A table column, a fact of a record and a search chip all need the word for the field, whether it is a
|
|
7
|
+
* number, whether it orders, and the text a value reads as («Pagada», «12/03/2026», «sí»,
|
|
8
|
+
* «3 elementos»). What they draw it WITH is theirs; what it SAYS is decided here, so they
|
|
9
|
+
* cannot disagree.
|
|
10
|
+
*/
|
|
11
|
+
export type FieldView = {
|
|
12
|
+
name: string;
|
|
13
|
+
label: string;
|
|
14
|
+
help: string | undefined;
|
|
15
|
+
type: FieldMeta["type"];
|
|
16
|
+
kind: FieldMeta["kind"];
|
|
17
|
+
/** Right-aligned, tabular: integer and number. */
|
|
18
|
+
numeric: boolean;
|
|
19
|
+
/** The server can order by it — never a nullable column. */
|
|
20
|
+
sortable: boolean;
|
|
21
|
+
required: boolean;
|
|
22
|
+
readonly: boolean;
|
|
23
|
+
};
|
|
24
|
+
export declare function viewOf(meta: Meta | null | undefined, name: string, locale?: string): FieldView;
|
|
25
|
+
/**
|
|
26
|
+
* The fields a screen shows when it names none: every scalar the model holds, in the order it
|
|
27
|
+
* declares them. Relations and value objects are left out — they are not a column, they are a
|
|
28
|
+
* page of their own (`RecordLines`) or a section.
|
|
29
|
+
*/
|
|
30
|
+
export declare function defaultFields(meta: Meta | null | undefined): string[];
|
|
31
|
+
/** The bookkeeping every framework entity carries; shown on demand, never by default. */
|
|
32
|
+
export declare const BOOKKEEPING: ReadonlySet<string>;
|
|
33
|
+
/**
|
|
34
|
+
* The text a value reads as. `null` when there is nothing to read — the caller draws its own
|
|
35
|
+
* «empty» (a dash in a fact, nothing in a cell).
|
|
36
|
+
*
|
|
37
|
+
* textOf(meta, "state", "paid", "es") -> "Pagada" (choice_labels)
|
|
38
|
+
* textOf(meta, "active", true, "es") -> "sí"
|
|
39
|
+
* textOf(meta, "due", "2026-03-12", "es") -> "12 mar 2026"
|
|
40
|
+
* textOf(meta, "total", "1150.5", "es") -> "1150,5" (exact: digits kept)
|
|
41
|
+
* textOf(meta, "lines", {items, count: {value: 30}}) -> "30 elementos"
|
|
42
|
+
*/
|
|
43
|
+
export declare function textOf(meta: Meta | null | undefined, name: string, value: unknown, locale?: string): string | null;
|
|
44
|
+
/**
|
|
45
|
+
* The value a column orders and groups on: the raw scalar, never the text. ISO dates order
|
|
46
|
+
* as text; a decimal that travels as text orders as a number.
|
|
47
|
+
*/
|
|
48
|
+
export declare function sortKeyOf(meta: Meta | null | undefined, name: string, value: unknown): string | number | boolean | null;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { choiceLabelOf, labelOf, } from "@sincpro/criteria";
|
|
2
|
+
import { frameworkCopy } from "./copy.js";
|
|
3
|
+
import { relatedOf } from "./related.js";
|
|
4
|
+
/** Present when a field is named that the model does not declare: drawn plain, never thrown. */
|
|
5
|
+
const UNKNOWN = {
|
|
6
|
+
type: "unknown",
|
|
7
|
+
kind: "scalar",
|
|
8
|
+
nullable: true,
|
|
9
|
+
sortable: false,
|
|
10
|
+
ops: [],
|
|
11
|
+
};
|
|
12
|
+
function fieldOf(meta, name) {
|
|
13
|
+
return meta?.fields[name] ?? UNKNOWN;
|
|
14
|
+
}
|
|
15
|
+
function helpOf(field, locale) {
|
|
16
|
+
const help = field.help;
|
|
17
|
+
if (help === undefined)
|
|
18
|
+
return undefined;
|
|
19
|
+
return (locale ? help[locale] : undefined) ?? help["default"];
|
|
20
|
+
}
|
|
21
|
+
export function viewOf(meta, name, locale) {
|
|
22
|
+
const field = fieldOf(meta, name);
|
|
23
|
+
return {
|
|
24
|
+
name,
|
|
25
|
+
label: labelOf(meta, name, locale),
|
|
26
|
+
help: helpOf(field, locale),
|
|
27
|
+
type: field.type,
|
|
28
|
+
kind: field.kind,
|
|
29
|
+
numeric: field.type === "integer" || field.type === "number",
|
|
30
|
+
sortable: field.sortable,
|
|
31
|
+
required: field.required ?? false,
|
|
32
|
+
readonly: field.readonly ?? false,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The fields a screen shows when it names none: every scalar the model holds, in the order it
|
|
37
|
+
* declares them. Relations and value objects are left out — they are not a column, they are a
|
|
38
|
+
* page of their own (`RecordLines`) or a section.
|
|
39
|
+
*/
|
|
40
|
+
export function defaultFields(meta) {
|
|
41
|
+
if (!meta)
|
|
42
|
+
return [];
|
|
43
|
+
return Object.entries(meta.fields)
|
|
44
|
+
.filter(([, field]) => field.kind === "scalar")
|
|
45
|
+
.map(([name]) => name);
|
|
46
|
+
}
|
|
47
|
+
/** The bookkeeping every framework entity carries; shown on demand, never by default. */
|
|
48
|
+
export const BOOKKEEPING = new Set(["version", "updated_at"]);
|
|
49
|
+
/** `"2026-03-12"` as a LOCAL date: `new Date("2026-03-12")` is UTC midnight, the day before
|
|
50
|
+
* anywhere west of Greenwich. */
|
|
51
|
+
function localDate(text) {
|
|
52
|
+
const [year, month, day] = text.split("-").map(Number);
|
|
53
|
+
return new Date(year ?? 0, (month ?? 1) - 1, day ?? 1);
|
|
54
|
+
}
|
|
55
|
+
function decimals(text) {
|
|
56
|
+
const dot = text.indexOf(".");
|
|
57
|
+
return dot < 0 ? 0 : text.length - dot - 1;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* One formatter per locale and shape, kept. Building an `Intl` formatter costs far more than
|
|
61
|
+
* using one: built per cell, 2 000 rows × 8 fields took ~150 ms — a list that stutters on
|
|
62
|
+
* every page. The set of shapes is tiny (three date/number styles × decimals × locales).
|
|
63
|
+
*/
|
|
64
|
+
const formatters = new Map();
|
|
65
|
+
function dateFormat(locale, withTime) {
|
|
66
|
+
const key = `d|${locale ?? ""}|${withTime}`;
|
|
67
|
+
let format = formatters.get(key);
|
|
68
|
+
if (format === undefined) {
|
|
69
|
+
format = new Intl.DateTimeFormat(locale, withTime ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" });
|
|
70
|
+
formatters.set(key, format);
|
|
71
|
+
}
|
|
72
|
+
return format;
|
|
73
|
+
}
|
|
74
|
+
function numberFormat(locale, kept) {
|
|
75
|
+
const key = `n|${locale ?? ""}|${kept ?? ""}`;
|
|
76
|
+
let format = formatters.get(key);
|
|
77
|
+
if (format === undefined) {
|
|
78
|
+
format = new Intl.NumberFormat(locale, kept === undefined ? {} : { minimumFractionDigits: kept, maximumFractionDigits: kept });
|
|
79
|
+
formatters.set(key, format);
|
|
80
|
+
}
|
|
81
|
+
return format;
|
|
82
|
+
}
|
|
83
|
+
/** The record a many2one points at, read by the display its definition names. */
|
|
84
|
+
function displayOf(field, value) {
|
|
85
|
+
const definition = field.definition;
|
|
86
|
+
const display = definition?.display || undefined;
|
|
87
|
+
const key = display ?? definition?.identity ?? "id";
|
|
88
|
+
const shown = value[key] ?? value["display_name"] ?? value["name"] ?? value["id"];
|
|
89
|
+
return shown === undefined || shown === null ? "" : String(shown);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The text a value reads as. `null` when there is nothing to read — the caller draws its own
|
|
93
|
+
* «empty» (a dash in a fact, nothing in a cell).
|
|
94
|
+
*
|
|
95
|
+
* textOf(meta, "state", "paid", "es") -> "Pagada" (choice_labels)
|
|
96
|
+
* textOf(meta, "active", true, "es") -> "sí"
|
|
97
|
+
* textOf(meta, "due", "2026-03-12", "es") -> "12 mar 2026"
|
|
98
|
+
* textOf(meta, "total", "1150.5", "es") -> "1150,5" (exact: digits kept)
|
|
99
|
+
* textOf(meta, "lines", {items, count: {value: 30}}) -> "30 elementos"
|
|
100
|
+
*/
|
|
101
|
+
export function textOf(meta, name, value, locale) {
|
|
102
|
+
if (value === null || value === undefined || value === "")
|
|
103
|
+
return null;
|
|
104
|
+
const field = fieldOf(meta, name);
|
|
105
|
+
const copy = frameworkCopy();
|
|
106
|
+
if (field.choices && field.choices.length > 0)
|
|
107
|
+
return choiceLabelOf(meta, name, value, locale);
|
|
108
|
+
switch (field.type) {
|
|
109
|
+
case "boolean":
|
|
110
|
+
return value ? copy.value.yes : copy.value.no;
|
|
111
|
+
case "date":
|
|
112
|
+
return typeof value === "string"
|
|
113
|
+
? dateFormat(locale, false).format(localDate(value))
|
|
114
|
+
: String(value);
|
|
115
|
+
case "datetime": {
|
|
116
|
+
const date = new Date(String(value));
|
|
117
|
+
return Number.isNaN(date.getTime())
|
|
118
|
+
? String(value)
|
|
119
|
+
: dateFormat(locale, true).format(date);
|
|
120
|
+
}
|
|
121
|
+
case "integer":
|
|
122
|
+
case "number": {
|
|
123
|
+
const number = Number(value);
|
|
124
|
+
if (Number.isNaN(number))
|
|
125
|
+
return String(value);
|
|
126
|
+
const kept = typeof value === "string" ? decimals(value) : undefined;
|
|
127
|
+
return numberFormat(locale, kept).format(number);
|
|
128
|
+
}
|
|
129
|
+
case "text[]":
|
|
130
|
+
return Array.isArray(value)
|
|
131
|
+
? value.length === 0
|
|
132
|
+
? null
|
|
133
|
+
: value.join(", ")
|
|
134
|
+
: String(value);
|
|
135
|
+
case "one2many":
|
|
136
|
+
case "many2many": {
|
|
137
|
+
const related = relatedOf(value);
|
|
138
|
+
return copy.value.items(related.count.value, related.count.exact);
|
|
139
|
+
}
|
|
140
|
+
case "many2one":
|
|
141
|
+
return typeof value === "object" ? displayOf(field, value) : String(value);
|
|
142
|
+
case "embedded":
|
|
143
|
+
return null;
|
|
144
|
+
default:
|
|
145
|
+
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The value a column orders and groups on: the raw scalar, never the text. ISO dates order
|
|
150
|
+
* as text; a decimal that travels as text orders as a number.
|
|
151
|
+
*/
|
|
152
|
+
export function sortKeyOf(meta, name, value) {
|
|
153
|
+
if (value === null || value === undefined)
|
|
154
|
+
return null;
|
|
155
|
+
const field = fieldOf(meta, name);
|
|
156
|
+
if (field.type === "number" || field.type === "integer") {
|
|
157
|
+
const number = Number(value);
|
|
158
|
+
return Number.isNaN(number) ? null : number;
|
|
159
|
+
}
|
|
160
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
|
161
|
+
return value;
|
|
162
|
+
if (field.type === "one2many" || field.type === "many2many")
|
|
163
|
+
return relatedOf(value).count.value;
|
|
164
|
+
return textOf(meta, name, value);
|
|
165
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Count } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* The records a relation holds inside its parent, however the server sent them.
|
|
4
|
+
*
|
|
5
|
+
* Context: sincpro_framework answers a one2many of a record as a PAGE of its own —
|
|
6
|
+
* `{items, count, cursor}` — so a tenant with a thousand members does not answer all of them;
|
|
7
|
+
* an older answer, or a domain declared by hand, sends a bare array. A screen that reads only
|
|
8
|
+
* `items` and counts them shows the first page and calls it the total (forge's console did:
|
|
9
|
+
* `count={repositories.length}`). This reads both shapes and keeps the real count.
|
|
10
|
+
*
|
|
11
|
+
* relatedOf({ items: [a, b], count: { value: 1000, exact: true }, cursor: "x" })
|
|
12
|
+
* -> { items: [a, b], count: { value: 1000, exact: true }, cursor: "x", partial: true }
|
|
13
|
+
* relatedOf([a, b]) -> { items: [a, b], count: { value: 2, exact: true }, cursor: null, partial: false }
|
|
14
|
+
*/
|
|
15
|
+
export type Related<T> = {
|
|
16
|
+
items: T[];
|
|
17
|
+
count: Count;
|
|
18
|
+
cursor: string | null;
|
|
19
|
+
/** There are more than `items` holds: the record brought one page of them. */
|
|
20
|
+
partial: boolean;
|
|
21
|
+
};
|
|
22
|
+
export declare function relatedOf<T>(value: unknown): Related<T>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function relatedOf(value) {
|
|
2
|
+
if (Array.isArray(value))
|
|
3
|
+
return {
|
|
4
|
+
items: value,
|
|
5
|
+
count: { value: value.length, exact: true },
|
|
6
|
+
cursor: null,
|
|
7
|
+
partial: false,
|
|
8
|
+
};
|
|
9
|
+
if (typeof value === "object" && value !== null && "items" in value) {
|
|
10
|
+
const page = value;
|
|
11
|
+
const items = page.items ?? [];
|
|
12
|
+
const count = page.count ?? { value: items.length, exact: page.cursor == null };
|
|
13
|
+
return {
|
|
14
|
+
items,
|
|
15
|
+
count,
|
|
16
|
+
cursor: page.cursor ?? null,
|
|
17
|
+
partial: count.value > items.length || !count.exact || page.cursor != null,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return { items: [], count: { value: 0, exact: true }, cursor: null, partial: false };
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Criteria } from "@sincpro/criteria";
|
|
2
|
+
import { type Transport } from "./transport.js";
|
|
3
|
+
/**
|
|
4
|
+
* The REST wire of sincpro_framework (`FastApiGateway`).
|
|
5
|
+
*
|
|
6
|
+
* Context: two things `restSource` from `@sincpro/criteria` 0.3 gets wrong against
|
|
7
|
+
* sincpro_framework 3.27, both found by forge's console, which had to write its own client:
|
|
8
|
+
*
|
|
9
|
+
* - it appends `?criteria=` to the route as it is, which breaks a route that already carries
|
|
10
|
+
* its own query (`/projects?tenant_id=acme`) — here the criteria joins that query;
|
|
11
|
+
* - it sends `pack()` (base64url), and the REST entrypoint reads the parameter as plain JSON
|
|
12
|
+
* and refuses anything else («criteria is not JSON») — here it travels as JSON.
|
|
13
|
+
*
|
|
14
|
+
* Once criteria fixes both, this keeps only what it adds: `one()`.
|
|
15
|
+
*
|
|
16
|
+
* const transport = restTransport({
|
|
17
|
+
* base: "/v1",
|
|
18
|
+
* headers: () => ({ Authorization: `Bearer ${session.secret}` }),
|
|
19
|
+
* });
|
|
20
|
+
*/
|
|
21
|
+
export type RestOptions = {
|
|
22
|
+
/** Prepended to every route: `"/v1"`, `"https://forge.sincpro.com.bo/v1"`. */
|
|
23
|
+
base?: string;
|
|
24
|
+
/** Sent with every request. A function is called fresh each time — a token that rotates. */
|
|
25
|
+
headers?: Record<string, string> | (() => Record<string, string>);
|
|
26
|
+
/** Defaults to the global `fetch`. Inject one for tests. */
|
|
27
|
+
fetch?: typeof fetch;
|
|
28
|
+
};
|
|
29
|
+
export declare function urlOf<T>(base: string, route: string, criteria?: Criteria<T>): string;
|
|
30
|
+
export declare function restTransport(options?: RestOptions): Transport;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { pageFrom, plain, readProblem, } from "@sincpro/criteria";
|
|
2
|
+
import { TransportError, } from "./transport.js";
|
|
3
|
+
/** The keys of an answer that are not the record: what is left is the record's own field. */
|
|
4
|
+
const ENVELOPE = new Set(["cursor", "count", "entity_meta_data", "dropped", "draft"]);
|
|
5
|
+
export function urlOf(base, route, criteria) {
|
|
6
|
+
const url = `${base}${route}`;
|
|
7
|
+
if (criteria === undefined)
|
|
8
|
+
return url;
|
|
9
|
+
const joiner = url.includes("?") ? "&" : "?";
|
|
10
|
+
return `${url}${joiner}criteria=${encodeURIComponent(JSON.stringify(criteria))}`;
|
|
11
|
+
}
|
|
12
|
+
export function restTransport(options = {}) {
|
|
13
|
+
const base = options.base ?? "";
|
|
14
|
+
const doFetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
15
|
+
async function get(route, url, signal, write) {
|
|
16
|
+
const own = typeof options.headers === "function" ? options.headers() : (options.headers ?? {});
|
|
17
|
+
const headers = write ? { ...own, "Content-Type": "application/json" } : own;
|
|
18
|
+
const response = await doFetch(url, {
|
|
19
|
+
headers,
|
|
20
|
+
...(write ? { method: write.method, body: JSON.stringify(write.body) } : {}),
|
|
21
|
+
...(signal ? { signal } : {}),
|
|
22
|
+
});
|
|
23
|
+
const text = await response.text();
|
|
24
|
+
if (!response.ok)
|
|
25
|
+
throw new TransportError(route, response.status, readProblem(text), undefined);
|
|
26
|
+
return text ? JSON.parse(text) : null;
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
async list(route, criteria, signal) {
|
|
30
|
+
const answer = await get(route, urlOf(base, route, plain(criteria)), signal);
|
|
31
|
+
return pageFrom(answer);
|
|
32
|
+
},
|
|
33
|
+
async one(route, signal) {
|
|
34
|
+
const answer = (await get(route, urlOf(base, route), signal));
|
|
35
|
+
const own = Object.keys(answer).filter((key) => !ENVELOPE.has(key));
|
|
36
|
+
const [key] = own;
|
|
37
|
+
if (own.length !== 1 || key === undefined)
|
|
38
|
+
throw new TransportError(route, 200, null, `${route} answered ${own.length} fields of its own (${own.join(", ")}); a record answers one`);
|
|
39
|
+
return {
|
|
40
|
+
record: answer[key],
|
|
41
|
+
meta: answer["entity_meta_data"] ?? null,
|
|
42
|
+
dropped: answer["dropped"] ?? [],
|
|
43
|
+
draft: answer["draft"] ?? null,
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
async send(method, route, body, signal) {
|
|
47
|
+
return (await get(route, urlOf(base, route), signal, { method, body }));
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Meta } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* A model definition read from a JSON Schema — what an OpenAPI document holds.
|
|
4
|
+
*
|
|
5
|
+
* Context: what sincpro_framework's OpenAPI 3.1 describes and what it does not, checked
|
|
6
|
+
* against a running forge:
|
|
7
|
+
*
|
|
8
|
+
* - the records a READ answers are invisible: the framework serialises them with its own
|
|
9
|
+
* envelope, so `GET /projects` and `GET /projects/{id}` answer schema `{}` and no
|
|
10
|
+
* `Project` schema exists. A list's definition comes from `entity_meta_data`, at runtime
|
|
11
|
+
* or preloaded (`FrameworkProvider metas`) — not from here;
|
|
12
|
+
* - the COMMANDS and the value objects are described well, and better than the meta in one
|
|
13
|
+
* place: inside a value object `entity_meta_data` sends a `Literal` and a `list[str]` as
|
|
14
|
+
* `unknown`, where the schema says `enum: ["es", "en"]` and `array<string>`.
|
|
15
|
+
*
|
|
16
|
+
* So this reads a component schema into a `Meta` — the form of a Command, a value object — and
|
|
17
|
+
* `completeMeta` fills, from it, what the server's meta left `unknown`.
|
|
18
|
+
*
|
|
19
|
+
* const spec = metaFromSchema("ProjectSpecification", doc.components.schemas);
|
|
20
|
+
* const fixed = completeMeta(project.fields.specification.definition, spec);
|
|
21
|
+
*/
|
|
22
|
+
export type JsonSchema = {
|
|
23
|
+
type?: string | string[];
|
|
24
|
+
format?: string;
|
|
25
|
+
enum?: readonly unknown[];
|
|
26
|
+
items?: JsonSchema;
|
|
27
|
+
$ref?: string;
|
|
28
|
+
anyOf?: readonly JsonSchema[];
|
|
29
|
+
oneOf?: readonly JsonSchema[];
|
|
30
|
+
properties?: Readonly<Record<string, JsonSchema>>;
|
|
31
|
+
required?: readonly string[];
|
|
32
|
+
title?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
default?: unknown;
|
|
35
|
+
};
|
|
36
|
+
export type SchemaComponents = Readonly<Record<string, JsonSchema>>;
|
|
37
|
+
/** The definition a component schema describes. `identity` empty: a value object has none. */
|
|
38
|
+
export declare function metaFromSchema(name: string, components: SchemaComponents, options?: {
|
|
39
|
+
identity?: string;
|
|
40
|
+
}): Meta;
|
|
41
|
+
/**
|
|
42
|
+
* The server's definition, with what it left `unknown` taken from the schema — recursively
|
|
43
|
+
* into value objects. What the server DID say always wins: its labels, its operators, its
|
|
44
|
+
* hints. A field the schema does not know is left as the server sent it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function completeMeta(meta: Meta, schema: Meta): Meta;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { field } from "./meta.js";
|
|
2
|
+
const UNKNOWN = {
|
|
3
|
+
type: "unknown",
|
|
4
|
+
kind: "scalar",
|
|
5
|
+
nullable: true,
|
|
6
|
+
sortable: false,
|
|
7
|
+
ops: [],
|
|
8
|
+
};
|
|
9
|
+
function nameOfRef(ref) {
|
|
10
|
+
return ref.slice(ref.lastIndexOf("/") + 1);
|
|
11
|
+
}
|
|
12
|
+
/** `anyOf: [X, {type: "null"}]` is `X`, nullable — how pydantic writes `X | None`. */
|
|
13
|
+
function unwrap(schema) {
|
|
14
|
+
const options = schema.anyOf ?? schema.oneOf;
|
|
15
|
+
if (options === undefined)
|
|
16
|
+
return { schema, nullable: false };
|
|
17
|
+
const real = options.filter((one) => one.type !== "null");
|
|
18
|
+
const [only] = real;
|
|
19
|
+
return real.length === 1 && only !== undefined
|
|
20
|
+
? { schema: { ...only, ...pick(schema) }, nullable: real.length < options.length }
|
|
21
|
+
: { schema, nullable: real.length < options.length };
|
|
22
|
+
}
|
|
23
|
+
/** The words of the outer schema win over the ones of what it wraps. */
|
|
24
|
+
function pick(schema) {
|
|
25
|
+
return {
|
|
26
|
+
...(schema.title === undefined ? {} : { title: schema.title }),
|
|
27
|
+
...(schema.description === undefined ? {} : { description: schema.description }),
|
|
28
|
+
...(schema.default === undefined ? {} : { default: schema.default }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function fieldFrom(raw, required, components, seen) {
|
|
32
|
+
const { schema: unwrapped, nullable } = unwrap(raw);
|
|
33
|
+
const target = unwrapped.$ref !== undefined
|
|
34
|
+
? { ...components[nameOfRef(unwrapped.$ref)], ...pick(unwrapped) }
|
|
35
|
+
: unwrapped;
|
|
36
|
+
const options = {
|
|
37
|
+
...(target.title === undefined ? {} : { label: target.title }),
|
|
38
|
+
...(target.description === undefined ? {} : { help: target.description }),
|
|
39
|
+
...(target.default === undefined ? {} : { default: target.default }),
|
|
40
|
+
...(nullable ? { nullable } : {}),
|
|
41
|
+
required: required && target.default === undefined && !nullable,
|
|
42
|
+
};
|
|
43
|
+
if (unwrapped.$ref !== undefined) {
|
|
44
|
+
const name = nameOfRef(unwrapped.$ref);
|
|
45
|
+
if (components[name] === undefined || seen.has(name))
|
|
46
|
+
return UNKNOWN;
|
|
47
|
+
if (target.properties === undefined)
|
|
48
|
+
return target.enum ? field.choice(target.enum.map(String), options) : UNKNOWN;
|
|
49
|
+
return field.embedded(metaOf(name, target, components, new Set([...seen, name])), options);
|
|
50
|
+
}
|
|
51
|
+
if (target.enum)
|
|
52
|
+
return field.choice(target.enum.map(String), options);
|
|
53
|
+
const type = Array.isArray(target.type)
|
|
54
|
+
? target.type.find((t) => t !== "null")
|
|
55
|
+
: target.type;
|
|
56
|
+
switch (type) {
|
|
57
|
+
case "string":
|
|
58
|
+
if (target.format === "date")
|
|
59
|
+
return field.date(options);
|
|
60
|
+
if (target.format === "date-time")
|
|
61
|
+
return field.datetime(options);
|
|
62
|
+
return field.text(options);
|
|
63
|
+
case "integer":
|
|
64
|
+
return field.integer(options);
|
|
65
|
+
case "number":
|
|
66
|
+
return field.number(options);
|
|
67
|
+
case "boolean":
|
|
68
|
+
return field.boolean(options);
|
|
69
|
+
case "array": {
|
|
70
|
+
const item = target.items === undefined ? undefined : unwrap(target.items).schema;
|
|
71
|
+
if (item?.$ref !== undefined) {
|
|
72
|
+
const inner = fieldFrom(item, false, components, seen);
|
|
73
|
+
return inner.kind === "embedded" && inner.definition
|
|
74
|
+
? field.embedded(inner.definition, { ...options, many: true })
|
|
75
|
+
: field.tags(options);
|
|
76
|
+
}
|
|
77
|
+
return item?.type === "string" || item?.enum ? field.tags(options) : UNKNOWN;
|
|
78
|
+
}
|
|
79
|
+
default:
|
|
80
|
+
return UNKNOWN;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function metaOf(name, schema, components, seen) {
|
|
84
|
+
const required = new Set(schema.required ?? []);
|
|
85
|
+
const fields = Object.fromEntries(Object.entries(schema.properties ?? {}).map(([key, property]) => [
|
|
86
|
+
key,
|
|
87
|
+
fieldFrom(property, required.has(key), components, seen),
|
|
88
|
+
]));
|
|
89
|
+
return {
|
|
90
|
+
aggregate: name,
|
|
91
|
+
identity: "",
|
|
92
|
+
default_order: "",
|
|
93
|
+
fields,
|
|
94
|
+
...(schema.title === undefined ? {} : { name: { default: schema.title } }),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** The definition a component schema describes. `identity` empty: a value object has none. */
|
|
98
|
+
export function metaFromSchema(name, components, options = {}) {
|
|
99
|
+
const schema = components[name];
|
|
100
|
+
if (schema === undefined)
|
|
101
|
+
throw new Error(`no schema named ${name}`);
|
|
102
|
+
const meta = metaOf(name, schema, components, new Set([name]));
|
|
103
|
+
return options.identity
|
|
104
|
+
? { ...meta, identity: options.identity, get_id: options.identity }
|
|
105
|
+
: meta;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The server's definition, with what it left `unknown` taken from the schema — recursively
|
|
109
|
+
* into value objects. What the server DID say always wins: its labels, its operators, its
|
|
110
|
+
* hints. A field the schema does not know is left as the server sent it.
|
|
111
|
+
*/
|
|
112
|
+
export function completeMeta(meta, schema) {
|
|
113
|
+
const fields = Object.fromEntries(Object.entries(meta.fields).map(([name, own]) => {
|
|
114
|
+
const known = schema.fields[name];
|
|
115
|
+
if (known === undefined)
|
|
116
|
+
return [name, own];
|
|
117
|
+
if (own.type === "unknown")
|
|
118
|
+
return [name, { ...known, ...labelsOf(own) }];
|
|
119
|
+
if (own.kind === "embedded" && own.definition && known.definition)
|
|
120
|
+
return [name, { ...own, definition: completeMeta(own.definition, known.definition) }];
|
|
121
|
+
return [name, own];
|
|
122
|
+
}));
|
|
123
|
+
return { ...meta, fields };
|
|
124
|
+
}
|
|
125
|
+
/** A label the server declared is kept over the schema's generated title. */
|
|
126
|
+
function labelsOf(own) {
|
|
127
|
+
return own.label && Object.keys(own.label).length > 0 ? { label: own.label } : {};
|
|
128
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Criteria, Dropped, Meta, Page, Problem } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* The one seam between this package and a server.
|
|
4
|
+
*
|
|
5
|
+
* Context: a list and a record are the same two questions whatever the wire is — «a page of
|
|
6
|
+
* these, under this criteria» and «this one». REST today; JSON-RPC and gRPC are the other
|
|
7
|
+
* wires sincpro_framework serves. Each of those is
|
|
8
|
+
* one adapter implementing this, and nothing above it changes. A route is a string the adapter
|
|
9
|
+
* understands: a path for REST, a method for RPC.
|
|
10
|
+
*
|
|
11
|
+
* Every adapter throws `TransportError` for a refusal, so a screen explains a failure once
|
|
12
|
+
* whichever wire it came over.
|
|
13
|
+
*/
|
|
14
|
+
export interface Transport {
|
|
15
|
+
/** A page of records under the criteria. */
|
|
16
|
+
list<T>(route: string, criteria: Criteria<T>, signal?: AbortSignal): Promise<Page<T>>;
|
|
17
|
+
/** One record, with the definition of its model. */
|
|
18
|
+
one<T>(route: string, signal?: AbortSignal): Promise<One<T>>;
|
|
19
|
+
/** A Command — a write, an action, a preview: `method` and a JSON body, its answer back. */
|
|
20
|
+
send<T>(method: Method, route: string, body: unknown, signal?: AbortSignal): Promise<T>;
|
|
21
|
+
}
|
|
22
|
+
export type Method = "POST" | "PUT" | "PATCH" | "DELETE";
|
|
23
|
+
/** One record as sincpro_framework answers it (`ResponseRecord`): the record, its model. */
|
|
24
|
+
export type One<T> = {
|
|
25
|
+
record: T;
|
|
26
|
+
meta: Meta | null;
|
|
27
|
+
dropped: Dropped[];
|
|
28
|
+
/** What this caller kept of an edit between requests (sincpro_framework `Drafts`). */
|
|
29
|
+
draft: Draft | null;
|
|
30
|
+
};
|
|
31
|
+
/** A kept edit: the values typed, the version of the draft, the record version it started on. */
|
|
32
|
+
export type Draft = {
|
|
33
|
+
values: Record<string, unknown>;
|
|
34
|
+
version: number;
|
|
35
|
+
origin_version?: number;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* A refusal, from any wire. `problem` is what the server said about it (RFC 9457 plus the
|
|
39
|
+
* engine's `kind`, `next_step`, `guidance`) or `null` when it said nothing readable — a proxy's
|
|
40
|
+
* HTML page. `status` is `null` when the request never got an answer.
|
|
41
|
+
*/
|
|
42
|
+
export declare class TransportError extends Error {
|
|
43
|
+
readonly route: string;
|
|
44
|
+
readonly status: number | null;
|
|
45
|
+
readonly problem: Problem | null;
|
|
46
|
+
constructor(route: string, status: number | null, problem: Problem | null, message?: string);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* What a person reads about a failure: a heading, and a sentence with what happened and what
|
|
50
|
+
* to do. The server's own words first; the copy table's when it sent none.
|
|
51
|
+
*
|
|
52
|
+
* explain(new TransportError("/x", 403, { kind: "permission_denied", guidance: "Pedí acceso.", … }))
|
|
53
|
+
* -> { heading: "No tenés acceso", sentence: "Pedí acceso.", retryable: false }
|
|
54
|
+
*/
|
|
55
|
+
export declare function explain(error: unknown): {
|
|
56
|
+
heading: string;
|
|
57
|
+
sentence: string;
|
|
58
|
+
retryable: boolean;
|
|
59
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { frameworkCopy } from "./copy.js";
|
|
2
|
+
/**
|
|
3
|
+
* A refusal, from any wire. `problem` is what the server said about it (RFC 9457 plus the
|
|
4
|
+
* engine's `kind`, `next_step`, `guidance`) or `null` when it said nothing readable — a proxy's
|
|
5
|
+
* HTML page. `status` is `null` when the request never got an answer.
|
|
6
|
+
*/
|
|
7
|
+
export class TransportError extends Error {
|
|
8
|
+
route;
|
|
9
|
+
status;
|
|
10
|
+
problem;
|
|
11
|
+
constructor(route, status, problem, message) {
|
|
12
|
+
super(message ?? problem?.detail ?? problem?.title ?? `${status ?? "no answer"} at ${route}`);
|
|
13
|
+
this.route = route;
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.problem = problem;
|
|
16
|
+
this.name = "TransportError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* What a person reads about a failure: a heading, and a sentence with what happened and what
|
|
21
|
+
* to do. The server's own words first; the copy table's when it sent none.
|
|
22
|
+
*
|
|
23
|
+
* explain(new TransportError("/x", 403, { kind: "permission_denied", guidance: "Pedí acceso.", … }))
|
|
24
|
+
* -> { heading: "No tenés acceso", sentence: "Pedí acceso.", retryable: false }
|
|
25
|
+
*/
|
|
26
|
+
export function explain(error) {
|
|
27
|
+
const copy = frameworkCopy().failure;
|
|
28
|
+
if (error instanceof TransportError && error.problem !== null) {
|
|
29
|
+
const { kind, title, detail, guidance, retryable } = error.problem;
|
|
30
|
+
return {
|
|
31
|
+
heading: title ?? copy.kind[kind] ?? copy.status(error.status ?? 0),
|
|
32
|
+
sentence: [detail, guidance].filter(Boolean).join(" "),
|
|
33
|
+
retryable,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (error instanceof TransportError && error.status !== null)
|
|
37
|
+
return { heading: copy.status(error.status), sentence: error.message, retryable: true };
|
|
38
|
+
return {
|
|
39
|
+
heading: copy.unreachable,
|
|
40
|
+
sentence: error instanceof Error ? error.message : String(error),
|
|
41
|
+
retryable: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public entry of @sincpro/web-framework — the web client of sincpro_framework, married to
|
|
3
|
+
* `@sincpro/web-ui` (how it looks) and `@sincpro/web-composer` (where it opens).
|
|
4
|
+
*
|
|
5
|
+
* Inside, three folders with one direction: `core/` is plain logic with no React (tested in
|
|
6
|
+
* Node), `react/` holds state and draws nothing, `web/` draws. `make doctor` keeps the arrows.
|
|
7
|
+
*/
|
|
8
|
+
export * from "./core/index.js";
|
|
9
|
+
export * from "./react/index.js";
|
|
10
|
+
export * from "./web/index.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public entry of @sincpro/web-framework — the web client of sincpro_framework, married to
|
|
3
|
+
* `@sincpro/web-ui` (how it looks) and `@sincpro/web-composer` (where it opens).
|
|
4
|
+
*
|
|
5
|
+
* Inside, three folders with one direction: `core/` is plain logic with no React (tested in
|
|
6
|
+
* Node), `react/` holds state and draws nothing, `web/` draws. `make doctor` keeps the arrows.
|
|
7
|
+
*/
|
|
8
|
+
export * from "./core/index.js";
|
|
9
|
+
export * from "./react/index.js";
|
|
10
|
+
export * from "./web/index.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Meta } from "@sincpro/criteria";
|
|
2
|
+
import { type MetaStore } from "../core/metas.js";
|
|
3
|
+
import type { Transport } from "../core/transport.js";
|
|
4
|
+
import { type ReactNode } from "react";
|
|
5
|
+
/**
|
|
6
|
+
* Who answers, in which language a screen reads, and which model definitions are already
|
|
7
|
+
* known — for everything under it.
|
|
8
|
+
*
|
|
9
|
+
* Context: a view receives its record reference and nothing else — the composer hands it no
|
|
10
|
+
* client on purpose — so the transport reaches it through context, set once where the
|
|
11
|
+
* application starts.
|
|
12
|
+
*/
|
|
13
|
+
type FrameworkContextValue = {
|
|
14
|
+
transport: Transport;
|
|
15
|
+
locale: string | undefined;
|
|
16
|
+
metas: MetaStore;
|
|
17
|
+
};
|
|
18
|
+
export declare function FrameworkProvider({ transport, locale, metas: seed, children, }: {
|
|
19
|
+
transport: Transport;
|
|
20
|
+
/** The language labels and values are read in (`"es"`); the model's `default` without it. */
|
|
21
|
+
locale?: string;
|
|
22
|
+
/** Definitions known before any request, by entity key: the preload. */
|
|
23
|
+
metas?: Readonly<Record<string, Meta>>;
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
}): import("react").JSX.Element;
|
|
26
|
+
export declare function useFramework(): FrameworkContextValue;
|
|
27
|
+
export {};
|