@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,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>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { resumingFrom } from "@sincpro/criteria";
|
|
2
|
+
/** Where a record stands in its trail: «3 of 64», 1-based. */
|
|
3
|
+
export function placeOf(trail, id) {
|
|
4
|
+
const at = trail.ids.indexOf(id);
|
|
5
|
+
if (at < 0)
|
|
6
|
+
return null;
|
|
7
|
+
const index = trail.offset + at + 1;
|
|
8
|
+
return { index, total: Math.max(trail.total ?? 0, trail.offset + trail.ids.length, index) };
|
|
9
|
+
}
|
|
10
|
+
async function page(transport, trail, cursor) {
|
|
11
|
+
const sized = {
|
|
12
|
+
...trail.question,
|
|
13
|
+
pagination: { ...trail.question.pagination, limit: trail.size },
|
|
14
|
+
};
|
|
15
|
+
const answer = await transport.list(trail.route, {
|
|
16
|
+
...resumingFrom(sized, cursor),
|
|
17
|
+
meta: false,
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
ids: answer.rows.map((row) => String(row[trail.key] ?? "")),
|
|
21
|
+
after: answer.cursor,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The record next to `id` (`step` 1) or before it (`step` -1), with the trail moved to its page
|
|
26
|
+
* when it lies on the neighbouring one. `null` at either end of the set.
|
|
27
|
+
*/
|
|
28
|
+
export async function stepIn(transport, trail, id, step) {
|
|
29
|
+
const at = trail.ids.indexOf(id);
|
|
30
|
+
if (at < 0)
|
|
31
|
+
return null;
|
|
32
|
+
const there = trail.ids[at + step];
|
|
33
|
+
if (there !== undefined)
|
|
34
|
+
return { id: there, trail };
|
|
35
|
+
if (step === 1) {
|
|
36
|
+
if (trail.after === null)
|
|
37
|
+
return null;
|
|
38
|
+
const next = await page(transport, trail, trail.after);
|
|
39
|
+
const [first] = next.ids;
|
|
40
|
+
if (first === undefined)
|
|
41
|
+
return null;
|
|
42
|
+
return {
|
|
43
|
+
id: first,
|
|
44
|
+
trail: {
|
|
45
|
+
...trail,
|
|
46
|
+
ids: next.ids,
|
|
47
|
+
offset: trail.offset + trail.ids.length,
|
|
48
|
+
cursors: [...trail.cursors, trail.after],
|
|
49
|
+
after: next.after,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (trail.cursors.length < 2)
|
|
54
|
+
return null;
|
|
55
|
+
const cursors = trail.cursors.slice(0, -1);
|
|
56
|
+
const before = await page(transport, trail, cursors[cursors.length - 1] ?? null);
|
|
57
|
+
const last = before.ids[before.ids.length - 1];
|
|
58
|
+
if (last === undefined)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
id: last,
|
|
62
|
+
trail: {
|
|
63
|
+
...trail,
|
|
64
|
+
ids: before.ids,
|
|
65
|
+
offset: Math.max(0, trail.offset - before.ids.length),
|
|
66
|
+
cursors,
|
|
67
|
+
after: trail.cursors[trail.cursors.length - 1] ?? null,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
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, options?: SendOptions): Promise<T>;
|
|
21
|
+
}
|
|
22
|
+
export type Method = "POST" | "PUT" | "PATCH" | "DELETE";
|
|
23
|
+
/**
|
|
24
|
+
* How a Command travels besides its body.
|
|
25
|
+
*
|
|
26
|
+
* Context: sincpro_framework runs a Command declared `@idempotency` once per key (REST: the
|
|
27
|
+
* `Idempotency-Key` header; 400 `IDEMPOTENCY_KEY_MISSING` without it) and answers a repeat with
|
|
28
|
+
* the first answer. That is what makes «retry» safe after a request that got no answer: the
|
|
29
|
+
* server may have created the invoice, and the same key cannot create it twice.
|
|
30
|
+
*/
|
|
31
|
+
export type SendOptions = {
|
|
32
|
+
/** One per gesture, kept while it is retried — `retryKeys()`. */
|
|
33
|
+
idempotencyKey?: string;
|
|
34
|
+
};
|
|
35
|
+
/** One record as sincpro_framework answers it (`ResponseRecord`): the record, its model. */
|
|
36
|
+
export type One<T> = {
|
|
37
|
+
record: T;
|
|
38
|
+
meta: Meta | null;
|
|
39
|
+
dropped: Dropped[];
|
|
40
|
+
/** What this caller kept of an edit between requests (sincpro_framework `Drafts`). */
|
|
41
|
+
draft: Draft | null;
|
|
42
|
+
};
|
|
43
|
+
/** A kept edit: the values typed, the version of the draft, the record version it started on. */
|
|
44
|
+
export type Draft = {
|
|
45
|
+
values: Record<string, unknown>;
|
|
46
|
+
version: number;
|
|
47
|
+
origin_version?: number;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* A refusal, from any wire. `problem` is what the server said about it (RFC 9457 plus the
|
|
51
|
+
* engine's `kind`, `next_step`, `guidance`) or `null` when it said nothing readable — a proxy's
|
|
52
|
+
* HTML page. `status` is `null` when the request never got an answer.
|
|
53
|
+
*/
|
|
54
|
+
export declare class TransportError extends Error {
|
|
55
|
+
readonly route: string;
|
|
56
|
+
readonly status: number | null;
|
|
57
|
+
readonly problem: Problem | null;
|
|
58
|
+
constructor(route: string, status: number | null, problem: Problem | null, message?: string);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* What a person reads about a failure: a heading, and a sentence with what happened and what
|
|
62
|
+
* to do. The server's own words first; the copy table's when it sent none.
|
|
63
|
+
*
|
|
64
|
+
* explain(new TransportError("/x", 403, { kind: "permission_denied", guidance: "Pedí acceso.", … }))
|
|
65
|
+
* -> { heading: "No tenés acceso", sentence: "Pedí acceso.", retryable: false }
|
|
66
|
+
*/
|
|
67
|
+
export declare function explain(error: unknown): {
|
|
68
|
+
heading: string;
|
|
69
|
+
sentence: string;
|
|
70
|
+
retryable: boolean;
|
|
71
|
+
};
|
|
@@ -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,38 @@
|
|
|
1
|
+
import type { Meta } from "@sincpro/criteria";
|
|
2
|
+
import type { Entity } from "../core/entity.js";
|
|
3
|
+
import { type MetaStore } from "../core/metas.js";
|
|
4
|
+
import type { Trail } from "../core/trail.js";
|
|
5
|
+
import type { Transport } from "../core/transport.js";
|
|
6
|
+
import { type ReactNode } from "react";
|
|
7
|
+
/**
|
|
8
|
+
* Who answers, in which language a screen reads, and which model definitions are already
|
|
9
|
+
* known — for everything under it.
|
|
10
|
+
*
|
|
11
|
+
* Context: a view receives its record reference and nothing else — the composer hands it no
|
|
12
|
+
* client on purpose — so the transport reaches it through context, set once where the
|
|
13
|
+
* application starts.
|
|
14
|
+
*/
|
|
15
|
+
type FrameworkContextValue = {
|
|
16
|
+
transport: Transport;
|
|
17
|
+
locale: string | undefined;
|
|
18
|
+
metas: MetaStore;
|
|
19
|
+
/** The application's entities: what a relation of a model is resolved to. */
|
|
20
|
+
entities: readonly Entity[];
|
|
21
|
+
/** Where each open record came from (`Trail`), by the key its reference carries. */
|
|
22
|
+
trails: Map<string, Trail>;
|
|
23
|
+
};
|
|
24
|
+
export declare function FrameworkProvider({ transport, locale, metas: seed, entities, children, }: {
|
|
25
|
+
transport: Transport;
|
|
26
|
+
/** The language labels and values are read in (`"es"`); the model's `default` without it. */
|
|
27
|
+
locale?: string;
|
|
28
|
+
/** Definitions known before any request, by entity key: the preload. */
|
|
29
|
+
metas?: Readonly<Record<string, Meta>>;
|
|
30
|
+
/** The entities of the application: a form draws a many2one as a picker of the one its
|
|
31
|
+
* model points at (`relation: "Cliente"`) without being told. */
|
|
32
|
+
entities?: readonly Entity[];
|
|
33
|
+
children: ReactNode;
|
|
34
|
+
}): import("react").JSX.Element;
|
|
35
|
+
export declare function useFramework(): FrameworkContextValue;
|
|
36
|
+
/** The declared entity a relation points at (`"Cliente"`), or `undefined`. */
|
|
37
|
+
export declare function useEntityFor(target: string | undefined): Entity | undefined;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { metaStore } from "../core/metas.js";
|
|
4
|
+
import { entityFor } from "../core/relation.js";
|
|
5
|
+
import { createContext, useContext, useMemo, useState } from "react";
|
|
6
|
+
const FrameworkContext = createContext(null);
|
|
7
|
+
export function FrameworkProvider({ transport, locale, metas: seed, entities = NO_ENTITIES, children, }) {
|
|
8
|
+
// One store for the provider's life: a new seed object on a re-render must not forget what
|
|
9
|
+
// the screens already learned.
|
|
10
|
+
const [metas] = useState(() => metaStore(seed));
|
|
11
|
+
const [trails] = useState(() => new Map());
|
|
12
|
+
const value = useMemo(() => ({ transport, locale, metas, entities, trails }), [transport, locale, metas, entities, trails]);
|
|
13
|
+
return _jsx(FrameworkContext.Provider, { value: value, children: children });
|
|
14
|
+
}
|
|
15
|
+
const NO_ENTITIES = [];
|
|
16
|
+
export function useFramework() {
|
|
17
|
+
const value = useContext(FrameworkContext);
|
|
18
|
+
if (value === null)
|
|
19
|
+
throw new Error("useFramework outside a FrameworkProvider");
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
/** The declared entity a relation points at (`"Cliente"`), or `undefined`. */
|
|
23
|
+
export function useEntityFor(target) {
|
|
24
|
+
const { entities, metas } = useFramework();
|
|
25
|
+
return target === undefined ? undefined : entityFor(entities, target, metas.get);
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The state of a list and a record and the gestures that drive them. It draws nothing — no
|
|
3
|
+
* DOM, no design system — so a screen can drop `EntityList` and still keep it. `make doctor`
|
|
4
|
+
* keeps it that way.
|
|
5
|
+
*/
|
|
6
|
+
export { FrameworkProvider, useEntityFor, useFramework, } from "./context.js";
|
|
7
|
+
export { type CommandState, type Rehearsal, useCommand, } from "./useCommand.js";
|
|
8
|
+
export { type Groups, useGroups } from "./useGroups.js";
|
|
9
|
+
export { useHistory } from "./useHistory.js";
|
|
10
|
+
export { type Listing, type ListingOptions, type Paging, useListing, } from "./useListing.js";
|
|
11
|
+
export { type Lookup, useLookup } from "./useLookup.js";
|
|
12
|
+
export { type Advice, type Editing, type RecordReading, type RecordState, useRecord, } from "./useRecord.js";
|
|
13
|
+
export { useRelated } from "./useRelated.js";
|
|
14
|
+
export { useSummaryOf } from "./useSummary.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The state of a list and a record and the gestures that drive them. It draws nothing — no
|
|
3
|
+
* DOM, no design system — so a screen can drop `EntityList` and still keep it. `make doctor`
|
|
4
|
+
* keeps it that way.
|
|
5
|
+
*/
|
|
6
|
+
export { FrameworkProvider, useEntityFor, useFramework, } from "./context.js";
|
|
7
|
+
export { useCommand, } from "./useCommand.js";
|
|
8
|
+
export { useGroups } from "./useGroups.js";
|
|
9
|
+
export { useHistory } from "./useHistory.js";
|
|
10
|
+
export { useListing, } from "./useListing.js";
|
|
11
|
+
export { useLookup } from "./useLookup.js";
|
|
12
|
+
export { useRecord, } from "./useRecord.js";
|
|
13
|
+
export { useRelated } from "./useRelated.js";
|
|
14
|
+
export { useSummaryOf } from "./useSummary.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AnyRecord } from "@sincpro/criteria";
|
|
2
|
+
import { type Entity } from "../core/entity.js";
|
|
3
|
+
/**
|
|
4
|
+
* One Command of an entity: run it, or ask first what it would do.
|
|
5
|
+
*
|
|
6
|
+
* Context: sincpro_framework runs a Command dry when it carries `dry_run: true` (a
|
|
7
|
+
* `DryRunnable`): the handler's `preview` answers what it WOULD do — «provider odoo deletes
|
|
8
|
+
* env-42; its record and history stay» — and nothing is touched. That is the confirmation a
|
|
9
|
+
* destructive action deserves: not «are you sure?», but what is about to happen. forge's
|
|
10
|
+
* console had the backend for it and no screen that asked.
|
|
11
|
+
*
|
|
12
|
+
* One at a time: `busy` turns the button off, because a payment sent twice is two payments. And
|
|
13
|
+
* once sent, the same gesture keeps its `Idempotency-Key` until the server answers: a retry
|
|
14
|
+
* after a dropped request is the same payment, never a second one (`retryKeys`).
|
|
15
|
+
*/
|
|
16
|
+
export type Rehearsal = {
|
|
17
|
+
text: string;
|
|
18
|
+
data: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
export type CommandState<R> = {
|
|
21
|
+
run: (id?: string, body?: AnyRecord) => Promise<R | undefined>;
|
|
22
|
+
/** The dry run: what it would do, nothing touched. */
|
|
23
|
+
rehearse: (id?: string, body?: AnyRecord) => Promise<Rehearsal | undefined>;
|
|
24
|
+
busy: boolean;
|
|
25
|
+
error: unknown;
|
|
26
|
+
/** The last dry run's answer, until `reset`. */
|
|
27
|
+
rehearsal: Rehearsal | null;
|
|
28
|
+
reset: () => void;
|
|
29
|
+
};
|
|
30
|
+
export declare function useCommand<T extends AnyRecord, R = unknown>(entity: Entity<T>, name: string): CommandState<R>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { pathFor } from "../core/entity.js";
|
|
3
|
+
import { retryKeys } from "../core/retry.js";
|
|
4
|
+
import { useFramework } from "./context.js";
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
export function useCommand(entity, name) {
|
|
7
|
+
const { transport } = useFramework();
|
|
8
|
+
const route = entity.commands[name];
|
|
9
|
+
if (route === undefined)
|
|
10
|
+
throw new Error(`${entity.key} declares no command "${name}"`);
|
|
11
|
+
const [busy, setBusy] = useState(false);
|
|
12
|
+
const [error, setError] = useState(null);
|
|
13
|
+
const [rehearsal, setRehearsal] = useState(null);
|
|
14
|
+
// A Command is never aborted once sent — the server may have done it — but its answer must
|
|
15
|
+
// not land on a screen that left.
|
|
16
|
+
const mounted = useRef(true);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
mounted.current = true;
|
|
19
|
+
return () => void (mounted.current = false);
|
|
20
|
+
}, []);
|
|
21
|
+
// One key per gesture, kept while it goes unanswered: «retry» after a dropped request must
|
|
22
|
+
// not run it twice. A dry run touches nothing, so each one gets a key of its own.
|
|
23
|
+
const keys = useRef(retryKeys());
|
|
24
|
+
const send = useCallback(async (id, body, gesture) => {
|
|
25
|
+
setBusy(true);
|
|
26
|
+
setError(null);
|
|
27
|
+
const idempotencyKey = gesture.keyFor({ id, body });
|
|
28
|
+
try {
|
|
29
|
+
const answer = await transport.send(route.method, pathFor(route, id), body, undefined, { idempotencyKey });
|
|
30
|
+
gesture.settle(null);
|
|
31
|
+
return answer;
|
|
32
|
+
}
|
|
33
|
+
catch (failure) {
|
|
34
|
+
gesture.settle(failure);
|
|
35
|
+
if (mounted.current)
|
|
36
|
+
setError(failure);
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
if (mounted.current)
|
|
41
|
+
setBusy(false);
|
|
42
|
+
}
|
|
43
|
+
}, [transport, route]);
|
|
44
|
+
return {
|
|
45
|
+
run: (id, body = {}) => send(id, body, keys.current),
|
|
46
|
+
rehearse: async (id, body = {}) => {
|
|
47
|
+
const answer = await send(id, { ...body, dry_run: true }, retryKeys());
|
|
48
|
+
if (answer !== undefined && mounted.current)
|
|
49
|
+
setRehearsal(answer);
|
|
50
|
+
return answer;
|
|
51
|
+
},
|
|
52
|
+
busy,
|
|
53
|
+
error,
|
|
54
|
+
rehearsal,
|
|
55
|
+
reset: () => {
|
|
56
|
+
setRehearsal(null);
|
|
57
|
+
setError(null);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AnyRecord, Bucket } from "@sincpro/criteria";
|
|
2
|
+
import type { GroupLevel } from "../core/list.js";
|
|
3
|
+
import type { Listing } from "./useListing.js";
|
|
4
|
+
/**
|
|
5
|
+
* The set of a list, split into groups — counted by the SERVER over every row that matches.
|
|
6
|
+
*
|
|
7
|
+
* Context: grouping is the same question as filtering («which part of this am I looking at»),
|
|
8
|
+
* and it obeys the same rule: never on the page. Twelve rows on screen cannot say «187 paid»;
|
|
9
|
+
* whoever keeps the set can. So the listing's own question (scope, search, filter) goes to the
|
|
10
|
+
* server with `group_by`, and what comes back is the groups: their value, their real count, the
|
|
11
|
+
* level below, and the criteria that opens each one (sincpro_framework's aggregation; the
|
|
12
|
+
* in-memory server answers the same with criteria's engine). A group's rows are read when it is
|
|
13
|
+
* opened, with that criteria — nobody rebuilds a filter here.
|
|
14
|
+
*
|
|
15
|
+
* const groups = useGroups(listing);
|
|
16
|
+
* groups.group([{ field: "state" }, { field: "invoice_date", grain: "month" }]);
|
|
17
|
+
* groups.buckets // [{ field: "state", value: "paid", count: 187, groups: [...] }, ...]
|
|
18
|
+
* groups.open(bucket); groups.rowsOf(bucket) // its first page of rows
|
|
19
|
+
*/
|
|
20
|
+
export type { GroupLevel };
|
|
21
|
+
export type Groups<T> = {
|
|
22
|
+
levels: readonly GroupLevel[];
|
|
23
|
+
/** Split by these levels, outermost first; none is no grouping. */
|
|
24
|
+
group: (levels: readonly GroupLevel[]) => void;
|
|
25
|
+
status: "idle" | "loading" | "ready" | "failed";
|
|
26
|
+
buckets: readonly Bucket<T>[];
|
|
27
|
+
error: unknown;
|
|
28
|
+
/** More groups exist than the ceiling shows: narrow the set instead. */
|
|
29
|
+
capped: boolean;
|
|
30
|
+
/** Read the rows of a group — its first page. */
|
|
31
|
+
open: (bucket: Bucket<T>) => void;
|
|
32
|
+
/** The rows of a group once read; `undefined` while it was never opened or is loading. */
|
|
33
|
+
rowsOf: (bucket: Bucket<T>) => readonly T[] | undefined;
|
|
34
|
+
};
|
|
35
|
+
export declare function useGroups<T extends AnyRecord>(listing: Listing<T>, options?: {
|
|
36
|
+
pageSize?: number;
|
|
37
|
+
/** The fields each group adds up, over every row of it (`detailOf` reads them). Without
|
|
38
|
+
* it, the figures the model computes (`sumsOf`); `[]` adds nothing up. */
|
|
39
|
+
sums?: readonly string[];
|
|
40
|
+
}): Groups<T>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { measuresOf, sumsOf } from "../core/measure.js";
|
|
3
|
+
import { useFramework } from "./context.js";
|
|
4
|
+
import { useCallback, useEffect, useState } from "react";
|
|
5
|
+
/** The most groups a level answers: a split into ten thousand groups is a list, not a split. */
|
|
6
|
+
const MAX_GROUPS = 100;
|
|
7
|
+
/** A group is known by its criteria: unique per group, and stable across renders. */
|
|
8
|
+
const keyOf = (bucket) => JSON.stringify(bucket.criteria);
|
|
9
|
+
export function useGroups(listing, options = {}) {
|
|
10
|
+
const { transport } = useFramework();
|
|
11
|
+
// A named list may open grouped (`groupBy`); a person regroups from there.
|
|
12
|
+
const [levels, setLevels] = useState(() => listing.definition?.groupBy ?? []);
|
|
13
|
+
const [state, setState] = useState({ status: "idle", buckets: [], error: null });
|
|
14
|
+
const [rows, setRows] = useState(new Map());
|
|
15
|
+
const pageSize = options.pageSize ?? 50;
|
|
16
|
+
// The question without what pages it: a cursor or a limit of rows has no meaning for groups.
|
|
17
|
+
const { pagination: _page, ...asked } = listing.question;
|
|
18
|
+
const question = JSON.stringify(asked);
|
|
19
|
+
const route = listing.route;
|
|
20
|
+
const sums = (options.sums ?? sumsOf(listing.meta)).join(",");
|
|
21
|
+
// What adds up by default is read from the model: without it yet, asking now and again when
|
|
22
|
+
// it arrives would be two questions — and the groups on screen replaced under the pointer.
|
|
23
|
+
const waiting = options.sums === undefined && listing.meta === null;
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
setRows(new Map());
|
|
26
|
+
if (levels.length === 0) {
|
|
27
|
+
setState({ status: "idle", buckets: [], error: null });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const aborter = new AbortController();
|
|
31
|
+
setState((now) => ({ ...now, status: "loading", error: null }));
|
|
32
|
+
if (waiting)
|
|
33
|
+
return () => aborter.abort();
|
|
34
|
+
const criteria = {
|
|
35
|
+
...JSON.parse(question),
|
|
36
|
+
meta: false,
|
|
37
|
+
grouping: {
|
|
38
|
+
group_by: levels.map((level) => ({ ...level })),
|
|
39
|
+
...(sums === "" ? {} : { measures: measuresOf(sums.split(",")) }),
|
|
40
|
+
pagination: { limit: MAX_GROUPS },
|
|
41
|
+
},
|
|
42
|
+
// One id per group is the least a grouped answer carries; the rows come on opening.
|
|
43
|
+
pagination: { limit: 1 },
|
|
44
|
+
};
|
|
45
|
+
transport.list(route, criteria, aborter.signal).then((page) => {
|
|
46
|
+
if (!aborter.signal.aborted)
|
|
47
|
+
setState({ status: "ready", buckets: page.rows, error: null });
|
|
48
|
+
}, (error) => {
|
|
49
|
+
if (!aborter.signal.aborted)
|
|
50
|
+
setState({ status: "failed", buckets: [], error });
|
|
51
|
+
});
|
|
52
|
+
return () => aborter.abort();
|
|
53
|
+
}, [transport, route, question, levels, sums, waiting]);
|
|
54
|
+
const open = useCallback((bucket) => {
|
|
55
|
+
const key = keyOf(bucket);
|
|
56
|
+
const { grouping: _grouping, pagination: _pagination, ...criteria } = bucket.criteria;
|
|
57
|
+
void transport
|
|
58
|
+
.list(route, { ...criteria, meta: false, pagination: { limit: pageSize } })
|
|
59
|
+
.then((page) => setRows((now) => new Map(now).set(key, page.rows)), (error) => setState((now) => ({ ...now, error })));
|
|
60
|
+
}, [transport, route, pageSize]);
|
|
61
|
+
return {
|
|
62
|
+
levels,
|
|
63
|
+
group: setLevels,
|
|
64
|
+
...state,
|
|
65
|
+
capped: state.buckets.length >= MAX_GROUPS,
|
|
66
|
+
open,
|
|
67
|
+
rowsOf: (bucket) => rows.get(keyOf(bucket)),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AnyRecord } from "@sincpro/criteria";
|
|
2
|
+
import type { Entity } from "../core/entity.js";
|
|
3
|
+
import { type HistoryEntry } from "../core/history.js";
|
|
4
|
+
import { type DependencyList } from "react";
|
|
5
|
+
export declare function useHistory<T extends AnyRecord>(entity: Entity<T>, id: string, deps?: DependencyList): HistoryEntry[] | undefined;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { historyOf } from "../core/history.js";
|
|
3
|
+
import { useFramework } from "./context.js";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
/**
|
|
6
|
+
* A record's business events, newest first — what `Record.Activity` draws.
|
|
7
|
+
*
|
|
8
|
+
* Context: the history of a record is a criteria over its context's events — `entity_type`
|
|
9
|
+
* and `entity_id` — so it is read like any list, with the same transport. `undefined` while it
|
|
10
|
+
* is not read, or when it cannot be: an empty history says «nothing happened», which is a
|
|
11
|
+
* different claim from «you may not see it». Pass the record among `deps` so a write that
|
|
12
|
+
* reloads it reads its new facts too.
|
|
13
|
+
*
|
|
14
|
+
* Named after sincpro_framework's `history_of`; web-ui's `useActivity` is something else (the
|
|
15
|
+
* panel's open state).
|
|
16
|
+
*/
|
|
17
|
+
const SHOWN = 100;
|
|
18
|
+
export function useHistory(entity, id, deps = []) {
|
|
19
|
+
const { transport, locale } = useFramework();
|
|
20
|
+
const [entries, setEntries] = useState(undefined);
|
|
21
|
+
const history = entity.history;
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (history === undefined)
|
|
24
|
+
return;
|
|
25
|
+
const aborter = new AbortController();
|
|
26
|
+
const criteria = {
|
|
27
|
+
where: {
|
|
28
|
+
all: [
|
|
29
|
+
{ field: "entity_type", operator: "=", value: history.type },
|
|
30
|
+
{ field: "entity_id", operator: "=", value: id },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
order: [{ field: "created_at", descending: true }],
|
|
34
|
+
pagination: { limit: SHOWN },
|
|
35
|
+
};
|
|
36
|
+
transport.list(history.route, criteria, aborter.signal).then((page) => aborter.signal.aborted || setEntries(historyOf(page.rows, page.meta, locale)), () => aborter.signal.aborted || setEntries(undefined));
|
|
37
|
+
return () => aborter.abort();
|
|
38
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- the caller names what reloads it
|
|
39
|
+
}, [transport, history, id, locale, ...deps]);
|
|
40
|
+
return entries;
|
|
41
|
+
}
|