@sincpro/web-framework 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +261 -40
- package/dist/core/cache.js +2 -2
- package/dist/core/copy.d.ts +24 -0
- package/dist/core/copy.js +30 -0
- package/dist/core/edit.d.ts +13 -0
- package/dist/core/edit.js +30 -0
- package/dist/core/entity.d.ts +34 -0
- package/dist/core/entity.js +25 -0
- package/dist/core/form.d.ts +21 -0
- package/dist/core/form.js +30 -0
- package/dist/core/index.d.ts +10 -3
- package/dist/core/index.js +9 -2
- package/dist/core/list.d.ts +64 -0
- package/dist/core/list.js +22 -0
- package/dist/core/lookup.d.ts +5 -2
- package/dist/core/lookup.js +6 -2
- package/dist/core/measure.d.ts +16 -0
- package/dist/core/measure.js +38 -0
- package/dist/core/memory.js +18 -4
- package/dist/core/relation.d.ts +28 -0
- package/dist/core/relation.js +30 -0
- package/dist/core/rest.js +13 -3
- package/dist/core/retry.d.ts +24 -0
- package/dist/core/retry.js +25 -0
- package/dist/core/summary.d.ts +50 -0
- package/dist/core/summary.js +54 -0
- package/dist/core/trail.d.ts +44 -0
- package/dist/core/trail.js +70 -0
- package/dist/core/transport.d.ts +13 -1
- package/dist/react/context.d.ts +12 -1
- package/dist/react/context.js +10 -2
- package/dist/react/index.d.ts +3 -1
- package/dist/react/index.js +3 -1
- package/dist/react/useCommand.d.ts +3 -1
- package/dist/react/useCommand.js +12 -4
- package/dist/react/useGroups.d.ts +40 -0
- package/dist/react/useGroups.js +69 -0
- package/dist/react/useListing.d.ts +21 -3
- package/dist/react/useListing.js +38 -3
- package/dist/react/useLookup.d.ts +30 -5
- package/dist/react/useLookup.js +56 -3
- package/dist/react/useRecord.d.ts +17 -2
- package/dist/react/useRecord.js +105 -9
- package/dist/react/useSummary.d.ts +15 -0
- package/dist/react/useSummary.js +22 -0
- package/dist/web/index.d.ts +9 -3
- package/dist/web/index.js +9 -3
- package/dist/web/list/EntityList.d.ts +27 -4
- package/dist/web/list/EntityList.js +162 -15
- package/dist/web/list/EntitySearchBar.d.ts +16 -8
- package/dist/web/list/EntitySearchBar.js +34 -4
- package/dist/web/list/groups.d.ts +15 -0
- package/dist/web/list/groups.js +73 -0
- package/dist/web/list/rowCommands.d.ts +41 -0
- package/dist/web/list/rowCommands.js +56 -0
- package/dist/web/record/EntityPicker.d.ts +42 -13
- package/dist/web/record/EntityPicker.js +53 -19
- package/dist/web/record/EntityTags.d.ts +38 -0
- package/dist/web/record/EntityTags.js +41 -0
- package/dist/web/record/RelatedList.d.ts +2 -1
- package/dist/web/record/RelatedList.js +12 -2
- package/dist/web/record/edit.d.ts +12 -5
- package/dist/web/record/edit.js +67 -8
- package/dist/web/record/opening.d.ts +26 -0
- package/dist/web/record/opening.js +10 -0
- package/dist/web/record/summary.d.ts +22 -0
- package/dist/web/record/summary.js +55 -0
- package/dist/web/workspace/SincproApp.d.ts +4 -1
- package/dist/web/workspace/SincproApp.js +2 -2
- package/dist/web/workspace/composer.d.ts +26 -4
- package/dist/web/workspace/composer.js +59 -2
- package/dist/web/workspace/open.d.ts +61 -0
- package/dist/web/workspace/open.js +62 -0
- package/dist/web/workspace/record.d.ts +30 -0
- package/dist/web/workspace/record.js +80 -0
- package/dist/web/workspace/skin.d.ts +0 -9
- package/dist/web/workspace/skin.js +25 -13
- package/dist/web/workspace/workspace.d.ts +31 -3
- package/dist/web/workspace/workspace.js +57 -4
- package/package.json +5 -5
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
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
223
|
+
function InvoiceList({ api }: ViewProps) {
|
|
93
224
|
const listing = useListing(invoices);
|
|
94
225
|
return (
|
|
95
226
|
<EntityList
|
|
96
227
|
listing={listing}
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
146
|
-
| --------------- |
|
|
147
|
-
| `core` | transport, cache, entity, model definition, what a value
|
|
148
|
-
| `react` | `useListing`, `useRecord`, `useRelated`, `
|
|
149
|
-
| `web/list` | a collection: `EntityList`, `EntitySearchBar`, `columnsOf`
|
|
150
|
-
| `web/record` | one record: `RecordField`, `factsOf`, `RelatedList`, `EntityPicker`, `
|
|
151
|
-
| `web/workspace` | the composer: `entityView`, `
|
|
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
|
package/dist/core/cache.js
CHANGED
|
@@ -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).
|
package/dist/core/copy.d.ts
CHANGED
|
@@ -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",
|
package/dist/core/edit.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/entity.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/entity.js
CHANGED
|
@@ -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
|
}
|