@sincpro/web-framework 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +261 -40
  2. package/dist/core/cache.js +2 -2
  3. package/dist/core/copy.d.ts +24 -0
  4. package/dist/core/copy.js +30 -0
  5. package/dist/core/edit.d.ts +13 -0
  6. package/dist/core/edit.js +30 -0
  7. package/dist/core/entity.d.ts +34 -0
  8. package/dist/core/entity.js +25 -0
  9. package/dist/core/form.d.ts +21 -0
  10. package/dist/core/form.js +30 -0
  11. package/dist/core/index.d.ts +10 -3
  12. package/dist/core/index.js +9 -2
  13. package/dist/core/list.d.ts +64 -0
  14. package/dist/core/list.js +22 -0
  15. package/dist/core/lookup.d.ts +5 -2
  16. package/dist/core/lookup.js +6 -2
  17. package/dist/core/measure.d.ts +16 -0
  18. package/dist/core/measure.js +38 -0
  19. package/dist/core/memory.js +18 -4
  20. package/dist/core/relation.d.ts +28 -0
  21. package/dist/core/relation.js +30 -0
  22. package/dist/core/rest.js +13 -3
  23. package/dist/core/retry.d.ts +24 -0
  24. package/dist/core/retry.js +25 -0
  25. package/dist/core/summary.d.ts +50 -0
  26. package/dist/core/summary.js +54 -0
  27. package/dist/core/trail.d.ts +44 -0
  28. package/dist/core/trail.js +70 -0
  29. package/dist/core/transport.d.ts +13 -1
  30. package/dist/react/context.d.ts +12 -1
  31. package/dist/react/context.js +10 -2
  32. package/dist/react/index.d.ts +3 -1
  33. package/dist/react/index.js +3 -1
  34. package/dist/react/useCommand.d.ts +3 -1
  35. package/dist/react/useCommand.js +12 -4
  36. package/dist/react/useGroups.d.ts +40 -0
  37. package/dist/react/useGroups.js +69 -0
  38. package/dist/react/useListing.d.ts +21 -3
  39. package/dist/react/useListing.js +38 -3
  40. package/dist/react/useLookup.d.ts +30 -5
  41. package/dist/react/useLookup.js +56 -3
  42. package/dist/react/useRecord.d.ts +17 -2
  43. package/dist/react/useRecord.js +105 -9
  44. package/dist/react/useSummary.d.ts +15 -0
  45. package/dist/react/useSummary.js +22 -0
  46. package/dist/web/index.d.ts +9 -3
  47. package/dist/web/index.js +9 -3
  48. package/dist/web/list/EntityList.d.ts +27 -4
  49. package/dist/web/list/EntityList.js +162 -15
  50. package/dist/web/list/EntitySearchBar.d.ts +16 -8
  51. package/dist/web/list/EntitySearchBar.js +34 -4
  52. package/dist/web/list/groups.d.ts +15 -0
  53. package/dist/web/list/groups.js +73 -0
  54. package/dist/web/list/rowCommands.d.ts +41 -0
  55. package/dist/web/list/rowCommands.js +56 -0
  56. package/dist/web/record/EntityPicker.d.ts +42 -13
  57. package/dist/web/record/EntityPicker.js +53 -19
  58. package/dist/web/record/EntityTags.d.ts +38 -0
  59. package/dist/web/record/EntityTags.js +41 -0
  60. package/dist/web/record/RelatedList.d.ts +2 -1
  61. package/dist/web/record/RelatedList.js +12 -2
  62. package/dist/web/record/edit.d.ts +12 -5
  63. package/dist/web/record/edit.js +67 -8
  64. package/dist/web/record/opening.d.ts +26 -0
  65. package/dist/web/record/opening.js +10 -0
  66. package/dist/web/record/summary.d.ts +22 -0
  67. package/dist/web/record/summary.js +55 -0
  68. package/dist/web/workspace/SincproApp.d.ts +4 -1
  69. package/dist/web/workspace/SincproApp.js +2 -2
  70. package/dist/web/workspace/composer.d.ts +26 -4
  71. package/dist/web/workspace/composer.js +59 -2
  72. package/dist/web/workspace/open.d.ts +61 -0
  73. package/dist/web/workspace/open.js +62 -0
  74. package/dist/web/workspace/record.d.ts +30 -0
  75. package/dist/web/workspace/record.js +80 -0
  76. package/dist/web/workspace/skin.d.ts +0 -9
  77. package/dist/web/workspace/skin.js +25 -13
  78. package/dist/web/workspace/workspace.d.ts +31 -3
  79. package/dist/web/workspace/workspace.js +57 -4
  80. package/package.json +5 -5
package/README.md CHANGED
@@ -8,7 +8,7 @@ server — entities, criteria, commands, drafts, events — and puts it on scree
8
8
  application declares its entities and names its fields; the rest follows.
9
9
 
10
10
  ```bash
11
- yarn add @sincpro/web-framework @sincpro/web-ui @sincpro/criteria @sincpro/criteria-react react react-dom
11
+ yarn add @sincpro/web-framework @sincpro/web-ui @sincpro/web-composer @sincpro/criteria @sincpro/criteria-react react react-dom
12
12
  ```
13
13
 
14
14
  Requires React 19 or newer.
@@ -61,64 +61,231 @@ be able to build it here, without asking us and without fighting us. So:
61
61
  | **this package** | The glue: entity + model definition + state + actions, drawn with web-ui, opened with composer |
62
62
  | Your application | What an invoice is |
63
63
 
64
- ## How it works
64
+ ## How it fits together
65
+
66
+ Three packages, one direction. **web-ui draws and tells** («they pressed this name, and asked
67
+ for a modal»); it never imports composer and never knows where anything opens. **Composer
68
+ decides where** — page, modal, preview, side panel, window, the breadcrumb, the phone — and never
69
+ draws a record. **This package connects them**, from what the server publishes about each model.
70
+
71
+ ```mermaid
72
+ flowchart LR
73
+ subgraph server["sincpro_framework · the backend"]
74
+ api["entities · criteria · commands<br/>drafts · events · model definition"]
75
+ end
76
+ subgraph fw["@sincpro/web-framework · connects"]
77
+ direction TB
78
+ core["core/<br/>Transport · entity() · Meta<br/>textOf · summaryOf"]
79
+ react["react/<br/>useListing · useRecord<br/>useLookup · useSummaryOf"]
80
+ web["web/<br/>EntityList · EntityPicker · RelatedRecord<br/>openIn · entityView · SincproApp"]
81
+ core --> react --> web
82
+ end
83
+ ui["@sincpro/web-ui · draws and tells<br/>List · Record · Picker<br/>RelationValue · RecordCard"]
84
+ composer["@sincpro/web-composer · decides where<br/>page · modal · preview · side · window<br/>crumbs · ⌘K · phone"]
85
+ app["your application<br/>entity() + its screens"]
86
+
87
+ api <-->|"criteria over the wire<br/>(@sincpro/criteria)"| core
88
+ web -->|"props read from the model"| ui
89
+ ui -->|"gestures: onOpenIn(to), onChoose,<br/>onBrowseAll, onCreate"| web
90
+ web -->|"api.open · api.pick · api.setTitle"| composer
91
+ composer -->|"mounts the entity's view"| web
92
+ app -->|"declares"| fw
93
+ ```
65
94
 
66
- Declare an entity once — where it is read, and its key in the workspace:
95
+ What happens when a person presses the customer of an invoice:
96
+
97
+ ```mermaid
98
+ sequenceDiagram
99
+ actor person as Person
100
+ participant value as RelationValue<br/>(web-ui)
101
+ participant related as RelatedRecord / openIn<br/>(this package)
102
+ participant composer as Composer
103
+ participant view as Customer view<br/>(entityView)
104
+ participant server as Server
105
+
106
+ person->>value: presses «Globex Bolivia»
107
+ value->>related: onOpenIn("modal") — the first of openTargets
108
+ related->>related: targetsFor(api): inside a modal? then beside, never a modal
109
+ related->>composer: api.open(ventas:cliente/C-004, { to: "modal", size: 760×560 })
110
+ composer->>view: mounts the registered view over the invoice
111
+ view->>server: useRecord → the record (and its model, once)
112
+ server-->>view: record + meta
113
+ view->>composer: api.setTitle("Globex Bolivia") — the modal and the crumb say its name
114
+ ```
67
115
 
68
- ```ts
69
- import { entity, restTransport } from "@sincpro/web-framework";
116
+ Everything a screen shows about a value or a record is read from the model the same way, so a
117
+ cell, a fact, a card and a link cannot disagree:
118
+
119
+ ```mermaid
120
+ flowchart LR
121
+ meta["Meta<br/>(server, defineMeta or OpenAPI)"] --> text["textOf<br/>«Pagada» · 12 mar 2026 · 1 150,50"]
122
+ meta --> summary["summaryOf<br/>name · description · state · type<br/>image · created · updated"]
123
+ record["a record"] --> text
124
+ record --> summary
125
+ declared["entity(…, { summary })"] -.->|"its own field names"| summary
126
+ text --> cells["columns · facts · chips"]
127
+ summary --> card["cardOf → RecordCard"]
128
+ summary --> relation["relationOf → RelationValue"]
129
+ ```
130
+
131
+ ## Integrating it in an application
132
+
133
+ **1. Styles.** web-ui's tokens, and Tailwind told to look inside the packages (Tailwind v4 skips
134
+ `node_modules`, so their classes would never be generated). Composer brings its own sheet.
135
+
136
+ ```css
137
+ @import "@sincpro/web-ui/tokens.css";
138
+ @source "../node_modules/@sincpro/web-ui/dist";
139
+ @source "../node_modules/@sincpro/web-framework/dist";
140
+ @source "./";
141
+ ```
142
+
143
+ **2. The transport** — the one door to the backend. The session is the application's: it puts
144
+ its headers here.
70
145
 
71
- export const invoices = entity<Invoice>("billing:invoice", { list: "/invoices" });
146
+ ```ts
72
147
  export const transport = restTransport({
73
148
  base: "/v1",
74
149
  headers: () => ({ Authorization: `Bearer ${session.token}` }),
75
150
  });
76
151
  ```
77
152
 
78
- Provide the transport where the application starts:
153
+ **3. The entities** — declared once: where each is read and written, and its key in the
154
+ workspace (`domain:kind`). `summary` only when its fields are not the conventional ones.
155
+
156
+ ```ts
157
+ export const customers = entity<Customer>("sales:customer", {
158
+ list: "/customers",
159
+ summary: { image: "logo", person: true },
160
+ });
161
+ export const invoices = entity<Invoice>("sales:invoice", {
162
+ list: "/invoices",
163
+ create: "POST /invoices",
164
+ save: "PUT /invoices/{id}",
165
+ commands: { post: "POST /invoices/{id}/post" },
166
+ history: { route: "/sales-events", type: "Invoice" },
167
+ });
168
+ ```
169
+
170
+ **4. The views** — each entity becomes a composer view: its list without an id, its record with
171
+ one, its search in ⌘K. Declared and nothing more, it already has both screens, drawn from its
172
+ model: `EntityList` (literal search on Enter, pages, «New») and `EntityRecord` (its name, type and
173
+ state on top; its fields in the model's order, a many2one as a picker of its entity; each
174
+ one2many in a tab; «save / discard», the conflict, the restored draft, who made it, its history;
175
+ «3 / 64» and the arrows to its neighbours when it was opened from a list).
176
+
177
+ ```ts
178
+ const views = [
179
+ entityView(invoices, { transport, name: "Invoices" }), // both screens, from the model
180
+ ];
181
+ ```
182
+
183
+ They are defaults, not cages: give your own screens, or keep `EntityRecord` and choose what it
184
+ shows.
185
+
186
+ ```ts
187
+ const views = [
188
+ entityView(invoices, {
189
+ transport,
190
+ name: "Invoices",
191
+ list: InvoiceList,
192
+ record: InvoiceView,
193
+ }),
194
+ entityView(customers, {
195
+ transport,
196
+ name: "Customers",
197
+ list: CustomerList,
198
+ record: CustomerView,
199
+ }),
200
+ ];
201
+ ```
202
+
203
+ **5. The application** — transport, language (web-ui's, composer's and the framework's words),
204
+ cache, toasts and the workspace, in one place:
79
205
 
80
206
  ```tsx
81
- <FrameworkProvider transport={transport} locale="es">
82
- <App />
83
- </FrameworkProvider>
207
+ <SincproApp
208
+ transport={transport}
209
+ views={views}
210
+ entities={[invoices, customers]} // a relation of a model resolves to one of these, alone
211
+ start={invoices.ref()}
212
+ language="es"
213
+ />
84
214
  ```
85
215
 
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:
216
+ An application with its own shell mounts the same pieces itself: `FrameworkProvider`, composer's
217
+ `Composer` with `webUiSkin`, and the three copy tables.
218
+
219
+ **6. The screens.** A list is the entity and the names of its fields — the server searches,
220
+ filters, orders, groups and pages; a row opens the record's page:
90
221
 
91
222
  ```tsx
92
- function Invoices({ api }: ViewProps) {
223
+ function InvoiceList({ api }: ViewProps) {
93
224
  const listing = useListing(invoices);
94
225
  return (
95
226
  <EntityList
96
227
  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))}
228
+ api={api}
229
+ columns={["number", "customer", "state", "amount_total"]}
106
230
  />
107
231
  );
108
232
  }
109
233
  ```
110
234
 
111
- One record keeps writing web-ui's `Record` tree; what comes from the model are its props:
235
+ A record keeps writing web-ui's `Record` tree. Its fields come from the model; a related record
236
+ is read with its summary and opened where it belongs; a many2one is chosen with a literal search
237
+ on the server, and «Buscar más…» opens the customers' own list to choose from:
112
238
 
113
239
  ```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>;
240
+ function InvoiceView({ api, resource }: ViewProps) {
241
+ const invoice = useRecord(invoices, resource.id ?? "");
242
+ const crumbs = useCrumbs(api);
243
+ useRecordInWorkspace(api, invoice); // its real name in the crumb, «unsaved» asks first
244
+ const dirty = dirtyOf(invoice); // «Save / Discard» while something was typed
245
+ if (invoice.status !== "ready") return <Skeleton rows={4} label="Loading" />;
246
+ return (
247
+ <Record
248
+ identity={{ title: invoice.record.number }}
249
+ {...(dirty === undefined ? {} : { dirty })}
250
+ >
251
+ <Record.Rail>
252
+ <Record.Crumbs crumbs={crumbs} />
253
+ {/* Always there on an edited record: without them web-ui remounts the paper on «dirty». */}
254
+ <Record.Actions>
255
+ <CommandButton entity={invoices} id={invoice.id} command="post" label="Post" />
256
+ </Record.Actions>
257
+ </Record.Rail>
258
+ <Record.Paper>
259
+ <Field label="Customer">
260
+ <RelatedRecord api={api} entity={customers} value={invoice.record.customer} />
261
+ </Field>
262
+ <RecordField
263
+ record={invoice}
264
+ name="customer_id"
265
+ as={({ value, set }) => (
266
+ <EntityPicker
267
+ entity={customers}
268
+ value={String(value ?? "")}
269
+ onChange={set}
270
+ onBrowseAll={chooserIn(api, customers, set)}
271
+ />
272
+ )}
273
+ />
274
+ <RecordField record={invoice} name="notes" />
275
+ </Record.Paper>
276
+ </Record>
277
+ );
278
+ }
279
+ ```
280
+
281
+ Any record, at any size, from its summary:
282
+
283
+ ```tsx
284
+ const summarize = useSummaryOf(customers);
285
+ <RecordCard
286
+ {...cardOf(summarize(row))}
287
+ onOpen={() => openIn(api, customers.ref(row.id), "page")}
288
+ />;
122
289
  ```
123
290
 
124
291
  A domain with no sincpro_framework behind it declares its model by hand and is drawn the same
@@ -138,17 +305,53 @@ const tickets = defineMeta({
138
305
  });
139
306
  ```
140
307
 
308
+ ## The abstractions
309
+
310
+ Each answers one question, and each comes in the three levels — use the component, drop to the
311
+ hook and draw your own, or go straight to the loose piece.
312
+
313
+ | Question | Declarative | Hook | Loose piece |
314
+ | -------------------------------- | --------------------------------------------- | ------------------------------------- | ------------------------------------ |
315
+ | Where is the data? | `entity()` | — | `restTransport`, `memoryTransport` |
316
+ | What is each field? | `Meta` from the server, or `defineMeta` | — | `textOf`, `viewOf`, `metaFromSchema` |
317
+ | What is a record, at any size? | `cardOf`, `relationOf` | `useSummaryOf` | `summaryOf` |
318
+ | N records: search, filter, page | `EntityList`, `EntitySearchBar` | `useListing`, `useGroups` | `transport.list(route, criteria)` |
319
+ | A named slice (to collect, …) | `defineList` + `listView` | `useListing(list)` | `scopeOf` |
320
+ | A record's whole screen | `EntityRecord`, `entityView` without `record` | — | `formOf` |
321
+ | Previous / next from a record | `EntityRecord` (from a list) | — | `placeOf`, `stepIn` (`Trail`) |
322
+ | How much each group adds up | `EntityList sums` | `useGroups` (`sums`) | `sumsOf`, `detailOf` |
323
+ | One record: read, edit, save | `RecordField`, `factsOf`, `RecordLines` | `useRecord` | `transport.one`, `transport.send` |
324
+ | Its relations, inside it | `RelatedList` | `useRelated` | `relatedOf` |
325
+ | Choose one (literal search) | `EntityPicker`, `chooserIn` | `useLookup` | `lookupOf` + criteria's `matching` |
326
+ | Choose several (many2many) | `EntityTags` | `useLookup` (`values`) | `lookupOf` |
327
+ | A relation, from the model alone | `RecordField name="customer_id"` | `useEntityFor` | `relationFieldOf`, `entityFor` |
328
+ | Open it where it belongs | `RelatedRecord`, `entityView` | — | `openIn`, `targetsFor` |
329
+ | Act on it (with a dry run) | `CommandButton`, `commands` on rows | `useCommand` | `transport.send` |
330
+ | What happened to it | `Record.Activity` fed by `useHistory` | `useHistory` | `historyOf` |
331
+ | The application context | `SincproApp` | `useRecordInWorkspace`, `useFollowed` | `FrameworkProvider`, `webUiSkin` |
332
+
333
+ The rules behind them, in short:
334
+
335
+ - **A reference names any record** (`domain:kind/id`, `entity.ref(id)`). Two contexts talk by
336
+ reference, never by import; whatever must survive a reload travels in it.
337
+ - **A related record opens in a modal** the size of a record, and «Abrir en…» offers preview,
338
+ side and page. **From inside a modal**, beside or as a preview — never a modal over a modal.
339
+ `openIn` decides it from what composer says about the view, so no screen writes it.
340
+ - **What a record does not have is not drawn**: no picture, no image; no state, no tag.
341
+ - **Every word on screen** is the model's (`labelOf`, `choiceLabelOf`) or the framework's copy
342
+ table (`frameworkCopy()`), never a string in a component.
343
+
141
344
  ### Inside
142
345
 
143
346
  Three folders, one direction — `make doctor` keeps the arrows:
144
347
 
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 |
348
+ | Folder | Knows | Why apart |
349
+ | --------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------- |
350
+ | `core` | transport, cache, entity, model definition, what a value and a record read as | plain logic, tested in Node in milliseconds |
351
+ | `react` | `useListing`, `useRecord`, `useRelated`, `useSummaryOf`…, the provider | state that draws nothing: usable without `EntityList` |
352
+ | `web/list` | a collection: `EntityList`, `EntitySearchBar`, `columnsOf` | N records, any size |
353
+ | `web/record` | one record: `RecordField`, `factsOf`, `RelatedList`, `EntityPicker`, `cardOf` | works outside a workspace (no composer) |
354
+ | `web/workspace` | the composer: `entityView`, `openIn`, `RelatedRecord`, `<SincproApp>` | where it opens, the application context |
152
355
 
153
356
  Everything is imported from `@sincpro/web-framework`.
154
357
 
@@ -174,6 +377,24 @@ Everything is imported from `@sincpro/web-framework`.
174
377
  | `EntityList` views | The same set as table, rows or board | ✅ |
175
378
  | `useRelated`, `RelatedList` | A record's relation as a live list: searched and paged by the server, inside a form | ✅ |
176
379
  | `EntityPicker` chooser | «see all…» opens the whole set, searched on the server: a many2one over millions | ✅ |
380
+ | `useRecord(entity, "")`, `create` | A new record: the model's defaults, saved to `create`, then it IS the record it became | ✅ |
381
+ | `retryKeys`, `Idempotency-Key` | A Command or a create retried after a lost answer runs once, not twice | ✅ |
382
+ | `entity.newRef()`, «New» in the list | The record's form with no id, in a page or a window; replaced by the record once created | ✅ |
383
+ | `EntityPicker criteria` | A many2one narrowed by another field: the invoices OF the chosen customer | ✅ |
384
+ | `chooserIn`, `entity.chooserRef()` | «see all…» as the composer's chooser: a row answers (`api.pick`) | ✅ |
385
+ | `useGroups`, «Group by» | Groups counted by the server over the whole set; a group's rows read when it is opened | ✅ |
386
+ | `defineList`, `listView` | A named list (a fixed scope, its columns and filters) as data, and as a workspace view | ✅ |
387
+ | `EntityPicker` `browse`, `columns` | The chooser always a click away, as a table searched literally by the server | ✅ |
388
+ | `useFollowed` | A detail panel follows the record the person is on, without knowing the list | ✅ |
389
+ | `commands`, `useRowCommands` | An entity's Commands on each row, idempotent, offered only where `when` holds | ✅ |
390
+ | `summaryOf`, `cardOf`, `relationOf` | Any record at any size: name, description, state, type, picture, who made and changed it | ✅ |
391
+ | `openIn`, `RelatedRecord` | A related record opens in a modal; «Abrir en…» for the rest; never a modal over a modal | ✅ |
392
+ | Literal search everywhere | Picker: the first ten, the server's search as you type; tables: «Buscar «X»» on Enter | ✅ |
393
+ | `EntityTags` | A many2many as chips over thousands: the server's search, faces, the chosen never again | ✅ |
394
+ | `RecordField` + `entities` | A many2one / one2many drawn from the model alone, opened where the view says | ✅ |
395
+ | `EntityRecord`, `formOf` | Any entity's record screen without writing it | ✅ |
396
+ | `Trail`, `Record.Pager` | Previous / next from a record, across the server's pages | ✅ |
397
+ | `EntityList sums` | Each group says how much it adds up, summed by the server over all of it | ✅ |
177
398
 
178
399
  What it does **not** do: mobile (`sincpro_mobile` has its own core), sessions and login (the
179
400
  application's), routing (composer's), string-named widget registries, technical logs on a
@@ -30,11 +30,11 @@ export function cachedTransport(transport, options = {}) {
30
30
  one(route, signal) {
31
31
  return remembered(`one ${route}`, () => transport.one(route, signal));
32
32
  },
33
- async send(method, route, body, signal) {
33
+ async send(method, route, body, signal, sending) {
34
34
  // A preview writes nothing; anything else may have changed what was read.
35
35
  const preview = route.endsWith("/preview");
36
36
  try {
37
- return await transport.send(method, route, body, signal);
37
+ return await transport.send(method, route, body, signal, sending);
38
38
  }
39
39
  finally {
40
40
  // Even a refused write may have changed something (a 409 says someone else did).
@@ -30,6 +30,12 @@ export type FrameworkCopy = {
30
30
  capped: (shown: number) => string;
31
31
  /** Conditions the server could not answer: the list is WIDER than asked. */
32
32
  dropped: (fields: readonly string[]) => string;
33
+ /** The group of the records that hold no value in the field grouped by. */
34
+ noValue: string;
35
+ /** More groups than shown: narrow the set instead of scrolling them. */
36
+ groupsCapped: (shown: number) => string;
37
+ /** «12 groups · 187 records»: what the grouping split. */
38
+ groups: (groups: number, records: number) => string;
33
39
  };
34
40
  record: {
35
41
  /** A draft this caller kept is back on screen. */
@@ -43,6 +49,24 @@ export type FrameworkCopy = {
43
49
  loading: string;
44
50
  /** Under an embedded list the record holds only one page of. */
45
51
  partial: (shown: number, total: number) => string;
52
+ /** The title of a record that does not exist yet, after the model's name. */
53
+ creating: (name: string) => string;
54
+ /** The list's button that opens the form of a new record. */
55
+ create: string;
56
+ };
57
+ picker: {
58
+ /** The button beside a many2one that opens the chooser over the whole set. */
59
+ browse: string;
60
+ };
61
+ summary: {
62
+ /** The facts of a record's summary: when it was made and last changed. */
63
+ created: string;
64
+ updated: string;
65
+ /** «12 mar 2026 · Ana Rojas»: when, and who when it is known. */
66
+ when: (at: string, by: string | null) => string;
67
+ /** A card's last line: «Updated 12 Mar 2026 by Ana», or «Created …» if never changed. */
68
+ updatedLine: (at: string, by: string | null) => string;
69
+ createdLine: (at: string, by: string | null) => string;
46
70
  };
47
71
  command: {
48
72
  /** The confirmation of an action that cannot be undone, after its dry run. */
package/dist/core/copy.js CHANGED
@@ -15,6 +15,9 @@ export const ENGLISH_FRAMEWORK_COPY = {
15
15
  builder: "Criteria builder",
16
16
  capped: (shown) => `Showing the first ${shown}. Narrow the search to see the rest.`,
17
17
  dropped: (fields) => `The server ignored a condition on ${fields.join(", ")}: the list shows more than asked.`,
18
+ noValue: "No value",
19
+ groupsCapped: (shown) => `The first ${shown} groups. Narrow the search to see the rest.`,
20
+ groups: (groups, records) => `${groups} ${groups === 1 ? "group" : "groups"} · ${records} ${records === 1 ? "record" : "records"}`,
18
21
  },
19
22
  record: {
20
23
  restored: "A draft you kept is back. Save it or discard it.",
@@ -24,6 +27,18 @@ export const ENGLISH_FRAMEWORK_COPY = {
24
27
  keep: "Keep draft",
25
28
  loading: "Loading the record",
26
29
  partial: (shown, total) => `Showing ${shown} of ${total}.`,
30
+ creating: (name) => (name ? `${name} · new` : "New"),
31
+ create: "New",
32
+ },
33
+ picker: {
34
+ browse: "Search all",
35
+ },
36
+ summary: {
37
+ created: "Created",
38
+ updated: "Updated",
39
+ when: (at, by) => (by === null ? at : `${at} · ${by}`),
40
+ updatedLine: (at, by) => (by === null ? `Updated ${at}` : `Updated ${at} by ${by}`),
41
+ createdLine: (at, by) => (by === null ? `Created ${at}` : `Created ${at} by ${by}`),
27
42
  },
28
43
  command: {
29
44
  confirm: "Do it",
@@ -67,6 +82,9 @@ export const SPANISH_FRAMEWORK_COPY = {
67
82
  builder: "Constructor de criteria",
68
83
  capped: (shown) => `Se muestran los primeros ${shown}. Refiná la búsqueda para ver el resto.`,
69
84
  dropped: (fields) => `El servidor ignoró una condición sobre ${fields.join(", ")}: la lista muestra más de lo pedido.`,
85
+ noValue: "Sin valor",
86
+ groupsCapped: (shown) => `Los primeros ${shown} grupos. Acotá la búsqueda para ver el resto.`,
87
+ groups: (groups, records) => `${groups} ${groups === 1 ? "grupo" : "grupos"} · ${records} ${records === 1 ? "registro" : "registros"}`,
70
88
  },
71
89
  record: {
72
90
  restored: "Volvió un borrador que guardaste. Guardalo o descartalo.",
@@ -76,6 +94,18 @@ export const SPANISH_FRAMEWORK_COPY = {
76
94
  keep: "Guardar borrador",
77
95
  loading: "Cargando el registro",
78
96
  partial: (shown, total) => `Se muestran ${shown} de ${total}.`,
97
+ creating: (name) => (name ? `${name} · nuevo` : "Nuevo"),
98
+ create: "Nuevo",
99
+ },
100
+ picker: {
101
+ browse: "Buscar en todos",
102
+ },
103
+ summary: {
104
+ created: "Creado",
105
+ updated: "Actualizado",
106
+ when: (at, by) => (by === null ? at : `${at} · ${by}`),
107
+ updatedLine: (at, by) => by === null ? `Actualizado el ${at}` : `Actualizado el ${at} por ${by}`,
108
+ createdLine: (at, by) => (by === null ? `Creado el ${at}` : `Creado el ${at} por ${by}`),
79
109
  },
80
110
  command: {
81
111
  confirm: "Hacerlo",
@@ -25,3 +25,16 @@ export declare function changesOf(record: AnyRecord, values: AnyRecord): AnyReco
25
25
  export declare function missingOf(meta: Meta | null | undefined, values: AnyRecord, fromServer?: FieldStates): string[];
26
26
  /** The server's word on a field that did not validate: `errors[].pointer` is a JSON pointer. */
27
27
  export declare function errorsByField(errors: readonly FieldError[] | undefined): Record<string, string>;
28
+ /**
29
+ * A record that does not exist yet: the defaults the model publishes for each field
30
+ * (`FieldMeta.default`, sincpro_framework's `defaults_of`), and nothing else.
31
+ *
32
+ * defaultsOf(invoiceMeta) -> { state: "draft", payment: "cash", discount: "0" }
33
+ */
34
+ export declare function defaultsOf(meta: Meta | null | undefined): AnyRecord;
35
+ /**
36
+ * What a create sends: every value on screen the caller may write. A field the model marks
37
+ * `readonly` is the server's to fill (the number, the totals), and an empty one is left for
38
+ * the server's own default — never sent as `""`.
39
+ */
40
+ export declare function creationOf(meta: Meta | null | undefined, values: AnyRecord): AnyRecord;
package/dist/core/edit.js CHANGED
@@ -49,3 +49,33 @@ export function errorsByField(errors) {
49
49
  }
50
50
  return found;
51
51
  }
52
+ /**
53
+ * A record that does not exist yet: the defaults the model publishes for each field
54
+ * (`FieldMeta.default`, sincpro_framework's `defaults_of`), and nothing else.
55
+ *
56
+ * defaultsOf(invoiceMeta) -> { state: "draft", payment: "cash", discount: "0" }
57
+ */
58
+ export function defaultsOf(meta) {
59
+ const values = {};
60
+ for (const [name, field] of Object.entries(meta?.fields ?? {}))
61
+ if (field.default !== null && field.default !== undefined)
62
+ values[name] = field.default;
63
+ return values;
64
+ }
65
+ /**
66
+ * What a create sends: every value on screen the caller may write. A field the model marks
67
+ * `readonly` is the server's to fill (the number, the totals), and an empty one is left for
68
+ * the server's own default — never sent as `""`.
69
+ */
70
+ export function creationOf(meta, values) {
71
+ const sent = {};
72
+ for (const [name, value] of Object.entries(values)) {
73
+ const field = meta?.fields[name];
74
+ if (field?.readonly === true)
75
+ continue;
76
+ if (value === undefined || value === null || value === "")
77
+ continue;
78
+ sent[name] = value;
79
+ }
80
+ return sent;
81
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AnyRecord } from "@sincpro/criteria";
2
+ import type { SummaryFields } from "./summary.js";
2
3
  import type { Method } from "./transport.js";
3
4
  /**
4
5
  * Everything a screen needs to know about where an entity lives on the server, declared once.
@@ -13,6 +14,7 @@ import type { Method } from "./transport.js";
13
14
  *
14
15
  * export const invoices = entity<Invoice>("billing:invoice", {
15
16
  * list: "/invoices",
17
+ * create: "POST /invoices",
16
18
  * save: "PUT /invoices/{id}",
17
19
  * preview: "POST /invoices/preview",
18
20
  * commands: { confirm: "POST /invoices/{id}/confirm", cancel: "POST /invoices/{id}/cancel" },
@@ -27,12 +29,19 @@ export type Entity<T extends AnyRecord = AnyRecord> = {
27
29
  /** Where the workspace finds it: the list without an id, one record with it — the shape of a
28
30
  * composer `ResourceRef`, built here so no screen writes `{ domain, kind, id }` by hand. */
29
31
  ref: (id?: string) => EntityRef;
32
+ /** A record that does not exist yet — the form that creates one. Its view is the record's. */
33
+ newRef: () => EntityRef;
34
+ /** Its list opened to CHOOSE one: a row answers whoever asked (composer's `api.pick`). With
35
+ * `query`, the list opens on that literal search — what was typed in the picker. */
36
+ chooserRef: (query?: string) => EntityRef;
30
37
  /** The route of its list. */
31
38
  list: string;
32
39
  /** The route of one record. */
33
40
  one: (id: string) => string;
34
41
  /** Its Commands, by name. */
35
42
  commands: Readonly<Record<string, WriteRoute>>;
43
+ /** How a new record is created: the list's own route, usually. */
44
+ create?: WriteRoute;
36
45
  /** How an edit is saved: the record's own route with `{id}`. */
37
46
  save?: WriteRoute;
38
47
  /** «What does this record become?» before saving — sincpro_framework's `Preview`. */
@@ -44,6 +53,12 @@ export type Entity<T extends AnyRecord = AnyRecord> = {
44
53
  route: string;
45
54
  type: string;
46
55
  };
56
+ /** Which fields are its description, state, picture… when they are not the conventional
57
+ * ones (`summaryOf`). Empty: the convention decides. */
58
+ summary: SummaryFields;
59
+ /** The aggregate it is, as a relation of another model names it (`relation: "Cliente"`).
60
+ * Without it, the `aggregate` of its model definition, once known. */
61
+ aggregate?: string;
47
62
  /** Only carries the record type; never read. */
48
63
  readonly _record?: T;
49
64
  };
@@ -52,7 +67,19 @@ export type EntityRef = {
52
67
  domain: string;
53
68
  kind: string;
54
69
  id?: string;
70
+ params?: Readonly<Record<string, string>>;
55
71
  };
72
+ /**
73
+ * The params that say what a reference without an id is for: `new` is the form of a record
74
+ * that does not exist yet, `pick` is the list opened to choose one. Params, because they are
75
+ * what a composer reference carries and survives a reload with.
76
+ */
77
+ export declare const NEW_PARAM = "new";
78
+ export declare const PICK_PARAM = "pick";
79
+ /** What was typed where the chooser was asked for: its list opens on that search. */
80
+ export declare const QUERY_PARAM = "q";
81
+ /** The list a record was opened from (its `Trail`): «3 / 64» and the arrows to its neighbours. */
82
+ export declare const TRAIL_PARAM = "from";
56
83
  /** A write: its method and its path, `{id}` where the record's key goes. */
57
84
  export type WriteRoute = {
58
85
  method: Method;
@@ -64,6 +91,8 @@ export type EntityRoutes = {
64
91
  one?: string;
65
92
  /** `"POST /invoices/{id}/confirm"`, by name. */
66
93
  commands?: Readonly<Record<string, string>>;
94
+ /** `"POST /invoices"`: where a new record goes. */
95
+ create?: string;
67
96
  save?: string;
68
97
  preview?: string;
69
98
  draft?: string;
@@ -71,6 +100,11 @@ export type EntityRoutes = {
71
100
  route: string;
72
101
  type: string;
73
102
  };
103
+ /** `{ image: "logo", state: "estado_pago", person: true }` — not a route, but declared once
104
+ * with the rest of what a screen should not have to know. */
105
+ summary?: SummaryFields;
106
+ /** `"Cliente"`: what a many2one of another model calls it, so a form resolves it alone. */
107
+ aggregate?: string;
74
108
  };
75
109
  /** `"POST /invoices/{id}/confirm"` → its method and path. A write without a method is a bug. */
76
110
  export declare function writeRoute(text: string): WriteRoute;
@@ -1,3 +1,14 @@
1
+ /**
2
+ * The params that say what a reference without an id is for: `new` is the form of a record
3
+ * that does not exist yet, `pick` is the list opened to choose one. Params, because they are
4
+ * what a composer reference carries and survives a reload with.
5
+ */
6
+ export const NEW_PARAM = "new";
7
+ export const PICK_PARAM = "pick";
8
+ /** What was typed where the chooser was asked for: its list opens on that search. */
9
+ export const QUERY_PARAM = "q";
10
+ /** The list a record was opened from (its `Trail`): «3 / 64» and the arrows to its neighbours. */
11
+ export const TRAIL_PARAM = "from";
1
12
  const METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
2
13
  /** `"POST /invoices/{id}/confirm"` → its method and path. A write without a method is a bug. */
3
14
  export function writeRoute(text) {
@@ -21,12 +32,26 @@ export function entity(key, routes) {
21
32
  domain,
22
33
  kind,
23
34
  ref: (id) => ({ domain, kind, ...(id === undefined || id === "" ? {} : { id }) }),
35
+ newRef: () => ({ domain, kind, params: { [NEW_PARAM]: "1" } }),
36
+ chooserRef: (query) => ({
37
+ domain,
38
+ kind,
39
+ params: {
40
+ [PICK_PARAM]: "1",
41
+ ...(query === undefined || query.trim() === ""
42
+ ? {}
43
+ : { [QUERY_PARAM]: query.trim() }),
44
+ },
45
+ }),
24
46
  list: routes.list,
25
47
  one: (id) => template.replace("{id}", encodeURIComponent(id)),
26
48
  commands: Object.fromEntries(Object.entries(routes.commands ?? {}).map(([name, text]) => [name, writeRoute(text)])),
49
+ ...(routes.create === undefined ? {} : { create: writeRoute(routes.create) }),
27
50
  ...(routes.save === undefined ? {} : { save: writeRoute(routes.save) }),
28
51
  ...(routes.preview === undefined ? {} : { preview: writeRoute(routes.preview) }),
29
52
  ...(routes.draft === undefined ? {} : { draft: writeRoute(routes.draft) }),
30
53
  ...(routes.history === undefined ? {} : { history: routes.history }),
54
+ summary: routes.summary ?? {},
55
+ ...(routes.aggregate === undefined ? {} : { aggregate: routes.aggregate }),
31
56
  };
32
57
  }