@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,56 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { describeRows } from "@sincpro/criteria";
4
+ import { frameworkCopy } from "../../core/copy.js";
5
+ import { textOf, viewOf } from "../../core/present.js";
6
+ import { relatedOf } from "../../core/related.js";
7
+ import { columnsOf } from "../list/columns.js";
8
+ import { List } from "@sincpro/web-ui";
9
+ /**
10
+ * The parts of ONE record, from the model.
11
+ *
12
+ * Context: `Record.*` in web-ui is a tree of slots — `Record.Facts` must be written as a child
13
+ * of `Record.Paper`, not hidden inside a component of ours, or the record does not find it.
14
+ * So these are not components that wrap `Record`: they are the PROPS of its slots, built from
15
+ * the model, and the screen keeps writing the tree:
16
+ *
17
+ * <Record.Paper>
18
+ * <Record.Facts fields={factsOf(record, meta, ["number", "customer", "due"])} />
19
+ * <Record.Pages>
20
+ * <Record.Page id="lines" label={labelOf(meta, "lines")} count={countOf(record, "lines")}>
21
+ * <RecordLines record={record} meta={meta} name="lines" />
22
+ * </Record.Page>
23
+ * </Record.Pages>
24
+ * </Record.Paper>
25
+ */
26
+ const EMPTY = "—";
27
+ export function factsOf(record, meta, names, options = {}) {
28
+ return names.map((name) => {
29
+ const view = viewOf(meta, name, options.locale);
30
+ const own = options.cells?.[name];
31
+ return {
32
+ label: view.label,
33
+ value: own ? own(record) : (textOf(meta, name, record[name], options.locale) ?? EMPTY),
34
+ ...(view.help === undefined ? {} : { hint: view.help }),
35
+ ...(name === meta?.identity ? { mono: true } : {}),
36
+ };
37
+ });
38
+ }
39
+ /** How many records a relation of this record holds — the TOTAL, not the page it brought. */
40
+ export function countOf(record, name) {
41
+ return relatedOf(record[name]).count.value;
42
+ }
43
+ /**
44
+ * A one2many / many2many of the record, as an embedded list.
45
+ *
46
+ * Its columns come from the relation's own definition when the server expanded it, and are
47
+ * read off the rows otherwise. When the record brought only one page of them, it says so —
48
+ * «se muestran 50 de 1000» — instead of drawing 50 as if they were all.
49
+ */
50
+ export function RecordLines({ record, meta, name, columns, cells, locale, onOpen, }) {
51
+ const related = relatedOf(record[name]);
52
+ const definition = meta?.fields[name]?.definition ?? describeRows(related.items);
53
+ const identity = definition.identity || "id";
54
+ const copy = frameworkCopy();
55
+ return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(List, { embedded: true, items: related.items, keyOf: (row) => String(row[identity]), present: "table", empty: { sentence: copy.list.empty }, ...(onOpen === undefined ? {} : { onOpen }), children: _jsx(List.Table, { columns: columnsOf(definition, columns, { locale, ...(cells ? { cells } : {}) }) }) }), related.partial ? (_jsx("p", { className: "text-meta text-muted", children: copy.record.partial(related.items.length, related.count.value) })) : null] }));
56
+ }
@@ -0,0 +1,22 @@
1
+ import type { RecordSummary } from "../../core/summary.js";
2
+ import { type RecordCard, type RelationValueProps } from "@sincpro/web-ui";
3
+ import type { ComponentProps } from "react";
4
+ /**
5
+ * A record's summary as the props of web-ui's pieces that draw one record at a size: the card
6
+ * of a board and the link (with the card that floats from it) of a many2one.
7
+ *
8
+ * Context: PROPS, not wrapper components — the same reason as `factsOf`/`columnsOf`: web-ui
9
+ * finds its pieces by type inside a list or a board, and a component of ours around one is not
10
+ * found. The screen keeps writing `<RecordCard {...cardOf(s)} onOpen={…} />`, and adds or
11
+ * overrides whatever it wants. What the record does not have is left out, never drawn empty.
12
+ */
13
+ type CardProps = ComponentProps<typeof RecordCard>;
14
+ type RelationProps = Pick<RelationValueProps, "name" | "secondary" | "media" | "summary">;
15
+ /** The board's card: the state above, the name, what it is, its description, its last change. */
16
+ export declare function cardOf(summary: RecordSummary): CardProps;
17
+ /**
18
+ * A many2one read: the name, what it is beside it, its face, and the card that floats from the
19
+ * name with its state and when it was made and last changed.
20
+ */
21
+ export declare function relationOf(summary: RecordSummary): RelationProps;
22
+ export {};
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { frameworkCopy } from "../../core/copy.js";
3
+ import { Tag } from "@sincpro/web-ui";
4
+ function lastChange(summary) {
5
+ const copy = frameworkCopy().summary;
6
+ const { created, updated } = summary;
7
+ if (updated.at !== null)
8
+ return copy.updatedLine(updated.at, updated.by);
9
+ return created.at === null ? null : copy.createdLine(created.at, created.by);
10
+ }
11
+ /** The board's card: the state above, the name, what it is, its description, its last change. */
12
+ export function cardOf(summary) {
13
+ const footer = lastChange(summary);
14
+ return {
15
+ title: summary.name,
16
+ ...(summary.type === null ? {} : { subtitle: summary.type }),
17
+ ...(summary.state === null ? {} : { tags: _jsx(Tag, { tone: "neutral", children: summary.state }) }),
18
+ ...(summary.description === null ? {} : { meta: summary.description }),
19
+ ...(summary.image === null ? {} : { media: { src: summary.image, alt: summary.name } }),
20
+ ...(footer === null
21
+ ? {}
22
+ : { footer: _jsx("span", { className: "text-micro text-muted", children: footer }) }),
23
+ };
24
+ }
25
+ /**
26
+ * A many2one read: the name, what it is beside it, its face, and the card that floats from the
27
+ * name with its state and when it was made and last changed.
28
+ */
29
+ export function relationOf(summary) {
30
+ const copy = frameworkCopy().summary;
31
+ const facts = [
32
+ ...(summary.created.at === null
33
+ ? []
34
+ : [{ label: copy.created, value: copy.when(summary.created.at, summary.created.by) }]),
35
+ ...(summary.updated.at === null
36
+ ? []
37
+ : [{ label: copy.updated, value: copy.when(summary.updated.at, summary.updated.by) }]),
38
+ ];
39
+ const media = summary.person
40
+ ? { person: true, ...(summary.image === null ? {} : { src: summary.image }) }
41
+ : summary.image === null
42
+ ? undefined
43
+ : { src: summary.image, alt: summary.name };
44
+ const secondary = summary.type ?? (summary.id === summary.name ? null : summary.id);
45
+ const card = {
46
+ ...(summary.state === null ? {} : { tag: { label: summary.state } }),
47
+ ...(facts.length === 0 ? {} : { facts }),
48
+ };
49
+ return {
50
+ name: summary.name,
51
+ ...(secondary === null ? {} : { secondary }),
52
+ ...(media === undefined ? {} : { media }),
53
+ ...(Object.keys(card).length === 0 ? {} : { summary: card }),
54
+ };
55
+ }
@@ -0,0 +1,40 @@
1
+ import type { Meta } from "@sincpro/criteria";
2
+ import { type ComposerSkin, type ResourceRef, type ViewSpec } from "@sincpro/web-composer";
3
+ import { type CacheOptions } from "../../core/cache.js";
4
+ import type { Entity } from "../../core/entity.js";
5
+ import type { Transport } from "../../core/transport.js";
6
+ import { type ReactNode } from "react";
7
+ /**
8
+ * The application context in one place: who answers (the transport), in which language, which
9
+ * model definitions are already known, the toasts, and the workspace with its views, ⌘K and
10
+ * shortcuts.
11
+ *
12
+ * Context: forge's `main.tsx` + `console.tsx` installed three copy tables, a toast provider, a
13
+ * context of its own and a composer by hand. This is that, once. It is a convenience, not the
14
+ * only door: everything it mounts is exported, and an app with its own shell mounts them itself.
15
+ * What it does NOT own: the session (the app builds the transport with its headers) and the
16
+ * application bar (the app draws its own, around this or beside it).
17
+ */
18
+ export declare function SincproApp({ transport, views, start, language, metas, entities, empty, skin, cache, }: {
19
+ transport: Transport;
20
+ /** The entities as composer views — `entityView(…)` — and any other view of the app. */
21
+ views: readonly ViewSpec[];
22
+ /** The application's entities: a form draws a many2one of any of them without being told. */
23
+ entities?: readonly Entity[];
24
+ /** What opens first: a list, usually — `invoices.ref()`. */
25
+ start?: ResourceRef;
26
+ /** The interface's language: web-ui's, the composer's and the framework's words. */
27
+ language?: "es" | "en";
28
+ /** Definitions known before any request, by entity key. */
29
+ metas?: Readonly<Record<string, Meta>>;
30
+ /** Drawn when nothing is open. */
31
+ empty?: ReactNode;
32
+ /** How the workspace looks. web-ui's by default (`webUiSkin`); any piece can be replaced. */
33
+ skin?: ComposerSkin;
34
+ /**
35
+ * Repeated reads served from memory — the list gone back to, the record reopened — and
36
+ * forgotten the moment a write touches their entity (`cachedTransport`). 30 s by default;
37
+ * `false` asks the server every time.
38
+ */
39
+ cache?: CacheOptions | false;
40
+ }): import("react").JSX.Element;
@@ -0,0 +1,45 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { Composer, installComposerCopy, SPANISH_COMPOSER_COPY, useWorkspace, } from "@sincpro/web-composer";
4
+ import { createRegistry } from "@sincpro/web-composer/domain";
5
+ import { cachedTransport } from "../../core/cache.js";
6
+ import { installFrameworkCopy, SPANISH_FRAMEWORK_COPY, } from "../../core/copy.js";
7
+ import { FrameworkProvider } from "../../react/context.js";
8
+ import { webUiSkin } from "./skin.js";
9
+ import { installCopy, SPANISH_COPY, ToastProvider } from "@sincpro/web-ui";
10
+ import { useEffect, useMemo, useState } from "react";
11
+ /**
12
+ * The application context in one place: who answers (the transport), in which language, which
13
+ * model definitions are already known, the toasts, and the workspace with its views, ⌘K and
14
+ * shortcuts.
15
+ *
16
+ * Context: forge's `main.tsx` + `console.tsx` installed three copy tables, a toast provider, a
17
+ * context of its own and a composer by hand. This is that, once. It is a convenience, not the
18
+ * only door: everything it mounts is exported, and an app with its own shell mounts them itself.
19
+ * What it does NOT own: the session (the app builds the transport with its headers) and the
20
+ * application bar (the app draws its own, around this or beside it).
21
+ */
22
+ export function SincproApp({ transport, views, start, language = "es", metas, entities, empty, skin = webUiSkin, cache = {}, }) {
23
+ // Installed once, before anything draws: the three tables are module state of their packages.
24
+ useState(() => {
25
+ if (language === "es") {
26
+ installCopy(SPANISH_COPY);
27
+ installComposerCopy(SPANISH_COMPOSER_COPY);
28
+ installFrameworkCopy(SPANISH_FRAMEWORK_COPY);
29
+ }
30
+ return null;
31
+ });
32
+ const cached = useMemo(() => (cache === false ? transport : cachedTransport(transport, cache)),
33
+ // The options are read once: a cache that changes shape mid-session forgets everything.
34
+ // eslint-disable-next-line react-hooks/exhaustive-deps
35
+ [transport]);
36
+ const workspace = useWorkspace();
37
+ const registry = useMemo(() => createRegistry(views), [views]);
38
+ useEffect(() => {
39
+ if (start !== undefined)
40
+ workspace.push(start);
41
+ // The first screen only: a re-render must not push it again.
42
+ // eslint-disable-next-line react-hooks/exhaustive-deps
43
+ }, [workspace]);
44
+ return (_jsx(FrameworkProvider, { transport: cached, locale: language, ...(metas === undefined ? {} : { metas }), ...(entities === undefined ? {} : { entities }), children: _jsx(ToastProvider, { children: _jsx("div", { className: "flex h-dvh min-h-0 flex-col bg-ground", children: _jsx(Composer, { workspace: workspace, registry: registry, skin: skin, ...(empty === undefined ? {} : { empty }) }) }) }) }));
45
+ }
@@ -0,0 +1,55 @@
1
+ import { type AnyRecord, type Meta } from "@sincpro/criteria";
2
+ import type { ViewProps, ViewSpec } from "@sincpro/web-composer";
3
+ import { type Entity } from "../../core/entity.js";
4
+ import type { ListDefinition } from "../../core/list.js";
5
+ import type { Transport } from "../../core/transport.js";
6
+ import { type ComponentType } from "react";
7
+ /**
8
+ * An entity as the composer's view: one `ViewSpec` for its list (a reference without an id)
9
+ * and its record (with one), searchable from ⌘K.
10
+ *
11
+ * Context: the composer opens references and never imports a domain — a domain DECLARES itself
12
+ * with a `ViewSpec`. The entity already knows its key (`domain:kind`), its routes and how a
13
+ * text finds one of its records (the framework's `LiteralSearch`: `meta.search` filled by
14
+ * `matching`), so its spec is written here once instead of by hand per kind (forge wrote
15
+ * eight). The screens stay the application's: `list` and `record` are its components.
16
+ *
17
+ * A reference with no id is the list — opened to choose one when it carries `pick` — and one
18
+ * with `new` is the record's own view with no id: the form of a record that does not exist yet
19
+ * (`entity.newRef()`), which `useRecord(entity, "")` fills with the model's defaults.
20
+ *
21
+ * ⌘K asks the server with `matching` — 8 records, identity and display — the same question a
22
+ * many2one asks. The model definition is asked for once, on the first search, unless `meta`
23
+ * is handed over.
24
+ */
25
+ export type EntityViewOptions = {
26
+ transport: Transport;
27
+ /** How it reads in the palette, a panel's frame and the breadcrumb. */
28
+ name: string;
29
+ /** Its list. Without it, `EntityList` over the entity: search bar, pages, «Nuevo». */
30
+ list?: ComponentType<ViewProps>;
31
+ /** Its record. Without it, `EntityRecord`: the form the model describes. */
32
+ record?: ComponentType<ViewProps>;
33
+ icon?: ViewSpec["icon"];
34
+ /** Listed under «Go to» in ⌘K. On by default. */
35
+ goto?: boolean;
36
+ /** Known before any search: no request asks for it. */
37
+ meta?: Meta;
38
+ actions?: ViewSpec["actions"];
39
+ };
40
+ export declare function entityView<T extends AnyRecord>(entity: Entity<T>, options: EntityViewOptions): ViewSpec;
41
+ /**
42
+ * A named list as a view of its own: «Por cobrar» in ⌘K's «Go to», its own breadcrumb, the
43
+ * list the definition declares. Its rows open the ENTITY's record — the view `entityView`
44
+ * registered — so a list view needs no record screen of its own.
45
+ *
46
+ * views: [entityView(facturas, …), listView(porCobrar), listView(borradores)]
47
+ */
48
+ export type ListViewOptions = {
49
+ /** The screen; without it, `EntityList` over the definition, as it declares. */
50
+ list?: ComponentType<ViewProps>;
51
+ icon?: ViewSpec["icon"];
52
+ /** Listed under «Go to» in ⌘K. On by default. */
53
+ goto?: boolean;
54
+ };
55
+ export declare function listView<T extends AnyRecord>(list: ListDefinition<T>, options?: ListViewOptions): ViewSpec;
@@ -0,0 +1,94 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { matching } from "@sincpro/criteria";
4
+ import { NEW_PARAM } from "../../core/entity.js";
5
+ import { lookupOf } from "../../core/lookup.js";
6
+ import { useListing } from "../../react/useListing.js";
7
+ import { EntityList } from "../list/EntityList.js";
8
+ import { RecordOpeningProvider, } from "../record/opening.js";
9
+ import { openIn, RECORD_SIZE, targetsFor } from "./open.js";
10
+ import { EntityRecord } from "./record.js";
11
+ import { useMemo } from "react";
12
+ /**
13
+ * The fields of a view open their records in the workspace: a modal over this surface by
14
+ * default, beside it from inside a modal, and «Buscar más…» as the entity's list that answers.
15
+ */
16
+ function InWorkspace({ api, children }) {
17
+ const targets = targetsFor(api);
18
+ const opening = useMemo(() => ({
19
+ open: (ref, to) => openIn(api, ref, to),
20
+ targets,
21
+ choose: (entity, answer, query) => api.pick(entity.chooserRef(query), (ref) => ref.id === undefined || answer(ref.id), {
22
+ // Never a modal over a modal: from inside one, the chooser docks beside it.
23
+ ...(targets[0] === "modal"
24
+ ? { to: "modal", size: RECORD_SIZE }
25
+ : { to: "side" }),
26
+ }),
27
+ }), [api, targets]);
28
+ return _jsx(RecordOpeningProvider, { value: opening, children: children });
29
+ }
30
+ /** The entity's list when the application writes none. */
31
+ function listOf(entity) {
32
+ return function DefaultList({ api }) {
33
+ return _jsx(EntityList, { listing: useListing(entity), api: api });
34
+ };
35
+ }
36
+ /** The entity's record when the application writes none. */
37
+ function recordOf(entity) {
38
+ return function DefaultRecord(props) {
39
+ return _jsx(EntityRecord, { ...props, entity: entity });
40
+ };
41
+ }
42
+ export function entityView(entity, options) {
43
+ const List = options.list ?? listOf(entity);
44
+ const One = options.record ?? recordOf(entity);
45
+ let meta = options.meta ?? null;
46
+ function EntityView(props) {
47
+ const record = props.resource.id !== undefined || props.resource.params?.[NEW_PARAM] !== undefined;
48
+ return (_jsx(InWorkspace, { api: props.api, children: record ? _jsx(One, { ...props }) : _jsx(List, { ...props }) }));
49
+ }
50
+ return {
51
+ domain: entity.domain,
52
+ kind: entity.kind,
53
+ title: (ref) => ref.id ?? options.name,
54
+ goto: options.goto ?? true,
55
+ ...(options.icon === undefined ? {} : { icon: options.icon }),
56
+ ...(options.actions === undefined ? {} : { actions: options.actions }),
57
+ search: async (query) => {
58
+ if (query.trim() === "")
59
+ return [];
60
+ if (meta === null)
61
+ meta = (await options.transport.list(entity.list, { pagination: { limit: 1 } })).meta;
62
+ if (meta?.search == null)
63
+ return [];
64
+ const page = await options.transport.list(entity.list, {
65
+ ...matching(meta, query),
66
+ meta: false,
67
+ });
68
+ return lookupOf(meta, page.rows).map((one) => ({
69
+ ref: entity.ref(one.id),
70
+ label: one.label,
71
+ ...(one.detail === undefined ? {} : { detail: one.detail }),
72
+ }));
73
+ },
74
+ load: async () => EntityView,
75
+ };
76
+ }
77
+ export function listView(list, options = {}) {
78
+ const Screen = options.list ?? definedList(list);
79
+ return {
80
+ domain: list.entity.domain,
81
+ kind: list.ref().kind,
82
+ title: () => list.title,
83
+ goto: options.goto ?? true,
84
+ ...(options.icon === undefined ? {} : { icon: options.icon }),
85
+ load: async () => Screen,
86
+ };
87
+ }
88
+ function definedList(list) {
89
+ function DefinedList({ api }) {
90
+ return _jsx(EntityList, { listing: useListing(list), api: api });
91
+ }
92
+ DefinedList.displayName = `List(${list.key})`;
93
+ return DefinedList;
94
+ }
@@ -0,0 +1,61 @@
1
+ import type { AnyRecord, Meta } from "@sincpro/criteria";
2
+ import type { OpenOptions, ResourceRef, WorkspaceApi } from "@sincpro/web-composer";
3
+ import type { Entity } from "../../core/entity.js";
4
+ import { type OpenTarget } from "@sincpro/web-ui";
5
+ import type { ReactNode } from "react";
6
+ /**
7
+ * Where a record opens, in the words of web-ui (`OpenTarget`) and the workspace of composer.
8
+ *
9
+ * Context: web-ui only SAYS where the person asked to open something — the name of a many2one
10
+ * opens in the first of its `openTargets`, «Abrir en…» offers the rest — and composer decides
11
+ * what each place is. This is the one translation between them, so every screen agrees:
12
+ *
13
+ * - a related record opens in a MODAL over the surface that asked: the person was doing
14
+ * something here, and comes back to exactly where they were;
15
+ * - from INSIDE a modal it opens beside or as a preview, never a modal over a modal (a second
16
+ * one would replace the first, and what was typed in it is lost) — composer says where a
17
+ * view is (`api.over`), so no screen has to;
18
+ * - a modal is the size of a record unless the caller says otherwise.
19
+ *
20
+ * The phone is composer's: a modal becomes a sheet, a side panel the page. No view asks.
21
+ */
22
+ /** A record's form fits; a short task (register a payment) passes its own size. */
23
+ export declare const RECORD_SIZE: {
24
+ readonly w: 760;
25
+ readonly h: 560;
26
+ };
27
+ /** Where a related record may open from this view, the default first. */
28
+ export declare function targetsFor(api: Pick<WorkspaceApi, "over">): readonly OpenTarget[];
29
+ /** web-ui's target as composer's options. Destinations share their names, so only the size is
30
+ * added — to the two that have one. */
31
+ export declare function openOptions(to: OpenTarget, size?: {
32
+ w: number;
33
+ h: number;
34
+ }): OpenOptions;
35
+ /**
36
+ * Open a record where it was asked, or where it belongs from here.
37
+ *
38
+ * <RelationValue onOpenIn={(to) => openIn(api, clientes.ref(id), to)} />
39
+ * openIn(api, pagoRef, "modal", { w: 640, h: 460 }) // a short task, at its own size
40
+ */
41
+ export declare function openIn(api: Pick<WorkspaceApi, "open" | "over">, ref: ResourceRef, to?: OpenTarget, size?: {
42
+ w: number;
43
+ h: number;
44
+ }): void;
45
+ /**
46
+ * A record this one points at — the customer of an invoice — read and opened: its name as a
47
+ * link, its face and what it is, the summary that floats from the name, and «Abrir en…».
48
+ *
49
+ * `value` is what the server sent for the many2one: the record it points at (expanded), or its
50
+ * key alone. Its model is the one this application knows for `entity`, unless given.
51
+ *
52
+ * <RelatedRecord api={api} entity={clientes} value={factura.record.customer} />
53
+ */
54
+ export declare function RelatedRecord<T extends AnyRecord>({ api, entity, value, meta, editor, }: {
55
+ api: WorkspaceApi;
56
+ entity: Entity<T>;
57
+ value: AnyRecord | string | null | undefined;
58
+ meta?: Meta | null;
59
+ /** The control that edits the relation — an `EntityPicker` — drawn in a form's edit mode. */
60
+ editor?: ReactNode;
61
+ }): import("react").JSX.Element;
@@ -0,0 +1,62 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useSummaryOf } from "../../react/useSummary.js";
4
+ import { relationOf } from "../record/summary.js";
5
+ import { RelationValue } from "@sincpro/web-ui";
6
+ /**
7
+ * Where a record opens, in the words of web-ui (`OpenTarget`) and the workspace of composer.
8
+ *
9
+ * Context: web-ui only SAYS where the person asked to open something — the name of a many2one
10
+ * opens in the first of its `openTargets`, «Abrir en…» offers the rest — and composer decides
11
+ * what each place is. This is the one translation between them, so every screen agrees:
12
+ *
13
+ * - a related record opens in a MODAL over the surface that asked: the person was doing
14
+ * something here, and comes back to exactly where they were;
15
+ * - from INSIDE a modal it opens beside or as a preview, never a modal over a modal (a second
16
+ * one would replace the first, and what was typed in it is lost) — composer says where a
17
+ * view is (`api.over`), so no screen has to;
18
+ * - a modal is the size of a record unless the caller says otherwise.
19
+ *
20
+ * The phone is composer's: a modal becomes a sheet, a side panel the page. No view asks.
21
+ */
22
+ /** A record's form fits; a short task (register a payment) passes its own size. */
23
+ export const RECORD_SIZE = { w: 760, h: 560 };
24
+ const IN_PLACE = ["modal", "peek", "side", "page"];
25
+ const FROM_A_MODAL = ["side", "peek", "page"];
26
+ /** Where a related record may open from this view, the default first. */
27
+ export function targetsFor(api) {
28
+ return api.over === null ? IN_PLACE : FROM_A_MODAL;
29
+ }
30
+ /** web-ui's target as composer's options. Destinations share their names, so only the size is
31
+ * added — to the two that have one. */
32
+ export function openOptions(to, size = RECORD_SIZE) {
33
+ return to === "modal" || to === "window" ? { to, size } : { to };
34
+ }
35
+ /**
36
+ * Open a record where it was asked, or where it belongs from here.
37
+ *
38
+ * <RelationValue onOpenIn={(to) => openIn(api, clientes.ref(id), to)} />
39
+ * openIn(api, pagoRef, "modal", { w: 640, h: 460 }) // a short task, at its own size
40
+ */
41
+ export function openIn(api, ref, to, size) {
42
+ const targets = targetsFor(api);
43
+ const where = to === undefined || !targets.includes(to) ? targets[0] : to;
44
+ api.open(ref, openOptions(where ?? "page", size));
45
+ }
46
+ /**
47
+ * A record this one points at — the customer of an invoice — read and opened: its name as a
48
+ * link, its face and what it is, the summary that floats from the name, and «Abrir en…».
49
+ *
50
+ * `value` is what the server sent for the many2one: the record it points at (expanded), or its
51
+ * key alone. Its model is the one this application knows for `entity`, unless given.
52
+ *
53
+ * <RelatedRecord api={api} entity={clientes} value={factura.record.customer} />
54
+ */
55
+ export function RelatedRecord({ api, entity, value, meta, editor, }) {
56
+ const summarize = useSummaryOf(entity);
57
+ const extra = editor === undefined ? {} : { editor };
58
+ if (value === null || value === undefined || value === "")
59
+ return _jsx(RelationValue, { name: null, ...extra });
60
+ const summary = summarize(typeof value === "string" ? { id: value } : value, meta);
61
+ return (_jsx(RelationValue, { ...relationOf(summary), onOpenIn: (to) => openIn(api, entity.ref(summary.id), to), openTargets: targetsFor(api), ...extra }));
62
+ }
@@ -0,0 +1,30 @@
1
+ import type { AnyRecord } from "@sincpro/criteria";
2
+ import { type ViewProps } from "@sincpro/web-composer";
3
+ import { type Entity } from "../../core/entity.js";
4
+ import { type RecordReading } from "../../react/useRecord.js";
5
+ import { type ReactNode } from "react";
6
+ /**
7
+ * The record of an entity, drawn from its model — the screen `entityView` opens when the
8
+ * application writes none: in the page, a modal, the preview or a panel, the same.
9
+ *
10
+ * Context: every Sincpro record screen was the same hundred lines: read it, tell the workspace
11
+ * its name and its unsaved work, draw its name and state on top, its fields, its lines, the
12
+ * conflict and the restored draft, who made it, its history. Here once, from what the model and
13
+ * the entity already say (`formOf`, `summaryOf`): a new entity has a useful screen before anyone
14
+ * writes it. It is a default, not a cage — `fields`/`pages` choose and order, `actions` puts the
15
+ * record's commands, and any screen can drop this and keep `useRecord` + `RecordField`.
16
+ *
17
+ * entityView(invoices, { transport, name: "Facturas" }) // this screen
18
+ * entityView(invoices, { …, record: (props) =>
19
+ * <EntityRecord {...props} entity={invoices} fields={["customer_id", "due_date"]}
20
+ * actions={(invoice) => <CommandButton entity={invoices} command="post" … />} /> })
21
+ */
22
+ export declare function EntityRecord<T extends AnyRecord>({ api, resource, entity, fields, pages, actions, }: ViewProps & {
23
+ entity: Entity<T>;
24
+ /** The fields, in this order. Without it, the model's (`formOf`). */
25
+ fields?: readonly string[];
26
+ /** The one2many shown as pages, in this order. Without it, every one the model has. */
27
+ pages?: readonly string[];
28
+ /** The record's own commands, beside «save / discard». */
29
+ actions?: (record: RecordReading<T>) => ReactNode;
30
+ }): import("react").JSX.Element;
@@ -0,0 +1,80 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { labelOf, nameOf } from "@sincpro/criteria";
4
+ import { useCrumbs } from "@sincpro/web-composer";
5
+ import { frameworkCopy } from "../../core/copy.js";
6
+ import { TRAIL_PARAM } from "../../core/entity.js";
7
+ import { formOf } from "../../core/form.js";
8
+ import { placeOf, stepIn } from "../../core/trail.js";
9
+ import { useFramework } from "../../react/context.js";
10
+ import { useHistory } from "../../react/useHistory.js";
11
+ import { useRecord } from "../../react/useRecord.js";
12
+ import { useSummaryOf } from "../../react/useSummary.js";
13
+ import { dirtyOf, RecordField } from "../record/edit.js";
14
+ import { Refused } from "../Refused.js";
15
+ import { useRecordInWorkspace } from "./workspace.js";
16
+ import { Button, Callout, Record, Skeleton } from "@sincpro/web-ui";
17
+ import { useState } from "react";
18
+ /**
19
+ * The record of an entity, drawn from its model — the screen `entityView` opens when the
20
+ * application writes none: in the page, a modal, the preview or a panel, the same.
21
+ *
22
+ * Context: every Sincpro record screen was the same hundred lines: read it, tell the workspace
23
+ * its name and its unsaved work, draw its name and state on top, its fields, its lines, the
24
+ * conflict and the restored draft, who made it, its history. Here once, from what the model and
25
+ * the entity already say (`formOf`, `summaryOf`): a new entity has a useful screen before anyone
26
+ * writes it. It is a default, not a cage — `fields`/`pages` choose and order, `actions` puts the
27
+ * record's commands, and any screen can drop this and keep `useRecord` + `RecordField`.
28
+ *
29
+ * entityView(invoices, { transport, name: "Facturas" }) // this screen
30
+ * entityView(invoices, { …, record: (props) =>
31
+ * <EntityRecord {...props} entity={invoices} fields={["customer_id", "due_date"]}
32
+ * actions={(invoice) => <CommandButton entity={invoices} command="post" … />} /> })
33
+ */
34
+ export function EntityRecord({ api, resource, entity, fields, pages, actions, }) {
35
+ const reading = useRecord(entity, resource.id ?? "");
36
+ const crumbs = useCrumbs(api);
37
+ useRecordInWorkspace(api, reading);
38
+ const history = useHistory(entity, reading.isNew ? "" : reading.id, [reading.record]);
39
+ const summarize = useSummaryOf(entity);
40
+ const [page, setPage] = useState(undefined);
41
+ const copy = frameworkCopy().record;
42
+ // Opened from a list: where it stands in it, and its neighbours (`Trail`).
43
+ const { trails, transport } = useFramework();
44
+ const trailKey = resource.params?.[TRAIL_PARAM];
45
+ const trail = trailKey === undefined ? undefined : trails.get(trailKey);
46
+ const go = (step) => () => {
47
+ if (trail === undefined || trailKey === undefined)
48
+ return;
49
+ void stepIn(transport, trail, reading.id, step).then((moved) => {
50
+ if (moved === null)
51
+ return;
52
+ trails.set(trailKey, moved.trail);
53
+ const ref = entity.ref(moved.id);
54
+ // In place, as Odoo's pager: the neighbour is not a step deeper, no crumb leads back.
55
+ api.replace({ ...ref, params: { ...ref.params, [TRAIL_PARAM]: trailKey } });
56
+ });
57
+ };
58
+ if (reading.status === "loading")
59
+ return _jsx(Skeleton, { rows: 6, label: copy.loading });
60
+ if (reading.status === "failed")
61
+ return _jsx(Refused, { error: reading.error, onRetry: reading.reload });
62
+ const { meta, locale, edit } = reading;
63
+ const layout = formOf(meta, entity.summary);
64
+ const shown = fields ?? layout.fields;
65
+ const tabs = pages ?? layout.pages;
66
+ const summary = reading.isNew ? undefined : summarize(reading.record, meta);
67
+ const dirty = dirtyOf(reading);
68
+ const created = summary?.created;
69
+ const updated = summary?.updated;
70
+ const stamp = (one) => one?.at == null ? undefined : { at: one.at, by: one.by ?? "—" };
71
+ const createdStamp = stamp(created);
72
+ const updatedStamp = stamp(updated);
73
+ // Moving away with something typed would lose it: «save / discard» comes first.
74
+ const place = trail === undefined || reading.isNew || edit.dirty ? null : placeOf(trail, reading.id);
75
+ return (_jsxs(Record, { identity: {
76
+ title: summary?.name ?? copy.creating(nameOf(meta, locale) || ""),
77
+ ...(summary?.type == null ? {} : { subtitle: summary.type }),
78
+ ...(summary?.state == null ? {} : { tag: { label: summary.state } }),
79
+ }, ...(dirty === undefined ? {} : { dirty }), ...(edit.failure === null ? {} : { error: String(edit.failure.message) }), children: [_jsxs(Record.Rail, { children: [crumbs.length === 0 ? null : _jsx(Record.Crumbs, { crumbs: crumbs }), place === null ? null : (_jsx(Record.Pager, { index: place.index, total: place.total, onPrev: go(-1), onNext: go(1) })), _jsx(Record.Actions, { children: actions?.(reading) ?? null })] }), _jsxs(Record.Paper, { layout: "horizontal", children: [_jsxs("div", { className: "flex flex-col gap-3 p-4", children: [edit.conflict ? (_jsx(Callout, { tone: "warn", title: copy.conflict, children: _jsx(Button, { size: "sm", role: "secondary", onClick: reading.reload, children: copy.reload }) })) : null, edit.restored ? (_jsx(Callout, { tone: "info", title: copy.restored, children: null })) : null, shown.map((name) => (_jsx(RecordField, { record: reading, name: name }, name))), edit.advice.map((one) => (_jsx(Callout, { tone: "info", title: one.text, children: null }, one.text)))] }), tabs.length === 0 || reading.isNew ? null : (_jsx(Record.Pages, { current: page ?? tabs[0], onChange: setPage, children: tabs.map((name) => (_jsx(Record.Page, { id: name, label: labelOf(meta, name, locale), children: _jsx(RecordField, { record: reading, name: name, bare: true }) }, name))) })), createdStamp === undefined && updatedStamp === undefined ? null : (_jsx(Record.Audit, { ...(createdStamp === undefined ? {} : { created: createdStamp }), ...(updatedStamp === undefined ? {} : { updated: updatedStamp }) }))] }), history === undefined ? null : _jsx(Record.Activity, { events: history })] }));
80
+ }
@@ -0,0 +1,2 @@
1
+ import type { ComposerSkin } from "@sincpro/web-composer";
2
+ export declare const webUiSkin: ComposerSkin;