@sincpro/web-framework 0.1.0 → 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 (80) hide show
  1. package/README.md +261 -40
  2. package/dist/core/cache.js +2 -2
  3. package/dist/core/copy.d.ts +24 -0
  4. package/dist/core/copy.js +30 -0
  5. package/dist/core/edit.d.ts +13 -0
  6. package/dist/core/edit.js +30 -0
  7. package/dist/core/entity.d.ts +34 -0
  8. package/dist/core/entity.js +25 -0
  9. package/dist/core/form.d.ts +21 -0
  10. package/dist/core/form.js +30 -0
  11. package/dist/core/index.d.ts +10 -3
  12. package/dist/core/index.js +9 -2
  13. package/dist/core/list.d.ts +64 -0
  14. package/dist/core/list.js +22 -0
  15. package/dist/core/lookup.d.ts +5 -2
  16. package/dist/core/lookup.js +6 -2
  17. package/dist/core/measure.d.ts +16 -0
  18. package/dist/core/measure.js +38 -0
  19. package/dist/core/memory.js +18 -4
  20. package/dist/core/relation.d.ts +28 -0
  21. package/dist/core/relation.js +30 -0
  22. package/dist/core/rest.js +13 -3
  23. package/dist/core/retry.d.ts +24 -0
  24. package/dist/core/retry.js +25 -0
  25. package/dist/core/summary.d.ts +50 -0
  26. package/dist/core/summary.js +54 -0
  27. package/dist/core/trail.d.ts +44 -0
  28. package/dist/core/trail.js +70 -0
  29. package/dist/core/transport.d.ts +13 -1
  30. package/dist/react/context.d.ts +12 -1
  31. package/dist/react/context.js +10 -2
  32. package/dist/react/index.d.ts +3 -1
  33. package/dist/react/index.js +3 -1
  34. package/dist/react/useCommand.d.ts +3 -1
  35. package/dist/react/useCommand.js +12 -4
  36. package/dist/react/useGroups.d.ts +40 -0
  37. package/dist/react/useGroups.js +69 -0
  38. package/dist/react/useListing.d.ts +21 -3
  39. package/dist/react/useListing.js +38 -3
  40. package/dist/react/useLookup.d.ts +30 -5
  41. package/dist/react/useLookup.js +56 -3
  42. package/dist/react/useRecord.d.ts +17 -2
  43. package/dist/react/useRecord.js +105 -9
  44. package/dist/react/useSummary.d.ts +15 -0
  45. package/dist/react/useSummary.js +22 -0
  46. package/dist/web/index.d.ts +9 -3
  47. package/dist/web/index.js +9 -3
  48. package/dist/web/list/EntityList.d.ts +27 -4
  49. package/dist/web/list/EntityList.js +162 -15
  50. package/dist/web/list/EntitySearchBar.d.ts +16 -8
  51. package/dist/web/list/EntitySearchBar.js +34 -4
  52. package/dist/web/list/groups.d.ts +15 -0
  53. package/dist/web/list/groups.js +73 -0
  54. package/dist/web/list/rowCommands.d.ts +41 -0
  55. package/dist/web/list/rowCommands.js +56 -0
  56. package/dist/web/record/EntityPicker.d.ts +42 -13
  57. package/dist/web/record/EntityPicker.js +53 -19
  58. package/dist/web/record/EntityTags.d.ts +38 -0
  59. package/dist/web/record/EntityTags.js +41 -0
  60. package/dist/web/record/RelatedList.d.ts +2 -1
  61. package/dist/web/record/RelatedList.js +12 -2
  62. package/dist/web/record/edit.d.ts +12 -5
  63. package/dist/web/record/edit.js +67 -8
  64. package/dist/web/record/opening.d.ts +26 -0
  65. package/dist/web/record/opening.js +10 -0
  66. package/dist/web/record/summary.d.ts +22 -0
  67. package/dist/web/record/summary.js +55 -0
  68. package/dist/web/workspace/SincproApp.d.ts +4 -1
  69. package/dist/web/workspace/SincproApp.js +2 -2
  70. package/dist/web/workspace/composer.d.ts +26 -4
  71. package/dist/web/workspace/composer.js +59 -2
  72. package/dist/web/workspace/open.d.ts +61 -0
  73. package/dist/web/workspace/open.js +62 -0
  74. package/dist/web/workspace/record.d.ts +30 -0
  75. package/dist/web/workspace/record.js +80 -0
  76. package/dist/web/workspace/skin.d.ts +0 -9
  77. package/dist/web/workspace/skin.js +25 -13
  78. package/dist/web/workspace/workspace.d.ts +31 -3
  79. package/dist/web/workspace/workspace.js +57 -4
  80. package/package.json +5 -5
@@ -1,16 +1,40 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { matching } from "@sincpro/criteria";
3
4
  import { frameworkCopy } from "../../core/copy.js";
5
+ import { groupableOf } from "./groups.js";
4
6
  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 = [], }) {
7
+ import { CriteriaBuilder, describeFilter, Dialog, fillCopy, filterCriteria, GroupingLevels, hasNamed, SearchBar, toggleNamed, useGroupingControl, useUiCopy, whereOf, } from "@sincpro/web-ui";
8
+ import { useEffect, useMemo, useRef, useState } from "react";
9
+ /** No grouping: one constant, so the control is not handed a new array on every render. */
10
+ const NO_LEVELS = [];
11
+ export function EntitySearchBar({ listing, filters = [], groups, seed, }) {
8
12
  const copy = frameworkCopy().list;
9
13
  const { meta, locale } = listing;
10
14
  const [put, setPut] = useState([]);
11
15
  const [building, setBuilding] = useState(false);
12
16
  const model = useMemo(() => (meta ? modelOf(meta, locale) : undefined), [meta, locale]);
13
17
  const facets = useMemo(() => (meta ? facetsOf(meta, locale) : []), [meta, locale]);
18
+ const groupable = useMemo(() => groupableOf(meta, locale), [meta, locale]);
19
+ const ui = useUiCopy();
20
+ const seeded = useRef(false);
21
+ useEffect(() => {
22
+ if (seeded.current || seed === undefined || seed === "" || meta?.search == null)
23
+ return;
24
+ seeded.current = true;
25
+ const where = matching(meta, seed).where;
26
+ if (where === undefined)
27
+ return;
28
+ const chip = filterCriteria(where, fillCopy(ui.search.literalChip, { text: seed }));
29
+ setPut([chip]);
30
+ listing.filter(whereOf([chip]));
31
+ // Once the definition is known; what the person changes afterwards is theirs.
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
33
+ }, [meta, seed]);
34
+ const control = useGroupingControl({
35
+ levels: groups?.levels ?? NO_LEVELS,
36
+ onLevelsChange: (next) => groups?.group(next),
37
+ });
14
38
  if (model === undefined)
15
39
  return null;
16
40
  const place = (next) => {
@@ -34,7 +58,13 @@ export function EntitySearchBar({ listing, filters = [], }) {
34
58
  if (named !== undefined)
35
59
  place(toggleNamed(put, { ...named, where: named.where }));
36
60
  },
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) => {
61
+ }), ...(groups === undefined || groupable.length === 0
62
+ ? {}
63
+ : { grouping: _jsx(GroupingLevels, { fields: groupable, control: control }) }), onOpenBuilder: () => setBuilding(true), ...(meta?.search == null
64
+ ? {}
65
+ : {
66
+ literal: (text) => matching(meta, text).where ?? null,
67
+ }) }), 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
68
  place(built?.where === undefined
39
69
  ? []
40
70
  : [filterCriteria(built.where, describeFilter(built.where, model))]);
@@ -0,0 +1,15 @@
1
+ import { type AnyRecord, type Bucket, type Meta } from "@sincpro/criteria";
2
+ import type { GroupLevel } from "../../react/useGroups.js";
3
+ import { type GroupableField, type Pile } from "@sincpro/web-ui";
4
+ /** The fields the model says may split a set (`groupable`), in its words. The identity splits
5
+ * into groups of one, and the version means nothing to a person: neither is offered. */
6
+ export declare function groupableOf(meta: Meta | null, locale?: string): GroupableField[];
7
+ /**
8
+ * The server's groups as piles, with the words a person reads, and each pile's group so that
9
+ * opening one reads its rows. Rows sit only on the deepest level, once read.
10
+ */
11
+ export declare function pilesOf<T extends AnyRecord>(buckets: readonly Bucket<T>[], levels: readonly GroupLevel[], meta: Meta | null, locale: string | undefined, rowsOf: (bucket: Bucket<T>) => readonly T[] | undefined): {
12
+ piles: Pile<T>[];
13
+ bucketAt: ReadonlyMap<string, Bucket<T>>;
14
+ deepest: number;
15
+ };
@@ -0,0 +1,73 @@
1
+ import { labelOf } from "@sincpro/criteria";
2
+ import { frameworkCopy } from "../../core/copy.js";
3
+ import { detailOf } from "../../core/measure.js";
4
+ import { textOf } from "../../core/present.js";
5
+ import { dateLabel, pathKey, } from "@sincpro/web-ui";
6
+ /**
7
+ * Grouping, as web-ui draws it: which fields may split a set, and the server's groups as the
8
+ * piles a `List` shows.
9
+ *
10
+ * Context: web-ui keeps «what was asked» (`GroupLevel`) apart from «what is drawn» (`Pile`)
11
+ * precisely so that piles can come counted from whoever keeps the data. These are the two
12
+ * translations from the model and from criteria's `Bucket`.
13
+ */
14
+ /** The cuts a date offers, coarse to fine — web-ui orders the levels by them. */
15
+ const DATE_GRAINS = ["year", "quarter", "month", "week", "day"];
16
+ /** The fields the model says may split a set (`groupable`), in its words. The identity splits
17
+ * into groups of one, and the version means nothing to a person: neither is offered. */
18
+ export function groupableOf(meta, locale) {
19
+ if (meta === null)
20
+ return [];
21
+ return Object.entries(meta.fields)
22
+ .filter(([name, field]) => field.groupable === true &&
23
+ field.kind === "scalar" &&
24
+ name !== meta.identity &&
25
+ name !== "version")
26
+ .map(([name, field]) => ({
27
+ field: name,
28
+ label: labelOf(meta, name, locale),
29
+ ...(field.type === "date" || field.type === "datetime" ? { grains: DATE_GRAINS } : {}),
30
+ }));
31
+ }
32
+ /**
33
+ * The server's groups as piles, with the words a person reads, and each pile's group so that
34
+ * opening one reads its rows. Rows sit only on the deepest level, once read.
35
+ */
36
+ export function pilesOf(buckets, levels, meta, locale, rowsOf) {
37
+ const bucketAt = new Map();
38
+ const none = frameworkCopy().list.noValue;
39
+ const labelFor = (bucket, depth, key) => {
40
+ const level = levels[depth];
41
+ if (bucket.value === null || bucket.value === undefined || bucket.value === "")
42
+ return none;
43
+ if (level?.grain) {
44
+ const within = levels
45
+ .slice(0, depth)
46
+ .flatMap((one) => (one.field === level.field && one.grain ? [one.grain] : []));
47
+ return dateLabel(key, level.grain, within);
48
+ }
49
+ return textOf(meta, bucket.field, bucket.value, locale) || key;
50
+ };
51
+ const detailed = (bucket) => {
52
+ const detail = detailOf(meta, bucket.measures, locale);
53
+ return detail === undefined ? {} : { detail };
54
+ };
55
+ const walk = (level, above, depth) => level.map((bucket) => {
56
+ const key = bucket.value === null || bucket.value === undefined ? "" : String(bucket.value);
57
+ const path = [...above, key];
58
+ bucketAt.set(pathKey(path), bucket);
59
+ const below = bucket.groups ?? [];
60
+ return {
61
+ path,
62
+ depth,
63
+ field: bucket.field,
64
+ key,
65
+ label: labelFor(bucket, depth, key),
66
+ count: bucket.count,
67
+ ...detailed(bucket),
68
+ items: below.length === 0 ? [...(rowsOf(bucket) ?? [])] : [],
69
+ piles: walk(below, path, depth + 1),
70
+ };
71
+ });
72
+ return { piles: walk(buckets, [], 0), bucketAt, deepest: levels.length - 1 };
73
+ }
@@ -0,0 +1,41 @@
1
+ import { type AnyRecord, type Expression } from "@sincpro/criteria";
2
+ import type { Listing } from "../../react/useListing.js";
3
+ import type { RowAction } from "@sincpro/web-ui";
4
+ import type { LucideIcon } from "lucide-react";
5
+ /**
6
+ * An entity's Commands as the actions of a row: «Confirmar» on each invoice, without opening it.
7
+ *
8
+ * Context: web-ui draws a row's actions (`rowActions`) and knows nothing of what they do; the
9
+ * entity knows its Commands and their routes. This joins them, with the rules a Command keeps
10
+ * everywhere: sent once per gesture with its `Idempotency-Key` (a retry is the same Command),
11
+ * the row's action disabled while it runs, and the list read again when it is done — the server
12
+ * says what the row became. `when` is a condition over the row, evaluated the way the server's
13
+ * hints are (`matches`): «Confirmar» only on drafts.
14
+ *
15
+ * Only Commands that can be undone belong on a row. One that destroys is confirmed by its dry
16
+ * run, and that is the record's `CommandButton`, where the consequence has room to be read.
17
+ *
18
+ * const commands = useRowCommands(listing, [
19
+ * { command: "confirmar", label: "Confirmar", icon: Check,
20
+ * when: { field: "state", operator: "=", value: "draft" } },
21
+ * ]);
22
+ * <EntityList listing={listing} rowActions={commands.rowActions} />
23
+ */
24
+ export type RowCommand<T> = {
25
+ /** A Command the entity declares (`entity({ commands })`). */
26
+ command: string;
27
+ label: string;
28
+ icon: LucideIcon;
29
+ tone?: RowAction["tone"];
30
+ /** Offered only on rows that hold it — a hint, not protection: the server decides. */
31
+ when?: Expression<T>;
32
+ /** What the Command is sent with. Without it, an empty body: the route names the row. */
33
+ body?: (row: T) => AnyRecord;
34
+ };
35
+ export type RowCommands<T> = {
36
+ rowActions: (row: T) => readonly RowAction[];
37
+ /** The last refusal, until `dismiss`. */
38
+ error: unknown;
39
+ dismiss: () => void;
40
+ };
41
+ export declare function useRowCommands<T extends AnyRecord>(listing: Listing<T>, commands: readonly RowCommand<T>[]): RowCommands<T>;
@@ -0,0 +1,56 @@
1
+ "use client";
2
+ import { matches } from "@sincpro/criteria";
3
+ import { pathFor } from "../../core/entity.js";
4
+ import { retryKeys } from "../../core/retry.js";
5
+ import { useFramework } from "../../react/context.js";
6
+ import { useCallback, useState } from "react";
7
+ export function useRowCommands(listing, commands) {
8
+ const { transport } = useFramework();
9
+ const [running, setRunning] = useState(new Set());
10
+ const [error, setError] = useState(null);
11
+ // One key per row and Command, kept while it goes unanswered: retrying is the same gesture.
12
+ const [keys] = useState(() => new Map());
13
+ const { entity, meta } = listing;
14
+ const key = meta?.get_id || meta?.identity || "id";
15
+ const run = useCallback(async (one, row) => {
16
+ const route = entity.commands[one.command];
17
+ if (route === undefined)
18
+ throw new Error(`${entity.key} declares no command "${one.command}"`);
19
+ const id = String(row[key]);
20
+ const gesture = `${one.command}\u0000${id}`;
21
+ const retry = keys.get(gesture) ?? retryKeys();
22
+ keys.set(gesture, retry);
23
+ const body = one.body?.(row) ?? {};
24
+ setRunning((now) => new Set(now).add(gesture));
25
+ setError(null);
26
+ try {
27
+ await transport.send(route.method, pathFor(route, id), body, undefined, {
28
+ idempotencyKey: retry.keyFor(body),
29
+ });
30
+ retry.settle(null);
31
+ listing.reload();
32
+ }
33
+ catch (failure) {
34
+ retry.settle(failure);
35
+ setError(failure);
36
+ }
37
+ finally {
38
+ setRunning((now) => {
39
+ const next = new Set(now);
40
+ next.delete(gesture);
41
+ return next;
42
+ });
43
+ }
44
+ }, [transport, entity, key, keys, listing]);
45
+ const rowActions = (row) => commands
46
+ .filter((one) => meta === null || one.when === undefined || matches(row, one.when, meta))
47
+ .map((one) => ({
48
+ id: one.command,
49
+ label: one.label,
50
+ icon: one.icon,
51
+ ...(one.tone === undefined ? {} : { tone: one.tone }),
52
+ disabled: running.has(`${one.command}\u0000${String(row[key])}`),
53
+ onSelect: () => void run(one, row),
54
+ }));
55
+ return { rowActions, error, dismiss: () => setError(null) };
56
+ }
@@ -1,26 +1,55 @@
1
- import type { AnyRecord } from "@sincpro/criteria";
1
+ import type { AnyRecord, Criteria } from "@sincpro/criteria";
2
2
  import type { Entity } from "../../core/entity.js";
3
+ import { type ListDefinition } from "../../core/list.js";
4
+ import { type OpenTarget } from "@sincpro/web-ui";
3
5
  /**
4
6
  * A many2one: pick ONE record of an entity — web-ui's `Picker` fed by `useLookup`, with a way
5
7
  * into the whole set however big it is.
6
8
  *
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.
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.
12
16
  *
13
17
  * 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.
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"]} … />
15
33
  */
16
- export declare function EntityPicker<T extends AnyRecord>({ entity, value, onChange, onOpen, onBrowseAll, placeholder, disabled, "aria-label": ariaLabel, }: {
17
- entity: Entity<T>;
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>;
18
43
  value?: string;
19
44
  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;
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;
24
53
  placeholder?: string;
25
54
  disabled?: boolean;
26
55
  "aria-label"?: string;
@@ -1,39 +1,73 @@
1
1
  "use client";
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { nameOf } from "@sincpro/criteria";
4
+ import { frameworkCopy } from "../../core/copy.js";
5
+ import { isList } from "../../core/list.js";
4
6
  import { useListing } from "../../react/useListing.js";
5
7
  import { useLookup } from "../../react/useLookup.js";
6
8
  import { EntityList } from "../list/EntityList.js";
7
- import { Dialog, Picker } from "@sincpro/web-ui";
9
+ import { Dialog, IconButton, Picker } from "@sincpro/web-ui";
10
+ import { TextSearch } from "lucide-react";
8
11
  import { useState } from "react";
9
12
  /**
10
13
  * A many2one: pick ONE record of an entity — web-ui's `Picker` fed by `useLookup`, with a way
11
14
  * into the whole set however big it is.
12
15
  *
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.
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.
18
23
  *
19
24
  * 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.
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"]} … />
21
40
  */
22
- export function EntityPicker({ entity, value, onChange, onOpen, onBrowseAll, placeholder, disabled, "aria-label": ariaLabel, }) {
23
- const lookup = useLookup(entity);
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;
24
47
  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) => {
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) => {
30
59
  onChange(id);
31
60
  setChoosing(false);
32
61
  }, onClose: () => setChoosing(false) })) : null] }));
33
62
  }
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 });
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
+ });
37
70
  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])) }) }));
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])) }) }));
39
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
+ }
@@ -8,7 +8,8 @@ import { type EntityListProps } from "../list/EntityList.js";
8
8
  * Context: the same `EntityList` a screen uses, `embedded` so it reads as a field of the record
9
9
  * (a one-line bar that does not stick), over `useRelated`. Its search box fills the RELATED
10
10
  * entity's own search template: the lines of an order are searched by product, the invoices of
11
- * a customer by number. Use `RecordLines` when the page the record brought is all there is.
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.
12
13
  */
13
14
  export declare function RelatedList<P extends AnyRecord, T extends AnyRecord>({ record, name, entity, pageSize, ...list }: {
14
15
  record: RecordReading<P>;
@@ -1,16 +1,26 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { defaultFields } from "../../core/present.js";
3
4
  import { useRelated } from "../../react/useRelated.js";
4
5
  import { EntityList } from "../list/EntityList.js";
6
+ import { useMemo } from "react";
5
7
  /**
6
8
  * A relation of a record as a list inside it — searched, ordered and paged by the server.
7
9
  *
8
10
  * Context: the same `EntityList` a screen uses, `embedded` so it reads as a field of the record
9
11
  * (a one-line bar that does not stick), over `useRelated`. Its search box fills the RELATED
10
12
  * entity's own search template: the lines of an order are searched by product, the invoices of
11
- * a customer by number. Use `RecordLines` when the page the record brought is all there is.
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.
12
15
  */
13
16
  export function RelatedList({ record, name, entity, pageSize, ...list }) {
14
17
  const listing = useRelated(record, name, entity, pageSize === undefined ? {} : { pageSize });
15
- return _jsx(EntityList, { listing: listing, embedded: true, search: "box", ...list });
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 }) }));
16
26
  }
@@ -9,12 +9,17 @@ import type { ReactNode } from "react";
9
9
  * Context: forge's forms wrote `<Field label="Nombre" required><TextInput … /></Field>` once per
10
10
  * field, with the label, the «required» and the value plumbing by hand, while the server already
11
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 many2one with `EntityPicker`, a rich text, a domain widget — and
13
- * still receives the value, the setter and the state.
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).
14
20
  *
15
21
  * <RecordField record={invoice} name="due_date" />
16
- * <RecordField record={invoice} name="customer_id"
17
- * as={({ value, set }) => <EntityPicker entity={customers} value={String(value)} onChange={set} />} />
22
+ * <RecordField record={invoice} name="customer_id" />
18
23
  */
19
24
  export type FieldControl = {
20
25
  value: unknown;
@@ -23,10 +28,12 @@ export type FieldControl = {
23
28
  invalid: boolean;
24
29
  label: string;
25
30
  };
26
- export declare function RecordField<T extends Record<string, unknown>>({ record, name, as, }: {
31
+ export declare function RecordField<T extends Record<string, unknown>>({ record, name, as, bare, }: {
27
32
  record: RecordReading<T>;
28
33
  name: string;
29
34
  as?: (control: FieldControl) => ReactNode;
35
+ /** The control without its label — inside a page or a section that already names it. */
36
+ bare?: boolean;
30
37
  }): import("react").JSX.Element | null;
31
38
  /**
32
39
  * The record's «save / discard», for `<Record dirty={…}>`: present only while something changed,