@sincpro/web-framework 0.0.0-stage → 0.1.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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -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 +69 -0
  6. package/dist/core/copy.js +111 -0
  7. package/dist/core/edit.d.ts +27 -0
  8. package/dist/core/edit.js +51 -0
  9. package/dist/core/entity.d.ts +79 -0
  10. package/dist/core/entity.js +32 -0
  11. package/dist/core/history.d.ts +20 -0
  12. package/dist/core/history.js +40 -0
  13. package/dist/core/index.d.ts +19 -0
  14. package/dist/core/index.js +19 -0
  15. package/dist/core/lookup.d.ts +16 -0
  16. package/dist/core/lookup.js +10 -0
  17. package/dist/core/memory.d.ts +43 -0
  18. package/dist/core/memory.js +85 -0
  19. package/dist/core/meta.d.ts +114 -0
  20. package/dist/core/meta.js +130 -0
  21. package/dist/core/metas.d.ts +18 -0
  22. package/dist/core/metas.js +7 -0
  23. package/dist/core/present.d.ts +48 -0
  24. package/dist/core/present.js +165 -0
  25. package/dist/core/related.d.ts +22 -0
  26. package/dist/core/related.js +21 -0
  27. package/dist/core/rest.d.ts +30 -0
  28. package/dist/core/rest.js +50 -0
  29. package/dist/core/schema.d.ts +46 -0
  30. package/dist/core/schema.js +128 -0
  31. package/dist/core/transport.d.ts +59 -0
  32. package/dist/core/transport.js +43 -0
  33. package/dist/index.d.ts +10 -0
  34. package/dist/index.js +10 -0
  35. package/dist/react/context.d.ts +27 -0
  36. package/dist/react/context.js +18 -0
  37. package/dist/react/index.d.ts +12 -0
  38. package/dist/react/index.js +12 -0
  39. package/dist/react/useCommand.d.ts +28 -0
  40. package/dist/react/useCommand.js +52 -0
  41. package/dist/react/useHistory.d.ts +5 -0
  42. package/dist/react/useHistory.js +41 -0
  43. package/dist/react/useListing.d.ts +75 -0
  44. package/dist/react/useListing.js +121 -0
  45. package/dist/react/useLookup.d.ts +22 -0
  46. package/dist/react/useLookup.js +15 -0
  47. package/dist/react/useRecord.d.ts +74 -0
  48. package/dist/react/useRecord.js +176 -0
  49. package/dist/react/useRelated.d.ts +21 -0
  50. package/dist/react/useRelated.js +32 -0
  51. package/dist/web/Refused.d.ts +9 -0
  52. package/dist/web/Refused.js +14 -0
  53. package/dist/web/index.d.ts +17 -0
  54. package/dist/web/index.js +17 -0
  55. package/dist/web/list/EntityList.d.ts +69 -0
  56. package/dist/web/list/EntityList.js +82 -0
  57. package/dist/web/list/EntitySearchBar.d.ts +26 -0
  58. package/dist/web/list/EntitySearchBar.js +43 -0
  59. package/dist/web/list/columns.d.ts +22 -0
  60. package/dist/web/list/columns.js +27 -0
  61. package/dist/web/list/search.d.ts +21 -0
  62. package/dist/web/list/search.js +72 -0
  63. package/dist/web/record/CommandButton.d.ts +22 -0
  64. package/dist/web/record/CommandButton.js +40 -0
  65. package/dist/web/record/EntityPicker.d.ts +27 -0
  66. package/dist/web/record/EntityPicker.js +39 -0
  67. package/dist/web/record/RelatedList.d.ts +18 -0
  68. package/dist/web/record/RelatedList.js +16 -0
  69. package/dist/web/record/edit.d.ts +35 -0
  70. package/dist/web/record/edit.js +63 -0
  71. package/dist/web/record/record.d.ts +25 -0
  72. package/dist/web/record/record.js +56 -0
  73. package/dist/web/workspace/SincproApp.d.ts +37 -0
  74. package/dist/web/workspace/SincproApp.js +45 -0
  75. package/dist/web/workspace/composer.d.ts +33 -0
  76. package/dist/web/workspace/composer.js +37 -0
  77. package/dist/web/workspace/skin.d.ts +11 -0
  78. package/dist/web/workspace/skin.js +33 -0
  79. package/dist/web/workspace/workspace.d.ts +16 -0
  80. package/dist/web/workspace/workspace.js +29 -0
  81. package/package.json +86 -4
@@ -0,0 +1,21 @@
1
+ import type { AnyRecord, Criteria } from "@sincpro/criteria";
2
+ import type { Entity } from "../core/entity.js";
3
+ import type { Listing, ListingOptions } from "./useListing.js";
4
+ import type { RecordReading } from "./useRecord.js";
5
+ /**
6
+ * The records a relation of ONE record points to, as a live listing of the related entity.
7
+ *
8
+ * Context: sincpro_framework sends a one2many inside its record as ONE page (`RecordLines` draws
9
+ * it). A customer with forty thousand invoices, a tenant with a thousand members, the lines of a
10
+ * big order need more than that page: search in them, order them, walk them. The model says how
11
+ * to ask — the related aggregate's key that points back (`identified_by`) — so the relation is
12
+ * a criteria over the related entity, `where <that key> = <this record's key>`, answered by the
13
+ * server like any list. The relation's scope is AND-ed with everything else, so no search, filter
14
+ * or page can ever leave it.
15
+ *
16
+ * const facturas = useRelated(cliente, "invoices", invoicesEntity);
17
+ * <EntityList listing={facturas} embedded />
18
+ */
19
+ export declare function useRelated<P extends AnyRecord, T extends AnyRecord>(record: RecordReading<P>, name: string, related: Entity<T>, options?: Omit<ListingOptions<T>, "criteria"> & {
20
+ criteria?: Criteria<T>;
21
+ }): Listing<T>;
@@ -0,0 +1,32 @@
1
+ "use client";
2
+ import { useListing } from "./useListing.js";
3
+ /**
4
+ * The records a relation of ONE record points to, as a live listing of the related entity.
5
+ *
6
+ * Context: sincpro_framework sends a one2many inside its record as ONE page (`RecordLines` draws
7
+ * it). A customer with forty thousand invoices, a tenant with a thousand members, the lines of a
8
+ * big order need more than that page: search in them, order them, walk them. The model says how
9
+ * to ask — the related aggregate's key that points back (`identified_by`) — so the relation is
10
+ * a criteria over the related entity, `where <that key> = <this record's key>`, answered by the
11
+ * server like any list. The relation's scope is AND-ed with everything else, so no search, filter
12
+ * or page can ever leave it.
13
+ *
14
+ * const facturas = useRelated(cliente, "invoices", invoicesEntity);
15
+ * <EntityList listing={facturas} embedded />
16
+ */
17
+ export function useRelated(record, name, related, options = {}) {
18
+ const field = record.meta?.fields[name];
19
+ const key = field?.identified_by ?? "";
20
+ const own = record.meta?.get_id || record.meta?.identity || "id";
21
+ const value = record.record === null ? null : record.record[own];
22
+ const scope = {
23
+ ...options.criteria,
24
+ where: {
25
+ all: [
26
+ ...(options.criteria?.where ? [options.criteria.where] : []),
27
+ { field: key, operator: "=", value: value },
28
+ ],
29
+ },
30
+ };
31
+ return useListing(related, { pageSize: 10, ...options, criteria: scope });
32
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * A failure, said the way the server said it: its title, what happened and what to do —
3
+ * `guidance` included, which is the part a person acts on. Retry only when retrying can help:
4
+ * a «you have no access» with a retry button is a button that lies.
5
+ */
6
+ export declare function Refused({ error, onRetry }: {
7
+ error: unknown;
8
+ onRetry?: () => void;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { frameworkCopy } from "../core/copy.js";
4
+ import { explain } from "../core/transport.js";
5
+ import { Button, Failed } from "@sincpro/web-ui";
6
+ /**
7
+ * A failure, said the way the server said it: its title, what happened and what to do —
8
+ * `guidance` included, which is the part a person acts on. Retry only when retrying can help:
9
+ * a «you have no access» with a retry button is a button that lies.
10
+ */
11
+ export function Refused({ error, onRetry }) {
12
+ const { heading, sentence, retryable } = explain(error);
13
+ return (_jsx(Failed, { heading: heading, sentence: sentence, action: onRetry !== undefined && retryable ? (_jsx(Button, { size: "sm", role: "secondary", onClick: onRetry, children: frameworkCopy().failure.retry })) : undefined }));
14
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The web bindings: what draws a `Listing` and a record with `@sincpro/web-ui`.
3
+ */
4
+ export { type ColumnOptions, columnsOf } from "./list/columns.js";
5
+ export { EntityList, type EntityListProps, type NamedFilter, } from "./list/EntityList.js";
6
+ export { EntitySearchBar } from "./list/EntitySearchBar.js";
7
+ export { facetsOf, modelOf } from "./list/search.js";
8
+ export { CommandButton } from "./record/CommandButton.js";
9
+ export { dirtyOf, type FieldControl, RecordField, } from "./record/edit.js";
10
+ export { EntityPicker } from "./record/EntityPicker.js";
11
+ export { countOf, factsOf, RecordLines } from "./record/record.js";
12
+ export { RelatedList } from "./record/RelatedList.js";
13
+ export { Refused } from "./Refused.js";
14
+ export { entityView, type EntityViewOptions, } from "./workspace/composer.js";
15
+ export { SincproApp } from "./workspace/SincproApp.js";
16
+ export { webUiSkin } from "./workspace/skin.js";
17
+ export { useRecordInWorkspace } from "./workspace/workspace.js";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The web bindings: what draws a `Listing` and a record with `@sincpro/web-ui`.
3
+ */
4
+ export { columnsOf } from "./list/columns.js";
5
+ export { EntityList, } from "./list/EntityList.js";
6
+ export { EntitySearchBar } from "./list/EntitySearchBar.js";
7
+ export { facetsOf, modelOf } from "./list/search.js";
8
+ export { CommandButton } from "./record/CommandButton.js";
9
+ export { dirtyOf, RecordField, } from "./record/edit.js";
10
+ export { EntityPicker } from "./record/EntityPicker.js";
11
+ export { countOf, factsOf, RecordLines } from "./record/record.js";
12
+ export { RelatedList } from "./record/RelatedList.js";
13
+ export { Refused } from "./Refused.js";
14
+ export { entityView, } from "./workspace/composer.js";
15
+ export { SincproApp } from "./workspace/SincproApp.js";
16
+ export { webUiSkin } from "./workspace/skin.js";
17
+ export { useRecordInWorkspace } from "./workspace/workspace.js";
@@ -0,0 +1,69 @@
1
+ import { type AnyRecord } from "@sincpro/criteria";
2
+ import { type WorkspaceApi } from "@sincpro/web-composer";
3
+ import type { Listing } from "../../react/useListing.js";
4
+ import { type NamedFilter } from "./EntitySearchBar.js";
5
+ import { type ListCrumb } from "@sincpro/web-ui";
6
+ import { type ReactNode } from "react";
7
+ /**
8
+ * A whole collection from a `Listing`: the bar, the search, the set in the views it offers,
9
+ * and the way through it — the screen forge wrote five times by hand, written once.
10
+ *
11
+ * Context: this is the ready-made composition, not a cage. Everything it does is reachable on
12
+ * its own — `columnsOf` for the columns, `EntitySearchBar` for the search, `useListing` for the
13
+ * state — so a screen that needs its own bar or a second table writes `List` with those.
14
+ *
15
+ * Built for sets of any size: in `pages` mode (the default) the screen holds ONE page and walks
16
+ * the server's cursor, so a million rows cost what fifty do; in `more` mode it stops growing at
17
+ * its ceiling and asks to narrow the search. Nothing is filtered, ordered or counted on the
18
+ * page: every gesture is a question to the server.
19
+ *
20
+ * Where a record opens is the composer's question: with `api`, a row opens the page and the
21
+ * identity column is the composer's `RecordLink` (peek, side panel, window); without it, the
22
+ * screen passes `onOpen`.
23
+ */
24
+ export type { NamedFilter };
25
+ export type EntityListProps<T extends AnyRecord> = {
26
+ listing: Listing<T>;
27
+ /**
28
+ * The workspace this list is a view of. With it, a row opens its record as a page, the
29
+ * identity column is the composer's `RecordLink` (peek, side panel, window on hover) and the
30
+ * breadcrumb is the composer's — nothing for the screen to write.
31
+ */
32
+ api?: WorkspaceApi;
33
+ /** The fields shown, in order. Without it, every scalar field of the model. */
34
+ columns?: readonly string[];
35
+ /** The cells that are not plain text: a link, a tag. Memoise it: compared by identity. */
36
+ cells?: Readonly<Partial<Record<string, (row: T) => ReactNode>>>;
37
+ /**
38
+ * The ways to look at the set, the first one shown: `table` (columns), `rows` (a name and
39
+ * its facts, what a narrow panel wants) and `board` (cards). The same set, the same search,
40
+ * the same page in each.
41
+ */
42
+ views?: readonly ("table" | "rows" | "board")[];
43
+ /** Without it, the model's own name. */
44
+ title?: string;
45
+ description?: string;
46
+ crumbs?: readonly ListCrumb[];
47
+ actions?: ReactNode;
48
+ onOpen?: (row: T) => void;
49
+ onPeek?: (row: T) => void;
50
+ /** What an empty list says. */
51
+ empty?: string;
52
+ /**
53
+ * `"bar"` (the default) is the full search bar with the criteria builder; `"box"` is one
54
+ * text box over the entity's search template — a chooser, a narrow panel; `false` is none.
55
+ */
56
+ search?: "bar" | "box" | false;
57
+ /** The cuts this list offers by name: «sólo pagadas», «vencidas». */
58
+ filters?: readonly NamedFilter<T>[];
59
+ /**
60
+ * Rows can be ticked. With `api`, what is ticked is PUBLISHED to the workspace
61
+ * (`api.select`): a panel beside it follows the selection without knowing this list.
62
+ */
63
+ selectable?: boolean;
64
+ /** What to do with what is ticked: drawn in the bar while something is. */
65
+ selectionActions?: ReactNode;
66
+ /** The list is a FIELD of a record — its lines, its members — and not a screen. */
67
+ embedded?: boolean;
68
+ };
69
+ export declare function EntityList<T extends AnyRecord>({ listing, api, columns, cells, views, title, description, crumbs, actions, onOpen, onPeek, empty, search, filters, selectable, selectionActions, embedded, }: EntityListProps<T>): import("react").JSX.Element;
@@ -0,0 +1,82 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { nameOf } from "@sincpro/criteria";
4
+ import { RecordLink } from "@sincpro/web-composer";
5
+ import { frameworkCopy } from "../../core/copy.js";
6
+ import { textOf, viewOf } from "../../core/present.js";
7
+ import { explain } from "../../core/transport.js";
8
+ import { columnsOf } from "./columns.js";
9
+ import { EntitySearchBar, } from "./EntitySearchBar.js";
10
+ import { Button, List, Pagination, SearchField, Tag } from "@sincpro/web-ui";
11
+ import { useMemo, useState } from "react";
12
+ export function EntityList({ listing, api, columns, cells, views = ["table"], title, description, crumbs, actions, onOpen, onPeek, empty, search = "bar", filters = [], selectable = false, selectionActions, embedded = false, }) {
13
+ const copy = frameworkCopy().list;
14
+ const { meta, locale, count, page } = listing;
15
+ const identity = meta?.identity ?? "id";
16
+ const idOf = (row) => String(row[identity]);
17
+ const failed = listing.status === "failed" ? explain(listing.error) : null;
18
+ const dropped = listing.dropped.map((d) => viewOf(meta, d.field, locale).label);
19
+ const [present, setPresent] = useState(views[0] ?? "table");
20
+ const opening = onOpen ?? (api ? (row) => api.open(listing.entity.ref(idOf(row))) : undefined);
21
+ // A row click opens the page — the composer's one predictable gesture. web-ui's table turns a
22
+ // row click into a PEEK whenever `onPeek` is given, so the list never passes one by itself:
23
+ // the peek, the side panel and the window are the composer's `RecordLink` on the identity.
24
+ const linked = api === undefined || cells?.[identity] !== undefined
25
+ ? cells
26
+ : {
27
+ ...cells,
28
+ [identity]: (row) => (_jsx(RecordLink, { api: api, resource: listing.entity.ref(idOf(row)), children: idOf(row) })),
29
+ };
30
+ const trail = crumbs ?? (embedded ? undefined : api?.crumbs());
31
+ const [ticked, setTicked] = useState(() => new Set());
32
+ const tick = (next) => {
33
+ setTicked(next);
34
+ api?.select([...next].map((id) => listing.entity.ref(id)));
35
+ };
36
+ const selection = selectable
37
+ ? {
38
+ has: (id) => ticked.has(id),
39
+ toggle: (id) => {
40
+ const next = new Set(ticked);
41
+ if (next.has(id))
42
+ next.delete(id);
43
+ else
44
+ next.add(id);
45
+ tick(next);
46
+ },
47
+ count: ticked.size,
48
+ clear: () => tick(new Set()),
49
+ ...(selectionActions === undefined ? {} : { actions: selectionActions }),
50
+ }
51
+ : undefined;
52
+ // Rebuilt only when what it reads changes: a new set of columns on every render redraws
53
+ // every cell of the table. `columns` is compared by content (it is usually a literal);
54
+ // `cells` by identity, since its functions may close over state — memoise it to keep this.
55
+ const names = columns?.join("\u0000");
56
+ const shown = useMemo(() => columnsOf(meta, columns, { locale, ...(linked ? { cells: linked } : {}) }),
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ [meta, locale, names, cells, api]);
59
+ // A row and a card read the record by its display and the first fields shown beside it.
60
+ const display = meta?.display || identity;
61
+ const facts = shown
62
+ .filter((c) => c.id !== display && c.id !== identity && !c.defaultHidden)
63
+ .slice(0, 3);
64
+ const rowOf = (row) => ({
65
+ name: textOf(meta, display, row[display], locale) ?? idOf(row),
66
+ meta: [
67
+ ...(display === identity ? [] : [idOf(row)]),
68
+ ...facts.map((c) => textOf(meta, c.id, row[c.id], locale)).filter(Boolean),
69
+ ].join(" · "),
70
+ });
71
+ return (_jsxs(List, { items: listing.rows, keyOf: idOf, embedded: embedded, present: present, onPresentChange: (next) => setPresent(next), sort: listing.order === null
72
+ ? null
73
+ : { key: listing.order.field, direction: listing.order.descending ? "desc" : "asc" }, onSortChange: (next) => listing.orderBy(next === null ? null : { field: next.key, descending: next.direction === "desc" }), state: {
74
+ loading: listing.status === "loading",
75
+ error: failed === null
76
+ ? null
77
+ : [failed.heading, failed.sentence].filter(Boolean).join(". "),
78
+ }, filtered: listing.where !== undefined || listing.text !== "", empty: {
79
+ sentence: empty ?? copy.empty,
80
+ whenFiltered: { sentence: copy.emptyFiltered },
81
+ }, ...(selection === undefined ? {} : { selection }), ...(opening === undefined ? {} : { onOpen: opening }), ...(onPeek === undefined ? {} : { onPeek }), children: [_jsxs(List.Bar, { children: [trail === undefined || trail.length === 0 ? null : _jsx(List.Crumbs, { crumbs: trail }), embedded ? null : (_jsx(List.Identity, { title: title ?? nameOf(meta, locale), ...(description === undefined ? {} : { description }) })), search === "bar" ? (_jsx(List.Search, { children: _jsx(EntitySearchBar, { listing: listing, filters: filters }) })) : null, search === "box" && meta?.search != null ? (_jsx(List.Search, { children: _jsx(SearchField, { value: listing.text, onChange: listing.search, placeholder: copy.search, "aria-label": copy.search }) })) : null, actions === undefined ? null : _jsx(List.Actions, { children: actions }), _jsxs(List.Pagination, { children: [dropped.length === 0 ? null : (_jsx(Tag, { tone: "warn", title: copy.dropped(dropped), children: copy.dropped(dropped) })), listing.paging === "pages" ? (page.from === 0 ? null : (_jsx(Pagination, { from: page.from, to: page.to, total: count?.value ?? page.to, ...(page.hasPrev ? { onPrev: listing.prev } : {}), ...(page.hasNext ? { onNext: listing.next } : {}) }))) : (_jsxs(_Fragment, { children: [count === null ? null : (_jsx("span", { className: "text-meta text-muted", children: copy.shown(listing.rows.length, count.value, count.exact) })), listing.capped ? (_jsx("span", { className: "text-meta text-muted", children: copy.capped(listing.rows.length) })) : listing.hasMore ? (_jsx(Button, { size: "sm", role: "secondary", disabled: listing.status === "loadingMore", onClick: listing.more, children: listing.status === "loadingMore" ? copy.loading : copy.loadMore })) : null] }))] })] }), views.includes("table") ? _jsx(List.Table, { columns: shown }) : null, views.includes("rows") ? _jsx(List.Rows, { row: rowOf }) : null, views.includes("board") ? _jsx(List.Board, {}) : null] }));
82
+ }
@@ -0,0 +1,26 @@
1
+ import type { AnyRecord, Expression } from "@sincpro/criteria";
2
+ import type { Listing } from "../../react/useListing.js";
3
+ /**
4
+ * web-ui's search bar and criteria builder, bound to a `Listing`.
5
+ *
6
+ * Context: this is the «search over a set» every collection screen needs — a list, the lines
7
+ * of a record, a chooser — so it is a piece of its own, not something only `EntityList` can do.
8
+ * A screen that writes its own `List` drops it in `List.Search` and gets the same behaviour:
9
+ *
10
+ * - «search by name»: facets are the fields the entity's search template names first;
11
+ * - a typed question («total > 5000») is checked against each field's operators;
12
+ * - a named filter is one click; the builder writes what nobody foresaw (all/any/not);
13
+ * - everything put becomes ONE `where` the SERVER answers (`listing.filter`).
14
+ *
15
+ * <List.Search><EntitySearchBar listing={listing} filters={…} /></List.Search>
16
+ */
17
+ export type NamedFilter<T> = {
18
+ id: string;
19
+ label: string;
20
+ where: Expression<T>;
21
+ };
22
+ export declare function EntitySearchBar<T extends AnyRecord>({ listing, filters, }: {
23
+ listing: Listing<T>;
24
+ /** The cuts this set offers by name: «sólo pagadas», «vencidas». */
25
+ filters?: readonly NamedFilter<T>[];
26
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,43 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { frameworkCopy } from "../../core/copy.js";
4
+ import { facetsOf, modelOf } from "./search.js";
5
+ import { CriteriaBuilder, describeFilter, Dialog, filterCriteria, hasNamed, SearchBar, toggleNamed, whereOf, } from "@sincpro/web-ui";
6
+ import { useMemo, useState } from "react";
7
+ export function EntitySearchBar({ listing, filters = [], }) {
8
+ const copy = frameworkCopy().list;
9
+ const { meta, locale } = listing;
10
+ const [put, setPut] = useState([]);
11
+ const [building, setBuilding] = useState(false);
12
+ const model = useMemo(() => (meta ? modelOf(meta, locale) : undefined), [meta, locale]);
13
+ const facets = useMemo(() => (meta ? facetsOf(meta, locale) : []), [meta, locale]);
14
+ if (model === undefined)
15
+ return null;
16
+ const place = (next) => {
17
+ setPut(next);
18
+ listing.filter(whereOf(next));
19
+ };
20
+ return (_jsxs(_Fragment, { children: [_jsx(SearchBar, { facets: facets, model: model, criteria: put, onCriteriaChange: place, placeholder: copy.searchBar, ...(filters.length === 0
21
+ ? {}
22
+ : {
23
+ filters: [
24
+ {
25
+ options: filters.map((one) => ({
26
+ id: one.id,
27
+ label: one.label,
28
+ checked: hasNamed(put, one.id),
29
+ })),
30
+ },
31
+ ],
32
+ onFilterSelect: (id) => {
33
+ const named = filters.find((one) => one.id === id);
34
+ if (named !== undefined)
35
+ place(toggleNamed(put, { ...named, where: named.where }));
36
+ },
37
+ }), onOpenBuilder: () => setBuilding(true) }), building ? (_jsx(Dialog, { open: true, width: "lg", title: copy.builder, onClose: () => setBuilding(false), children: _jsx(CriteriaBuilder, { model: model, ...(whereOf(put) === undefined ? {} : { initial: { where: whereOf(put) } }), onApply: (built) => {
38
+ place(built?.where === undefined
39
+ ? []
40
+ : [filterCriteria(built.where, describeFilter(built.where, model))]);
41
+ setBuilding(false);
42
+ }, onCancel: () => setBuilding(false) }) })) : null] }));
43
+ }
@@ -0,0 +1,22 @@
1
+ import type { AnyRecord, Meta } from "@sincpro/criteria";
2
+ import type { DataColumn } from "@sincpro/web-ui";
3
+ import type { ReactNode } from "react";
4
+ /**
5
+ * The columns of a table, from the model: name the fields, the rest follows.
6
+ *
7
+ * Context: forge's lists wrote every column by hand — `header: "nombre"`, `value: (p) =>
8
+ * p.display_name`, a date formatter per screen — while the server already said what each field
9
+ * is called and what type it holds. Here a column is a NAME: its header is the model's label,
10
+ * its cell the text the value reads as (`textOf`), its sort key the raw value, and whether it
11
+ * can be ordered is what the server said (`sortable`). A cell that needs more than text — a
12
+ * link, a tag — is the one thing a screen writes (`cells`).
13
+ *
14
+ * The fields NOT named still exist: they come after, `optional` and hidden, so the columns
15
+ * menu offers them and nobody edits the screen to see a field the server already sends.
16
+ */
17
+ export type ColumnOptions<T> = {
18
+ locale?: string | undefined;
19
+ /** The cells that are not plain text, by field name. */
20
+ cells?: Readonly<Partial<Record<string, (row: T) => ReactNode>>>;
21
+ };
22
+ export declare function columnsOf<T extends AnyRecord>(meta: Meta | null | undefined, names?: readonly string[], options?: ColumnOptions<T>): DataColumn<T>[];
@@ -0,0 +1,27 @@
1
+ import { BOOKKEEPING, defaultFields, sortKeyOf, textOf, viewOf, } from "../../core/present.js";
2
+ function columnOf(meta, name, options, hidden) {
3
+ const view = viewOf(meta, name, options.locale);
4
+ const text = (row) => textOf(meta, name, row[name], options.locale) ?? "";
5
+ const own = options.cells?.[name];
6
+ return {
7
+ id: name,
8
+ header: view.label,
9
+ field: name,
10
+ value: (row) => sortKeyOf(meta, name, row[name]),
11
+ cell: own ?? text,
12
+ text,
13
+ numeric: view.numeric,
14
+ sortable: view.sortable,
15
+ ...(view.name === meta?.identity ? { mono: true } : {}),
16
+ ...(hidden ? { optional: true, defaultHidden: true } : {}),
17
+ };
18
+ }
19
+ export function columnsOf(meta, names, options = {}) {
20
+ const all = defaultFields(meta);
21
+ const shown = names ?? all.filter((name) => !BOOKKEEPING.has(name));
22
+ const rest = all.filter((name) => !shown.includes(name));
23
+ return [
24
+ ...shown.map((name) => columnOf(meta, name, options, false)),
25
+ ...rest.map((name) => columnOf(meta, name, options, true)),
26
+ ];
27
+ }
@@ -0,0 +1,21 @@
1
+ import { type Meta } from "@sincpro/criteria";
2
+ import type { ModelDefinition, SearchFacet } from "@sincpro/web-ui";
3
+ /**
4
+ * The model, as web-ui's search bar and criteria builder read it.
5
+ *
6
+ * Context: web-ui keeps its OWN vocabulary of a model (`ModelDefinition`), written before the
7
+ * framework settled its wire, and the two drifted: `identifiedBy` against `identified_by`, one
8
+ * `translations` object against a `label` on each field. web-ui is right to own it — it is a
9
+ * design system and must not depend on one backend — so the translation lives here, the one
10
+ * place that knows both.
11
+ *
12
+ * Known gap: web-ui's field has `choices` but no word for each choice, so the bar offers an
13
+ * enum's raw values («paid») where a cell says «Pagada». Fixing it is web-ui's.
14
+ */
15
+ export declare function modelOf(meta: Meta, locale?: string): ModelDefinition;
16
+ /**
17
+ * Where a typed text can be looked for: the fields the entity's own search template names
18
+ * first (what «search by name» means for THIS entity), then every other text field it can
19
+ * filter on.
20
+ */
21
+ export declare function facetsOf(meta: Meta, locale?: string): SearchFacet[];
@@ -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,27 @@
1
+ import type { AnyRecord } from "@sincpro/criteria";
2
+ import type { Entity } from "../../core/entity.js";
3
+ /**
4
+ * A many2one: pick ONE record of an entity — web-ui's `Picker` fed by `useLookup`, with a way
5
+ * into the whole set however big it is.
6
+ *
7
+ * Context: two ways to choose, for two sizes. The picker holds the first page of choices and
8
+ * narrows it as you type — instant for a set of tens. For a set of millions, «browse
9
+ * everything» opens a chooser: the entity's full list, searched, ordered and paged by the
10
+ * SERVER, where clicking a row answers the field. web-ui's `Picker` keeps what is typed to
11
+ * itself and cannot ask the server by text; the chooser is how this scales without it.
12
+ *
13
+ * Inside a workspace, «open» and the chooser can be the composer's instead (`api.open`,
14
+ * `api.pick`): pass `onOpen` / `onBrowseAll` and they replace the defaults.
15
+ */
16
+ export declare function EntityPicker<T extends AnyRecord>({ entity, value, onChange, onOpen, onBrowseAll, placeholder, disabled, "aria-label": ariaLabel, }: {
17
+ entity: Entity<T>;
18
+ value?: string;
19
+ onChange: (id: string) => void;
20
+ /** Open the chosen record — in composer, `(id) => api.open(entity.ref(id), { to: "window" })`. */
21
+ onOpen?: (id: string) => void;
22
+ /** Replace the built-in chooser — in composer, `api.pick(entity.ref(), …)`. */
23
+ onBrowseAll?: () => void;
24
+ placeholder?: string;
25
+ disabled?: boolean;
26
+ "aria-label"?: string;
27
+ }): import("react").JSX.Element;
@@ -0,0 +1,39 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { nameOf } from "@sincpro/criteria";
4
+ import { useListing } from "../../react/useListing.js";
5
+ import { useLookup } from "../../react/useLookup.js";
6
+ import { EntityList } from "../list/EntityList.js";
7
+ import { Dialog, Picker } from "@sincpro/web-ui";
8
+ import { useState } from "react";
9
+ /**
10
+ * A many2one: pick ONE record of an entity — web-ui's `Picker` fed by `useLookup`, with a way
11
+ * into the whole set however big it is.
12
+ *
13
+ * Context: two ways to choose, for two sizes. The picker holds the first page of choices and
14
+ * narrows it as you type — instant for a set of tens. For a set of millions, «browse
15
+ * everything» opens a chooser: the entity's full list, searched, ordered and paged by the
16
+ * SERVER, where clicking a row answers the field. web-ui's `Picker` keeps what is typed to
17
+ * itself and cannot ask the server by text; the chooser is how this scales without it.
18
+ *
19
+ * Inside a workspace, «open» and the chooser can be the composer's instead (`api.open`,
20
+ * `api.pick`): pass `onOpen` / `onBrowseAll` and they replace the defaults.
21
+ */
22
+ export function EntityPicker({ entity, value, onChange, onOpen, onBrowseAll, placeholder, disabled, "aria-label": ariaLabel, }) {
23
+ const lookup = useLookup(entity);
24
+ const [choosing, setChoosing] = useState(false);
25
+ return (_jsxs(_Fragment, { children: [_jsx(Picker, { items: lookup.options.map((one) => ({
26
+ id: one.id,
27
+ name: one.label,
28
+ ...(one.detail === undefined ? {} : { meta: one.detail, haystack: one.detail }),
29
+ })), ...(value === undefined ? {} : { value }), onChange: onChange, loading: lookup.loading, ...(onOpen === undefined ? {} : { onOpen }), onBrowseAll: onBrowseAll ?? (() => setChoosing(true)), ...(placeholder === undefined ? {} : { placeholder }), ...(disabled === undefined ? {} : { disabled }), ...(ariaLabel === undefined ? {} : { "aria-label": ariaLabel }) }), choosing ? (_jsx(Chooser, { entity: entity, onChoose: (id) => {
30
+ onChange(id);
31
+ setChoosing(false);
32
+ }, onClose: () => setChoosing(false) })) : null] }));
33
+ }
34
+ /** The whole set in a window: search and pages on the server, a row answers the field. */
35
+ function Chooser({ entity, onChoose, onClose, }) {
36
+ const listing = useListing(entity, { pageSize: 20 });
37
+ const key = listing.meta?.get_id || listing.meta?.identity || "id";
38
+ return (_jsx(Dialog, { open: true, width: "lg", title: nameOf(listing.meta, listing.locale) || entity.kind, onClose: onClose, children: _jsx(EntityList, { listing: listing, embedded: true, search: "box", onOpen: (row) => onChoose(String(row[key])) }) }));
39
+ }