@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.
- package/LICENSE +21 -0
- package/README.md +224 -2
- package/dist/core/cache.d.ts +30 -0
- package/dist/core/cache.js +47 -0
- package/dist/core/copy.d.ts +69 -0
- package/dist/core/copy.js +111 -0
- package/dist/core/edit.d.ts +27 -0
- package/dist/core/edit.js +51 -0
- package/dist/core/entity.d.ts +79 -0
- package/dist/core/entity.js +32 -0
- package/dist/core/history.d.ts +20 -0
- package/dist/core/history.js +40 -0
- package/dist/core/index.d.ts +19 -0
- package/dist/core/index.js +19 -0
- package/dist/core/lookup.d.ts +16 -0
- package/dist/core/lookup.js +10 -0
- package/dist/core/memory.d.ts +43 -0
- package/dist/core/memory.js +85 -0
- package/dist/core/meta.d.ts +114 -0
- package/dist/core/meta.js +130 -0
- package/dist/core/metas.d.ts +18 -0
- package/dist/core/metas.js +7 -0
- package/dist/core/present.d.ts +48 -0
- package/dist/core/present.js +165 -0
- package/dist/core/related.d.ts +22 -0
- package/dist/core/related.js +21 -0
- package/dist/core/rest.d.ts +30 -0
- package/dist/core/rest.js +50 -0
- package/dist/core/schema.d.ts +46 -0
- package/dist/core/schema.js +128 -0
- package/dist/core/transport.d.ts +59 -0
- package/dist/core/transport.js +43 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/react/context.d.ts +27 -0
- package/dist/react/context.js +18 -0
- package/dist/react/index.d.ts +12 -0
- package/dist/react/index.js +12 -0
- package/dist/react/useCommand.d.ts +28 -0
- package/dist/react/useCommand.js +52 -0
- package/dist/react/useHistory.d.ts +5 -0
- package/dist/react/useHistory.js +41 -0
- package/dist/react/useListing.d.ts +75 -0
- package/dist/react/useListing.js +121 -0
- package/dist/react/useLookup.d.ts +22 -0
- package/dist/react/useLookup.js +15 -0
- package/dist/react/useRecord.d.ts +74 -0
- package/dist/react/useRecord.js +176 -0
- package/dist/react/useRelated.d.ts +21 -0
- package/dist/react/useRelated.js +32 -0
- package/dist/web/Refused.d.ts +9 -0
- package/dist/web/Refused.js +14 -0
- package/dist/web/index.d.ts +17 -0
- package/dist/web/index.js +17 -0
- package/dist/web/list/EntityList.d.ts +69 -0
- package/dist/web/list/EntityList.js +82 -0
- package/dist/web/list/EntitySearchBar.d.ts +26 -0
- package/dist/web/list/EntitySearchBar.js +43 -0
- package/dist/web/list/columns.d.ts +22 -0
- package/dist/web/list/columns.js +27 -0
- package/dist/web/list/search.d.ts +21 -0
- package/dist/web/list/search.js +72 -0
- package/dist/web/record/CommandButton.d.ts +22 -0
- package/dist/web/record/CommandButton.js +40 -0
- package/dist/web/record/EntityPicker.d.ts +27 -0
- package/dist/web/record/EntityPicker.js +39 -0
- package/dist/web/record/RelatedList.d.ts +18 -0
- package/dist/web/record/RelatedList.js +16 -0
- package/dist/web/record/edit.d.ts +35 -0
- package/dist/web/record/edit.js +63 -0
- package/dist/web/record/record.d.ts +25 -0
- package/dist/web/record/record.js +56 -0
- package/dist/web/workspace/SincproApp.d.ts +37 -0
- package/dist/web/workspace/SincproApp.js +45 -0
- package/dist/web/workspace/composer.d.ts +33 -0
- package/dist/web/workspace/composer.js +37 -0
- package/dist/web/workspace/skin.d.ts +11 -0
- package/dist/web/workspace/skin.js +33 -0
- package/dist/web/workspace/workspace.d.ts +16 -0
- package/dist/web/workspace/workspace.js +29 -0
- package/package.json +86 -4
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AnyRecord } from "@sincpro/criteria";
|
|
2
|
+
import type { Entity } from "../../core/entity.js";
|
|
3
|
+
import type { RecordReading } from "../../react/useRecord.js";
|
|
4
|
+
import { type EntityListProps } from "../list/EntityList.js";
|
|
5
|
+
/**
|
|
6
|
+
* A relation of a record as a list inside it — searched, ordered and paged by the server.
|
|
7
|
+
*
|
|
8
|
+
* Context: the same `EntityList` a screen uses, `embedded` so it reads as a field of the record
|
|
9
|
+
* (a one-line bar that does not stick), over `useRelated`. Its search box fills the RELATED
|
|
10
|
+
* entity's own search template: the lines of an order are searched by product, the invoices of
|
|
11
|
+
* a customer by number. Use `RecordLines` when the page the record brought is all there is.
|
|
12
|
+
*/
|
|
13
|
+
export declare function RelatedList<P extends AnyRecord, T extends AnyRecord>({ record, name, entity, pageSize, ...list }: {
|
|
14
|
+
record: RecordReading<P>;
|
|
15
|
+
name: string;
|
|
16
|
+
entity: Entity<T>;
|
|
17
|
+
pageSize?: number;
|
|
18
|
+
} & Omit<EntityListProps<T>, "listing" | "embedded">): import("react").JSX.Element;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useRelated } from "../../react/useRelated.js";
|
|
4
|
+
import { EntityList } from "../list/EntityList.js";
|
|
5
|
+
/**
|
|
6
|
+
* A relation of a record as a list inside it — searched, ordered and paged by the server.
|
|
7
|
+
*
|
|
8
|
+
* Context: the same `EntityList` a screen uses, `embedded` so it reads as a field of the record
|
|
9
|
+
* (a one-line bar that does not stick), over `useRelated`. Its search box fills the RELATED
|
|
10
|
+
* entity's own search template: the lines of an order are searched by product, the invoices of
|
|
11
|
+
* a customer by number. Use `RecordLines` when the page the record brought is all there is.
|
|
12
|
+
*/
|
|
13
|
+
export function RelatedList({ record, name, entity, pageSize, ...list }) {
|
|
14
|
+
const listing = useRelated(record, name, entity, pageSize === undefined ? {} : { pageSize });
|
|
15
|
+
return _jsx(EntityList, { listing: listing, embedded: true, search: "box", ...list });
|
|
16
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { RecordReading } from "../../react/useRecord.js";
|
|
2
|
+
import { type DirtyState } from "@sincpro/web-ui";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
/**
|
|
5
|
+
* One field of a record being edited, from the model: its word, whether it is required or
|
|
6
|
+
* read-only now (the hints, evaluated over what is on screen), the server's error for it, and a
|
|
7
|
+
* control chosen by its type.
|
|
8
|
+
*
|
|
9
|
+
* Context: forge's forms wrote `<Field label="Nombre" required><TextInput … /></Field>` once per
|
|
10
|
+
* field, with the label, the «required» and the value plumbing by hand, while the server already
|
|
11
|
+
* said all three. Here a field is a NAME. The control by type is a default, never the only way:
|
|
12
|
+
* `as` draws any control — a many2one with `EntityPicker`, a rich text, a domain widget — and
|
|
13
|
+
* still receives the value, the setter and the state.
|
|
14
|
+
*
|
|
15
|
+
* <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} />} />
|
|
18
|
+
*/
|
|
19
|
+
export type FieldControl = {
|
|
20
|
+
value: unknown;
|
|
21
|
+
set: (value: unknown) => void;
|
|
22
|
+
readonly: boolean;
|
|
23
|
+
invalid: boolean;
|
|
24
|
+
label: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function RecordField<T extends Record<string, unknown>>({ record, name, as, }: {
|
|
27
|
+
record: RecordReading<T>;
|
|
28
|
+
name: string;
|
|
29
|
+
as?: (control: FieldControl) => ReactNode;
|
|
30
|
+
}): import("react").JSX.Element | null;
|
|
31
|
+
/**
|
|
32
|
+
* The record's «save / discard», for `<Record dirty={…}>`: present only while something changed,
|
|
33
|
+
* off while saving, and off with the reason while a required field is empty.
|
|
34
|
+
*/
|
|
35
|
+
export declare function dirtyOf<T extends Record<string, unknown>>(record: RecordReading<T>): DirtyState | undefined;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { choicesOf } from "@sincpro/criteria";
|
|
4
|
+
import { frameworkCopy } from "../../core/copy.js";
|
|
5
|
+
import { textOf, viewOf } from "../../core/present.js";
|
|
6
|
+
import { Checkbox, DatePicker, Field, NumberInput, ReadValue, Select, TextArea, TextInput, } from "@sincpro/web-ui";
|
|
7
|
+
export function RecordField({ record, name, as, }) {
|
|
8
|
+
const { meta, locale, edit } = record;
|
|
9
|
+
const view = viewOf(meta, name, locale);
|
|
10
|
+
const state = edit.state(name);
|
|
11
|
+
if (!state.visible)
|
|
12
|
+
return null;
|
|
13
|
+
const value = edit.values[name];
|
|
14
|
+
const error = edit.errors[name];
|
|
15
|
+
const control = {
|
|
16
|
+
value,
|
|
17
|
+
set: (next) => edit.set(name, next),
|
|
18
|
+
readonly: state.readonly,
|
|
19
|
+
invalid: error !== undefined,
|
|
20
|
+
label: view.label,
|
|
21
|
+
};
|
|
22
|
+
const field = meta?.fields[name];
|
|
23
|
+
if (view.type === "boolean" && as === undefined)
|
|
24
|
+
return (_jsx(Checkbox, { checked: value === true, onChange: control.set, label: view.label, disabled: state.readonly }));
|
|
25
|
+
return (_jsx(Field, { label: view.label, required: state.required && !state.readonly, ...(view.help === undefined ? {} : { hint: view.help }), ...(error === undefined ? {} : { error }), children: as !== undefined ? (as(control)) : state.readonly ? (_jsx(ReadValue, { value: textOf(meta, name, value, locale) ?? "—" })) : (controlFor(control, view.type, field?.exact === true, choicesOf(meta, name, locale))) }));
|
|
26
|
+
}
|
|
27
|
+
function controlFor(control, type, exact, choices) {
|
|
28
|
+
const { value, set } = control;
|
|
29
|
+
if (choices.length > 0)
|
|
30
|
+
return (_jsx(Select, { value: value === null || value === undefined ? "" : String(value), options: choices.map((one) => ({ value: String(one.value), label: one.label })), onChange: set }));
|
|
31
|
+
switch (type) {
|
|
32
|
+
case "integer":
|
|
33
|
+
case "number":
|
|
34
|
+
return (_jsx(NumberInput, { value: value === null || value === undefined || value === "" ? "" : Number(value),
|
|
35
|
+
// A decimal travels as text so no digit is lost; the server reads it that way back.
|
|
36
|
+
onChange: (next) => set(next === "" ? null : exact ? String(next) : next) }));
|
|
37
|
+
case "date":
|
|
38
|
+
return (_jsx(DatePicker, { value: typeof value === "string" && value !== "" ? value : null, onChange: set }));
|
|
39
|
+
case "text[]":
|
|
40
|
+
return (_jsx(TextArea, { value: Array.isArray(value) ? value.join("\n") : "", onChange: (next) => set(next
|
|
41
|
+
.split("\n")
|
|
42
|
+
.map((line) => line.trim())
|
|
43
|
+
.filter(Boolean)) }));
|
|
44
|
+
default:
|
|
45
|
+
return (_jsx(TextInput, { value: value === null || value === undefined ? "" : String(value), onChange: set }));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The record's «save / discard», for `<Record dirty={…}>`: present only while something changed,
|
|
50
|
+
* off while saving, and off with the reason while a required field is empty.
|
|
51
|
+
*/
|
|
52
|
+
export function dirtyOf(record) {
|
|
53
|
+
const { edit, meta, locale } = record;
|
|
54
|
+
if (!edit.dirty)
|
|
55
|
+
return undefined;
|
|
56
|
+
const missing = edit.missing.map((name) => viewOf(meta, name, locale).label);
|
|
57
|
+
return {
|
|
58
|
+
onSave: () => void edit.save(),
|
|
59
|
+
onDiscard: edit.discard,
|
|
60
|
+
saving: edit.saving,
|
|
61
|
+
...(missing.length === 0 ? {} : { blocked: frameworkCopy().record.missing(missing) }),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type AnyRecord, type Meta } from "@sincpro/criteria";
|
|
2
|
+
import { type SheetField } from "@sincpro/web-ui";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
export declare function factsOf<T extends AnyRecord>(record: T, meta: Meta | null | undefined, names: readonly string[], options?: {
|
|
5
|
+
locale?: string | undefined;
|
|
6
|
+
cells?: Readonly<Partial<Record<string, (record: T) => ReactNode>>>;
|
|
7
|
+
}): SheetField[];
|
|
8
|
+
/** How many records a relation of this record holds — the TOTAL, not the page it brought. */
|
|
9
|
+
export declare function countOf(record: AnyRecord, name: string): number;
|
|
10
|
+
/**
|
|
11
|
+
* A one2many / many2many of the record, as an embedded list.
|
|
12
|
+
*
|
|
13
|
+
* Its columns come from the relation's own definition when the server expanded it, and are
|
|
14
|
+
* read off the rows otherwise. When the record brought only one page of them, it says so —
|
|
15
|
+
* «se muestran 50 de 1000» — instead of drawing 50 as if they were all.
|
|
16
|
+
*/
|
|
17
|
+
export declare function RecordLines<T extends AnyRecord>({ record, meta, name, columns, cells, locale, onOpen, }: {
|
|
18
|
+
record: AnyRecord;
|
|
19
|
+
meta: Meta | null | undefined;
|
|
20
|
+
name: string;
|
|
21
|
+
columns?: readonly string[];
|
|
22
|
+
cells?: Readonly<Partial<Record<string, (row: T) => ReactNode>>>;
|
|
23
|
+
locale?: string | undefined;
|
|
24
|
+
onOpen?: (row: T) => void;
|
|
25
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { describeRows } from "@sincpro/criteria";
|
|
4
|
+
import { frameworkCopy } from "../../core/copy.js";
|
|
5
|
+
import { textOf, viewOf } from "../../core/present.js";
|
|
6
|
+
import { relatedOf } from "../../core/related.js";
|
|
7
|
+
import { columnsOf } from "../list/columns.js";
|
|
8
|
+
import { List } from "@sincpro/web-ui";
|
|
9
|
+
/**
|
|
10
|
+
* The parts of ONE record, from the model.
|
|
11
|
+
*
|
|
12
|
+
* Context: `Record.*` in web-ui is a tree of slots — `Record.Facts` must be written as a child
|
|
13
|
+
* of `Record.Paper`, not hidden inside a component of ours, or the record does not find it.
|
|
14
|
+
* So these are not components that wrap `Record`: they are the PROPS of its slots, built from
|
|
15
|
+
* the model, and the screen keeps writing the tree:
|
|
16
|
+
*
|
|
17
|
+
* <Record.Paper>
|
|
18
|
+
* <Record.Facts fields={factsOf(record, meta, ["number", "customer", "due"])} />
|
|
19
|
+
* <Record.Pages>
|
|
20
|
+
* <Record.Page id="lines" label={labelOf(meta, "lines")} count={countOf(record, "lines")}>
|
|
21
|
+
* <RecordLines record={record} meta={meta} name="lines" />
|
|
22
|
+
* </Record.Page>
|
|
23
|
+
* </Record.Pages>
|
|
24
|
+
* </Record.Paper>
|
|
25
|
+
*/
|
|
26
|
+
const EMPTY = "—";
|
|
27
|
+
export function factsOf(record, meta, names, options = {}) {
|
|
28
|
+
return names.map((name) => {
|
|
29
|
+
const view = viewOf(meta, name, options.locale);
|
|
30
|
+
const own = options.cells?.[name];
|
|
31
|
+
return {
|
|
32
|
+
label: view.label,
|
|
33
|
+
value: own ? own(record) : (textOf(meta, name, record[name], options.locale) ?? EMPTY),
|
|
34
|
+
...(view.help === undefined ? {} : { hint: view.help }),
|
|
35
|
+
...(name === meta?.identity ? { mono: true } : {}),
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** How many records a relation of this record holds — the TOTAL, not the page it brought. */
|
|
40
|
+
export function countOf(record, name) {
|
|
41
|
+
return relatedOf(record[name]).count.value;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A one2many / many2many of the record, as an embedded list.
|
|
45
|
+
*
|
|
46
|
+
* Its columns come from the relation's own definition when the server expanded it, and are
|
|
47
|
+
* read off the rows otherwise. When the record brought only one page of them, it says so —
|
|
48
|
+
* «se muestran 50 de 1000» — instead of drawing 50 as if they were all.
|
|
49
|
+
*/
|
|
50
|
+
export function RecordLines({ record, meta, name, columns, cells, locale, onOpen, }) {
|
|
51
|
+
const related = relatedOf(record[name]);
|
|
52
|
+
const definition = meta?.fields[name]?.definition ?? describeRows(related.items);
|
|
53
|
+
const identity = definition.identity || "id";
|
|
54
|
+
const copy = frameworkCopy();
|
|
55
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(List, { embedded: true, items: related.items, keyOf: (row) => String(row[identity]), present: "table", empty: { sentence: copy.list.empty }, ...(onOpen === undefined ? {} : { onOpen }), children: _jsx(List.Table, { columns: columnsOf(definition, columns, { locale, ...(cells ? { cells } : {}) }) }) }), related.partial ? (_jsx("p", { className: "text-meta text-muted", children: copy.record.partial(related.items.length, related.count.value) })) : null] }));
|
|
56
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Meta } from "@sincpro/criteria";
|
|
2
|
+
import { type ComposerSkin, type ResourceRef, type ViewSpec } from "@sincpro/web-composer";
|
|
3
|
+
import { type CacheOptions } from "../../core/cache.js";
|
|
4
|
+
import type { Transport } from "../../core/transport.js";
|
|
5
|
+
import { type ReactNode } from "react";
|
|
6
|
+
/**
|
|
7
|
+
* The application context in one place: who answers (the transport), in which language, which
|
|
8
|
+
* model definitions are already known, the toasts, and the workspace with its views, ⌘K and
|
|
9
|
+
* shortcuts.
|
|
10
|
+
*
|
|
11
|
+
* Context: forge's `main.tsx` + `console.tsx` installed three copy tables, a toast provider, a
|
|
12
|
+
* context of its own and a composer by hand. This is that, once. It is a convenience, not the
|
|
13
|
+
* only door: everything it mounts is exported, and an app with its own shell mounts them itself.
|
|
14
|
+
* What it does NOT own: the session (the app builds the transport with its headers) and the
|
|
15
|
+
* application bar (the app draws its own, around this or beside it).
|
|
16
|
+
*/
|
|
17
|
+
export declare function SincproApp({ transport, views, start, language, metas, empty, skin, cache, }: {
|
|
18
|
+
transport: Transport;
|
|
19
|
+
/** The entities as composer views — `entityView(…)` — and any other view of the app. */
|
|
20
|
+
views: readonly ViewSpec[];
|
|
21
|
+
/** What opens first: a list, usually — `invoices.ref()`. */
|
|
22
|
+
start?: ResourceRef;
|
|
23
|
+
/** The interface's language: web-ui's, the composer's and the framework's words. */
|
|
24
|
+
language?: "es" | "en";
|
|
25
|
+
/** Definitions known before any request, by entity key. */
|
|
26
|
+
metas?: Readonly<Record<string, Meta>>;
|
|
27
|
+
/** Drawn when nothing is open. */
|
|
28
|
+
empty?: ReactNode;
|
|
29
|
+
/** How the workspace looks. web-ui's by default (`webUiSkin`); any piece can be replaced. */
|
|
30
|
+
skin?: ComposerSkin;
|
|
31
|
+
/**
|
|
32
|
+
* Repeated reads served from memory — the list gone back to, the record reopened — and
|
|
33
|
+
* forgotten the moment a write touches their entity (`cachedTransport`). 30 s by default;
|
|
34
|
+
* `false` asks the server every time.
|
|
35
|
+
*/
|
|
36
|
+
cache?: CacheOptions | false;
|
|
37
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { Composer, installComposerCopy, SPANISH_COMPOSER_COPY, useWorkspace, } from "@sincpro/web-composer";
|
|
4
|
+
import { createRegistry } from "@sincpro/web-composer/domain";
|
|
5
|
+
import { cachedTransport } from "../../core/cache.js";
|
|
6
|
+
import { installFrameworkCopy, SPANISH_FRAMEWORK_COPY, } from "../../core/copy.js";
|
|
7
|
+
import { FrameworkProvider } from "../../react/context.js";
|
|
8
|
+
import { webUiSkin } from "./skin.js";
|
|
9
|
+
import { installCopy, SPANISH_COPY, ToastProvider } from "@sincpro/web-ui";
|
|
10
|
+
import { useEffect, useMemo, useState } from "react";
|
|
11
|
+
/**
|
|
12
|
+
* The application context in one place: who answers (the transport), in which language, which
|
|
13
|
+
* model definitions are already known, the toasts, and the workspace with its views, ⌘K and
|
|
14
|
+
* shortcuts.
|
|
15
|
+
*
|
|
16
|
+
* Context: forge's `main.tsx` + `console.tsx` installed three copy tables, a toast provider, a
|
|
17
|
+
* context of its own and a composer by hand. This is that, once. It is a convenience, not the
|
|
18
|
+
* only door: everything it mounts is exported, and an app with its own shell mounts them itself.
|
|
19
|
+
* What it does NOT own: the session (the app builds the transport with its headers) and the
|
|
20
|
+
* application bar (the app draws its own, around this or beside it).
|
|
21
|
+
*/
|
|
22
|
+
export function SincproApp({ transport, views, start, language = "es", metas, empty, skin = webUiSkin, cache = {}, }) {
|
|
23
|
+
// Installed once, before anything draws: the three tables are module state of their packages.
|
|
24
|
+
useState(() => {
|
|
25
|
+
if (language === "es") {
|
|
26
|
+
installCopy(SPANISH_COPY);
|
|
27
|
+
installComposerCopy(SPANISH_COMPOSER_COPY);
|
|
28
|
+
installFrameworkCopy(SPANISH_FRAMEWORK_COPY);
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
});
|
|
32
|
+
const cached = useMemo(() => (cache === false ? transport : cachedTransport(transport, cache)),
|
|
33
|
+
// The options are read once: a cache that changes shape mid-session forgets everything.
|
|
34
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
35
|
+
[transport]);
|
|
36
|
+
const workspace = useWorkspace();
|
|
37
|
+
const registry = useMemo(() => createRegistry(views), [views]);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (start !== undefined)
|
|
40
|
+
workspace.push(start);
|
|
41
|
+
// The first screen only: a re-render must not push it again.
|
|
42
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
43
|
+
}, [workspace]);
|
|
44
|
+
return (_jsx(FrameworkProvider, { transport: cached, locale: language, ...(metas === undefined ? {} : { metas }), children: _jsx(ToastProvider, { children: _jsx("div", { className: "flex h-dvh min-h-0 flex-col bg-ground", children: _jsx(Composer, { workspace: workspace, registry: registry, skin: skin, ...(empty === undefined ? {} : { empty }) }) }) }) }));
|
|
45
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type AnyRecord, type Meta } from "@sincpro/criteria";
|
|
2
|
+
import type { ViewProps, ViewSpec } from "@sincpro/web-composer";
|
|
3
|
+
import type { Entity } from "../../core/entity.js";
|
|
4
|
+
import type { Transport } from "../../core/transport.js";
|
|
5
|
+
import type { ComponentType } from "react";
|
|
6
|
+
/**
|
|
7
|
+
* An entity as the composer's view: one `ViewSpec` for its list (a reference without an id)
|
|
8
|
+
* and its record (with one), searchable from ⌘K.
|
|
9
|
+
*
|
|
10
|
+
* Context: the composer opens references and never imports a domain — a domain DECLARES itself
|
|
11
|
+
* with a `ViewSpec`. The entity already knows its key (`domain:kind`), its routes and how a
|
|
12
|
+
* text finds one of its records (the framework's `LiteralSearch`: `meta.search` filled by
|
|
13
|
+
* `matching`), so its spec is written here once instead of by hand per kind (forge wrote
|
|
14
|
+
* eight). The screens stay the application's: `list` and `record` are its components.
|
|
15
|
+
*
|
|
16
|
+
* ⌘K asks the server with `matching` — 8 records, identity and display — the same question a
|
|
17
|
+
* many2one asks. The model definition is asked for once, on the first search, unless `meta`
|
|
18
|
+
* is handed over.
|
|
19
|
+
*/
|
|
20
|
+
export type EntityViewOptions = {
|
|
21
|
+
transport: Transport;
|
|
22
|
+
/** How it reads in the palette, a panel's frame and the breadcrumb. */
|
|
23
|
+
name: string;
|
|
24
|
+
list: ComponentType<ViewProps>;
|
|
25
|
+
record: ComponentType<ViewProps>;
|
|
26
|
+
icon?: ViewSpec["icon"];
|
|
27
|
+
/** Listed under «Go to» in ⌘K. On by default. */
|
|
28
|
+
goto?: boolean;
|
|
29
|
+
/** Known before any search: no request asks for it. */
|
|
30
|
+
meta?: Meta;
|
|
31
|
+
actions?: ViewSpec["actions"];
|
|
32
|
+
};
|
|
33
|
+
export declare function entityView<T extends AnyRecord>(entity: Entity<T>, options: EntityViewOptions): ViewSpec;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { matching } from "@sincpro/criteria";
|
|
4
|
+
import { lookupOf } from "../../core/lookup.js";
|
|
5
|
+
export function entityView(entity, options) {
|
|
6
|
+
const { list: List, record: One } = options;
|
|
7
|
+
let meta = options.meta ?? null;
|
|
8
|
+
function EntityView(props) {
|
|
9
|
+
return props.resource.id === undefined ? _jsx(List, { ...props }) : _jsx(One, { ...props });
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
domain: entity.domain,
|
|
13
|
+
kind: entity.kind,
|
|
14
|
+
title: (ref) => ref.id ?? options.name,
|
|
15
|
+
goto: options.goto ?? true,
|
|
16
|
+
...(options.icon === undefined ? {} : { icon: options.icon }),
|
|
17
|
+
...(options.actions === undefined ? {} : { actions: options.actions }),
|
|
18
|
+
search: async (query) => {
|
|
19
|
+
if (query.trim() === "")
|
|
20
|
+
return [];
|
|
21
|
+
if (meta === null)
|
|
22
|
+
meta = (await options.transport.list(entity.list, { pagination: { limit: 1 } })).meta;
|
|
23
|
+
if (meta?.search == null)
|
|
24
|
+
return [];
|
|
25
|
+
const page = await options.transport.list(entity.list, {
|
|
26
|
+
...matching(meta, query),
|
|
27
|
+
meta: false,
|
|
28
|
+
});
|
|
29
|
+
return lookupOf(meta, page.rows).map((one) => ({
|
|
30
|
+
ref: entity.ref(one.id),
|
|
31
|
+
label: one.label,
|
|
32
|
+
...(one.detail === undefined ? {} : { detail: one.detail }),
|
|
33
|
+
}));
|
|
34
|
+
},
|
|
35
|
+
load: async () => EntityView,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ComposerSkin } from "@sincpro/web-composer";
|
|
2
|
+
/**
|
|
3
|
+
* The composer, dressed as web-ui.
|
|
4
|
+
*
|
|
5
|
+
* Context: since 1.0 the composer owns the BEHAVIOUR of the workspace and ships a minimal look
|
|
6
|
+
* of its own so it works alone; the look is the application's, piece by piece (`skin`). This
|
|
7
|
+
* package is married to web-ui, so its workspace should read as web-ui: lucide's icons, web-ui's
|
|
8
|
+
* `Peek` drawer, its `Dialog` for «discard what you typed?», its `Missing` where a view could not
|
|
9
|
+
* be drawn. `<SincproApp>` uses it by default; any piece can still be replaced on top of it.
|
|
10
|
+
*/
|
|
11
|
+
export declare const webUiSkin: ComposerSkin;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Button, Dialog, Missing, Peek } from "@sincpro/web-ui";
|
|
4
|
+
import { AppWindow, ArrowLeft, Ellipsis, Eye, Maximize2, Minimize2, Minus, PanelBottom, PanelLeft, PanelRight, Search, SquareArrowOutUpRight, X, } from "lucide-react";
|
|
5
|
+
/**
|
|
6
|
+
* The composer, dressed as web-ui.
|
|
7
|
+
*
|
|
8
|
+
* Context: since 1.0 the composer owns the BEHAVIOUR of the workspace and ships a minimal look
|
|
9
|
+
* of its own so it works alone; the look is the application's, piece by piece (`skin`). This
|
|
10
|
+
* package is married to web-ui, so its workspace should read as web-ui: lucide's icons, web-ui's
|
|
11
|
+
* `Peek` drawer, its `Dialog` for «discard what you typed?», its `Missing` where a view could not
|
|
12
|
+
* be drawn. `<SincproApp>` uses it by default; any piece can still be replaced on top of it.
|
|
13
|
+
*/
|
|
14
|
+
export const webUiSkin = {
|
|
15
|
+
icons: {
|
|
16
|
+
more: Ellipsis,
|
|
17
|
+
expand: SquareArrowOutUpRight,
|
|
18
|
+
minimize: Minus,
|
|
19
|
+
maximize: Maximize2,
|
|
20
|
+
restore: Minimize2,
|
|
21
|
+
close: X,
|
|
22
|
+
left: PanelLeft,
|
|
23
|
+
right: PanelRight,
|
|
24
|
+
bottom: PanelBottom,
|
|
25
|
+
window: AppWindow,
|
|
26
|
+
back: ArrowLeft,
|
|
27
|
+
search: Search,
|
|
28
|
+
peek: Eye,
|
|
29
|
+
},
|
|
30
|
+
Peek: ({ title, onClose, actions, children }) => (_jsx(Peek, { open: true, title: title, onClose: onClose, actions: actions, children: children })),
|
|
31
|
+
Confirm: ({ open, title, detail, keep, discard, onAnswer }) => (_jsx(Dialog, { open: open, title: title, onClose: () => onAnswer(false), footer: _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { size: "sm", role: "secondary", onClick: () => onAnswer(true), children: discard }), _jsx(Button, { size: "sm", role: "primary", onClick: () => onAnswer(false), children: keep })] }), children: _jsx("p", { className: "text-ink", children: detail }) })),
|
|
32
|
+
Notice: ({ heading, detail, bad }) => (_jsx(Missing, { heading: heading, detail: detail, tone: bad ? "bad" : "warn" })),
|
|
33
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AnyRecord } from "@sincpro/criteria";
|
|
2
|
+
import type { WorkspaceApi } from "@sincpro/web-composer";
|
|
3
|
+
import type { RecordReading } from "../../react/useRecord.js";
|
|
4
|
+
/**
|
|
5
|
+
* A record told to the workspace it is open in: its real name and whether it holds unsaved work.
|
|
6
|
+
*
|
|
7
|
+
* Context: the composer 1.0 lets a view say both (`setTitle`, `setDirty`). The name replaces the
|
|
8
|
+
* reference in the breadcrumb, the panel frame and ⌘K once the record is read — «Globex Bolivia»
|
|
9
|
+
* instead of «C-004». «Dirty» makes a crumb, a close or the browser's Back ask before throwing
|
|
10
|
+
* away what was typed. The record already knows both (its `display`, its `edit.dirty`), so the
|
|
11
|
+
* screen only has to connect them — one line.
|
|
12
|
+
*
|
|
13
|
+
* const invoice = useRecord(invoices, resource.id);
|
|
14
|
+
* useRecordInWorkspace(api, invoice);
|
|
15
|
+
*/
|
|
16
|
+
export declare function useRecordInWorkspace<T extends AnyRecord>(api: WorkspaceApi, record: RecordReading<T>): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { textOf } from "../../core/present.js";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
/**
|
|
5
|
+
* A record told to the workspace it is open in: its real name and whether it holds unsaved work.
|
|
6
|
+
*
|
|
7
|
+
* Context: the composer 1.0 lets a view say both (`setTitle`, `setDirty`). The name replaces the
|
|
8
|
+
* reference in the breadcrumb, the panel frame and ⌘K once the record is read — «Globex Bolivia»
|
|
9
|
+
* instead of «C-004». «Dirty» makes a crumb, a close or the browser's Back ask before throwing
|
|
10
|
+
* away what was typed. The record already knows both (its `display`, its `edit.dirty`), so the
|
|
11
|
+
* screen only has to connect them — one line.
|
|
12
|
+
*
|
|
13
|
+
* const invoice = useRecord(invoices, resource.id);
|
|
14
|
+
* useRecordInWorkspace(api, invoice);
|
|
15
|
+
*/
|
|
16
|
+
export function useRecordInWorkspace(api, record) {
|
|
17
|
+
const display = record.meta?.display || record.meta?.identity || "";
|
|
18
|
+
const title = record.status === "ready" && display !== ""
|
|
19
|
+
? textOf(record.meta, display, record.record[display], record.locale)
|
|
20
|
+
: null;
|
|
21
|
+
const dirty = record.edit.dirty;
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
api.setTitle(title);
|
|
24
|
+
}, [api, title]);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
api.setDirty(dirty);
|
|
27
|
+
return () => api.setDirty(false);
|
|
28
|
+
}, [api, dirty]);
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,88 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sincpro/web-framework",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The web client of sincpro_framework: entities, lists and records bound to the metadata the server publishes, drawn with @sincpro/web-ui and arranged with @sincpro/web-composer.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"prettier": {
|
|
23
|
+
"printWidth": 94
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@sincpro/criteria": "^0.3.0",
|
|
27
|
+
"@sincpro/criteria-react": "^0.3.0",
|
|
28
|
+
"@sincpro/web-composer": "^1.0.0",
|
|
29
|
+
"@sincpro/web-ui": "^0.2.0",
|
|
30
|
+
"react": ">=19",
|
|
31
|
+
"react-dom": ">=19"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@eslint/js": "^10.0.1",
|
|
35
|
+
"@sincpro/criteria": "^0.3.0",
|
|
36
|
+
"@sincpro/criteria-react": "^0.3.0",
|
|
37
|
+
"@sincpro/web-composer": "^1.0.0",
|
|
38
|
+
"@sincpro/web-ui": "^0.2.0",
|
|
39
|
+
"@storybook/addon-a11y": "10.6.5",
|
|
40
|
+
"@storybook/addon-docs": "10.6.5",
|
|
41
|
+
"@storybook/addon-mcp": "10.6.5",
|
|
42
|
+
"@storybook/addon-vitest": "10.6.5",
|
|
43
|
+
"@storybook/react-vite": "10.6.5",
|
|
44
|
+
"@tailwindcss/vite": "4.3.3",
|
|
45
|
+
"@tanstack/react-table": "^9.3.1",
|
|
46
|
+
"@tanstack/react-virtual": "^3.14.24",
|
|
47
|
+
"@types/node": "^26.6.6",
|
|
48
|
+
"@types/react": "^19.3.0",
|
|
49
|
+
"@types/react-dom": "^19.3.0",
|
|
50
|
+
"@vitejs/plugin-react": "6.1.2",
|
|
51
|
+
"@vitest/browser-playwright": "5.0.5",
|
|
52
|
+
"eslint": "^10.14.0",
|
|
53
|
+
"eslint-config-prettier": "^10.1.8",
|
|
54
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
55
|
+
"eslint-plugin-simple-import-sort": "^14.0.0",
|
|
56
|
+
"playwright": "1.64.1",
|
|
57
|
+
"prettier": "^3.9.10",
|
|
58
|
+
"react": "19.3.1",
|
|
59
|
+
"react-dom": "19.3.1",
|
|
60
|
+
"storybook": "10.6.5",
|
|
61
|
+
"tailwindcss": "4.3.3",
|
|
62
|
+
"tsc-alias": "^1.9.9",
|
|
63
|
+
"typescript": "~6.0.3",
|
|
64
|
+
"typescript-eslint": "^8.72.1",
|
|
65
|
+
"vite": "8.3.5",
|
|
66
|
+
"vitest": "5.0.5"
|
|
67
|
+
},
|
|
68
|
+
"keywords": [
|
|
69
|
+
"sincpro",
|
|
70
|
+
"sincpro-framework",
|
|
71
|
+
"react",
|
|
72
|
+
"metadata",
|
|
73
|
+
"criteria",
|
|
74
|
+
"erp"
|
|
75
|
+
],
|
|
76
|
+
"repository": {
|
|
77
|
+
"type": "git",
|
|
78
|
+
"url": "git+https://github.com/Sincpro-SRL/sincpro_web_framework.git"
|
|
79
|
+
},
|
|
80
|
+
"author": "Gutierrez, Andres <andres.gutierrez@sincpro.com.bo>",
|
|
81
|
+
"license": "MIT",
|
|
82
|
+
"engines": {
|
|
83
|
+
"node": ">=26"
|
|
84
|
+
},
|
|
85
|
+
"dependencies": {
|
|
86
|
+
"lucide-react": "^1.45.0"
|
|
87
|
+
}
|
|
88
|
+
}
|