@sincpro/web-framework 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -2
  3. package/dist/core/cache.d.ts +30 -0
  4. package/dist/core/cache.js +47 -0
  5. package/dist/core/copy.d.ts +69 -0
  6. package/dist/core/copy.js +111 -0
  7. package/dist/core/edit.d.ts +27 -0
  8. package/dist/core/edit.js +51 -0
  9. package/dist/core/entity.d.ts +79 -0
  10. package/dist/core/entity.js +32 -0
  11. package/dist/core/history.d.ts +20 -0
  12. package/dist/core/history.js +40 -0
  13. package/dist/core/index.d.ts +19 -0
  14. package/dist/core/index.js +19 -0
  15. package/dist/core/lookup.d.ts +16 -0
  16. package/dist/core/lookup.js +10 -0
  17. package/dist/core/memory.d.ts +43 -0
  18. package/dist/core/memory.js +85 -0
  19. package/dist/core/meta.d.ts +114 -0
  20. package/dist/core/meta.js +130 -0
  21. package/dist/core/metas.d.ts +18 -0
  22. package/dist/core/metas.js +7 -0
  23. package/dist/core/present.d.ts +48 -0
  24. package/dist/core/present.js +165 -0
  25. package/dist/core/related.d.ts +22 -0
  26. package/dist/core/related.js +21 -0
  27. package/dist/core/rest.d.ts +30 -0
  28. package/dist/core/rest.js +50 -0
  29. package/dist/core/schema.d.ts +46 -0
  30. package/dist/core/schema.js +128 -0
  31. package/dist/core/transport.d.ts +59 -0
  32. package/dist/core/transport.js +43 -0
  33. package/dist/index.d.ts +10 -0
  34. package/dist/index.js +10 -0
  35. package/dist/react/context.d.ts +27 -0
  36. package/dist/react/context.js +18 -0
  37. package/dist/react/index.d.ts +12 -0
  38. package/dist/react/index.js +12 -0
  39. package/dist/react/useCommand.d.ts +28 -0
  40. package/dist/react/useCommand.js +52 -0
  41. package/dist/react/useHistory.d.ts +5 -0
  42. package/dist/react/useHistory.js +41 -0
  43. package/dist/react/useListing.d.ts +75 -0
  44. package/dist/react/useListing.js +121 -0
  45. package/dist/react/useLookup.d.ts +22 -0
  46. package/dist/react/useLookup.js +15 -0
  47. package/dist/react/useRecord.d.ts +74 -0
  48. package/dist/react/useRecord.js +176 -0
  49. package/dist/react/useRelated.d.ts +21 -0
  50. package/dist/react/useRelated.js +32 -0
  51. package/dist/web/Refused.d.ts +9 -0
  52. package/dist/web/Refused.js +14 -0
  53. package/dist/web/index.d.ts +17 -0
  54. package/dist/web/index.js +17 -0
  55. package/dist/web/list/EntityList.d.ts +69 -0
  56. package/dist/web/list/EntityList.js +82 -0
  57. package/dist/web/list/EntitySearchBar.d.ts +26 -0
  58. package/dist/web/list/EntitySearchBar.js +43 -0
  59. package/dist/web/list/columns.d.ts +22 -0
  60. package/dist/web/list/columns.js +27 -0
  61. package/dist/web/list/search.d.ts +21 -0
  62. package/dist/web/list/search.js +72 -0
  63. package/dist/web/record/CommandButton.d.ts +22 -0
  64. package/dist/web/record/CommandButton.js +40 -0
  65. package/dist/web/record/EntityPicker.d.ts +27 -0
  66. package/dist/web/record/EntityPicker.js +39 -0
  67. package/dist/web/record/RelatedList.d.ts +18 -0
  68. package/dist/web/record/RelatedList.js +16 -0
  69. package/dist/web/record/edit.d.ts +35 -0
  70. package/dist/web/record/edit.js +63 -0
  71. package/dist/web/record/record.d.ts +25 -0
  72. package/dist/web/record/record.js +56 -0
  73. package/dist/web/workspace/SincproApp.d.ts +37 -0
  74. package/dist/web/workspace/SincproApp.js +45 -0
  75. package/dist/web/workspace/composer.d.ts +33 -0
  76. package/dist/web/workspace/composer.js +37 -0
  77. package/dist/web/workspace/skin.d.ts +11 -0
  78. package/dist/web/workspace/skin.js +33 -0
  79. package/dist/web/workspace/workspace.d.ts +16 -0
  80. package/dist/web/workspace/workspace.js +29 -0
  81. package/package.json +86 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SINCPRO S.R.L
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,225 @@
1
- # Temporary Holding Version
1
+ # @sincpro/web-framework
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **The web side of how Sincpro builds business software.** It takes what
4
+ [`sincpro_framework`](https://github.com/Sincpro-SRL/sincpro_framework) already resolves on the
5
+ server — entities, criteria, commands, drafts, events — and puts it on screen with
6
+ [`@sincpro/web-ui`](https://github.com/Sincpro-SRL/sincpro_web_ui), opened where
7
+ [`@sincpro/web-composer`](https://github.com/Sincpro-SRL/sincpro_web_composer) says. An
8
+ application declares its entities and names its fields; the rest follows.
9
+
10
+ ```bash
11
+ yarn add @sincpro/web-framework @sincpro/web-ui @sincpro/criteria @sincpro/criteria-react react react-dom
12
+ ```
13
+
14
+ Requires React 19 or newer.
15
+
16
+ ## What we believe
17
+
18
+ **An ERP is modular, so its framework must be open.** Somebody will always have the case nobody
19
+ foresaw — a reconciliation screen, a strange relation, a wizard over three entities. They must
20
+ be able to build it here, without asking us and without fighting us. So:
21
+
22
+ 1. **Defaults, never cages.** Every piece comes in three levels, and any of them can be skipped:
23
+
24
+ | Level | What you write | Example |
25
+ | ------------ | ------------------------------------------- | ---------------------------------------------------------- |
26
+ | Declarative | names of fields and entities | `<EntityList listing={l} columns={["number", "state"]} />` |
27
+ | Hooks | your own screen over our state | `useListing` + web-ui's `List` |
28
+ | Loose pieces | straight to criteria, the transport, web-ui | `transport.list(route, criteria)` |
29
+
30
+ Adding a piece obliges nothing else; removing it breaks nothing else.
31
+
32
+ 2. **We ship good practice, not business rules.** A rule lives in the backend. Here the
33
+ framework reads what the backend publishes — the model definition, the form hints, the
34
+ preview, the refusal — and never re-implements it. If a rule matters, the server keeps it.
35
+
36
+ 3. **Integration should be solved once, here.** Every Sincpro application used to write its
37
+ own client, its own date formatting, its own error box, its own "load more". That is the
38
+ same code everywhere, and it belongs in one place, written well.
39
+
40
+ 4. **The server describes, the screen decides.** The backend says what a field _is_ — its
41
+ word, its type, what it accepts, what it points at. Where it goes, how prominent it is and
42
+ what colour a state is stay in the screen's code. That keeps us out of server-side view
43
+ XML, which is where frameworks like Odoo stop being extensible.
44
+
45
+ 5. **Many entities, one screen.** Business work happens _between_ records: a list with its
46
+ detail beside it, an invoice with its lines, a statement reconciled against invoices. The
47
+ framework is built for that, not for one form at a time.
48
+
49
+ 6. **Use the whole Sincpro stack, not a copy of it.** Composer already knows where things open,
50
+ what is selected and which keys do what; web-ui already knows how a record looks. This
51
+ package uses them — it does not wrap them in a second, weaker version.
52
+
53
+ ## The stack
54
+
55
+ | Project | Brings |
56
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
57
+ | `sincpro_framework` | Entities, criteria, reads (`Get`, `LiteralSearch`, `Search`, `Preview`), commands and dry runs, drafts, events, permissions, protocols (REST, JSON-RPC, gRPC, MCP) |
58
+ | `@sincpro/criteria` | The contract with the server: `Criteria`, `Meta`, `Page`, `Problem` |
59
+ | `@sincpro/web-ui` | What a screen looks like: `List`, `Record`, `SearchBar`, `Picker`, `Activity` |
60
+ | `@sincpro/web-composer` | Where it opens and the context: page, peek, window, panels, shared selection, ⌘K, shortcuts |
61
+ | **this package** | The glue: entity + model definition + state + actions, drawn with web-ui, opened with composer |
62
+ | Your application | What an invoice is |
63
+
64
+ ## How it works
65
+
66
+ Declare an entity once — where it is read, and its key in the workspace:
67
+
68
+ ```ts
69
+ import { entity, restTransport } from "@sincpro/web-framework";
70
+
71
+ export const invoices = entity<Invoice>("billing:invoice", { list: "/invoices" });
72
+ export const transport = restTransport({
73
+ base: "/v1",
74
+ headers: () => ({ Authorization: `Bearer ${session.token}` }),
75
+ });
76
+ ```
77
+
78
+ Provide the transport where the application starts:
79
+
80
+ ```tsx
81
+ <FrameworkProvider transport={transport} locale="es">
82
+ <App />
83
+ </FrameworkProvider>
84
+ ```
85
+
86
+ A list is the entity and the names of its fields. Headers come from the model's labels, each
87
+ cell reads as its value means («Pagada», a local date, an amount with its decimals, a many2one
88
+ as the name of what it points at), the search bar and the criteria builder ask the server, and
89
+ «load more» pages with its cursor:
90
+
91
+ ```tsx
92
+ function Invoices({ api }: ViewProps) {
93
+ const listing = useListing(invoices);
94
+ return (
95
+ <EntityList
96
+ listing={listing}
97
+ columns={["number", "customer", "state", "invoice_date", "amount_total"]}
98
+ filters={[
99
+ {
100
+ id: "paid",
101
+ label: "Only paid",
102
+ where: { field: "state", operator: "=", value: "paid" },
103
+ },
104
+ ]}
105
+ onOpen={(row) => api.open(invoiceRef(row.number))}
106
+ />
107
+ );
108
+ }
109
+ ```
110
+
111
+ One record keeps writing web-ui's `Record` tree; what comes from the model are its props:
112
+
113
+ ```tsx
114
+ const invoice = useRecord(invoices, id);
115
+ // …
116
+ <Record.Paper>
117
+ <Record.Facts fields={factsOf(invoice.record, invoice.meta, ["customer", "due_date"])} />
118
+ <Record.Page id="lines" label="Lines" count={countOf(invoice.record, "lines")}>
119
+ <RecordLines record={invoice.record} meta={invoice.meta} name="lines" />
120
+ </Record.Page>
121
+ </Record.Paper>;
122
+ ```
123
+
124
+ A domain with no sincpro_framework behind it declares its model by hand and is drawn the same
125
+ way:
126
+
127
+ ```ts
128
+ const tickets = defineMeta({
129
+ aggregate: "Ticket",
130
+ identity: "id",
131
+ display: "title",
132
+ search: ["title"],
133
+ fields: {
134
+ id: field.text({ readonly: true }),
135
+ title: field.text({ label: { es: "Título" }, required: true }),
136
+ stage: field.choice(["new", "done"], { labels: { new: { es: "Nuevo" } } }),
137
+ },
138
+ });
139
+ ```
140
+
141
+ ### Inside
142
+
143
+ Three folders, one direction — `make doctor` keeps the arrows:
144
+
145
+ | Folder | Knows | Why apart |
146
+ | --------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------- |
147
+ | `core` | transport, cache, entity, model definition, what a value reads as | plain logic, tested in Node in milliseconds |
148
+ | `react` | `useListing`, `useRecord`, `useRelated`, `useCommand`…, the provider | state that draws nothing: usable without `EntityList` |
149
+ | `web/list` | a collection: `EntityList`, `EntitySearchBar`, `columnsOf` | N records, any size |
150
+ | `web/record` | one record: `RecordField`, `factsOf`, `RelatedList`, `EntityPicker`, `CommandButton` | works outside a workspace (no composer) |
151
+ | `web/workspace` | the composer: `entityView`, `webUiSkin`, `useRecordInWorkspace`, `<SincproApp>` | where it opens, the application context |
152
+
153
+ Everything is imported from `@sincpro/web-framework`.
154
+
155
+ ## Where it stands
156
+
157
+ | Piece | What it solves | Status |
158
+ | -------------------------------------- | ------------------------------------------------------------------------------------------ | ------ |
159
+ | `restTransport`, `memoryTransport` | The backend, or the same engine in memory | ✅ |
160
+ | `defineMeta`, `metaFromSchema` | A model by hand, or completed from OpenAPI | ✅ |
161
+ | `useListing`, `EntityList` | N records: criteria, search bar, builder, paging | ✅ |
162
+ | `useRecord`, `factsOf`, `RecordLines` | One record: facts, relations with their real total | ✅ |
163
+ | Preloaded definitions | `FrameworkProvider metas`: the model asked once, or never | ✅ |
164
+ | `useLookup`, `EntityPicker` | Literal search: a many2one select, ⌘K | ✅ |
165
+ | `useRecord` + `RecordField`, `dirtyOf` | Editing: hints evaluated live, preview, save with version (409), server drafts | ✅ |
166
+ | `useCommand`, `CommandButton` | An entity's actions, with a dry run before what destroys | ✅ |
167
+ | `useHistory` | Business events in `Record.Activity` (comments: backend first) | ✅ |
168
+ | `entityView` | An entity as a composer view: list, record, ⌘K search | ✅ |
169
+ | `<SincproApp>` | The application context in one place, the workspace dressed as web-ui (`webUiSkin`) | ✅ |
170
+ | `useRecordInWorkspace` | A record tells the workspace its real name and its unsaved work (composer 1.0) | ✅ |
171
+ | Paging by pages, ceiling on «more» | One page on screen, walked by the server's cursor: a million rows never freeze it | ✅ |
172
+ | `cachedTransport` | Repeated reads from memory, forgotten when a write touches the entity (on in `SincproApp`) | ✅ |
173
+ | `EntitySearchBar` | The search bar + criteria builder as a loose piece, for any `List` | ✅ |
174
+ | `EntityList` views | The same set as table, rows or board | ✅ |
175
+ | `useRelated`, `RelatedList` | A record's relation as a live list: searched and paged by the server, inside a form | ✅ |
176
+ | `EntityPicker` chooser | «see all…» opens the whole set, searched on the server: a many2one over millions | ✅ |
177
+
178
+ What it does **not** do: mobile (`sincpro_mobile` has its own core), sessions and login (the
179
+ application's), routing (composer's), string-named widget registries, technical logs on a
180
+ record's screen, business rules.
181
+
182
+ The full purpose — literal search, drafts, composer context, several entities per screen — is
183
+ in [`docs/PURPOSE.md`](docs/PURPOSE.md); what was found while building and what comes next is in
184
+ [`docs/ROADMAP.md`](docs/ROADMAP.md) (both in Spanish).
185
+
186
+ ## Built for large sets
187
+
188
+ Nothing is filtered, ordered, counted or grouped on the page: every gesture is a question the
189
+ server answers. A list holds ONE page by default and walks the server's keyset cursor (page
190
+ 4 000 costs what page 1 costs); «load more» stops at a ceiling and asks to narrow the search; the
191
+ count is the server's, capped; typing waits for a pause; a newer question cancels the older one;
192
+ the model definition is asked once per entity; repeated reads come from a cache that a write
193
+ clears. Story «15 · Un conjunto grande sin congelar» runs it over 200 000 rows.
194
+
195
+ ## The use cases are the tests
196
+
197
+ Every use case is a Storybook story run against an in-memory server that answers with the same
198
+ criteria engine as sincpro_framework, and each one carries a `play`. The story IS the test:
199
+ `make test` runs every story in a real Chrome with vitest (`@storybook/addon-vitest`), awaits its
200
+ play and checks its a11y — and the same tests run from Storybook's sidebar («Run tests»). The
201
+ plain logic is tested in Node by the same runner. A case the framework cannot do yet shows up
202
+ there first.
203
+
204
+ ## Development
205
+
206
+ ```bash
207
+ nvm use # Node 26, from .nvmrc
208
+ make init # pre-commit + yarn install
209
+ make test # vitest: the logic in Node + every use case (story + play) in Chrome
210
+ make dev # Storybook on :6008 — the use cases, «Run tests» in the sidebar
211
+ make build
212
+ ```
213
+
214
+ Every operation — local or CI — goes through the `Makefile`; run `make` to list the targets.
215
+ Working on this repository, human or agent: read [`AGENTS.md`](AGENTS.md) first.
216
+
217
+ ## CI/CD
218
+
219
+ The workflows in `.github/workflows/` only delegate to the shared `Sincpro-SRL/.github`
220
+ pipeline, which calls `make init`, `make verify-format`, `make test`,
221
+ `make update-version VERSION=x` and `make publish`.
222
+
223
+ ## License
224
+
225
+ MIT © Sincpro S.R.L.
@@ -0,0 +1,30 @@
1
+ import type { Transport } from "./transport.js";
2
+ /**
3
+ * A transport that remembers what it read, for a while, and forgets it when something writes.
4
+ *
5
+ * Context: in a workspace the same questions come back all the time — the list you go back to
6
+ * through the breadcrumb, the record reopened from ⌘K, the select that points at the same
7
+ * customers, two panels on the same entity. Each one was a request. This answers a repeated
8
+ * read from memory while it is fresh (`ttl`), and drops every answer of an entity the moment
9
+ * a write touches it: a save, a Command, a draft. Stale data after a write is worse than a
10
+ * request, so a write always wins.
11
+ *
12
+ * What it keys on is the question as it travels — the route and the criteria — so two
13
+ * different filters, orders or pages never share an answer. A read that fails is not kept.
14
+ * Only completed answers are remembered: a request aborted half-way is simply not cached.
15
+ *
16
+ * const transport = cachedTransport(restTransport({ base: "/v1" }), { ttl: 30_000 });
17
+ * transport.invalidate("/facturas"); // forget an entity by hand (a push from the server)
18
+ */
19
+ export type CachedTransport = Transport & {
20
+ /** Forget every answer whose route starts with this path: `"/facturas"` forgets the list,
21
+ * every page and filter of it, and every record under it. */
22
+ invalidate: (path?: string) => void;
23
+ };
24
+ export type CacheOptions = {
25
+ /** How long an answer is served from memory, in milliseconds. */
26
+ ttl?: number;
27
+ /** The clock — for tests. */
28
+ now?: () => number;
29
+ };
30
+ export declare function cachedTransport(transport: Transport, options?: CacheOptions): CachedTransport;
@@ -0,0 +1,47 @@
1
+ /** The entity a route belongs to: its first segment, `"/facturas/F-1/confirm"` → `"/facturas"`. */
2
+ function rootOf(route) {
3
+ const path = route.split("?")[0] ?? route;
4
+ const [, first = ""] = path.split("/");
5
+ return `/${first}`;
6
+ }
7
+ export function cachedTransport(transport, options = {}) {
8
+ const ttl = options.ttl ?? 30_000;
9
+ const now = options.now ?? (() => Date.now());
10
+ const entries = new Map();
11
+ async function remembered(key, read) {
12
+ const kept = entries.get(key);
13
+ if (kept !== undefined && now() - kept.at < ttl)
14
+ return kept.answer;
15
+ const answer = await read();
16
+ entries.set(key, { at: now(), answer });
17
+ return answer;
18
+ }
19
+ const invalidate = (path) => {
20
+ if (path === undefined)
21
+ return entries.clear();
22
+ for (const key of [...entries.keys()])
23
+ if (key.slice(key.indexOf(" ") + 1).startsWith(path))
24
+ entries.delete(key);
25
+ };
26
+ return {
27
+ list(route, criteria, signal) {
28
+ return remembered(`list ${route} ${JSON.stringify(criteria)}`, () => transport.list(route, criteria, signal));
29
+ },
30
+ one(route, signal) {
31
+ return remembered(`one ${route}`, () => transport.one(route, signal));
32
+ },
33
+ async send(method, route, body, signal) {
34
+ // A preview writes nothing; anything else may have changed what was read.
35
+ const preview = route.endsWith("/preview");
36
+ try {
37
+ return await transport.send(method, route, body, signal);
38
+ }
39
+ finally {
40
+ // Even a refused write may have changed something (a 409 says someone else did).
41
+ if (!preview)
42
+ invalidate(rootOf(route));
43
+ }
44
+ },
45
+ invalidate,
46
+ };
47
+ }
@@ -0,0 +1,69 @@
1
+ import type { FailureKind } from "@sincpro/criteria";
2
+ /**
3
+ * Every word the framework writes on its own, in one table — the way `@sincpro/web-ui` and the
4
+ * composer do it. English by default; an application installs its language ONCE with
5
+ * `installFrameworkCopy`, and nothing carries a hard-coded string.
6
+ *
7
+ * Context: it lives in `core/` and not next to the components because a value is turned into
8
+ * text here («sí», «3 elementos»), and a cell, a fact and a chip must read it the same.
9
+ */
10
+ export type FrameworkCopy = {
11
+ value: {
12
+ yes: string;
13
+ no: string;
14
+ /** A one2many/many2many cell: how many there are, `exact: false` meaning «at least». */
15
+ items: (count: number, exact: boolean) => string;
16
+ };
17
+ list: {
18
+ loadMore: string;
19
+ loading: string;
20
+ /** «12 of 120»: what is on screen against what matches. */
21
+ shown: (shown: number, total: number, exact: boolean) => string;
22
+ empty: string;
23
+ emptyFiltered: string;
24
+ search: string;
25
+ /** The search bar, which also takes `#` to pick a field. */
26
+ searchBar: string;
27
+ /** The title of the criteria builder's window. */
28
+ builder: string;
29
+ /** `more` mode reached its ceiling: what is on screen, and what to do instead. */
30
+ capped: (shown: number) => string;
31
+ /** Conditions the server could not answer: the list is WIDER than asked. */
32
+ dropped: (fields: readonly string[]) => string;
33
+ };
34
+ record: {
35
+ /** A draft this caller kept is back on screen. */
36
+ restored: string;
37
+ /** Someone saved it after it was read: nothing was overwritten. */
38
+ conflict: string;
39
+ reload: string;
40
+ /** What is missing before it can be saved. */
41
+ missing: (fields: readonly string[]) => string;
42
+ keep: string;
43
+ loading: string;
44
+ /** Under an embedded list the record holds only one page of. */
45
+ partial: (shown: number, total: number) => string;
46
+ };
47
+ command: {
48
+ /** The confirmation of an action that cannot be undone, after its dry run. */
49
+ confirm: string;
50
+ cancel: string;
51
+ /** While the dry run is asked. */
52
+ rehearsing: string;
53
+ };
54
+ failure: {
55
+ /** The server could not be reached at all — nothing it said is known. */
56
+ unreachable: string;
57
+ /** It answered with a status and no problem to read. */
58
+ status: (status: number) => string;
59
+ retry: string;
60
+ /** What each kind of refusal is called when the server sent no title of its own. */
61
+ kind: Record<FailureKind, string>;
62
+ };
63
+ };
64
+ export declare const ENGLISH_FRAMEWORK_COPY: FrameworkCopy;
65
+ export declare const SPANISH_FRAMEWORK_COPY: FrameworkCopy;
66
+ /** Install the framework's words once, where the application starts. */
67
+ export declare function installFrameworkCopy(copy: FrameworkCopy): void;
68
+ /** The words in use. A plain read: they are installed before anything draws. */
69
+ export declare const frameworkCopy: () => FrameworkCopy;
@@ -0,0 +1,111 @@
1
+ export const ENGLISH_FRAMEWORK_COPY = {
2
+ value: {
3
+ yes: "yes",
4
+ no: "no",
5
+ items: (count, exact) => `${count}${exact ? "" : "+"} ${count === 1 ? "item" : "items"}`,
6
+ },
7
+ list: {
8
+ loadMore: "Load more",
9
+ loading: "Loading…",
10
+ shown: (shown, total, exact) => `${shown} of ${total}${exact ? "" : "+"}`,
11
+ empty: "Nothing here yet.",
12
+ emptyFiltered: "Nothing matches.",
13
+ search: "Search",
14
+ searchBar: "Search, or # to pick a field…",
15
+ builder: "Criteria builder",
16
+ capped: (shown) => `Showing the first ${shown}. Narrow the search to see the rest.`,
17
+ dropped: (fields) => `The server ignored a condition on ${fields.join(", ")}: the list shows more than asked.`,
18
+ },
19
+ record: {
20
+ restored: "A draft you kept is back. Save it or discard it.",
21
+ conflict: "Someone saved this record after you opened it. Nothing was overwritten: read it again.",
22
+ reload: "Read it again",
23
+ missing: (fields) => `Missing: ${fields.join(", ")}.`,
24
+ keep: "Keep draft",
25
+ loading: "Loading the record",
26
+ partial: (shown, total) => `Showing ${shown} of ${total}.`,
27
+ },
28
+ command: {
29
+ confirm: "Do it",
30
+ cancel: "Cancel",
31
+ rehearsing: "Checking what it would do…",
32
+ },
33
+ failure: {
34
+ unreachable: "The server could not be reached",
35
+ status: (status) => `The server answered ${status}`,
36
+ retry: "Retry",
37
+ kind: {
38
+ invalid: "The request is not valid",
39
+ unauthenticated: "Sign in again",
40
+ permission_denied: "You do not have access",
41
+ not_found: "It does not exist",
42
+ conflict: "Someone changed it first",
43
+ in_progress: "It is still in progress",
44
+ key_reused: "That request was already sent",
45
+ domain: "The operation was refused",
46
+ exhausted: "Too many requests",
47
+ unavailable: "The service is not available",
48
+ unknown_outcome: "It is not known whether it was done",
49
+ internal: "Something failed on the server",
50
+ },
51
+ },
52
+ };
53
+ export const SPANISH_FRAMEWORK_COPY = {
54
+ value: {
55
+ yes: "sí",
56
+ no: "no",
57
+ items: (count, exact) => `${count}${exact ? "" : "+"} ${count === 1 ? "elemento" : "elementos"}`,
58
+ },
59
+ list: {
60
+ loadMore: "Cargar más",
61
+ loading: "Cargando…",
62
+ shown: (shown, total, exact) => `${shown} de ${total}${exact ? "" : "+"}`,
63
+ empty: "Todavía no hay nada.",
64
+ emptyFiltered: "Nada coincide.",
65
+ search: "Buscar",
66
+ searchBar: "Buscar, o # para elegir el campo…",
67
+ builder: "Constructor de criteria",
68
+ capped: (shown) => `Se muestran los primeros ${shown}. Refiná la búsqueda para ver el resto.`,
69
+ dropped: (fields) => `El servidor ignoró una condición sobre ${fields.join(", ")}: la lista muestra más de lo pedido.`,
70
+ },
71
+ record: {
72
+ restored: "Volvió un borrador que guardaste. Guardalo o descartalo.",
73
+ conflict: "Alguien guardó este registro después de que lo abriste. No se pisó nada: volvé a leerlo.",
74
+ reload: "Volver a leer",
75
+ missing: (fields) => `Falta: ${fields.join(", ")}.`,
76
+ keep: "Guardar borrador",
77
+ loading: "Cargando el registro",
78
+ partial: (shown, total) => `Se muestran ${shown} de ${total}.`,
79
+ },
80
+ command: {
81
+ confirm: "Hacerlo",
82
+ cancel: "Cancelar",
83
+ rehearsing: "Revisando qué haría…",
84
+ },
85
+ failure: {
86
+ unreachable: "No se pudo hablar con el servidor",
87
+ status: (status) => `El servidor respondió ${status}`,
88
+ retry: "Reintentar",
89
+ kind: {
90
+ invalid: "El pedido no es válido",
91
+ unauthenticated: "Volvé a iniciar sesión",
92
+ permission_denied: "No tenés acceso",
93
+ not_found: "No existe",
94
+ conflict: "Alguien lo cambió antes",
95
+ in_progress: "Todavía está en curso",
96
+ key_reused: "Ese pedido ya se envió",
97
+ domain: "La operación fue rechazada",
98
+ exhausted: "Demasiados pedidos",
99
+ unavailable: "El servicio no está disponible",
100
+ unknown_outcome: "No se sabe si se hizo",
101
+ internal: "Algo falló en el servidor",
102
+ },
103
+ },
104
+ };
105
+ let installed = ENGLISH_FRAMEWORK_COPY;
106
+ /** Install the framework's words once, where the application starts. */
107
+ export function installFrameworkCopy(copy) {
108
+ installed = copy;
109
+ }
110
+ /** The words in use. A plain read: they are installed before anything draws. */
111
+ export const frameworkCopy = () => installed;
@@ -0,0 +1,27 @@
1
+ import { type AnyRecord, type FieldError, type Meta } from "@sincpro/criteria";
2
+ /**
3
+ * What an edit is, as plain functions: the state of each field and what changed.
4
+ *
5
+ * Context: sincpro_framework publishes form HINTS on each field — `readonly`, `required`, and
6
+ * `readonly_when` / `required_when` / `visible_when` as criteria conditions over the record —
7
+ * and says they are evaluated «with the same evaluator a client filters with». So a field's
8
+ * state is `matches(values, condition, meta)` over what is on screen NOW: typing «card» in the
9
+ * payment makes «card reference» required at once, with no round trip. A `Preview` from the
10
+ * server, when the entity has one, refines it — the server's word wins.
11
+ *
12
+ * Hints are defaults for a screen, never protection: the server keeps whatever rule matters.
13
+ */
14
+ export type FieldState = {
15
+ readonly: boolean;
16
+ required: boolean;
17
+ visible: boolean;
18
+ };
19
+ /** What a server preview said about a field. */
20
+ export type FieldStates = Readonly<Record<string, Partial<FieldState>>>;
21
+ export declare function fieldStateOf(meta: Meta | null | undefined, name: string, values: AnyRecord, fromServer?: FieldStates): FieldState;
22
+ /** The fields whose value differs from the record's — what a save or a draft sends. */
23
+ export declare function changesOf(record: AnyRecord, values: AnyRecord): AnyRecord;
24
+ /** What is missing before it can be saved: a required, visible, editable field left empty. */
25
+ export declare function missingOf(meta: Meta | null | undefined, values: AnyRecord, fromServer?: FieldStates): string[];
26
+ /** The server's word on a field that did not validate: `errors[].pointer` is a JSON pointer. */
27
+ export declare function errorsByField(errors: readonly FieldError[] | undefined): Record<string, string>;
@@ -0,0 +1,51 @@
1
+ import { matches, } from "@sincpro/criteria";
2
+ function holds(values, condition, meta) {
3
+ if (condition === null || condition === undefined)
4
+ return false;
5
+ return matches(values, condition, meta);
6
+ }
7
+ export function fieldStateOf(meta, name, values, fromServer) {
8
+ const field = meta?.fields[name];
9
+ const own = field === undefined || meta == null
10
+ ? { readonly: false, required: false, visible: true }
11
+ : {
12
+ readonly: (field.readonly ?? false) || holds(values, field.readonly_when, meta),
13
+ required: (field.required ?? false) || holds(values, field.required_when, meta),
14
+ visible: field.visible_when === null || field.visible_when === undefined
15
+ ? true
16
+ : holds(values, field.visible_when, meta),
17
+ };
18
+ return { ...own, ...fromServer?.[name] };
19
+ }
20
+ /** The fields whose value differs from the record's — what a save or a draft sends. */
21
+ export function changesOf(record, values) {
22
+ const changed = {};
23
+ for (const [name, value] of Object.entries(values))
24
+ if (JSON.stringify(value) !== JSON.stringify(record[name]))
25
+ changed[name] = value;
26
+ return changed;
27
+ }
28
+ /** What is missing before it can be saved: a required, visible, editable field left empty. */
29
+ export function missingOf(meta, values, fromServer) {
30
+ if (!meta)
31
+ return [];
32
+ return Object.keys(meta.fields).filter((name) => {
33
+ const state = fieldStateOf(meta, name, values, fromServer);
34
+ const value = values[name];
35
+ const empty = value === null ||
36
+ value === undefined ||
37
+ value === "" ||
38
+ (Array.isArray(value) && value.length === 0);
39
+ return state.required && state.visible && !state.readonly && empty;
40
+ });
41
+ }
42
+ /** The server's word on a field that did not validate: `errors[].pointer` is a JSON pointer. */
43
+ export function errorsByField(errors) {
44
+ const found = {};
45
+ for (const error of errors ?? []) {
46
+ const name = error.pointer.replace(/^\/(values\/)?/, "").split("/")[0] ?? "";
47
+ if (name !== "" && found[name] === undefined)
48
+ found[name] = error.detail;
49
+ }
50
+ return found;
51
+ }