@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,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
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AnyRecord, Meta } from "@sincpro/criteria";
|
|
2
|
+
import { type SummaryFields } from "./summary.js";
|
|
3
|
+
/**
|
|
4
|
+
* A record as a choice: what a many2one select, a «share with» and ⌘K show for it.
|
|
5
|
+
*
|
|
6
|
+
* Context: sincpro_framework answers this question on its own — `LiteralSearch`, the entity's
|
|
7
|
+
* `DEFAULT_LITERAL_SEARCH` and `DEFAULT_DISPLAY` — and publishes both in the model definition
|
|
8
|
+
* (`meta.search`, `meta.display`). So a choice is read from the model: its key is the field a
|
|
9
|
+
* record is fetched by, its label the display, and the key again beside it when the two differ
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
export type LookupOption = {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
detail?: string;
|
|
17
|
+
image?: string;
|
|
18
|
+
};
|
|
19
|
+
export declare function lookupOf(meta: Meta | null | undefined, rows: readonly AnyRecord[], summary?: SummaryFields): LookupOption[];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { summaryFieldsOf } from "./summary.js";
|
|
2
|
+
export function lookupOf(meta, rows, summary = {}) {
|
|
3
|
+
const key = meta?.get_id || meta?.identity || "id";
|
|
4
|
+
const display = meta?.display || key;
|
|
5
|
+
return rows.map((row) => {
|
|
6
|
+
const id = String(row[key] ?? "");
|
|
7
|
+
const shown = row[display];
|
|
8
|
+
const label = shown === undefined || shown === null || shown === "" ? id : String(shown);
|
|
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 };
|
|
13
|
+
});
|
|
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
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AnyRecord, Criteria, Meta, Problem } from "@sincpro/criteria";
|
|
2
|
+
import { type Draft, type Method, type Transport } from "./transport.js";
|
|
3
|
+
/**
|
|
4
|
+
* A server held in memory: tables of rows with their definition, answered with the same
|
|
5
|
+
* engine sincpro_framework's criteria runs on.
|
|
6
|
+
*
|
|
7
|
+
* Context: not a test double. It is what a screen runs against before its server exists,
|
|
8
|
+
* what Storybook and the tests run against, and what a domain with no server at all uses —
|
|
9
|
+
* rows a screen already holds. It filters, orders, pages and counts
|
|
10
|
+
* exactly as the server would (`answer` from `@sincpro/criteria/engine`), drops what the
|
|
11
|
+
* model cannot answer and reports it, and refuses what the server would refuse. So the day a
|
|
12
|
+
* real server answers, only the transport changes.
|
|
13
|
+
*
|
|
14
|
+
* A record is found at `<list route>/<id>`, by the field the model fetches by (`get_id`, else
|
|
15
|
+
* the identity) — the convention sincpro_framework's REST routes follow.
|
|
16
|
+
*
|
|
17
|
+
* const transport = memoryTransport({
|
|
18
|
+
* "/invoices": { meta: invoiceMeta, rows: invoices },
|
|
19
|
+
* }, { latency: 300 });
|
|
20
|
+
*/
|
|
21
|
+
export type MemoryTable = {
|
|
22
|
+
meta: Meta;
|
|
23
|
+
rows: readonly AnyRecord[];
|
|
24
|
+
};
|
|
25
|
+
export type MemoryOptions = {
|
|
26
|
+
/** Milliseconds every answer waits, so a screen's «loading» can be seen and tested. */
|
|
27
|
+
latency?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Answer a Command — a write, an action, a preview — the way the service would. It may
|
|
30
|
+
* change the tables' rows (they are read on every request) and throw a `TransportError` to
|
|
31
|
+
* refuse. Without it, every Command is a 404.
|
|
32
|
+
*/
|
|
33
|
+
handle?: (method: Method, route: string, body: unknown) => unknown;
|
|
34
|
+
/** The draft this caller kept of a record, answered with it — sincpro_framework's `Drafts`. */
|
|
35
|
+
draft?: (route: string) => Draft | null;
|
|
36
|
+
/** Every list request, as it arrived: what a story or a test checks was really asked. */
|
|
37
|
+
observe?: (route: string, criteria: Criteria) => void;
|
|
38
|
+
/** Refuse a route the way a server would: return the problem it answers with. */
|
|
39
|
+
refuse?: (route: string) => (Problem & {
|
|
40
|
+
status?: number;
|
|
41
|
+
}) | undefined;
|
|
42
|
+
};
|
|
43
|
+
export declare function memoryTransport(tables: Readonly<Record<string, MemoryTable>>, options?: MemoryOptions): Transport;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { answer, answerGroups } from "@sincpro/criteria/engine";
|
|
2
|
+
import { TransportError, } from "./transport.js";
|
|
3
|
+
function wait(ms, signal) {
|
|
4
|
+
if (ms <= 0)
|
|
5
|
+
return Promise.resolve();
|
|
6
|
+
return new Promise((done, failed) => {
|
|
7
|
+
const timer = setTimeout(done, ms);
|
|
8
|
+
signal?.addEventListener("abort", () => {
|
|
9
|
+
clearTimeout(timer);
|
|
10
|
+
failed(signal.reason);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export function memoryTransport(tables, options = {}) {
|
|
15
|
+
const answered = new Map();
|
|
16
|
+
async function check(route, signal) {
|
|
17
|
+
await wait(options.latency ?? 0, signal);
|
|
18
|
+
const refused = options.refuse?.(route);
|
|
19
|
+
if (refused !== undefined)
|
|
20
|
+
throw new TransportError(route, refused.status ?? 400, refused);
|
|
21
|
+
}
|
|
22
|
+
function tableAt(route) {
|
|
23
|
+
const path = route.split("?")[0] ?? route;
|
|
24
|
+
const table = tables[path];
|
|
25
|
+
if (table === undefined)
|
|
26
|
+
throw new TransportError(route, 404, {
|
|
27
|
+
kind: "not_found",
|
|
28
|
+
reason: "ROUTE_NOT_FOUND",
|
|
29
|
+
next_step: "fix_request",
|
|
30
|
+
retryable: false,
|
|
31
|
+
errors: [],
|
|
32
|
+
extensions: {},
|
|
33
|
+
detail: `No table answers ${path}.`,
|
|
34
|
+
});
|
|
35
|
+
return table;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
async list(route, criteria, signal) {
|
|
39
|
+
options.observe?.(route, criteria);
|
|
40
|
+
await check(route, signal);
|
|
41
|
+
const table = tableAt(route);
|
|
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);
|
|
48
|
+
// A copy, as over a wire: a screen holding a row must not see the server change it.
|
|
49
|
+
return { ...page, rows: structuredClone(page.rows) };
|
|
50
|
+
},
|
|
51
|
+
async one(route, signal) {
|
|
52
|
+
await check(route, signal);
|
|
53
|
+
const slash = route.lastIndexOf("/");
|
|
54
|
+
const table = tableAt(route.slice(0, slash));
|
|
55
|
+
const id = decodeURIComponent(route.slice(slash + 1));
|
|
56
|
+
const key = table.meta.get_id || table.meta.identity;
|
|
57
|
+
const record = table.rows.find((row) => String(row[key]) === id);
|
|
58
|
+
if (record === undefined)
|
|
59
|
+
throw new TransportError(route, 404, {
|
|
60
|
+
kind: "not_found",
|
|
61
|
+
reason: "AGGREGATE_NOT_FOUND",
|
|
62
|
+
next_step: "do_not_retry",
|
|
63
|
+
retryable: false,
|
|
64
|
+
errors: [],
|
|
65
|
+
extensions: {},
|
|
66
|
+
detail: `${table.meta.aggregate} ${id} does not exist.`,
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
record: structuredClone(record),
|
|
70
|
+
meta: table.meta,
|
|
71
|
+
dropped: [],
|
|
72
|
+
draft: options.draft?.(route) ?? null,
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
async send(method, route, body, signal, sending) {
|
|
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));
|
|
82
|
+
if (options.handle === undefined)
|
|
83
|
+
throw new TransportError(route, 404, {
|
|
84
|
+
kind: "not_found",
|
|
85
|
+
reason: "ROUTE_NOT_FOUND",
|
|
86
|
+
next_step: "fix_request",
|
|
87
|
+
retryable: false,
|
|
88
|
+
errors: [],
|
|
89
|
+
extensions: {},
|
|
90
|
+
detail: `Nothing answers ${method} ${route}.`,
|
|
91
|
+
});
|
|
92
|
+
// A deep copy: what the caller sent is not what the server keeps.
|
|
93
|
+
const answer = await options.handle(method, route, structuredClone(body));
|
|
94
|
+
if (key !== undefined)
|
|
95
|
+
answered.set(key, structuredClone(answer));
|
|
96
|
+
return answer;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Expression, FieldMeta, FieldType, Meta, Operator } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* A model definition written by hand, in the shape sincpro_framework publishes.
|
|
4
|
+
*
|
|
5
|
+
* Context: everything in this package reads a `Meta` — the labels, the columns, the controls,
|
|
6
|
+
* the search box. A service built with sincpro_framework sends one with every answer. A domain
|
|
7
|
+
* that has none (another backend, rows a screen already holds, a prototype
|
|
8
|
+
* with no server yet) declares it here, and from then on it is indistinguishable: the same
|
|
9
|
+
* list, the same record, the same criteria. `describeRows` from `@sincpro/criteria` guesses
|
|
10
|
+
* one from rows; this one is written on purpose, with the words a person reads.
|
|
11
|
+
*
|
|
12
|
+
* const tickets = defineMeta({
|
|
13
|
+
* aggregate: "Ticket", identity: "id", display: "title", search: ["title"],
|
|
14
|
+
* name: { es: "Tickets" },
|
|
15
|
+
* fields: {
|
|
16
|
+
* id: field.text({ readonly: true }),
|
|
17
|
+
* title: field.text({ label: { es: "Título" }, required: true }),
|
|
18
|
+
* stage: field.choice(["new", "done"], { labels: { new: { es: "Nuevo" } } }),
|
|
19
|
+
* opened_at: field.datetime({ label: { es: "Abierto" } }),
|
|
20
|
+
* },
|
|
21
|
+
* });
|
|
22
|
+
*/
|
|
23
|
+
/** A word in several languages, `{default?, es?, en?, …}`; a bare string is the default. */
|
|
24
|
+
export type Words = string | Readonly<Record<string, string>>;
|
|
25
|
+
export type FieldOptions = {
|
|
26
|
+
label?: Words;
|
|
27
|
+
help?: Words;
|
|
28
|
+
required?: boolean;
|
|
29
|
+
readonly?: boolean;
|
|
30
|
+
/** Holds no value sometimes. A nullable field is never sortable: keyset paging over it
|
|
31
|
+
* loses the rows that hold NULL, which is why the framework refuses to order by one. */
|
|
32
|
+
nullable?: boolean;
|
|
33
|
+
default?: unknown;
|
|
34
|
+
/** Form hints over the record, as sincpro_framework's `Presentation` publishes them. */
|
|
35
|
+
readonlyWhen?: Expression;
|
|
36
|
+
requiredWhen?: Expression;
|
|
37
|
+
visibleWhen?: Expression;
|
|
38
|
+
};
|
|
39
|
+
/** The field builders: one per type the framework publishes. */
|
|
40
|
+
export declare const field: {
|
|
41
|
+
text: (options?: FieldOptions) => FieldMeta;
|
|
42
|
+
integer: (options?: FieldOptions) => FieldMeta;
|
|
43
|
+
/** A decimal travels as text so no digit is lost (`"10.50"`): pass `exact: true`. */
|
|
44
|
+
number: (options?: FieldOptions & {
|
|
45
|
+
exact?: boolean;
|
|
46
|
+
}) => {
|
|
47
|
+
exact?: boolean;
|
|
48
|
+
type: FieldType;
|
|
49
|
+
nullable: boolean;
|
|
50
|
+
choices?: unknown[];
|
|
51
|
+
choice_labels?: Record<string, Record<string, string>>;
|
|
52
|
+
label?: Record<string, string>;
|
|
53
|
+
help?: Record<string, string>;
|
|
54
|
+
groupable?: boolean;
|
|
55
|
+
aggregates?: string[];
|
|
56
|
+
computed?: boolean;
|
|
57
|
+
owner?: string | null;
|
|
58
|
+
readonly?: boolean;
|
|
59
|
+
required?: boolean;
|
|
60
|
+
default?: unknown;
|
|
61
|
+
readonly_when?: Expression | null;
|
|
62
|
+
required_when?: Expression | null;
|
|
63
|
+
visible_when?: Expression | null;
|
|
64
|
+
sortable: boolean;
|
|
65
|
+
ops: Operator[];
|
|
66
|
+
many?: boolean;
|
|
67
|
+
relation?: string | null;
|
|
68
|
+
identified_by?: string | null;
|
|
69
|
+
parent_field?: string | null;
|
|
70
|
+
related_field?: string | null;
|
|
71
|
+
definition?: Meta | null;
|
|
72
|
+
kind: import("@sincpro/criteria").FieldKind;
|
|
73
|
+
};
|
|
74
|
+
boolean: (options?: FieldOptions) => FieldMeta;
|
|
75
|
+
date: (options?: FieldOptions) => FieldMeta;
|
|
76
|
+
datetime: (options?: FieldOptions) => FieldMeta;
|
|
77
|
+
tags: (options?: FieldOptions) => FieldMeta;
|
|
78
|
+
/** An enum: the values in order, and the word each one is read as. */
|
|
79
|
+
choice: (values: readonly string[], options?: FieldOptions & {
|
|
80
|
+
labels?: Readonly<Record<string, Words>>;
|
|
81
|
+
}) => FieldMeta;
|
|
82
|
+
/** Points at ONE record of another aggregate, by the scalar key this record holds. */
|
|
83
|
+
many2one: (relation: string, keys: {
|
|
84
|
+
identifiedBy: string;
|
|
85
|
+
definition?: Meta;
|
|
86
|
+
}, options?: FieldOptions) => FieldMeta;
|
|
87
|
+
/** The records of another aggregate that point back here, by THEIR key. */
|
|
88
|
+
one2many: (relation: string, keys: {
|
|
89
|
+
identifiedBy: string;
|
|
90
|
+
definition?: Meta;
|
|
91
|
+
}, options?: FieldOptions) => FieldMeta;
|
|
92
|
+
many2many: (relation: string, keys: {
|
|
93
|
+
identifiedBy: string;
|
|
94
|
+
definition?: Meta;
|
|
95
|
+
}, options?: FieldOptions) => FieldMeta;
|
|
96
|
+
/** A value object kept inside the record — an address, a specification. No identity. */
|
|
97
|
+
embedded: (definition: Meta, options?: FieldOptions & {
|
|
98
|
+
many?: boolean;
|
|
99
|
+
}) => FieldMeta;
|
|
100
|
+
};
|
|
101
|
+
export type MetaSpec = {
|
|
102
|
+
aggregate: string;
|
|
103
|
+
identity: string;
|
|
104
|
+
fields: Record<string, FieldMeta>;
|
|
105
|
+
name?: Words;
|
|
106
|
+
/** The field shown beside the identity: what a select and a many2one show. */
|
|
107
|
+
display?: string;
|
|
108
|
+
/** `"-created_at"`, the way a URL writes it. Without it, the identity ascending. */
|
|
109
|
+
defaultOrder?: string;
|
|
110
|
+
/** The text fields a typed text is looked for in — the entity's search template. */
|
|
111
|
+
search?: readonly string[];
|
|
112
|
+
};
|
|
113
|
+
/** A whole definition, from its fields. See the file header. */
|
|
114
|
+
export declare function defineMeta(spec: MetaSpec): Meta;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
function hints(options) {
|
|
2
|
+
return {
|
|
3
|
+
...(options.readonlyWhen ? { readonly_when: options.readonlyWhen } : {}),
|
|
4
|
+
...(options.requiredWhen ? { required_when: options.requiredWhen } : {}),
|
|
5
|
+
...(options.visibleWhen ? { visible_when: options.visibleWhen } : {}),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
/** The comparisons each type takes — the same table sincpro_framework publishes. */
|
|
9
|
+
const OPS = {
|
|
10
|
+
text: ["=", "!=", "like", "starts with", "in", "not in"],
|
|
11
|
+
integer: ["=", "!=", ">", ">=", "<", "<=", "between", "in", "not in"],
|
|
12
|
+
number: ["=", "!=", ">", ">=", "<", "<=", "between", "in", "not in"],
|
|
13
|
+
date: ["=", "!=", ">", ">=", "<", "<=", "between"],
|
|
14
|
+
datetime: ["=", "!=", ">", ">=", "<", "<=", "between"],
|
|
15
|
+
boolean: ["="],
|
|
16
|
+
"text[]": ["contains", "not contains", "=", "!="],
|
|
17
|
+
};
|
|
18
|
+
function words(value) {
|
|
19
|
+
if (value === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
return typeof value === "string" ? { default: value } : { ...value };
|
|
22
|
+
}
|
|
23
|
+
function scalar(type, options = {}) {
|
|
24
|
+
const nullable = options.nullable ?? false;
|
|
25
|
+
const ops = [...(OPS[type] ?? [])];
|
|
26
|
+
if (nullable && type !== "text[]" && type !== "boolean")
|
|
27
|
+
ops.push("is null");
|
|
28
|
+
const listed = type === "text[]";
|
|
29
|
+
return {
|
|
30
|
+
type,
|
|
31
|
+
kind: "scalar",
|
|
32
|
+
nullable,
|
|
33
|
+
sortable: !nullable && !listed,
|
|
34
|
+
groupable: !listed,
|
|
35
|
+
ops,
|
|
36
|
+
...(options.label === undefined ? {} : { label: words(options.label) }),
|
|
37
|
+
...(options.help === undefined ? {} : { help: words(options.help) }),
|
|
38
|
+
...(options.required === undefined ? {} : { required: options.required }),
|
|
39
|
+
...(options.readonly === undefined ? {} : { readonly: options.readonly }),
|
|
40
|
+
...(options.default === undefined ? {} : { default: options.default }),
|
|
41
|
+
...hints(options),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function relational(type, relation, keys, options = {}) {
|
|
45
|
+
return {
|
|
46
|
+
type,
|
|
47
|
+
kind: "relation",
|
|
48
|
+
nullable: options.nullable ?? type === "many2one",
|
|
49
|
+
sortable: false,
|
|
50
|
+
ops: [],
|
|
51
|
+
many: type !== "many2one",
|
|
52
|
+
relation,
|
|
53
|
+
identified_by: keys.identifiedBy,
|
|
54
|
+
...(keys.definition === undefined ? {} : { definition: keys.definition }),
|
|
55
|
+
...(options.label === undefined ? {} : { label: words(options.label) }),
|
|
56
|
+
...(options.help === undefined ? {} : { help: words(options.help) }),
|
|
57
|
+
...(options.readonly === undefined ? {} : { readonly: options.readonly }),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** The field builders: one per type the framework publishes. */
|
|
61
|
+
export const field = {
|
|
62
|
+
text: (options) => scalar("text", options),
|
|
63
|
+
integer: (options) => scalar("integer", options),
|
|
64
|
+
/** A decimal travels as text so no digit is lost (`"10.50"`): pass `exact: true`. */
|
|
65
|
+
number: (options) => ({
|
|
66
|
+
...scalar("number", options),
|
|
67
|
+
...(options?.exact ? { exact: true } : {}),
|
|
68
|
+
}),
|
|
69
|
+
boolean: (options) => scalar("boolean", options),
|
|
70
|
+
date: (options) => scalar("date", options),
|
|
71
|
+
datetime: (options) => scalar("datetime", options),
|
|
72
|
+
tags: (options) => scalar("text[]", options),
|
|
73
|
+
/** An enum: the values in order, and the word each one is read as. */
|
|
74
|
+
choice: (values, options = {}) => ({
|
|
75
|
+
...scalar("text", options),
|
|
76
|
+
ops: ["=", "!=", "in", "not in", ...(options.nullable ? ["is null"] : [])],
|
|
77
|
+
choices: [...values],
|
|
78
|
+
...(options.labels === undefined
|
|
79
|
+
? {}
|
|
80
|
+
: {
|
|
81
|
+
choice_labels: Object.fromEntries(Object.entries(options.labels).map(([value, said]) => [value, words(said) ?? {}])),
|
|
82
|
+
}),
|
|
83
|
+
}),
|
|
84
|
+
/** Points at ONE record of another aggregate, by the scalar key this record holds. */
|
|
85
|
+
many2one: (relation, keys, options) => relational("many2one", relation, keys, options),
|
|
86
|
+
/** The records of another aggregate that point back here, by THEIR key. */
|
|
87
|
+
one2many: (relation, keys, options) => relational("one2many", relation, keys, options),
|
|
88
|
+
many2many: (relation, keys, options) => relational("many2many", relation, keys, options),
|
|
89
|
+
/** A value object kept inside the record — an address, a specification. No identity. */
|
|
90
|
+
embedded: (definition, options = {}) => ({
|
|
91
|
+
type: "embedded",
|
|
92
|
+
kind: "embedded",
|
|
93
|
+
nullable: options.nullable ?? false,
|
|
94
|
+
sortable: false,
|
|
95
|
+
ops: [],
|
|
96
|
+
definition,
|
|
97
|
+
...(options.many ? { many: true } : {}),
|
|
98
|
+
...(options.label === undefined ? {} : { label: words(options.label) }),
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
/** The template a typed text fills: `like` over each field, any of them matching. */
|
|
102
|
+
function searchTemplate(fields) {
|
|
103
|
+
if (fields.length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
const like = (name) => ({
|
|
106
|
+
field: name,
|
|
107
|
+
operator: "like",
|
|
108
|
+
value: { $: "param", name: "text" },
|
|
109
|
+
});
|
|
110
|
+
const [only] = fields;
|
|
111
|
+
return {
|
|
112
|
+
where: fields.length === 1 && only !== undefined ? like(only) : { any: fields.map(like) },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** A whole definition, from its fields. See the file header. */
|
|
116
|
+
export function defineMeta(spec) {
|
|
117
|
+
const missing = [spec.identity, spec.display, ...(spec.search ?? [])].filter((name) => name !== undefined && name !== "" && !(name in spec.fields));
|
|
118
|
+
if (missing.length > 0)
|
|
119
|
+
throw new Error(`${spec.aggregate}: ${missing.join(", ")} named but not declared`);
|
|
120
|
+
return {
|
|
121
|
+
aggregate: spec.aggregate,
|
|
122
|
+
identity: spec.identity,
|
|
123
|
+
default_order: spec.defaultOrder ?? spec.identity,
|
|
124
|
+
fields: spec.fields,
|
|
125
|
+
get_id: spec.identity,
|
|
126
|
+
...(spec.name === undefined ? {} : { name: words(spec.name) }),
|
|
127
|
+
...(spec.display === undefined ? {} : { display: spec.display }),
|
|
128
|
+
search: searchTemplate(spec.search ?? []),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Meta } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* The model definitions already known, by entity key (`"billing:invoice"`).
|
|
4
|
+
*
|
|
5
|
+
* Context: sincpro_framework sends `entity_meta_data` with every answer that does not say
|
|
6
|
+
* `meta: false`, and it is not small — forge's `Project` weighs ~97 KB even with zero rows.
|
|
7
|
+
* A reading already asks for it only on its first page; this keeps it ACROSS readings, so the
|
|
8
|
+
* second list of the same entity, the record opened from it and the select that points at it
|
|
9
|
+
* ask with `meta: false` and draw their labels before the rows arrive.
|
|
10
|
+
*
|
|
11
|
+
* Seeded, it is the preload: definitions written at build time (a snapshot of the server's,
|
|
12
|
+
* or `defineMeta`) reach the first screen with no request at all.
|
|
13
|
+
*/
|
|
14
|
+
export type MetaStore = {
|
|
15
|
+
get(key: string): Meta | undefined;
|
|
16
|
+
set(key: string, meta: Meta): void;
|
|
17
|
+
};
|
|
18
|
+
export declare function metaStore(seed?: Readonly<Record<string, Meta>>): MetaStore;
|
|
@@ -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;
|