@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.
- package/README.md +261 -40
- package/dist/core/cache.js +2 -2
- package/dist/core/copy.d.ts +24 -0
- package/dist/core/copy.js +30 -0
- package/dist/core/edit.d.ts +13 -0
- package/dist/core/edit.js +30 -0
- package/dist/core/entity.d.ts +34 -0
- package/dist/core/entity.js +25 -0
- package/dist/core/form.d.ts +21 -0
- package/dist/core/form.js +30 -0
- package/dist/core/index.d.ts +10 -3
- package/dist/core/index.js +9 -2
- package/dist/core/list.d.ts +64 -0
- package/dist/core/list.js +22 -0
- package/dist/core/lookup.d.ts +5 -2
- package/dist/core/lookup.js +6 -2
- package/dist/core/measure.d.ts +16 -0
- package/dist/core/measure.js +38 -0
- package/dist/core/memory.js +18 -4
- package/dist/core/relation.d.ts +28 -0
- package/dist/core/relation.js +30 -0
- package/dist/core/rest.js +13 -3
- package/dist/core/retry.d.ts +24 -0
- package/dist/core/retry.js +25 -0
- package/dist/core/summary.d.ts +50 -0
- package/dist/core/summary.js +54 -0
- package/dist/core/trail.d.ts +44 -0
- package/dist/core/trail.js +70 -0
- package/dist/core/transport.d.ts +13 -1
- package/dist/react/context.d.ts +12 -1
- package/dist/react/context.js +10 -2
- package/dist/react/index.d.ts +3 -1
- package/dist/react/index.js +3 -1
- package/dist/react/useCommand.d.ts +3 -1
- package/dist/react/useCommand.js +12 -4
- package/dist/react/useGroups.d.ts +40 -0
- package/dist/react/useGroups.js +69 -0
- package/dist/react/useListing.d.ts +21 -3
- package/dist/react/useListing.js +38 -3
- package/dist/react/useLookup.d.ts +30 -5
- package/dist/react/useLookup.js +56 -3
- package/dist/react/useRecord.d.ts +17 -2
- package/dist/react/useRecord.js +105 -9
- package/dist/react/useSummary.d.ts +15 -0
- package/dist/react/useSummary.js +22 -0
- package/dist/web/index.d.ts +9 -3
- package/dist/web/index.js +9 -3
- package/dist/web/list/EntityList.d.ts +27 -4
- package/dist/web/list/EntityList.js +162 -15
- package/dist/web/list/EntitySearchBar.d.ts +16 -8
- package/dist/web/list/EntitySearchBar.js +34 -4
- package/dist/web/list/groups.d.ts +15 -0
- package/dist/web/list/groups.js +73 -0
- package/dist/web/list/rowCommands.d.ts +41 -0
- package/dist/web/list/rowCommands.js +56 -0
- package/dist/web/record/EntityPicker.d.ts +42 -13
- package/dist/web/record/EntityPicker.js +53 -19
- package/dist/web/record/EntityTags.d.ts +38 -0
- package/dist/web/record/EntityTags.js +41 -0
- package/dist/web/record/RelatedList.d.ts +2 -1
- package/dist/web/record/RelatedList.js +12 -2
- package/dist/web/record/edit.d.ts +12 -5
- package/dist/web/record/edit.js +67 -8
- package/dist/web/record/opening.d.ts +26 -0
- package/dist/web/record/opening.js +10 -0
- package/dist/web/record/summary.d.ts +22 -0
- package/dist/web/record/summary.js +55 -0
- package/dist/web/workspace/SincproApp.d.ts +4 -1
- package/dist/web/workspace/SincproApp.js +2 -2
- package/dist/web/workspace/composer.d.ts +26 -4
- package/dist/web/workspace/composer.js +59 -2
- package/dist/web/workspace/open.d.ts +61 -0
- package/dist/web/workspace/open.js +62 -0
- package/dist/web/workspace/record.d.ts +30 -0
- package/dist/web/workspace/record.js +80 -0
- package/dist/web/workspace/skin.d.ts +0 -9
- package/dist/web/workspace/skin.js +25 -13
- package/dist/web/workspace/workspace.d.ts +31 -3
- package/dist/web/workspace/workspace.js +57 -4
- 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
|
-
|
|
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
|
-
}),
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
13
|
-
*
|
|
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,
|