@sincpro/web-framework 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +261 -40
- package/dist/core/cache.js +2 -2
- package/dist/core/copy.d.ts +24 -0
- package/dist/core/copy.js +30 -0
- package/dist/core/edit.d.ts +13 -0
- package/dist/core/edit.js +30 -0
- package/dist/core/entity.d.ts +34 -0
- package/dist/core/entity.js +25 -0
- package/dist/core/form.d.ts +21 -0
- package/dist/core/form.js +30 -0
- package/dist/core/index.d.ts +10 -3
- package/dist/core/index.js +9 -2
- package/dist/core/list.d.ts +64 -0
- package/dist/core/list.js +22 -0
- package/dist/core/lookup.d.ts +5 -2
- package/dist/core/lookup.js +6 -2
- package/dist/core/measure.d.ts +16 -0
- package/dist/core/measure.js +38 -0
- package/dist/core/memory.js +18 -4
- package/dist/core/relation.d.ts +28 -0
- package/dist/core/relation.js +30 -0
- package/dist/core/rest.js +13 -3
- package/dist/core/retry.d.ts +24 -0
- package/dist/core/retry.js +25 -0
- package/dist/core/summary.d.ts +50 -0
- package/dist/core/summary.js +54 -0
- package/dist/core/trail.d.ts +44 -0
- package/dist/core/trail.js +70 -0
- package/dist/core/transport.d.ts +13 -1
- package/dist/react/context.d.ts +12 -1
- package/dist/react/context.js +10 -2
- package/dist/react/index.d.ts +3 -1
- package/dist/react/index.js +3 -1
- package/dist/react/useCommand.d.ts +3 -1
- package/dist/react/useCommand.js +12 -4
- package/dist/react/useGroups.d.ts +40 -0
- package/dist/react/useGroups.js +69 -0
- package/dist/react/useListing.d.ts +21 -3
- package/dist/react/useListing.js +38 -3
- package/dist/react/useLookup.d.ts +30 -5
- package/dist/react/useLookup.js +56 -3
- package/dist/react/useRecord.d.ts +17 -2
- package/dist/react/useRecord.js +105 -9
- package/dist/react/useSummary.d.ts +15 -0
- package/dist/react/useSummary.js +22 -0
- package/dist/web/index.d.ts +9 -3
- package/dist/web/index.js +9 -3
- package/dist/web/list/EntityList.d.ts +27 -4
- package/dist/web/list/EntityList.js +162 -15
- package/dist/web/list/EntitySearchBar.d.ts +16 -8
- package/dist/web/list/EntitySearchBar.js +34 -4
- package/dist/web/list/groups.d.ts +15 -0
- package/dist/web/list/groups.js +73 -0
- package/dist/web/list/rowCommands.d.ts +41 -0
- package/dist/web/list/rowCommands.js +56 -0
- package/dist/web/record/EntityPicker.d.ts +42 -13
- package/dist/web/record/EntityPicker.js +53 -19
- package/dist/web/record/EntityTags.d.ts +38 -0
- package/dist/web/record/EntityTags.js +41 -0
- package/dist/web/record/RelatedList.d.ts +2 -1
- package/dist/web/record/RelatedList.js +12 -2
- package/dist/web/record/edit.d.ts +12 -5
- package/dist/web/record/edit.js +67 -8
- package/dist/web/record/opening.d.ts +26 -0
- package/dist/web/record/opening.js +10 -0
- package/dist/web/record/summary.d.ts +22 -0
- package/dist/web/record/summary.js +55 -0
- package/dist/web/workspace/SincproApp.d.ts +4 -1
- package/dist/web/workspace/SincproApp.js +2 -2
- package/dist/web/workspace/composer.d.ts +26 -4
- package/dist/web/workspace/composer.js +59 -2
- package/dist/web/workspace/open.d.ts +61 -0
- package/dist/web/workspace/open.js +62 -0
- package/dist/web/workspace/record.d.ts +30 -0
- package/dist/web/workspace/record.js +80 -0
- package/dist/web/workspace/skin.d.ts +0 -9
- package/dist/web/workspace/skin.js +25 -13
- package/dist/web/workspace/workspace.d.ts +31 -3
- package/dist/web/workspace/workspace.js +57 -4
- package/package.json +5 -5
|
@@ -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
|
+
}
|
package/dist/core/index.d.ts
CHANGED
|
@@ -5,15 +5,22 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export { type CachedTransport, cachedTransport, type CacheOptions, } from "./cache.js";
|
|
7
7
|
export { ENGLISH_FRAMEWORK_COPY, type FrameworkCopy, frameworkCopy, installFrameworkCopy, SPANISH_FRAMEWORK_COPY, } from "./copy.js";
|
|
8
|
-
export { changesOf, errorsByField, type FieldState, fieldStateOf, type FieldStates, missingOf, } from "./edit.js";
|
|
9
|
-
export { type Entity, entity, type EntityRef, type EntityRoutes, pathFor, type WriteRoute, writeRoute, } from "./entity.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";
|
|
10
11
|
export { type HistoryEntry, historyOf } from "./history.js";
|
|
12
|
+
export { defineList, type GroupLevel, isList, type ListDefinition, type ListSpec, type NamedFilter, scopeOf, } from "./list.js";
|
|
11
13
|
export { lookupOf, type LookupOption } from "./lookup.js";
|
|
14
|
+
export { detailOf, measuresOf, sumsOf } from "./measure.js";
|
|
12
15
|
export { type MemoryOptions, type MemoryTable, memoryTransport, } from "./memory.js";
|
|
13
16
|
export { defineMeta, field, type FieldOptions, type MetaSpec, type Words, } from "./meta.js";
|
|
14
17
|
export { type MetaStore, metaStore } from "./metas.js";
|
|
15
18
|
export { BOOKKEEPING, defaultFields, type FieldView, sortKeyOf, textOf, viewOf, } from "./present.js";
|
|
16
19
|
export { type Related, relatedOf } from "./related.js";
|
|
20
|
+
export { entityFor, type RelationField, relationFieldOf, } from "./relation.js";
|
|
17
21
|
export { type RestOptions, restTransport, urlOf } from "./rest.js";
|
|
22
|
+
export { type RetryKeys, retryKeys, unanswered } from "./retry.js";
|
|
18
23
|
export { completeMeta, type JsonSchema, metaFromSchema, type SchemaComponents, } from "./schema.js";
|
|
19
|
-
export { type
|
|
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";
|
package/dist/core/index.js
CHANGED
|
@@ -5,15 +5,22 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export { cachedTransport, } from "./cache.js";
|
|
7
7
|
export { ENGLISH_FRAMEWORK_COPY, frameworkCopy, installFrameworkCopy, SPANISH_FRAMEWORK_COPY, } from "./copy.js";
|
|
8
|
-
export { changesOf, errorsByField, fieldStateOf, missingOf, } from "./edit.js";
|
|
9
|
-
export { entity, pathFor, writeRoute, } from "./entity.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";
|
|
10
11
|
export { historyOf } from "./history.js";
|
|
12
|
+
export { defineList, isList, scopeOf, } from "./list.js";
|
|
11
13
|
export { lookupOf } from "./lookup.js";
|
|
14
|
+
export { detailOf, measuresOf, sumsOf } from "./measure.js";
|
|
12
15
|
export { memoryTransport, } from "./memory.js";
|
|
13
16
|
export { defineMeta, field, } from "./meta.js";
|
|
14
17
|
export { metaStore } from "./metas.js";
|
|
15
18
|
export { BOOKKEEPING, defaultFields, sortKeyOf, textOf, viewOf, } from "./present.js";
|
|
16
19
|
export { relatedOf } from "./related.js";
|
|
20
|
+
export { entityFor, relationFieldOf, } from "./relation.js";
|
|
17
21
|
export { restTransport, urlOf } from "./rest.js";
|
|
22
|
+
export { retryKeys, unanswered } from "./retry.js";
|
|
18
23
|
export { completeMeta, metaFromSchema, } from "./schema.js";
|
|
24
|
+
export { summaryFieldsOf, summaryOf, } from "./summary.js";
|
|
25
|
+
export { placeOf, stepIn } from "./trail.js";
|
|
19
26
|
export { explain, TransportError, } from "./transport.js";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { AnyRecord, Criteria, Expression, Grain, Sort } from "@sincpro/criteria";
|
|
2
|
+
import type { Entity, EntityRef } from "./entity.js";
|
|
3
|
+
/**
|
|
4
|
+
* A named way of looking at an entity: «Por cobrar», «Borradores», «Mis tickets».
|
|
5
|
+
*
|
|
6
|
+
* Context: an ERP rarely shows ONE list of invoices. It shows the invoices to collect, the
|
|
7
|
+
* drafts, the overdue ones — the same entity under a fixed cut, each with its own columns,
|
|
8
|
+
* its own named filters and maybe its own grouping (Odoo's window actions with a `domain`).
|
|
9
|
+
* Written by hand, each of those is a screen repeating `useListing` options and `EntityList`
|
|
10
|
+
* props. Declared once, it is data: `useListing(list)` reads it, `EntityList` draws it,
|
|
11
|
+
* `listView(list)` opens it in the workspace with its own «Go to».
|
|
12
|
+
*
|
|
13
|
+
* The SCOPE is the cut: always asked, ANDed under whatever is searched, filtered or grouped,
|
|
14
|
+
* and never in the search bar — a person cannot remove the «por cobrar» of «Por cobrar». It is
|
|
15
|
+
* a convenience of the screen, not protection: the server answers whatever it allows.
|
|
16
|
+
*
|
|
17
|
+
* export const porCobrar = defineList(facturas, {
|
|
18
|
+
* id: "por-cobrar",
|
|
19
|
+
* title: "Por cobrar",
|
|
20
|
+
* scope: { where: { field: "state", operator: "=", value: "posted" } },
|
|
21
|
+
* columns: ["number", "customer", "due_date", "amount_total"],
|
|
22
|
+
* filters: [{ id: "vencidas", label: "Vencidas", where: … }],
|
|
23
|
+
* });
|
|
24
|
+
*/
|
|
25
|
+
export type ListDefinition<T extends AnyRecord = AnyRecord> = {
|
|
26
|
+
entity: Entity<T>;
|
|
27
|
+
/** Unique within its entity: `"por-cobrar"`. */
|
|
28
|
+
id: string;
|
|
29
|
+
/** `domain:kind-id` — how the workspace and the definitions store refer to it. */
|
|
30
|
+
key: string;
|
|
31
|
+
/** Where the workspace opens it: a kind of its own, so ⌘K can offer it by name. */
|
|
32
|
+
ref: () => EntityRef;
|
|
33
|
+
title: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
/** The fixed cut, ANDed under everything a person searches or filters. */
|
|
36
|
+
scope?: Criteria<T>;
|
|
37
|
+
/** The order it opens in, before anyone clicks a header. */
|
|
38
|
+
order?: Sort;
|
|
39
|
+
/** The fields shown, in order. */
|
|
40
|
+
columns?: readonly string[];
|
|
41
|
+
/** The cuts it offers by name in the search bar. */
|
|
42
|
+
filters?: readonly NamedFilter<T>[];
|
|
43
|
+
/** The faces it offers, the first one shown. */
|
|
44
|
+
views?: readonly ("table" | "rows" | "board")[];
|
|
45
|
+
/** Grouped from the start — the server counts the groups. */
|
|
46
|
+
groupBy?: readonly GroupLevel[];
|
|
47
|
+
};
|
|
48
|
+
/** A cut offered by name in the search bar: «sólo pagadas», «vencidas». */
|
|
49
|
+
export type NamedFilter<T> = {
|
|
50
|
+
id: string;
|
|
51
|
+
label: string;
|
|
52
|
+
where: Expression<T>;
|
|
53
|
+
};
|
|
54
|
+
/** One level of a grouping: by which field, and by which cut of time when it is a date. */
|
|
55
|
+
export type GroupLevel = {
|
|
56
|
+
field: string;
|
|
57
|
+
grain?: Grain;
|
|
58
|
+
};
|
|
59
|
+
export type ListSpec<T extends AnyRecord> = Omit<ListDefinition<T>, "entity" | "key" | "ref">;
|
|
60
|
+
export declare function defineList<T extends AnyRecord>(entity: Entity<T>, spec: ListSpec<T>): ListDefinition<T>;
|
|
61
|
+
/** Whether what `useListing` was handed is a list definition rather than a bare entity. */
|
|
62
|
+
export declare function isList<T extends AnyRecord>(given: Entity<T> | ListDefinition<T>): given is ListDefinition<T>;
|
|
63
|
+
/** The criteria a definition always asks: its scope, in its order. */
|
|
64
|
+
export declare function scopeOf<T extends AnyRecord>(list: ListDefinition<T>): Criteria<T>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function defineList(entity, spec) {
|
|
2
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(spec.id))
|
|
3
|
+
throw new Error(`list id "${spec.id}" is not kebab-case: it becomes a workspace kind`);
|
|
4
|
+
const kind = `${entity.kind}-${spec.id}`;
|
|
5
|
+
return {
|
|
6
|
+
...spec,
|
|
7
|
+
entity,
|
|
8
|
+
key: `${entity.domain}:${kind}`,
|
|
9
|
+
ref: () => ({ domain: entity.domain, kind }),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** Whether what `useListing` was handed is a list definition rather than a bare entity. */
|
|
13
|
+
export function isList(given) {
|
|
14
|
+
return "entity" in given && "title" in given;
|
|
15
|
+
}
|
|
16
|
+
/** The criteria a definition always asks: its scope, in its order. */
|
|
17
|
+
export function scopeOf(list) {
|
|
18
|
+
return {
|
|
19
|
+
...list.scope,
|
|
20
|
+
...(list.order === undefined ? {} : { order: [list.order] }),
|
|
21
|
+
};
|
|
22
|
+
}
|
package/dist/core/lookup.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AnyRecord, Meta } from "@sincpro/criteria";
|
|
2
|
+
import { type SummaryFields } from "./summary.js";
|
|
2
3
|
/**
|
|
3
4
|
* A record as a choice: what a many2one select, a «share with» and ⌘K show for it.
|
|
4
5
|
*
|
|
@@ -6,11 +7,13 @@ import type { AnyRecord, Meta } from "@sincpro/criteria";
|
|
|
6
7
|
* `DEFAULT_LITERAL_SEARCH` and `DEFAULT_DISPLAY` — and publishes both in the model definition
|
|
7
8
|
* (`meta.search`, `meta.display`). So a choice is read from the model: its key is the field a
|
|
8
9
|
* record is fetched by, its label the display, and the key again beside it when the two differ
|
|
9
|
-
* («Globex Bolivia · C-004»), which is what tells two «Globex» apart.
|
|
10
|
+
* («Globex Bolivia · C-004»), which is what tells two «Globex» apart. Its picture is the
|
|
11
|
+
* record's summary image (`entity(…, { summary })`): a person is chosen by their face.
|
|
10
12
|
*/
|
|
11
13
|
export type LookupOption = {
|
|
12
14
|
id: string;
|
|
13
15
|
label: string;
|
|
14
16
|
detail?: string;
|
|
17
|
+
image?: string;
|
|
15
18
|
};
|
|
16
|
-
export declare function lookupOf(meta: Meta | null | undefined, rows: readonly AnyRecord[]): LookupOption[];
|
|
19
|
+
export declare function lookupOf(meta: Meta | null | undefined, rows: readonly AnyRecord[], summary?: SummaryFields): LookupOption[];
|
package/dist/core/lookup.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
import { summaryFieldsOf } from "./summary.js";
|
|
2
|
+
export function lookupOf(meta, rows, summary = {}) {
|
|
2
3
|
const key = meta?.get_id || meta?.identity || "id";
|
|
3
4
|
const display = meta?.display || key;
|
|
4
5
|
return rows.map((row) => {
|
|
5
6
|
const id = String(row[key] ?? "");
|
|
6
7
|
const shown = row[display];
|
|
7
8
|
const label = shown === undefined || shown === null || shown === "" ? id : String(shown);
|
|
8
|
-
|
|
9
|
+
const imageField = summaryFieldsOf(meta, row, summary).image;
|
|
10
|
+
const picture = imageField === undefined ? undefined : row[imageField];
|
|
11
|
+
const image = typeof picture === "string" && picture !== "" ? { image: picture } : {};
|
|
12
|
+
return label === id ? { id, label, ...image } : { id, label, detail: id, ...image };
|
|
9
13
|
});
|
|
10
14
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Measure, type Meta } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* What a group ADDS UP TO — «Total 1.204.933,00» beside «Pagadas · 187» — summed by the server
|
|
4
|
+
* over every row of the group, never the page.
|
|
5
|
+
*
|
|
6
|
+
* Context: a count says how many; whoever groups invoices by state wants to know how much. Which
|
|
7
|
+
* fields add up is the domain's, and the model does not say it yet, so the default is the
|
|
8
|
+
* figure a record computes: an exact number that is read-only (a total, a subtotal, a balance).
|
|
9
|
+
* What a person types — a discount, a percentage, a limit — is not added up: a sum of
|
|
10
|
+
* percentages means nothing. A screen names its own (`EntityList sums`).
|
|
11
|
+
*/
|
|
12
|
+
export declare function sumsOf(meta: Meta | null | undefined): string[];
|
|
13
|
+
/** The grouping's measures for these fields, each under its own name. */
|
|
14
|
+
export declare function measuresOf(fields: readonly string[]): Record<string, Measure>;
|
|
15
|
+
/** What a group's measures read as: «Total 1.204.933,00 · Saldo 12,50». */
|
|
16
|
+
export declare function detailOf(meta: Meta | null | undefined, measures: Readonly<Record<string, unknown>> | undefined, locale?: string): string | undefined;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { labelOf, measure } from "@sincpro/criteria";
|
|
2
|
+
import { BOOKKEEPING, textOf } from "./present.js";
|
|
3
|
+
/**
|
|
4
|
+
* What a group ADDS UP TO — «Total 1.204.933,00» beside «Pagadas · 187» — summed by the server
|
|
5
|
+
* over every row of the group, never the page.
|
|
6
|
+
*
|
|
7
|
+
* Context: a count says how many; whoever groups invoices by state wants to know how much. Which
|
|
8
|
+
* fields add up is the domain's, and the model does not say it yet, so the default is the
|
|
9
|
+
* figure a record computes: an exact number that is read-only (a total, a subtotal, a balance).
|
|
10
|
+
* What a person types — a discount, a percentage, a limit — is not added up: a sum of
|
|
11
|
+
* percentages means nothing. A screen names its own (`EntityList sums`).
|
|
12
|
+
*/
|
|
13
|
+
export function sumsOf(meta) {
|
|
14
|
+
if (!meta)
|
|
15
|
+
return [];
|
|
16
|
+
return Object.entries(meta.fields)
|
|
17
|
+
.filter(([name, field]) => !BOOKKEEPING.has(name) &&
|
|
18
|
+
(field.type === "number" || field.type === "integer") &&
|
|
19
|
+
field.exact === true &&
|
|
20
|
+
field.readonly === true)
|
|
21
|
+
.map(([name]) => name);
|
|
22
|
+
}
|
|
23
|
+
/** The grouping's measures for these fields, each under its own name. */
|
|
24
|
+
export function measuresOf(fields) {
|
|
25
|
+
return Object.fromEntries(fields.map((field) => [field, measure.sum(field)]));
|
|
26
|
+
}
|
|
27
|
+
/** What a group's measures read as: «Total 1.204.933,00 · Saldo 12,50». */
|
|
28
|
+
export function detailOf(meta, measures, locale) {
|
|
29
|
+
if (measures === undefined)
|
|
30
|
+
return undefined;
|
|
31
|
+
const parts = Object.entries(measures)
|
|
32
|
+
.map(([field, value]) => {
|
|
33
|
+
const text = textOf(meta, field, value, locale);
|
|
34
|
+
return text === null ? null : `${labelOf(meta, field, locale)} ${text}`;
|
|
35
|
+
})
|
|
36
|
+
.filter((part) => part !== null);
|
|
37
|
+
return parts.length === 0 ? undefined : parts.join(" · ");
|
|
38
|
+
}
|
package/dist/core/memory.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { answer } from "@sincpro/criteria/engine";
|
|
1
|
+
import { answer, answerGroups } from "@sincpro/criteria/engine";
|
|
2
2
|
import { TransportError, } from "./transport.js";
|
|
3
3
|
function wait(ms, signal) {
|
|
4
4
|
if (ms <= 0)
|
|
@@ -12,6 +12,7 @@ function wait(ms, signal) {
|
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
14
|
export function memoryTransport(tables, options = {}) {
|
|
15
|
+
const answered = new Map();
|
|
15
16
|
async function check(route, signal) {
|
|
16
17
|
await wait(options.latency ?? 0, signal);
|
|
17
18
|
const refused = options.refuse?.(route);
|
|
@@ -38,7 +39,12 @@ export function memoryTransport(tables, options = {}) {
|
|
|
38
39
|
options.observe?.(route, criteria);
|
|
39
40
|
await check(route, signal);
|
|
40
41
|
const table = tableAt(route);
|
|
41
|
-
|
|
42
|
+
// A grouped question is answered with the groups — counted over EVERY row that matches,
|
|
43
|
+
// each with the criteria that opens it — the way the server's aggregation answers it.
|
|
44
|
+
const grouped = (criteria.grouping?.group_by?.length ?? 0) > 0;
|
|
45
|
+
const page = grouped
|
|
46
|
+
? answerGroups(table.rows, criteria, table.meta)
|
|
47
|
+
: answer(table.rows, criteria, table.meta);
|
|
42
48
|
// A copy, as over a wire: a screen holding a row must not see the server change it.
|
|
43
49
|
return { ...page, rows: structuredClone(page.rows) };
|
|
44
50
|
},
|
|
@@ -66,8 +72,13 @@ export function memoryTransport(tables, options = {}) {
|
|
|
66
72
|
draft: options.draft?.(route) ?? null,
|
|
67
73
|
};
|
|
68
74
|
},
|
|
69
|
-
async send(method, route, body, signal) {
|
|
75
|
+
async send(method, route, body, signal, sending) {
|
|
70
76
|
await check(route, signal);
|
|
77
|
+
// A key already answered is answered again, without running the Command twice — what
|
|
78
|
+
// sincpro_framework's `@idempotency` does with a repeated `Idempotency-Key`.
|
|
79
|
+
const key = sending?.idempotencyKey;
|
|
80
|
+
if (key !== undefined && answered.has(key))
|
|
81
|
+
return structuredClone(answered.get(key));
|
|
71
82
|
if (options.handle === undefined)
|
|
72
83
|
throw new TransportError(route, 404, {
|
|
73
84
|
kind: "not_found",
|
|
@@ -79,7 +90,10 @@ export function memoryTransport(tables, options = {}) {
|
|
|
79
90
|
detail: `Nothing answers ${method} ${route}.`,
|
|
80
91
|
});
|
|
81
92
|
// A deep copy: what the caller sent is not what the server keeps.
|
|
82
|
-
|
|
93
|
+
const answer = await options.handle(method, route, structuredClone(body));
|
|
94
|
+
if (key !== undefined)
|
|
95
|
+
answered.set(key, structuredClone(answer));
|
|
96
|
+
return answer;
|
|
83
97
|
},
|
|
84
98
|
};
|
|
85
99
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FieldMeta, Meta } from "@sincpro/criteria";
|
|
2
|
+
import type { Entity } from "./entity.js";
|
|
3
|
+
/**
|
|
4
|
+
* Which record a field of a form is about, read from the model — so a form draws a many2one
|
|
5
|
+
* as a picker of the right entity without being told.
|
|
6
|
+
*
|
|
7
|
+
* Context: sincpro_framework publishes a many2one twice: the RELATION (`customer`, expanded as
|
|
8
|
+
* the record it points at, read-only) and its scalar KEY (`customer_id`, what is written),
|
|
9
|
+
* joined by `identified_by`. A form may name either; both mean «the customer». A relation
|
|
10
|
+
* names the other side by its AGGREGATE («Cliente»), which says nothing about where it is read
|
|
11
|
+
* — that is the entity the application declared (`entityFor`).
|
|
12
|
+
*/
|
|
13
|
+
export type RelationField = {
|
|
14
|
+
/** The relation field: `customer`. */
|
|
15
|
+
relation: string;
|
|
16
|
+
/** The scalar written for it: `customer_id` (the relation itself when it has none). */
|
|
17
|
+
key: string;
|
|
18
|
+
/** The aggregate on the other side: `"Cliente"`. */
|
|
19
|
+
target: string;
|
|
20
|
+
many: boolean;
|
|
21
|
+
field: FieldMeta;
|
|
22
|
+
};
|
|
23
|
+
export declare function relationFieldOf(meta: Meta | null | undefined, name: string): RelationField | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* The declared entity a relation points at: the one that says it is that aggregate, else the
|
|
26
|
+
* one whose model definition is, else the one whose key it is (`"ventas:cliente"`).
|
|
27
|
+
*/
|
|
28
|
+
export declare function entityFor(entities: readonly Entity[], target: string, metaOf?: (key: string) => Meta | undefined): Entity | undefined;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function relationFieldOf(meta, name) {
|
|
2
|
+
if (!meta)
|
|
3
|
+
return undefined;
|
|
4
|
+
const of = (relation, field) => field.kind !== "relation" || !field.relation
|
|
5
|
+
? undefined
|
|
6
|
+
: {
|
|
7
|
+
relation,
|
|
8
|
+
key: field.identified_by || relation,
|
|
9
|
+
target: field.relation,
|
|
10
|
+
many: field.many === true,
|
|
11
|
+
field,
|
|
12
|
+
};
|
|
13
|
+
const own = meta.fields[name];
|
|
14
|
+
if (own?.kind === "relation")
|
|
15
|
+
return of(name, own);
|
|
16
|
+
// A scalar that a relation is identified by: `customer_id` IS the customer.
|
|
17
|
+
for (const [relation, field] of Object.entries(meta.fields))
|
|
18
|
+
if (field.kind === "relation" && field.identified_by === name)
|
|
19
|
+
return of(relation, field);
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The declared entity a relation points at: the one that says it is that aggregate, else the
|
|
24
|
+
* one whose model definition is, else the one whose key it is (`"ventas:cliente"`).
|
|
25
|
+
*/
|
|
26
|
+
export function entityFor(entities, target, metaOf = () => undefined) {
|
|
27
|
+
return (entities.find((one) => one.aggregate === target) ??
|
|
28
|
+
entities.find((one) => metaOf(one.key)?.aggregate === target) ??
|
|
29
|
+
entities.find((one) => one.key === target));
|
|
30
|
+
}
|
package/dist/core/rest.js
CHANGED
|
@@ -14,7 +14,13 @@ export function restTransport(options = {}) {
|
|
|
14
14
|
const doFetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
15
15
|
async function get(route, url, signal, write) {
|
|
16
16
|
const own = typeof options.headers === "function" ? options.headers() : (options.headers ?? {});
|
|
17
|
-
const headers = write
|
|
17
|
+
const headers = write
|
|
18
|
+
? {
|
|
19
|
+
...own,
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
...(write.key === undefined ? {} : { "Idempotency-Key": write.key }),
|
|
22
|
+
}
|
|
23
|
+
: own;
|
|
18
24
|
const response = await doFetch(url, {
|
|
19
25
|
headers,
|
|
20
26
|
...(write ? { method: write.method, body: JSON.stringify(write.body) } : {}),
|
|
@@ -43,8 +49,12 @@ export function restTransport(options = {}) {
|
|
|
43
49
|
draft: answer["draft"] ?? null,
|
|
44
50
|
};
|
|
45
51
|
},
|
|
46
|
-
async send(method, route, body, signal) {
|
|
47
|
-
return (await get(route, urlOf(base, route), signal, {
|
|
52
|
+
async send(method, route, body, signal, sending) {
|
|
53
|
+
return (await get(route, urlOf(base, route), signal, {
|
|
54
|
+
method,
|
|
55
|
+
body,
|
|
56
|
+
key: sending?.idempotencyKey,
|
|
57
|
+
}));
|
|
48
58
|
},
|
|
49
59
|
};
|
|
50
60
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Idempotency-Key` of a gesture: one per thing a person meant to do, the SAME one while
|
|
3
|
+
* that thing is retried.
|
|
4
|
+
*
|
|
5
|
+
* Context: a Command that got no answer — the network dropped, a proxy answered 502 — may have
|
|
6
|
+
* run. Sending it again with a new key could pay twice; with the same key the server answers
|
|
7
|
+
* what it already did (sincpro_framework's `@idempotency`). So a key lives until the server
|
|
8
|
+
* gives a definite answer: done, or refused for a reason a retry does not change. Changing the
|
|
9
|
+
* body is a different gesture, and gets a new key — the server refuses a key that comes back
|
|
10
|
+
* with another payload.
|
|
11
|
+
*
|
|
12
|
+
* const keys = retryKeys();
|
|
13
|
+
* const key = keys.keyFor(body); // the same while the previous one went unanswered
|
|
14
|
+
* try { await send(…, { idempotencyKey: key }); keys.settle(null); }
|
|
15
|
+
* catch (error) { keys.settle(error); } // kept when nobody knows what happened
|
|
16
|
+
*/
|
|
17
|
+
export type RetryKeys = {
|
|
18
|
+
keyFor: (body: unknown) => string;
|
|
19
|
+
/** What the last attempt ended in: `null` when it was answered. */
|
|
20
|
+
settle: (error: unknown) => void;
|
|
21
|
+
};
|
|
22
|
+
/** Whether a failure leaves it unknown if the Command ran — so a retry must reuse its key. */
|
|
23
|
+
export declare function unanswered(error: unknown): boolean;
|
|
24
|
+
export declare function retryKeys(make?: () => string): RetryKeys;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { TransportError } from "./transport.js";
|
|
2
|
+
/** Whether a failure leaves it unknown if the Command ran — so a retry must reuse its key. */
|
|
3
|
+
export function unanswered(error) {
|
|
4
|
+
if (!(error instanceof TransportError))
|
|
5
|
+
return true;
|
|
6
|
+
if (error.status === null || error.status >= 500)
|
|
7
|
+
return true;
|
|
8
|
+
// The same key is still running elsewhere: retry once it completes, with that key.
|
|
9
|
+
return error.problem?.kind === "in_progress";
|
|
10
|
+
}
|
|
11
|
+
export function retryKeys(make = () => globalThis.crypto.randomUUID()) {
|
|
12
|
+
let pending = null;
|
|
13
|
+
return {
|
|
14
|
+
keyFor: (body) => {
|
|
15
|
+
const sent = JSON.stringify(body ?? null);
|
|
16
|
+
if (pending === null || pending.body !== sent)
|
|
17
|
+
pending = { body: sent, key: make() };
|
|
18
|
+
return pending.key;
|
|
19
|
+
},
|
|
20
|
+
settle: (error) => {
|
|
21
|
+
if (error === null || !unanswered(error))
|
|
22
|
+
pending = null;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AnyRecord, Meta } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* What is known about ANY record, the same for every entity: its name and, when it has them,
|
|
4
|
+
* a description, a state, a type, a picture and who made and last changed it.
|
|
5
|
+
*
|
|
6
|
+
* Context: a link, a chip, a card, the summary that floats from a name and the header of a
|
|
7
|
+
* record all say the same few things about a record — only how much of it changes. Read once
|
|
8
|
+
* here, from the model, they cannot disagree, and a new entity looks right without a line
|
|
9
|
+
* written for it. What a record does not have is `null`, and nothing is drawn for it.
|
|
10
|
+
*
|
|
11
|
+
* Which field plays each part: the one the entity declares (`entity(…, { summary })`), else
|
|
12
|
+
* the first conventional name the model holds (`description`, `state`/`status`, `created_at`,
|
|
13
|
+
* …). The name is the model's own `display`. When the backend publishes these parts in its
|
|
14
|
+
* model definition, they are read from there and the convention steps aside.
|
|
15
|
+
*/
|
|
16
|
+
export type SummaryPart = "description" | "state" | "type" | "image" | "createdAt" | "createdBy" | "updatedAt" | "updatedBy";
|
|
17
|
+
/** Which field of the entity plays each part, when it is not the conventional one.
|
|
18
|
+
* `person`: its records are people or companies — a round face, initials without a photo. */
|
|
19
|
+
export type SummaryFields = Partial<Record<SummaryPart, string>> & {
|
|
20
|
+
person?: boolean;
|
|
21
|
+
};
|
|
22
|
+
export type RecordSummary = {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
description: string | null;
|
|
26
|
+
state: string | null;
|
|
27
|
+
type: string | null;
|
|
28
|
+
/** The picture's address, as the server sent it. */
|
|
29
|
+
image: string | null;
|
|
30
|
+
person: boolean;
|
|
31
|
+
created: {
|
|
32
|
+
at: string | null;
|
|
33
|
+
by: string | null;
|
|
34
|
+
};
|
|
35
|
+
updated: {
|
|
36
|
+
at: string | null;
|
|
37
|
+
by: string | null;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
/** The field that plays each part. With a model, only fields it declares; without one (a
|
|
41
|
+
* record that came alone), the record's own keys. */
|
|
42
|
+
export declare function summaryFieldsOf(meta: Meta | null | undefined, record: AnyRecord, declared?: SummaryFields): Partial<Record<SummaryPart, string>>;
|
|
43
|
+
/**
|
|
44
|
+
* summaryOf(meta, invoice, { locale: "es" })
|
|
45
|
+
* -> { name: "F-2026-0004", state: "Pagada", created: { at: "4 ene 2026, 14:13", … } }
|
|
46
|
+
*/
|
|
47
|
+
export declare function summaryOf(meta: Meta | null | undefined, record: AnyRecord, options?: {
|
|
48
|
+
fields?: SummaryFields;
|
|
49
|
+
locale?: string;
|
|
50
|
+
}): RecordSummary;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { textOf } from "./present.js";
|
|
2
|
+
const CONVENTION = {
|
|
3
|
+
description: ["description", "summary"],
|
|
4
|
+
state: ["state", "status"],
|
|
5
|
+
type: ["type", "kind", "category"],
|
|
6
|
+
image: ["image", "image_url", "avatar", "avatar_url", "photo", "picture", "logo"],
|
|
7
|
+
createdAt: ["created_at"],
|
|
8
|
+
createdBy: ["created_by"],
|
|
9
|
+
updatedAt: ["updated_at"],
|
|
10
|
+
updatedBy: ["updated_by"],
|
|
11
|
+
};
|
|
12
|
+
const PARTS = Object.keys(CONVENTION);
|
|
13
|
+
/** The field that plays each part. With a model, only fields it declares; without one (a
|
|
14
|
+
* record that came alone), the record's own keys. */
|
|
15
|
+
export function summaryFieldsOf(meta, record, declared = {}) {
|
|
16
|
+
const holds = (name) => meta?.fields ? name in meta.fields : Object.hasOwn(record, name);
|
|
17
|
+
const found = {};
|
|
18
|
+
for (const part of PARTS) {
|
|
19
|
+
const name = declared[part] ?? CONVENTION[part].find(holds);
|
|
20
|
+
if (name !== undefined)
|
|
21
|
+
found[part] = name;
|
|
22
|
+
}
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* summaryOf(meta, invoice, { locale: "es" })
|
|
27
|
+
* -> { name: "F-2026-0004", state: "Pagada", created: { at: "4 ene 2026, 14:13", … } }
|
|
28
|
+
*/
|
|
29
|
+
export function summaryOf(meta, record, options = {}) {
|
|
30
|
+
const { fields = {}, locale } = options;
|
|
31
|
+
const parts = summaryFieldsOf(meta, record, fields);
|
|
32
|
+
const text = (part) => {
|
|
33
|
+
const name = parts[part];
|
|
34
|
+
return name === undefined ? null : textOf(meta, name, record[name], locale);
|
|
35
|
+
};
|
|
36
|
+
const key = meta?.get_id || meta?.identity || "id";
|
|
37
|
+
const id = String(record[key] ?? record["id"] ?? "");
|
|
38
|
+
const display = meta?.display || undefined;
|
|
39
|
+
const shown = display === undefined
|
|
40
|
+
? (record["display_name"] ?? record["name"])
|
|
41
|
+
: textOf(meta, display, record[display], locale);
|
|
42
|
+
const image = parts.image === undefined ? null : record[parts.image];
|
|
43
|
+
return {
|
|
44
|
+
id,
|
|
45
|
+
name: shown === undefined || shown === null || shown === "" ? id : String(shown),
|
|
46
|
+
description: text("description"),
|
|
47
|
+
state: text("state"),
|
|
48
|
+
type: text("type"),
|
|
49
|
+
image: typeof image === "string" && image !== "" ? image : null,
|
|
50
|
+
person: fields.person ?? false,
|
|
51
|
+
created: { at: text("createdAt"), by: text("createdBy") },
|
|
52
|
+
updated: { at: text("updatedAt"), by: text("updatedBy") },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Criteria } from "@sincpro/criteria";
|
|
2
|
+
import type { Transport } from "./transport.js";
|
|
3
|
+
/**
|
|
4
|
+
* Where a record was opened from: the page of the list it was a row of, so the record can go
|
|
5
|
+
* to the previous and the next one — «3 / 64» and two arrows — without going back to the list.
|
|
6
|
+
*
|
|
7
|
+
* Context: Odoo keeps the ids of the whole search in the browser to page through records. That
|
|
8
|
+
* caps at what was loaded. Here the trail keeps ONE page and the server's cursors around it: at
|
|
9
|
+
* the edge of the page it asks the server for the neighbouring page, with the same question —
|
|
10
|
+
* scope, search, filter, order — so the record after the last of page 4 000 is one request, as
|
|
11
|
+
* cheap as the first. Over a list of millions the arrows never stop at the page.
|
|
12
|
+
*/
|
|
13
|
+
export type Trail = {
|
|
14
|
+
route: string;
|
|
15
|
+
/** The list's question, without a page: what the neighbouring pages are asked with. */
|
|
16
|
+
question: Criteria;
|
|
17
|
+
/** The field the ids are read from. */
|
|
18
|
+
key: string;
|
|
19
|
+
/** The ids of the page on screen, in order. */
|
|
20
|
+
ids: readonly string[];
|
|
21
|
+
/** Where that page starts in the whole set, counting from 0. */
|
|
22
|
+
offset: number;
|
|
23
|
+
/** Rows per page: the cursors belong to pages of this size, and only to them. */
|
|
24
|
+
size: number;
|
|
25
|
+
/** The cursors that start each page up to this one (`null`: the first page). */
|
|
26
|
+
cursors: readonly (string | null)[];
|
|
27
|
+
/** The cursor that starts the next page; `null` when this is the last. */
|
|
28
|
+
after: string | null;
|
|
29
|
+
/** How many there are in all, when the server counted them. */
|
|
30
|
+
total: number | null;
|
|
31
|
+
};
|
|
32
|
+
/** Where a record stands in its trail: «3 of 64», 1-based. */
|
|
33
|
+
export declare function placeOf(trail: Trail, id: string): {
|
|
34
|
+
index: number;
|
|
35
|
+
total: number;
|
|
36
|
+
} | null;
|
|
37
|
+
/**
|
|
38
|
+
* The record next to `id` (`step` 1) or before it (`step` -1), with the trail moved to its page
|
|
39
|
+
* when it lies on the neighbouring one. `null` at either end of the set.
|
|
40
|
+
*/
|
|
41
|
+
export declare function stepIn(transport: Transport, trail: Trail, id: string, step: 1 | -1): Promise<{
|
|
42
|
+
id: string;
|
|
43
|
+
trail: Trail;
|
|
44
|
+
} | null>;
|