@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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +445 -2
  3. package/dist/core/cache.d.ts +30 -0
  4. package/dist/core/cache.js +47 -0
  5. package/dist/core/copy.d.ts +93 -0
  6. package/dist/core/copy.js +141 -0
  7. package/dist/core/edit.d.ts +40 -0
  8. package/dist/core/edit.js +81 -0
  9. package/dist/core/entity.d.ts +113 -0
  10. package/dist/core/entity.js +57 -0
  11. package/dist/core/form.d.ts +21 -0
  12. package/dist/core/form.js +30 -0
  13. package/dist/core/history.d.ts +20 -0
  14. package/dist/core/history.js +40 -0
  15. package/dist/core/index.d.ts +26 -0
  16. package/dist/core/index.js +26 -0
  17. package/dist/core/list.d.ts +64 -0
  18. package/dist/core/list.js +22 -0
  19. package/dist/core/lookup.d.ts +19 -0
  20. package/dist/core/lookup.js +14 -0
  21. package/dist/core/measure.d.ts +16 -0
  22. package/dist/core/measure.js +38 -0
  23. package/dist/core/memory.d.ts +43 -0
  24. package/dist/core/memory.js +99 -0
  25. package/dist/core/meta.d.ts +114 -0
  26. package/dist/core/meta.js +130 -0
  27. package/dist/core/metas.d.ts +18 -0
  28. package/dist/core/metas.js +7 -0
  29. package/dist/core/present.d.ts +48 -0
  30. package/dist/core/present.js +165 -0
  31. package/dist/core/related.d.ts +22 -0
  32. package/dist/core/related.js +21 -0
  33. package/dist/core/relation.d.ts +28 -0
  34. package/dist/core/relation.js +30 -0
  35. package/dist/core/rest.d.ts +30 -0
  36. package/dist/core/rest.js +60 -0
  37. package/dist/core/retry.d.ts +24 -0
  38. package/dist/core/retry.js +25 -0
  39. package/dist/core/schema.d.ts +46 -0
  40. package/dist/core/schema.js +128 -0
  41. package/dist/core/summary.d.ts +50 -0
  42. package/dist/core/summary.js +54 -0
  43. package/dist/core/trail.d.ts +44 -0
  44. package/dist/core/trail.js +70 -0
  45. package/dist/core/transport.d.ts +71 -0
  46. package/dist/core/transport.js +43 -0
  47. package/dist/index.d.ts +10 -0
  48. package/dist/index.js +10 -0
  49. package/dist/react/context.d.ts +38 -0
  50. package/dist/react/context.js +26 -0
  51. package/dist/react/index.d.ts +14 -0
  52. package/dist/react/index.js +14 -0
  53. package/dist/react/useCommand.d.ts +30 -0
  54. package/dist/react/useCommand.js +60 -0
  55. package/dist/react/useGroups.d.ts +40 -0
  56. package/dist/react/useGroups.js +69 -0
  57. package/dist/react/useHistory.d.ts +5 -0
  58. package/dist/react/useHistory.js +41 -0
  59. package/dist/react/useListing.d.ts +93 -0
  60. package/dist/react/useListing.js +156 -0
  61. package/dist/react/useLookup.d.ts +47 -0
  62. package/dist/react/useLookup.js +68 -0
  63. package/dist/react/useRecord.d.ts +89 -0
  64. package/dist/react/useRecord.js +272 -0
  65. package/dist/react/useRelated.d.ts +21 -0
  66. package/dist/react/useRelated.js +32 -0
  67. package/dist/react/useSummary.d.ts +15 -0
  68. package/dist/react/useSummary.js +22 -0
  69. package/dist/web/Refused.d.ts +9 -0
  70. package/dist/web/Refused.js +14 -0
  71. package/dist/web/index.d.ts +23 -0
  72. package/dist/web/index.js +23 -0
  73. package/dist/web/list/EntityList.d.ts +92 -0
  74. package/dist/web/list/EntityList.js +229 -0
  75. package/dist/web/list/EntitySearchBar.d.ts +34 -0
  76. package/dist/web/list/EntitySearchBar.js +73 -0
  77. package/dist/web/list/columns.d.ts +22 -0
  78. package/dist/web/list/columns.js +27 -0
  79. package/dist/web/list/groups.d.ts +15 -0
  80. package/dist/web/list/groups.js +73 -0
  81. package/dist/web/list/rowCommands.d.ts +41 -0
  82. package/dist/web/list/rowCommands.js +56 -0
  83. package/dist/web/list/search.d.ts +21 -0
  84. package/dist/web/list/search.js +72 -0
  85. package/dist/web/record/CommandButton.d.ts +22 -0
  86. package/dist/web/record/CommandButton.js +40 -0
  87. package/dist/web/record/EntityPicker.d.ts +56 -0
  88. package/dist/web/record/EntityPicker.js +73 -0
  89. package/dist/web/record/EntityTags.d.ts +38 -0
  90. package/dist/web/record/EntityTags.js +41 -0
  91. package/dist/web/record/RelatedList.d.ts +19 -0
  92. package/dist/web/record/RelatedList.js +26 -0
  93. package/dist/web/record/edit.d.ts +42 -0
  94. package/dist/web/record/edit.js +122 -0
  95. package/dist/web/record/opening.d.ts +26 -0
  96. package/dist/web/record/opening.js +10 -0
  97. package/dist/web/record/record.d.ts +25 -0
  98. package/dist/web/record/record.js +56 -0
  99. package/dist/web/record/summary.d.ts +22 -0
  100. package/dist/web/record/summary.js +55 -0
  101. package/dist/web/workspace/SincproApp.d.ts +40 -0
  102. package/dist/web/workspace/SincproApp.js +45 -0
  103. package/dist/web/workspace/composer.d.ts +55 -0
  104. package/dist/web/workspace/composer.js +94 -0
  105. package/dist/web/workspace/open.d.ts +61 -0
  106. package/dist/web/workspace/open.js +62 -0
  107. package/dist/web/workspace/record.d.ts +30 -0
  108. package/dist/web/workspace/record.js +80 -0
  109. package/dist/web/workspace/skin.d.ts +2 -0
  110. package/dist/web/workspace/skin.js +45 -0
  111. package/dist/web/workspace/workspace.d.ts +44 -0
  112. package/dist/web/workspace/workspace.js +82 -0
  113. package/package.json +86 -4
@@ -0,0 +1,72 @@
1
+ import { labelOf } from "@sincpro/criteria";
2
+ /**
3
+ * The model, as web-ui's search bar and criteria builder read it.
4
+ *
5
+ * Context: web-ui keeps its OWN vocabulary of a model (`ModelDefinition`), written before the
6
+ * framework settled its wire, and the two drifted: `identifiedBy` against `identified_by`, one
7
+ * `translations` object against a `label` on each field. web-ui is right to own it — it is a
8
+ * design system and must not depend on one backend — so the translation lives here, the one
9
+ * place that knows both.
10
+ *
11
+ * Known gap: web-ui's field has `choices` but no word for each choice, so the bar offers an
12
+ * enum's raw values («paid») where a cell says «Pagada». Fixing it is web-ui's.
13
+ */
14
+ export function modelOf(meta, locale) {
15
+ const names = Object.keys(meta.fields);
16
+ return {
17
+ aggregate: meta.aggregate,
18
+ identity: meta.identity,
19
+ fields: Object.fromEntries(Object.entries(meta.fields).map(([name, field]) => [
20
+ name,
21
+ {
22
+ type: field.type,
23
+ nullable: field.nullable,
24
+ sortable: field.sortable,
25
+ ops: field.ops,
26
+ kind: field.kind,
27
+ ...(field.many === undefined ? {} : { many: field.many }),
28
+ ...(field.relation === undefined ? {} : { relation: field.relation }),
29
+ ...(field.identified_by === undefined ? {} : { identifiedBy: field.identified_by }),
30
+ ...(field.choices && field.choices.length > 0 ? { choices: field.choices } : {}),
31
+ },
32
+ ])),
33
+ translations: {
34
+ name: {
35
+ default: meta.name?.[locale ?? ""] ?? meta.name?.["default"] ?? meta.aggregate,
36
+ },
37
+ labels: Object.fromEntries(names.map((name) => [name, { default: labelOf(meta, name, locale) }])),
38
+ },
39
+ };
40
+ }
41
+ /** The fields its search template looks in, as named in `meta.search`. */
42
+ function searchedFields(meta) {
43
+ const found = [];
44
+ const walk = (node) => {
45
+ if (typeof node !== "object" || node === null)
46
+ return;
47
+ const record = node;
48
+ if (typeof record["field"] === "string")
49
+ found.push(record["field"]);
50
+ for (const value of Object.values(record)) {
51
+ if (Array.isArray(value))
52
+ value.forEach(walk);
53
+ else
54
+ walk(value);
55
+ }
56
+ };
57
+ walk(meta.search?.["where"]);
58
+ return found;
59
+ }
60
+ /**
61
+ * Where a typed text can be looked for: the fields the entity's own search template names
62
+ * first (what «search by name» means for THIS entity), then every other text field it can
63
+ * filter on.
64
+ */
65
+ export function facetsOf(meta, locale) {
66
+ const first = searchedFields(meta);
67
+ const texts = Object.entries(meta.fields)
68
+ .filter(([, field]) => field.type === "text" && field.ops.includes("like") && !field.choices?.length)
69
+ .map(([name]) => name);
70
+ const order = [...new Set([...first, ...texts])];
71
+ return order.map((name) => ({ id: name, label: labelOf(meta, name, locale) }));
72
+ }
@@ -0,0 +1,22 @@
1
+ import type { AnyRecord } from "@sincpro/criteria";
2
+ import type { Entity } from "../../core/entity.js";
3
+ /**
4
+ * An entity's Command as a button.
5
+ *
6
+ * Context: two kinds of action, two gestures. One that can be undone runs on the click — asking
7
+ * «are you sure?» before every confirm teaches people to click «yes» without reading. One that
8
+ * destroys (`destructive`) asks the server FIRST what it would do — the Command's dry run — and
9
+ * shows THAT in the window: the confirmation is the consequence, in the server's words, not a
10
+ * question. A refusal is shown where it happened, with what to do.
11
+ */
12
+ export declare function CommandButton<T extends AnyRecord, R = unknown>({ entity, command, id, body, label, destructive, role, onDone, }: {
13
+ entity: Entity<T>;
14
+ command: string;
15
+ id?: string;
16
+ body?: AnyRecord;
17
+ label: string;
18
+ /** Asks the server what it would do before doing it. */
19
+ destructive?: boolean;
20
+ role?: "primary" | "secondary" | "ghost";
21
+ onDone?: (answer: R) => void;
22
+ }): import("react").JSX.Element;
@@ -0,0 +1,40 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { frameworkCopy } from "../../core/copy.js";
4
+ import { useCommand } from "../../react/useCommand.js";
5
+ import { Refused } from "../Refused.js";
6
+ import { Button, Dialog } from "@sincpro/web-ui";
7
+ import { useState } from "react";
8
+ /**
9
+ * An entity's Command as a button.
10
+ *
11
+ * Context: two kinds of action, two gestures. One that can be undone runs on the click — asking
12
+ * «are you sure?» before every confirm teaches people to click «yes» without reading. One that
13
+ * destroys (`destructive`) asks the server FIRST what it would do — the Command's dry run — and
14
+ * shows THAT in the window: the confirmation is the consequence, in the server's words, not a
15
+ * question. A refusal is shown where it happened, with what to do.
16
+ */
17
+ export function CommandButton({ entity, command, id, body, label, destructive = false, role = "secondary", onDone, }) {
18
+ const copy = frameworkCopy().command;
19
+ const action = useCommand(entity, command);
20
+ const [open, setOpen] = useState(false);
21
+ const run = async () => {
22
+ const answer = await action.run(id, body);
23
+ if (answer !== undefined) {
24
+ setOpen(false);
25
+ action.reset();
26
+ onDone?.(answer);
27
+ }
28
+ else
29
+ setOpen(true);
30
+ };
31
+ const ask = async () => {
32
+ setOpen(true);
33
+ await action.rehearse(id, body);
34
+ };
35
+ const close = () => {
36
+ setOpen(false);
37
+ action.reset();
38
+ };
39
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { size: "sm", role: destructive ? "secondary" : role, disabled: action.busy, onClick: () => void (destructive ? ask() : run()), children: label }), open ? (_jsx(Dialog, { open: true, title: label, onClose: close, footer: destructive && action.error === null ? (_jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { size: "sm", role: "secondary", onClick: close, children: copy.cancel }), _jsx(Button, { size: "sm", role: "primary", disabled: action.busy || action.rehearsal === null, onClick: () => void run(), children: copy.confirm })] })) : undefined, children: action.error !== null ? (_jsx(Refused, { error: action.error })) : action.rehearsal === null ? (_jsx("p", { className: "text-meta text-muted", children: copy.rehearsing })) : (_jsx("p", { className: "text-ink", children: action.rehearsal.text })) })) : null] }));
40
+ }
@@ -0,0 +1,56 @@
1
+ import type { AnyRecord, Criteria } from "@sincpro/criteria";
2
+ import type { Entity } from "../../core/entity.js";
3
+ import { type ListDefinition } from "../../core/list.js";
4
+ import { type OpenTarget } from "@sincpro/web-ui";
5
+ /**
6
+ * A many2one: pick ONE record of an entity — web-ui's `Picker` fed by `useLookup`, with a way
7
+ * into the whole set however big it is.
8
+ *
9
+ * Context: two ways to choose, for two sizes. The picker opens on the first ten in the entity's
10
+ * order, and what is typed is the entity's literal search, asked of the SERVER (`Picker
11
+ * onSearch`): the record 123 455 of 200 000 is found by its name. The chosen one keeps its name
12
+ * when it is not among the ten (`valueLabel`). For a question the ten cannot answer — two of the
13
+ * same name, a column to compare — «Buscar más…» opens a chooser: the entity's full list,
14
+ * searched, ordered and paged by the server, where clicking a row answers the field. An entity
15
+ * without a search template is filtered where it is.
16
+ *
17
+ * Inside a workspace, «open» and the chooser can be the composer's instead (`api.open`,
18
+ * `api.pick`): pass `onOpen` / `onBrowseAll` and they replace the defaults — `chooserIn(api,
19
+ * entity, onChange)` is that `onBrowseAll`.
20
+ *
21
+ * `criteria` narrows what may be chosen — the contacts of the chosen customer — in the picker
22
+ * and in its chooser alike; the server applies it, and a new one starts the choices over. A
23
+ * named list (`defineList`) does the same declared once: «active customers» brings its cut, its
24
+ * columns and its title to the chooser.
25
+ *
26
+ * The chooser is ALWAYS one click away (`browse`, on by default): web-ui's `Picker` offers
27
+ * «see all…» only while it holds rows it does not draw, so with a short first page there was no
28
+ * way to the server's literal search. The chooser is a table — the columns the list declares,
29
+ * or `columns` — searched by the entity's literal search on the server.
30
+ *
31
+ * <EntityPicker entity={customers} value={id} onChange={set} />
32
+ * <EntityPicker entity={activeCustomers} columns={["name", "tax_id", "city"]} … />
33
+ */
34
+ export declare function EntityPicker<T extends AnyRecord>({ entity, value, onChange, onOpen, openTargets, onBrowseAll, criteria, columns, browse, placeholder, disabled, "aria-label": ariaLabel, }: {
35
+ /** What is chosen from: an entity, or a named list of it (its cut, columns and title). */
36
+ entity: Entity<T> | ListDefinition<T>;
37
+ /** The chooser's columns. Without it, the named list's, or every scalar field. */
38
+ columns?: readonly string[];
39
+ /** A button beside the field that opens the chooser: the server's literal search, as a table. */
40
+ browse?: boolean;
41
+ /** Which records may be chosen at all: applied by the server under what is typed. */
42
+ criteria?: Criteria<T>;
43
+ value?: string;
44
+ onChange: (id: string) => void;
45
+ /** Open the chosen record where the person asked — in composer,
46
+ * `(id, to) => openIn(api, entity.ref(id), to)`. */
47
+ onOpen?: (id: string, to: OpenTarget) => void;
48
+ /** Where it may open, the default first (a modal; from inside a modal, beside). */
49
+ openTargets?: readonly OpenTarget[];
50
+ /** Replace the built-in chooser — in composer, `api.pick(entity.chooserRef(), …)`. It gets
51
+ * what was typed, so the list opens on the same search. */
52
+ onBrowseAll?: (query: string) => void;
53
+ placeholder?: string;
54
+ disabled?: boolean;
55
+ "aria-label"?: string;
56
+ }): import("react").JSX.Element;
@@ -0,0 +1,73 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { nameOf } from "@sincpro/criteria";
4
+ import { frameworkCopy } from "../../core/copy.js";
5
+ import { isList } from "../../core/list.js";
6
+ import { useListing } from "../../react/useListing.js";
7
+ import { useLookup } from "../../react/useLookup.js";
8
+ import { EntityList } from "../list/EntityList.js";
9
+ import { Dialog, IconButton, Picker } from "@sincpro/web-ui";
10
+ import { TextSearch } from "lucide-react";
11
+ import { useState } from "react";
12
+ /**
13
+ * A many2one: pick ONE record of an entity — web-ui's `Picker` fed by `useLookup`, with a way
14
+ * into the whole set however big it is.
15
+ *
16
+ * Context: two ways to choose, for two sizes. The picker opens on the first ten in the entity's
17
+ * order, and what is typed is the entity's literal search, asked of the SERVER (`Picker
18
+ * onSearch`): the record 123 455 of 200 000 is found by its name. The chosen one keeps its name
19
+ * when it is not among the ten (`valueLabel`). For a question the ten cannot answer — two of the
20
+ * same name, a column to compare — «Buscar más…» opens a chooser: the entity's full list,
21
+ * searched, ordered and paged by the server, where clicking a row answers the field. An entity
22
+ * without a search template is filtered where it is.
23
+ *
24
+ * Inside a workspace, «open» and the chooser can be the composer's instead (`api.open`,
25
+ * `api.pick`): pass `onOpen` / `onBrowseAll` and they replace the defaults — `chooserIn(api,
26
+ * entity, onChange)` is that `onBrowseAll`.
27
+ *
28
+ * `criteria` narrows what may be chosen — the contacts of the chosen customer — in the picker
29
+ * and in its chooser alike; the server applies it, and a new one starts the choices over. A
30
+ * named list (`defineList`) does the same declared once: «active customers» brings its cut, its
31
+ * columns and its title to the chooser.
32
+ *
33
+ * The chooser is ALWAYS one click away (`browse`, on by default): web-ui's `Picker` offers
34
+ * «see all…» only while it holds rows it does not draw, so with a short first page there was no
35
+ * way to the server's literal search. The chooser is a table — the columns the list declares,
36
+ * or `columns` — searched by the entity's literal search on the server.
37
+ *
38
+ * <EntityPicker entity={customers} value={id} onChange={set} />
39
+ * <EntityPicker entity={activeCustomers} columns={["name", "tax_id", "city"]} … />
40
+ */
41
+ export function EntityPicker({ entity, value, onChange, onOpen, openTargets, onBrowseAll, criteria, columns, browse = true, placeholder, disabled, "aria-label": ariaLabel, }) {
42
+ const lookup = useLookup(entity, {
43
+ ...(criteria === undefined ? {} : { criteria }),
44
+ ...(value === undefined ? {} : { value }),
45
+ });
46
+ const person = (isList(entity) ? entity.entity : entity).summary.person === true;
47
+ const [choosing, setChoosing] = useState(false);
48
+ const browseAll = onBrowseAll ?? (() => setChoosing(true));
49
+ const picker = (_jsx(Picker, { items: lookup.options.map((one) => ({
50
+ id: one.id,
51
+ name: one.label,
52
+ ...(one.detail === undefined ? {} : { meta: one.detail, haystack: one.detail }),
53
+ // A person or a company is chosen by their face: the photo, or their initials.
54
+ ...(person ? { avatar: one.image ?? true } : {}),
55
+ })), ...(value === undefined ? {} : { value }), ...(lookup.chosen === undefined ? {} : { valueLabel: lookup.chosen.label }), onChange: onChange, ...(lookup.searchable ? { onSearch: lookup.search } : {}),
56
+ // «Loading» only before there is anything: refining keeps the ten on screen.
57
+ loading: lookup.loading && lookup.options.length === 0, ...(onOpen === undefined ? {} : { onOpen }), ...(openTargets === undefined ? {} : { openTargets }), onBrowseAll: browseAll, ...(placeholder === undefined ? {} : { placeholder }), ...(disabled === undefined ? {} : { disabled }), ...(ariaLabel === undefined ? {} : { "aria-label": ariaLabel }) }));
58
+ return (_jsxs(_Fragment, { children: [browse ? (_jsxs("span", { className: "flex min-w-0 items-start gap-1", children: [_jsx("span", { className: "min-w-0 flex-1", children: picker }), _jsx(IconButton, { icon: TextSearch, label: frameworkCopy().picker.browse, onClick: () => browseAll(lookup.text), ...(disabled === undefined ? {} : { disabled }) })] })) : (picker), choosing ? (_jsx(Chooser, { entity: entity, ...(columns === undefined ? {} : { columns }), ...(criteria === undefined ? {} : { criteria }), onChoose: (id) => {
59
+ onChange(id);
60
+ setChoosing(false);
61
+ }, onClose: () => setChoosing(false) })) : null] }));
62
+ }
63
+ /** The whole set in a window, as a table: literal search and pages on the server, a row
64
+ * answers the field. */
65
+ function Chooser({ entity, columns, criteria, onChoose, onClose, }) {
66
+ const listing = useListing(entity, {
67
+ pageSize: 20,
68
+ ...(criteria === undefined ? {} : { criteria }),
69
+ });
70
+ const key = listing.meta?.get_id || listing.meta?.identity || "id";
71
+ return (_jsx(Dialog, { open: true, width: "lg", title: (isList(entity) ? entity.title : nameOf(listing.meta, listing.locale)) ||
72
+ listing.entity.kind, onClose: onClose, children: _jsx(EntityList, { listing: listing, embedded: true, search: "box", ...(columns === undefined ? {} : { columns }), onOpen: (row) => onChoose(String(row[key])) }) }));
73
+ }
@@ -0,0 +1,38 @@
1
+ import type { AnyRecord, Criteria } from "@sincpro/criteria";
2
+ import type { Entity } from "../../core/entity.js";
3
+ import { type ListDefinition } from "../../core/list.js";
4
+ import { type OpenTarget } from "@sincpro/web-ui";
5
+ /**
6
+ * A many2many: put SEVERAL records of an entity on this one — the followers of an invoice,
7
+ * the products of a promotion — as web-ui's `TagsField` chips, fed by `useLookup`.
8
+ *
9
+ * Context: the same literal search as a many2one, for N. The field opens on the first ten in
10
+ * the entity's order, and what is typed is asked of the SERVER (`TagsField onSearch`): a
11
+ * product among three thousand is found by its code though no chip shows it. The chosen never
12
+ * come back as options, and they keep their names wherever they lie — remembered, or asked
13
+ * once by their keys. A person or a company (`entity(…, { summary: { person: true } })`) is
14
+ * chosen by their face.
15
+ *
16
+ * Opening a chip is the composer's (`onOpen` — a preview by default: a chip is a glance at one
17
+ * of many), and «Buscar más…» is the list that adds (`onBrowseAll`, `api.pick` with the list in
18
+ * its choosing mode). Without a workspace a chip is a value.
19
+ *
20
+ * <EntityTags entity={people} value={followers} onChange={setFollowers} />
21
+ */
22
+ export declare function EntityTags<T extends AnyRecord>({ entity, value, onChange, criteria, onOpen, openTarget, onBrowseAll, placeholder, disabled, "aria-label": ariaLabel, }: {
23
+ /** What is chosen from: an entity, or a named list of it. */
24
+ entity: Entity<T> | ListDefinition<T>;
25
+ /** The keys chosen, in the order they were added — what the backend stores. */
26
+ value: readonly string[];
27
+ onChange: (next: string[]) => void;
28
+ /** Which records may be put at all: applied by the server under what is typed. */
29
+ criteria?: Criteria<T>;
30
+ /** Open the record behind a chip — in composer, `(id, to) => openIn(api, entity.ref(id), to)`. */
31
+ onOpen?: (id: string, to: OpenTarget) => void;
32
+ openTarget?: OpenTarget;
33
+ /** «Buscar más…»: the list that adds several at once, opened on what was typed. */
34
+ onBrowseAll?: (query: string) => void;
35
+ placeholder?: string;
36
+ disabled?: boolean;
37
+ "aria-label"?: string;
38
+ }): import("react").JSX.Element;
@@ -0,0 +1,41 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { isList } from "../../core/list.js";
4
+ import { useLookup } from "../../react/useLookup.js";
5
+ import { TagsField } from "@sincpro/web-ui";
6
+ /**
7
+ * A many2many: put SEVERAL records of an entity on this one — the followers of an invoice,
8
+ * the products of a promotion — as web-ui's `TagsField` chips, fed by `useLookup`.
9
+ *
10
+ * Context: the same literal search as a many2one, for N. The field opens on the first ten in
11
+ * the entity's order, and what is typed is asked of the SERVER (`TagsField onSearch`): a
12
+ * product among three thousand is found by its code though no chip shows it. The chosen never
13
+ * come back as options, and they keep their names wherever they lie — remembered, or asked
14
+ * once by their keys. A person or a company (`entity(…, { summary: { person: true } })`) is
15
+ * chosen by their face.
16
+ *
17
+ * Opening a chip is the composer's (`onOpen` — a preview by default: a chip is a glance at one
18
+ * of many), and «Buscar más…» is the list that adds (`onBrowseAll`, `api.pick` with the list in
19
+ * its choosing mode). Without a workspace a chip is a value.
20
+ *
21
+ * <EntityTags entity={people} value={followers} onChange={setFollowers} />
22
+ */
23
+ export function EntityTags({ entity, value, onChange, criteria, onOpen, openTarget, onBrowseAll, placeholder, disabled, "aria-label": ariaLabel, }) {
24
+ const lookup = useLookup(entity, {
25
+ ...(criteria === undefined ? {} : { criteria }),
26
+ values: value,
27
+ });
28
+ const person = (isList(entity) ? entity.entity : entity).summary.person === true;
29
+ const asTag = (one) => ({
30
+ value: one.id,
31
+ label: one.label,
32
+ ...(person ? { avatar: one.image ?? true } : {}),
33
+ });
34
+ const offered = new Set(lookup.options.map((one) => one.id));
35
+ // The chosen ride along for their labels; the field never offers them again.
36
+ const options = [
37
+ ...lookup.options.map(asTag),
38
+ ...lookup.chosenAll.filter((one) => !offered.has(one.id)).map(asTag),
39
+ ];
40
+ return (_jsx(TagsField, { value: value, onChange: onChange, options: options, ...(lookup.searchable ? { onSearch: lookup.search } : {}), ...(onOpen === undefined ? {} : { onOpen }), ...(openTarget === undefined ? {} : { openTarget }), ...(onBrowseAll === undefined ? {} : { onBrowseAll }), ...(placeholder === undefined ? {} : { placeholder }), ...(disabled === undefined ? {} : { disabled }), ...(ariaLabel === undefined ? {} : { "aria-label": ariaLabel }) }));
41
+ }
@@ -0,0 +1,19 @@
1
+ import type { AnyRecord } from "@sincpro/criteria";
2
+ import type { Entity } from "../../core/entity.js";
3
+ import type { RecordReading } from "../../react/useRecord.js";
4
+ import { type EntityListProps } from "../list/EntityList.js";
5
+ /**
6
+ * A relation of a record as a list inside it — searched, ordered and paged by the server.
7
+ *
8
+ * Context: the same `EntityList` a screen uses, `embedded` so it reads as a field of the record
9
+ * (a one-line bar that does not stick), over `useRelated`. Its search box fills the RELATED
10
+ * entity's own search template: the lines of an order are searched by product, the invoices of
11
+ * a customer by number. The column that points back to this record is left out — it is the
12
+ * same on every row. Use `RecordLines` when the page the record brought is all there is.
13
+ */
14
+ export declare function RelatedList<P extends AnyRecord, T extends AnyRecord>({ record, name, entity, pageSize, ...list }: {
15
+ record: RecordReading<P>;
16
+ name: string;
17
+ entity: Entity<T>;
18
+ pageSize?: number;
19
+ } & Omit<EntityListProps<T>, "listing" | "embedded">): import("react").JSX.Element;
@@ -0,0 +1,26 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { defaultFields } from "../../core/present.js";
4
+ import { useRelated } from "../../react/useRelated.js";
5
+ import { EntityList } from "../list/EntityList.js";
6
+ import { useMemo } from "react";
7
+ /**
8
+ * A relation of a record as a list inside it — searched, ordered and paged by the server.
9
+ *
10
+ * Context: the same `EntityList` a screen uses, `embedded` so it reads as a field of the record
11
+ * (a one-line bar that does not stick), over `useRelated`. Its search box fills the RELATED
12
+ * entity's own search template: the lines of an order are searched by product, the invoices of
13
+ * a customer by number. The column that points back to this record is left out — it is the
14
+ * same on every row. Use `RecordLines` when the page the record brought is all there is.
15
+ */
16
+ export function RelatedList({ record, name, entity, pageSize, ...list }) {
17
+ const listing = useRelated(record, name, entity, pageSize === undefined ? {} : { pageSize });
18
+ // Every row points back to THIS record: the column that says so is the same on all of them.
19
+ const back = record.meta?.fields[name]?.identified_by;
20
+ const own = list.columns;
21
+ const columns = useMemo(() => own ??
22
+ (listing.meta === null || !back
23
+ ? undefined
24
+ : defaultFields(listing.meta).filter((field) => field !== back)), [own, listing.meta, back]);
25
+ return (_jsx(EntityList, { listing: listing, embedded: true, search: "box", ...list, ...(columns === undefined ? {} : { columns }) }));
26
+ }
@@ -0,0 +1,42 @@
1
+ import type { RecordReading } from "../../react/useRecord.js";
2
+ import { type DirtyState } from "@sincpro/web-ui";
3
+ import type { ReactNode } from "react";
4
+ /**
5
+ * One field of a record being edited, from the model: its word, whether it is required or
6
+ * read-only now (the hints, evaluated over what is on screen), the server's error for it, and a
7
+ * control chosen by its type.
8
+ *
9
+ * Context: forge's forms wrote `<Field label="Nombre" required><TextInput … /></Field>` once per
10
+ * field, with the label, the «required» and the value plumbing by hand, while the server already
11
+ * said all three. Here a field is a NAME. The control by type is a default, never the only way:
12
+ * `as` draws any control — a rich text, a domain widget — and still receives the value, the
13
+ * setter and the state.
14
+ *
15
+ * A many2one needs no `as`: the model says what it points at (`relation: "Cliente"`, the key
16
+ * `customer_id` it is written by), the application declared that entity
17
+ * (`SincproApp entities`), so the field is an `EntityPicker` of it — the server's literal
18
+ * search, the ten first, «Buscar más…» — and, read-only, the record's name as a link. Where it
19
+ * opens is the view's (`RecordOpening`: a modal in a workspace, nothing outside one).
20
+ *
21
+ * <RecordField record={invoice} name="due_date" />
22
+ * <RecordField record={invoice} name="customer_id" />
23
+ */
24
+ export type FieldControl = {
25
+ value: unknown;
26
+ set: (value: unknown) => void;
27
+ readonly: boolean;
28
+ invalid: boolean;
29
+ label: string;
30
+ };
31
+ export declare function RecordField<T extends Record<string, unknown>>({ record, name, as, bare, }: {
32
+ record: RecordReading<T>;
33
+ name: string;
34
+ as?: (control: FieldControl) => ReactNode;
35
+ /** The control without its label — inside a page or a section that already names it. */
36
+ bare?: boolean;
37
+ }): import("react").JSX.Element | null;
38
+ /**
39
+ * The record's «save / discard», for `<Record dirty={…}>`: present only while something changed,
40
+ * off while saving, and off with the reason while a required field is empty.
41
+ */
42
+ export declare function dirtyOf<T extends Record<string, unknown>>(record: RecordReading<T>): DirtyState | undefined;
@@ -0,0 +1,122 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { choicesOf } from "@sincpro/criteria";
4
+ import { frameworkCopy } from "../../core/copy.js";
5
+ import { textOf, viewOf } from "../../core/present.js";
6
+ import { relationFieldOf } from "../../core/relation.js";
7
+ import { useEntityFor, useFramework } from "../../react/context.js";
8
+ import { EntityPicker } from "./EntityPicker.js";
9
+ import { useRecordOpening } from "./opening.js";
10
+ import { RecordLines } from "./record.js";
11
+ import { RelatedList } from "./RelatedList.js";
12
+ import { Checkbox, DatePicker, Field, NumberInput, ReadValue, RelationValue, Select, TagsField, TextInput, } from "@sincpro/web-ui";
13
+ export function RecordField({ record, name, as, bare = false, }) {
14
+ const { meta, locale, edit } = record;
15
+ const view = viewOf(meta, name, locale);
16
+ const state = edit.state(name);
17
+ const relation = relationFieldOf(meta, name);
18
+ const target = useEntityFor(relation?.target);
19
+ const opening = useRecordOpening();
20
+ const { metas } = useFramework();
21
+ if (!state.visible)
22
+ return null;
23
+ // A one2many of a declared entity is that entity's own list, filtered to this record by
24
+ // the server — never the page the record happened to bring — and a row opens its record.
25
+ // A one2many of no declared entity (the lines of an invoice): the page the record brought,
26
+ // with its real total.
27
+ if (as === undefined && relation?.field.type === "one2many" && target === undefined) {
28
+ const lines = (_jsx(RecordLines, { record: record.record ?? {}, meta: meta, name: name, locale: locale }));
29
+ return bare ? lines : _jsx(Field, { label: view.label, children: lines });
30
+ }
31
+ if (as === undefined && relation?.field.type === "one2many" && target !== undefined) {
32
+ const list = (_jsx(RelatedList, { record: record, name: name, entity: target, ...(opening === null
33
+ ? {}
34
+ : {
35
+ onOpen: (row) => {
36
+ const known = metas.get(target.key);
37
+ const key = known?.get_id || known?.identity || "id";
38
+ opening.open(target.ref(String(row[key])), opening.targets[0] ?? "page");
39
+ },
40
+ }) }));
41
+ return bare ? list : _jsx(Field, { label: view.label, children: list });
42
+ }
43
+ const value = edit.values[name];
44
+ const error = edit.errors[name];
45
+ const control = {
46
+ value,
47
+ set: (next) => edit.set(name, next),
48
+ readonly: state.readonly,
49
+ invalid: error !== undefined,
50
+ label: view.label,
51
+ };
52
+ const field = meta?.fields[name];
53
+ /** A many2one of a declared entity: chosen by the server's literal search, opened where the
54
+ * view says. Written by its key; read by the record it points at. */
55
+ function manyToOne() {
56
+ if (relation === undefined || target === undefined)
57
+ return null;
58
+ const key = edit.values[relation.key];
59
+ const id = key === null || key === undefined ? "" : String(key);
60
+ if (state.readonly)
61
+ return (_jsx(RelationValue, { name: textOf(meta, relation.relation, edit.values[relation.relation], locale) ?? id, ...(opening === null || id === ""
62
+ ? {}
63
+ : {
64
+ onOpenIn: (to) => opening.open(target.ref(id), to),
65
+ openTargets: opening.targets,
66
+ }) }));
67
+ const answer = (next) => edit.set(relation.key, next);
68
+ return (_jsx(EntityPicker, { entity: target, value: id, onChange: answer,
69
+ // «Buscar más…» is an option of the picker already: no second button beside it.
70
+ browse: false, "aria-label": view.label, ...(opening === null
71
+ ? {}
72
+ : {
73
+ onOpen: (chosen, to) => opening.open(target.ref(chosen), to),
74
+ openTargets: opening.targets,
75
+ ...(opening.choose === undefined
76
+ ? {}
77
+ : {
78
+ onBrowseAll: (query) => opening.choose?.(target, answer, query),
79
+ }),
80
+ }) }));
81
+ }
82
+ if (view.type === "boolean" && as === undefined)
83
+ return (_jsx(Checkbox, { checked: value === true, onChange: control.set, label: view.label, disabled: state.readonly }));
84
+ return (_jsx(Field, { label: view.label, required: state.required && !state.readonly, ...(view.help === undefined ? {} : { hint: view.help }), ...(error === undefined ? {} : { error }), children: as !== undefined ? (as(control)) : relation !== undefined && !relation.many && target !== undefined ? (manyToOne()) : state.readonly ? (_jsx(ReadValue, { value: textOf(meta, name, value, locale) ?? "—" })) : (controlFor(control, view.type, field?.exact === true, choicesOf(meta, name, locale))) }));
85
+ }
86
+ function controlFor(control, type, exact, choices) {
87
+ const { value, set } = control;
88
+ if (choices.length > 0)
89
+ return (_jsx(Select, { value: value === null || value === undefined ? "" : String(value), options: choices.map((one) => ({ value: String(one.value), label: one.label })), onChange: set }));
90
+ switch (type) {
91
+ case "integer":
92
+ case "number":
93
+ return (_jsx(NumberInput, { value: value === null || value === undefined || value === "" ? "" : Number(value),
94
+ // A decimal travels as text so no digit is lost; the server reads it that way back.
95
+ onChange: (next) => set(next === "" ? null : exact ? String(next) : next) }));
96
+ case "date":
97
+ return (_jsx(DatePicker, { value: typeof value === "string" && value !== "" ? value : null, onChange: set }));
98
+ case "text[]": {
99
+ // A list of words is chips: each word is its own key, and typing a new one adds it.
100
+ const words = Array.isArray(value) ? value.map(String) : [];
101
+ return (_jsx(TagsField, { value: words, options: words.map((word) => ({ value: word, label: word })), onChange: set, onCreate: (word) => word }));
102
+ }
103
+ default:
104
+ return (_jsx(TextInput, { value: value === null || value === undefined ? "" : String(value), onChange: set }));
105
+ }
106
+ }
107
+ /**
108
+ * The record's «save / discard», for `<Record dirty={…}>`: present only while something changed,
109
+ * off while saving, and off with the reason while a required field is empty.
110
+ */
111
+ export function dirtyOf(record) {
112
+ const { edit, meta, locale } = record;
113
+ if (!edit.dirty)
114
+ return undefined;
115
+ const missing = edit.missing.map((name) => viewOf(meta, name, locale).label);
116
+ return {
117
+ onSave: () => void edit.save(),
118
+ onDiscard: edit.discard,
119
+ saving: edit.saving,
120
+ ...(missing.length === 0 ? {} : { blocked: frameworkCopy().record.missing(missing) }),
121
+ };
122
+ }
@@ -0,0 +1,26 @@
1
+ import type { Entity, EntityRef } from "../../core/entity.js";
2
+ import type { OpenTarget } from "@sincpro/web-ui";
3
+ import { type ReactNode } from "react";
4
+ /**
5
+ * How a record shown in a form opens from here — given by whoever holds the form, used by the
6
+ * fields inside it.
7
+ *
8
+ * Context: a form must work outside a workspace (`web/record/` imports no composer), yet a
9
+ * many2one inside one opens its record in a modal, with «Abrir en…» for the rest. So the form
10
+ * does not decide: the view that holds it says how to open (`entityView` fills it with the
11
+ * composer's `openIn` and the places allowed from where the view is), and a field asks. Without
12
+ * it a related record is a value, not a door.
13
+ */
14
+ export type RecordOpening = {
15
+ open: (ref: EntityRef, to: OpenTarget) => void;
16
+ /** Where it may open from here, the default first. */
17
+ targets: readonly OpenTarget[];
18
+ /** «Buscar más…»: the entity's own list opened to choose (in composer, a modal that answers),
19
+ * on what was typed. Without it the picker brings its own chooser. */
20
+ choose?: (entity: Entity, answer: (id: string) => void, query: string) => void;
21
+ };
22
+ export declare function RecordOpeningProvider({ value, children, }: {
23
+ value: RecordOpening;
24
+ children: ReactNode;
25
+ }): import("react").JSX.Element;
26
+ export declare function useRecordOpening(): RecordOpening | null;
@@ -0,0 +1,10 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useContext } from "react";
4
+ const Opening = createContext(null);
5
+ export function RecordOpeningProvider({ value, children, }) {
6
+ return _jsx(Opening.Provider, { value: value, children: children });
7
+ }
8
+ export function useRecordOpening() {
9
+ return useContext(Opening);
10
+ }
@@ -0,0 +1,25 @@
1
+ import { type AnyRecord, type Meta } from "@sincpro/criteria";
2
+ import { type SheetField } from "@sincpro/web-ui";
3
+ import type { ReactNode } from "react";
4
+ export declare function factsOf<T extends AnyRecord>(record: T, meta: Meta | null | undefined, names: readonly string[], options?: {
5
+ locale?: string | undefined;
6
+ cells?: Readonly<Partial<Record<string, (record: T) => ReactNode>>>;
7
+ }): SheetField[];
8
+ /** How many records a relation of this record holds — the TOTAL, not the page it brought. */
9
+ export declare function countOf(record: AnyRecord, name: string): number;
10
+ /**
11
+ * A one2many / many2many of the record, as an embedded list.
12
+ *
13
+ * Its columns come from the relation's own definition when the server expanded it, and are
14
+ * read off the rows otherwise. When the record brought only one page of them, it says so —
15
+ * «se muestran 50 de 1000» — instead of drawing 50 as if they were all.
16
+ */
17
+ export declare function RecordLines<T extends AnyRecord>({ record, meta, name, columns, cells, locale, onOpen, }: {
18
+ record: AnyRecord;
19
+ meta: Meta | null | undefined;
20
+ name: string;
21
+ columns?: readonly string[];
22
+ cells?: Readonly<Partial<Record<string, (row: T) => ReactNode>>>;
23
+ locale?: string | undefined;
24
+ onOpen?: (row: T) => void;
25
+ }): import("react").JSX.Element;