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