@sincpro/web-framework 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +445 -2
- package/dist/core/cache.d.ts +30 -0
- package/dist/core/cache.js +47 -0
- package/dist/core/copy.d.ts +93 -0
- package/dist/core/copy.js +141 -0
- package/dist/core/edit.d.ts +40 -0
- package/dist/core/edit.js +81 -0
- package/dist/core/entity.d.ts +113 -0
- package/dist/core/entity.js +57 -0
- package/dist/core/form.d.ts +21 -0
- package/dist/core/form.js +30 -0
- package/dist/core/history.d.ts +20 -0
- package/dist/core/history.js +40 -0
- package/dist/core/index.d.ts +26 -0
- package/dist/core/index.js +26 -0
- package/dist/core/list.d.ts +64 -0
- package/dist/core/list.js +22 -0
- package/dist/core/lookup.d.ts +19 -0
- package/dist/core/lookup.js +14 -0
- package/dist/core/measure.d.ts +16 -0
- package/dist/core/measure.js +38 -0
- package/dist/core/memory.d.ts +43 -0
- package/dist/core/memory.js +99 -0
- package/dist/core/meta.d.ts +114 -0
- package/dist/core/meta.js +130 -0
- package/dist/core/metas.d.ts +18 -0
- package/dist/core/metas.js +7 -0
- package/dist/core/present.d.ts +48 -0
- package/dist/core/present.js +165 -0
- package/dist/core/related.d.ts +22 -0
- package/dist/core/related.js +21 -0
- package/dist/core/relation.d.ts +28 -0
- package/dist/core/relation.js +30 -0
- package/dist/core/rest.d.ts +30 -0
- package/dist/core/rest.js +60 -0
- package/dist/core/retry.d.ts +24 -0
- package/dist/core/retry.js +25 -0
- package/dist/core/schema.d.ts +46 -0
- package/dist/core/schema.js +128 -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 +71 -0
- package/dist/core/transport.js +43 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/react/context.d.ts +38 -0
- package/dist/react/context.js +26 -0
- package/dist/react/index.d.ts +14 -0
- package/dist/react/index.js +14 -0
- package/dist/react/useCommand.d.ts +30 -0
- package/dist/react/useCommand.js +60 -0
- package/dist/react/useGroups.d.ts +40 -0
- package/dist/react/useGroups.js +69 -0
- package/dist/react/useHistory.d.ts +5 -0
- package/dist/react/useHistory.js +41 -0
- package/dist/react/useListing.d.ts +93 -0
- package/dist/react/useListing.js +156 -0
- package/dist/react/useLookup.d.ts +47 -0
- package/dist/react/useLookup.js +68 -0
- package/dist/react/useRecord.d.ts +89 -0
- package/dist/react/useRecord.js +272 -0
- package/dist/react/useRelated.d.ts +21 -0
- package/dist/react/useRelated.js +32 -0
- package/dist/react/useSummary.d.ts +15 -0
- package/dist/react/useSummary.js +22 -0
- package/dist/web/Refused.d.ts +9 -0
- package/dist/web/Refused.js +14 -0
- package/dist/web/index.d.ts +23 -0
- package/dist/web/index.js +23 -0
- package/dist/web/list/EntityList.d.ts +92 -0
- package/dist/web/list/EntityList.js +229 -0
- package/dist/web/list/EntitySearchBar.d.ts +34 -0
- package/dist/web/list/EntitySearchBar.js +73 -0
- package/dist/web/list/columns.d.ts +22 -0
- package/dist/web/list/columns.js +27 -0
- 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/list/search.d.ts +21 -0
- package/dist/web/list/search.js +72 -0
- package/dist/web/record/CommandButton.d.ts +22 -0
- package/dist/web/record/CommandButton.js +40 -0
- package/dist/web/record/EntityPicker.d.ts +56 -0
- package/dist/web/record/EntityPicker.js +73 -0
- package/dist/web/record/EntityTags.d.ts +38 -0
- package/dist/web/record/EntityTags.js +41 -0
- package/dist/web/record/RelatedList.d.ts +19 -0
- package/dist/web/record/RelatedList.js +26 -0
- package/dist/web/record/edit.d.ts +42 -0
- package/dist/web/record/edit.js +122 -0
- package/dist/web/record/opening.d.ts +26 -0
- package/dist/web/record/opening.js +10 -0
- package/dist/web/record/record.d.ts +25 -0
- package/dist/web/record/record.js +56 -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 +40 -0
- package/dist/web/workspace/SincproApp.js +45 -0
- package/dist/web/workspace/composer.d.ts +55 -0
- package/dist/web/workspace/composer.js +94 -0
- 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 +2 -0
- package/dist/web/workspace/skin.js +45 -0
- package/dist/web/workspace/workspace.d.ts +44 -0
- package/dist/web/workspace/workspace.js +82 -0
- 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,446 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sincpro/web-framework
|
|
2
2
|
|
|
3
|
-
|
|
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/web-composer @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 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
|
+
```
|
|
94
|
+
|
|
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
|
+
```
|
|
115
|
+
|
|
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.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
export const transport = restTransport({
|
|
148
|
+
base: "/v1",
|
|
149
|
+
headers: () => ({ Authorization: `Bearer ${session.token}` }),
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
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:
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
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
|
+
/>
|
|
214
|
+
```
|
|
215
|
+
|
|
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:
|
|
221
|
+
|
|
222
|
+
```tsx
|
|
223
|
+
function InvoiceList({ api }: ViewProps) {
|
|
224
|
+
const listing = useListing(invoices);
|
|
225
|
+
return (
|
|
226
|
+
<EntityList
|
|
227
|
+
listing={listing}
|
|
228
|
+
api={api}
|
|
229
|
+
columns={["number", "customer", "state", "amount_total"]}
|
|
230
|
+
/>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
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:
|
|
238
|
+
|
|
239
|
+
```tsx
|
|
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
|
+
/>;
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
A domain with no sincpro_framework behind it declares its model by hand and is drawn the same
|
|
292
|
+
way:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
const tickets = defineMeta({
|
|
296
|
+
aggregate: "Ticket",
|
|
297
|
+
identity: "id",
|
|
298
|
+
display: "title",
|
|
299
|
+
search: ["title"],
|
|
300
|
+
fields: {
|
|
301
|
+
id: field.text({ readonly: true }),
|
|
302
|
+
title: field.text({ label: { es: "Título" }, required: true }),
|
|
303
|
+
stage: field.choice(["new", "done"], { labels: { new: { es: "Nuevo" } } }),
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
```
|
|
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
|
+
|
|
344
|
+
### Inside
|
|
345
|
+
|
|
346
|
+
Three folders, one direction — `make doctor` keeps the arrows:
|
|
347
|
+
|
|
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 |
|
|
355
|
+
|
|
356
|
+
Everything is imported from `@sincpro/web-framework`.
|
|
357
|
+
|
|
358
|
+
## Where it stands
|
|
359
|
+
|
|
360
|
+
| Piece | What it solves | Status |
|
|
361
|
+
| -------------------------------------- | ------------------------------------------------------------------------------------------ | ------ |
|
|
362
|
+
| `restTransport`, `memoryTransport` | The backend, or the same engine in memory | ✅ |
|
|
363
|
+
| `defineMeta`, `metaFromSchema` | A model by hand, or completed from OpenAPI | ✅ |
|
|
364
|
+
| `useListing`, `EntityList` | N records: criteria, search bar, builder, paging | ✅ |
|
|
365
|
+
| `useRecord`, `factsOf`, `RecordLines` | One record: facts, relations with their real total | ✅ |
|
|
366
|
+
| Preloaded definitions | `FrameworkProvider metas`: the model asked once, or never | ✅ |
|
|
367
|
+
| `useLookup`, `EntityPicker` | Literal search: a many2one select, ⌘K | ✅ |
|
|
368
|
+
| `useRecord` + `RecordField`, `dirtyOf` | Editing: hints evaluated live, preview, save with version (409), server drafts | ✅ |
|
|
369
|
+
| `useCommand`, `CommandButton` | An entity's actions, with a dry run before what destroys | ✅ |
|
|
370
|
+
| `useHistory` | Business events in `Record.Activity` (comments: backend first) | ✅ |
|
|
371
|
+
| `entityView` | An entity as a composer view: list, record, ⌘K search | ✅ |
|
|
372
|
+
| `<SincproApp>` | The application context in one place, the workspace dressed as web-ui (`webUiSkin`) | ✅ |
|
|
373
|
+
| `useRecordInWorkspace` | A record tells the workspace its real name and its unsaved work (composer 1.0) | ✅ |
|
|
374
|
+
| Paging by pages, ceiling on «more» | One page on screen, walked by the server's cursor: a million rows never freeze it | ✅ |
|
|
375
|
+
| `cachedTransport` | Repeated reads from memory, forgotten when a write touches the entity (on in `SincproApp`) | ✅ |
|
|
376
|
+
| `EntitySearchBar` | The search bar + criteria builder as a loose piece, for any `List` | ✅ |
|
|
377
|
+
| `EntityList` views | The same set as table, rows or board | ✅ |
|
|
378
|
+
| `useRelated`, `RelatedList` | A record's relation as a live list: searched and paged by the server, inside a form | ✅ |
|
|
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 | ✅ |
|
|
398
|
+
|
|
399
|
+
What it does **not** do: mobile (`sincpro_mobile` has its own core), sessions and login (the
|
|
400
|
+
application's), routing (composer's), string-named widget registries, technical logs on a
|
|
401
|
+
record's screen, business rules.
|
|
402
|
+
|
|
403
|
+
The full purpose — literal search, drafts, composer context, several entities per screen — is
|
|
404
|
+
in [`docs/PURPOSE.md`](docs/PURPOSE.md); what was found while building and what comes next is in
|
|
405
|
+
[`docs/ROADMAP.md`](docs/ROADMAP.md) (both in Spanish).
|
|
406
|
+
|
|
407
|
+
## Built for large sets
|
|
408
|
+
|
|
409
|
+
Nothing is filtered, ordered, counted or grouped on the page: every gesture is a question the
|
|
410
|
+
server answers. A list holds ONE page by default and walks the server's keyset cursor (page
|
|
411
|
+
4 000 costs what page 1 costs); «load more» stops at a ceiling and asks to narrow the search; the
|
|
412
|
+
count is the server's, capped; typing waits for a pause; a newer question cancels the older one;
|
|
413
|
+
the model definition is asked once per entity; repeated reads come from a cache that a write
|
|
414
|
+
clears. Story «15 · Un conjunto grande sin congelar» runs it over 200 000 rows.
|
|
415
|
+
|
|
416
|
+
## The use cases are the tests
|
|
417
|
+
|
|
418
|
+
Every use case is a Storybook story run against an in-memory server that answers with the same
|
|
419
|
+
criteria engine as sincpro_framework, and each one carries a `play`. The story IS the test:
|
|
420
|
+
`make test` runs every story in a real Chrome with vitest (`@storybook/addon-vitest`), awaits its
|
|
421
|
+
play and checks its a11y — and the same tests run from Storybook's sidebar («Run tests»). The
|
|
422
|
+
plain logic is tested in Node by the same runner. A case the framework cannot do yet shows up
|
|
423
|
+
there first.
|
|
424
|
+
|
|
425
|
+
## Development
|
|
426
|
+
|
|
427
|
+
```bash
|
|
428
|
+
nvm use # Node 26, from .nvmrc
|
|
429
|
+
make init # pre-commit + yarn install
|
|
430
|
+
make test # vitest: the logic in Node + every use case (story + play) in Chrome
|
|
431
|
+
make dev # Storybook on :6008 — the use cases, «Run tests» in the sidebar
|
|
432
|
+
make build
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Every operation — local or CI — goes through the `Makefile`; run `make` to list the targets.
|
|
436
|
+
Working on this repository, human or agent: read [`AGENTS.md`](AGENTS.md) first.
|
|
437
|
+
|
|
438
|
+
## CI/CD
|
|
439
|
+
|
|
440
|
+
The workflows in `.github/workflows/` only delegate to the shared `Sincpro-SRL/.github`
|
|
441
|
+
pipeline, which calls `make init`, `make verify-format`, `make test`,
|
|
442
|
+
`make update-version VERSION=x` and `make publish`.
|
|
443
|
+
|
|
444
|
+
## License
|
|
445
|
+
|
|
446
|
+
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, sending) {
|
|
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, sending);
|
|
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,93 @@
|
|
|
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
|
+
/** 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;
|
|
39
|
+
};
|
|
40
|
+
record: {
|
|
41
|
+
/** A draft this caller kept is back on screen. */
|
|
42
|
+
restored: string;
|
|
43
|
+
/** Someone saved it after it was read: nothing was overwritten. */
|
|
44
|
+
conflict: string;
|
|
45
|
+
reload: string;
|
|
46
|
+
/** What is missing before it can be saved. */
|
|
47
|
+
missing: (fields: readonly string[]) => string;
|
|
48
|
+
keep: string;
|
|
49
|
+
loading: string;
|
|
50
|
+
/** Under an embedded list the record holds only one page of. */
|
|
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;
|
|
70
|
+
};
|
|
71
|
+
command: {
|
|
72
|
+
/** The confirmation of an action that cannot be undone, after its dry run. */
|
|
73
|
+
confirm: string;
|
|
74
|
+
cancel: string;
|
|
75
|
+
/** While the dry run is asked. */
|
|
76
|
+
rehearsing: string;
|
|
77
|
+
};
|
|
78
|
+
failure: {
|
|
79
|
+
/** The server could not be reached at all — nothing it said is known. */
|
|
80
|
+
unreachable: string;
|
|
81
|
+
/** It answered with a status and no problem to read. */
|
|
82
|
+
status: (status: number) => string;
|
|
83
|
+
retry: string;
|
|
84
|
+
/** What each kind of refusal is called when the server sent no title of its own. */
|
|
85
|
+
kind: Record<FailureKind, string>;
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
export declare const ENGLISH_FRAMEWORK_COPY: FrameworkCopy;
|
|
89
|
+
export declare const SPANISH_FRAMEWORK_COPY: FrameworkCopy;
|
|
90
|
+
/** Install the framework's words once, where the application starts. */
|
|
91
|
+
export declare function installFrameworkCopy(copy: FrameworkCopy): void;
|
|
92
|
+
/** The words in use. A plain read: they are installed before anything draws. */
|
|
93
|
+
export declare const frameworkCopy: () => FrameworkCopy;
|