@live-change/frontend-template 0.9.201 → 0.9.204
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/.claude/rules/live-change-backend-actions-views-triggers.md +49 -1
- package/.claude/rules/live-change-backend-architecture.md +5 -5
- package/.claude/rules/live-change-backend-event-sourcing.md +2 -2
- package/.claude/rules/live-change-backend-models-and-relations.md +65 -8
- package/.claude/rules/live-change-frontend-e2e-lifecycle.md +42 -0
- package/.claude/rules/live-change-frontend-i18n-locales.md +25 -0
- package/.claude/rules/live-change-frontend-vue-primevue.md +100 -4
- package/.claude/rules/live-change-node-toolchain-fnm.md +39 -0
- package/.claude/rules/live-change-service-structure.md +2 -2
- package/.claude/settings.json +3 -1
- package/.claude/skills/create-skills-and-rules/SKILL.md +23 -0
- package/.claude/skills/live-change-backend-change-triggers/SKILL.md +15 -0
- package/.claude/skills/live-change-dao-protocol/SKILL.md +46 -0
- package/.claude/skills/live-change-design-actions-views-triggers/SKILL.md +116 -0
- package/.claude/skills/live-change-design-models-relations/SKILL.md +63 -5
- package/.claude/skills/live-change-frontend-command-forms/SKILL.md +6 -5
- package/.claude/skills/live-change-frontend-data-views/SKILL.md +75 -4
- package/.claude/skills/live-change-frontend-e2e-lifecycle/SKILL.md +82 -0
- package/.claude/skills/live-change-frontend-range-list/SKILL.md +90 -0
- package/.claude/skills/live-change-frontend-synchronized/SKILL.md +101 -0
- package/.claude/skills/live-change-node-toolchain-fnm/SKILL.md +44 -0
- package/.cursor/rules/live-change-backend-actions-views-triggers.mdc +58 -4
- package/.cursor/rules/live-change-backend-architecture.mdc +1 -1
- package/.cursor/rules/live-change-backend-event-sourcing.mdc +1 -1
- package/.cursor/rules/live-change-backend-models-and-relations.mdc +69 -7
- package/.cursor/rules/live-change-backend-views-vs-triggers-for-reads-writes.mdc +28 -0
- package/.cursor/rules/live-change-dao-protocol.mdc +47 -0
- package/.cursor/rules/live-change-frontend-e2e-lifecycle.mdc +15 -0
- package/.cursor/rules/live-change-frontend-i18n-locales.mdc +26 -0
- package/.cursor/rules/live-change-frontend-views-not-commands-for-reads.mdc +30 -0
- package/.cursor/rules/live-change-frontend-vue-primevue.mdc +91 -0
- package/.cursor/rules/live-change-node-toolchain-fnm.mdc +40 -0
- package/.cursor/rules/live-change-service-structure.mdc +1 -1
- package/.cursor/skills/create-skills-and-rules.md +23 -0
- package/.cursor/skills/live-change-backend-change-triggers.md +15 -0
- package/.cursor/skills/live-change-design-actions-views-triggers.md +57 -0
- package/.cursor/skills/live-change-design-models-relations.md +23 -5
- package/.cursor/skills/live-change-frontend-command-forms.md +6 -5
- package/.cursor/skills/live-change-frontend-data-views.md +17 -0
- package/.cursor/skills/live-change-frontend-e2e-lifecycle.md +82 -0
- package/.cursor/skills/live-change-frontend-range-list.md +45 -0
- package/.cursor/skills/live-change-frontend-synchronized.md +101 -0
- package/.cursor/skills/live-change-node-toolchain-fnm.md +44 -0
- package/.node-version +1 -0
- package/.nvmrc +1 -0
- package/README.md +18 -0
- package/e2e/client-session.test.ts +17 -0
- package/e2e/e2eSuite.ts +12 -0
- package/e2e/env.ts +130 -0
- package/e2e/homepage.test.ts +17 -0
- package/e2e/steps.ts +3 -0
- package/e2e/withBrowser.ts +18 -0
- package/opencode.json +4 -1
- package/package.json +55 -53
|
@@ -7,6 +7,12 @@ description: Design actions, views, triggers with indexes and batch processing p
|
|
|
7
7
|
|
|
8
8
|
Use this skill to design **actions, views, and triggers** in LiveChange services while making good use of indexes and avoiding full-table scans.
|
|
9
9
|
|
|
10
|
+
## Reads vs writes (CQRS-like)
|
|
11
|
+
|
|
12
|
+
**Frontend (Vue):** load data with `usePath` + `live` / `useFetch` on **views**. Do **not** use `api.command` or `useActions()` only to fetch, preview, or compute display-only values — add a `definition.view` on the server and read it on the client.
|
|
13
|
+
|
|
14
|
+
**Backend (services):** **`definition.view`** is the read surface (including computed or preview data). From triggers/actions use **`app.viewGet`** / **`app.serviceViewGet`** when you need the same view layer as the client, or direct model/index reads where appropriate. **Actions and triggers** change state via **`emit`** / **`trigger`** / **`triggerService`**, not via fake “read-only actions.”
|
|
15
|
+
|
|
10
16
|
## When to use
|
|
11
17
|
|
|
12
18
|
- You add or change actions on existing models.
|
|
@@ -73,6 +79,116 @@ definition.action({
|
|
|
73
79
|
- use model paths (`Model.path`, `Model.rangePath`, `Model.sortedIndexRangePath`, `Model.indexObjectPath`)
|
|
74
80
|
- use `...App.rangeProperties` + `App.extractRange(props)` for range views
|
|
75
81
|
|
|
82
|
+
### Step 2a – Prefix-aware range filtering
|
|
83
|
+
|
|
84
|
+
When you query an index with `Model.sortedIndexRangePath(indexName, keyPrefix, range)`, remember:
|
|
85
|
+
|
|
86
|
+
- `keyPrefix` is matched first (for example `[bankAccount]` or `[bankAccount, month]`).
|
|
87
|
+
- `range.gt/gte/lt/lte` is applied to full serialized index keys, not to a single field.
|
|
88
|
+
- If you need optional narrowing, pass a dedicated filter parameter (`month`, `state`, etc.) and keep range for cursor pagination.
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
definition.view({
|
|
92
|
+
name: 'bankTransactionsByBankAccountAndDate',
|
|
93
|
+
properties: {
|
|
94
|
+
bankAccount: { type: String },
|
|
95
|
+
month: { type: String },
|
|
96
|
+
...App.rangeProperties
|
|
97
|
+
},
|
|
98
|
+
returns: { type: Array, of: { type: Object } },
|
|
99
|
+
async daoPath({ bankAccount, month, ...props }) {
|
|
100
|
+
const range = App.extractRange(props)
|
|
101
|
+
if(month) {
|
|
102
|
+
const prefix = [bankAccount, month].map(v => JSON.stringify(v)).join(':')
|
|
103
|
+
return BankTransaction.rangePath(App.utils.prefixRange(range, prefix, prefix + ':'))
|
|
104
|
+
}
|
|
105
|
+
return BankTransaction.sortedIndexRangePath('byBankAccountAndDate', [bankAccount], range)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
If filtering by month is a frequent query, prefer a dedicated index like `byBankAccountAndMonthAndDate` and query it with:
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
BankTransaction.sortedIndexRangePath('byBankAccountAndMonthAndDate', [bankAccount, month], range)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
For that index, prefer this function-index style:
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
function: async (input, output, { tableName }) => {
|
|
120
|
+
const table = await input.table(tableName)
|
|
121
|
+
const mapper = obj => ({
|
|
122
|
+
id: [obj.bankAccount, obj.date?.slice(0, 7), obj.date]
|
|
123
|
+
.map(v => JSON.stringify(v)).join(':') + '_' + obj.id,
|
|
124
|
+
to: obj.id
|
|
125
|
+
})
|
|
126
|
+
await table.map(mapper).to(output)
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`map()` automatically filters out `null`, so you can keep mapper logic concise.
|
|
131
|
+
|
|
132
|
+
### Step 2b – RangeViewer/rangeBuckets compatibility
|
|
133
|
+
|
|
134
|
+
When a view is consumed by `RangeViewer` or `rangeBuckets`:
|
|
135
|
+
|
|
136
|
+
- prefer `Model.sortedIndexRangePath(...)` for index-backed list views,
|
|
137
|
+
- keep `App.extractRange(props)` as pagination cursor input,
|
|
138
|
+
- do not reinterpret `gt/gte/lt/lte` as domain filters.
|
|
139
|
+
|
|
140
|
+
Anti-patterns:
|
|
141
|
+
|
|
142
|
+
- using `indexRangePath` for frontend bucket pagination flow,
|
|
143
|
+
- injecting custom month/year bounds into cursor fields in frontend,
|
|
144
|
+
- rewriting cursor values in backend with unrelated filter semantics.
|
|
145
|
+
|
|
146
|
+
Preferred filtering strategy:
|
|
147
|
+
|
|
148
|
+
1. design index prefix for frequent filters,
|
|
149
|
+
2. use `App.utils.prefixRange` only as backend fallback,
|
|
150
|
+
3. keep string min/max hacks as last resort.
|
|
151
|
+
|
|
152
|
+
### Step 2c – Standalone indexes for union/equal sources
|
|
153
|
+
|
|
154
|
+
When index rows are built from multiple equal tables (union-like flow), do not force the index into one model definition.
|
|
155
|
+
|
|
156
|
+
Use `definition.index(...)` at service level (typically `indexes.js`) when:
|
|
157
|
+
|
|
158
|
+
- index combines rows from two or more source tables,
|
|
159
|
+
- source tables are peer entities (no natural single owner model),
|
|
160
|
+
- index is a projection layer for cross-table reads.
|
|
161
|
+
|
|
162
|
+
> **IMPORTANT — serialization constraint:** Index functions are serialized via `toString()` and executed remotely. All helpers, mappers, and variables **must be defined inside the function body**. References to outer scope (module-level functions, imports) will be `undefined` at runtime.
|
|
163
|
+
|
|
164
|
+
Example:
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
definition.index({
|
|
168
|
+
name: 'Urls',
|
|
169
|
+
function: async (input, output) => {
|
|
170
|
+
const mapRedirect = obj => obj && ({
|
|
171
|
+
id: /* composed key */, to: obj.target
|
|
172
|
+
})
|
|
173
|
+
const mapCanonical = obj => obj && ({
|
|
174
|
+
id: /* composed key */, to: obj.target
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
await input.table('url_Redirect').onChange((obj, oldObj) =>
|
|
178
|
+
output.change(mapRedirect(obj), mapRedirect(oldObj))
|
|
179
|
+
)
|
|
180
|
+
await input.table('url_Canonical').onChange((obj, oldObj) =>
|
|
181
|
+
output.change(mapCanonical(obj), mapCanonical(oldObj))
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Decision rule:
|
|
188
|
+
|
|
189
|
+
- model-local index -> `definition.model({ indexes: ... })`,
|
|
190
|
+
- union/peer-source index -> standalone `definition.index(...)` in `indexes.js`.
|
|
191
|
+
|
|
76
192
|
### Example: `daoPath` (preferred, DAO-backed)
|
|
77
193
|
|
|
78
194
|
```js
|
|
@@ -62,6 +62,25 @@ properties: {
|
|
|
62
62
|
}
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
## Step 2b – Relation arity rules (critical)
|
|
66
|
+
|
|
67
|
+
Treat arity on two levels:
|
|
68
|
+
|
|
69
|
+
- **Annotation arity**: can the annotation itself be a list of configs?
|
|
70
|
+
- **Parent tuple arity**: can one config point to multiple parents/dimensions?
|
|
71
|
+
|
|
72
|
+
| Relation | Annotation arity | Parent tuple arity |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `propertyOf`, `itemOf`, `boundTo` | single config only | `what` can be one model or `[A, B, ...]` |
|
|
75
|
+
| `relatedTo` | single config or config list | each config uses `what` with one model or `[A, B, ...]` |
|
|
76
|
+
| `propertyOfAny`, `itemOfAny`, `boundToAny` | single config only | `to` can contain one or many names |
|
|
77
|
+
| `relatedToAny` | single config or config list | each config uses `to` with one or many names |
|
|
78
|
+
|
|
79
|
+
Guardrail:
|
|
80
|
+
|
|
81
|
+
- valid: `propertyOf: { what: [A, B] }`
|
|
82
|
+
- invalid: `propertyOf: [configA, configB]`
|
|
83
|
+
|
|
65
84
|
## Step 3 – Configure the relation
|
|
66
85
|
|
|
67
86
|
### `userItem`
|
|
@@ -110,7 +129,7 @@ so the relations/CRUD generator can treat it as a relation rather than a plain `
|
|
|
110
129
|
|
|
111
130
|
Notes:
|
|
112
131
|
|
|
113
|
-
- Usually you’ll have 1–2 parents, but
|
|
132
|
+
- Usually you’ll have 1–2 parents, but `what` may contain **any number** of parent models (including 3+).
|
|
114
133
|
- If the entity is a relation, avoid adding manual `...Id` fields in `properties` just to represent the link — CRUD generators won’t treat it as a relation.
|
|
115
134
|
|
|
116
135
|
Example:
|
|
@@ -124,10 +143,9 @@ definition.model({
|
|
|
124
143
|
properties: {
|
|
125
144
|
// optional extra fields
|
|
126
145
|
},
|
|
127
|
-
propertyOf:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
]
|
|
146
|
+
propertyOf: {
|
|
147
|
+
what: [CostInvoice, Contractor]
|
|
148
|
+
}
|
|
131
149
|
})
|
|
132
150
|
```
|
|
133
151
|
|
|
@@ -168,6 +186,46 @@ indexes: {
|
|
|
168
186
|
|
|
169
187
|
3. Use these indexes in views/actions, via `indexObjectGet` / `indexRangeGet`.
|
|
170
188
|
|
|
189
|
+
### Step 5b – Use `function` indexes for derived keys
|
|
190
|
+
|
|
191
|
+
Use a `function` index when key parts are not stored directly as properties (for example `yearMonth` derived from `date`).
|
|
192
|
+
|
|
193
|
+
Key rules:
|
|
194
|
+
|
|
195
|
+
- Keep index entries stable and deterministic.
|
|
196
|
+
- Build composite keys as serialized parts joined with `:` and append `_' + id`.
|
|
197
|
+
- Emit `{ id, to }` objects so `to` points to the source model id.
|
|
198
|
+
- Prefer `table.map(mapper).to(output)` over manual `onChange(...output.change...)`.
|
|
199
|
+
- `map()` drops `null` results automatically, so mapper can stay clean.
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
indexes: {
|
|
205
|
+
byBankAccountAndMonthAndDate: {
|
|
206
|
+
function: async (input, output, { tableName }) => {
|
|
207
|
+
const table = await input.table(tableName)
|
|
208
|
+
const mapper = obj => ({
|
|
209
|
+
id: [
|
|
210
|
+
obj.bankAccount,
|
|
211
|
+
obj.date?.slice(0, 7), // YYYY-MM month bucket
|
|
212
|
+
obj.date
|
|
213
|
+
].map(v => JSON.stringify(v)).join(':') + '_' + obj.id,
|
|
214
|
+
to: obj.id
|
|
215
|
+
})
|
|
216
|
+
await table.map(mapper).to(output)
|
|
217
|
+
},
|
|
218
|
+
parameters: {
|
|
219
|
+
tableName: definition.name + '_BankTransaction'
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
This format matches how property indexes are serialized internally and works well with range-prefix filtering in views.
|
|
226
|
+
|
|
227
|
+
> **IMPORTANT — serialization constraint:** Function indexes are serialized via `toString()` and executed remotely. The mapper and all helpers **must be defined inside the function body** — not in module scope. References to outer variables or imports will be `undefined` at runtime.
|
|
228
|
+
|
|
171
229
|
## Step 6 – Set access control on relations
|
|
172
230
|
|
|
173
231
|
1. For `userItem`, `itemOf`, and `propertyOf`, always define:
|
|
@@ -19,15 +19,16 @@ Before using this skill, pick the right approach:
|
|
|
19
19
|
|---|---|
|
|
20
20
|
| `editorData` | **Editing model records** (create/update). Drafts, validation, `AutoField`. See `live-change-frontend-editor-form` skill. |
|
|
21
21
|
| `actionData` | **One-shot action forms** (not CRUD). Submit once → done. See `live-change-frontend-action-form` skill. |
|
|
22
|
-
| `api.command` | **
|
|
22
|
+
| `api.command` | **Mutations only** — single button or programmatic calls that **change persisted state** (no form fields). This skill, Step 1. Not for loading or previewing data (use views + `live` / `useFetch`; see `live-change-frontend-data-views`). |
|
|
23
23
|
| `<command-form>` | **Avoid.** Legacy. Only for trivial prototypes without drafts or `AutoField`. This skill, Step 2. |
|
|
24
24
|
|
|
25
25
|
**Decision flow:**
|
|
26
26
|
|
|
27
|
-
1.
|
|
28
|
-
2.
|
|
29
|
-
3. Is it a
|
|
30
|
-
4.
|
|
27
|
+
1. Do you only need to **read** data (including previews)? → **Use views** (`live` / `useFetch`), not this skill.
|
|
28
|
+
2. Does the user fill in form fields? → **No**: use `api.command` (this skill) **only if** the operation **mutates** state.
|
|
29
|
+
3. Is it editing a model record? → **Yes**: use `editorData`.
|
|
30
|
+
4. Is it a one-shot action? → **Yes**: use `actionData`.
|
|
31
|
+
5. Only use `<command-form>` for the simplest throwaway cases.
|
|
31
32
|
|
|
32
33
|
## Step 1 – Use `api.command` directly
|
|
33
34
|
|
|
@@ -14,6 +14,8 @@ Use this skill when you build **reactive data views** using `usePath`, `live`, `
|
|
|
14
14
|
- You need to load related objects alongside the main data.
|
|
15
15
|
- You need to restrict data loading for unauthenticated users.
|
|
16
16
|
|
|
17
|
+
**Do not use `api.command` / `useActions()` to load data.** Commands are for **mutations**. For every read (lists, details, previews, “next number”), use **views** with `live` or `useFetch` as in this skill.
|
|
18
|
+
|
|
17
19
|
## Step 1 – Basic data loading with computed paths
|
|
18
20
|
|
|
19
21
|
When paths depend on reactive values (route params, props), wrap them in `computed()`:
|
|
@@ -36,6 +38,21 @@ const [article, comments] = await Promise.all([
|
|
|
36
38
|
])
|
|
37
39
|
```
|
|
38
40
|
|
|
41
|
+
- Call `usePath()` **once** at the top of `setup` (synchronously). Inside `computed`, use only the returned `path` object to build paths — **never** call `usePath()` or the legacy `path()` inside the getter (there is often no active component instance, which breaks `getCurrentInstance()` / `appContext`).
|
|
42
|
+
|
|
43
|
+
Wrong:
|
|
44
|
+
|
|
45
|
+
```javascript
|
|
46
|
+
computed(() => usePath().blog.article({ article: id }))
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Right:
|
|
50
|
+
|
|
51
|
+
```javascript
|
|
52
|
+
const path = usePath()
|
|
53
|
+
const articlePath = computed(() => path.blog.article({ article: id }))
|
|
54
|
+
```
|
|
55
|
+
|
|
39
56
|
In templates access `.value`:
|
|
40
57
|
|
|
41
58
|
```vue
|
|
@@ -45,7 +62,26 @@ In templates access `.value`:
|
|
|
45
62
|
</div>
|
|
46
63
|
```
|
|
47
64
|
|
|
48
|
-
## Step 2 –
|
|
65
|
+
## Step 2 – One-time fetches with `useFetch`
|
|
66
|
+
|
|
67
|
+
When you need data once (e.g. after an upload, in an event handler), use `useFetch` instead of `live`:
|
|
68
|
+
|
|
69
|
+
```javascript
|
|
70
|
+
import { usePath, useFetch } from '@live-change/vue3-ssr'
|
|
71
|
+
|
|
72
|
+
const path = usePath()
|
|
73
|
+
const data = await useFetch(path.paperInvoice.invoiceFileInfo({ invoiceFile: fileId }))
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Do NOT use `api.get()` with Path objects.** `path.service.view()` returns a Path object (with `.what`, `.more`, `.to` properties), not a raw array. `api.get()` only accepts raw arrays like `['service', 'view', { params }]`.
|
|
77
|
+
|
|
78
|
+
| Method | Input | Returns | Use when |
|
|
79
|
+
|---|---|---|---|
|
|
80
|
+
| `live(path)` | Path or array | Reactive Ref | Live-updating data |
|
|
81
|
+
| `useFetch(path)` | Path or array | Promise | One-time fetch |
|
|
82
|
+
| `api.get([...])` | Raw array only | Promise | Low-level, avoid in app code |
|
|
83
|
+
|
|
84
|
+
## Step 3 – Load related objects with `.with()`
|
|
49
85
|
|
|
50
86
|
Chain `.with()` to attach related data to each item:
|
|
51
87
|
|
|
@@ -62,6 +98,40 @@ const articlesPath = computed(() =>
|
|
|
62
98
|
const [articles] = await Promise.all([live(articlesPath)])
|
|
63
99
|
```
|
|
64
100
|
|
|
101
|
+
### `.with()` callback guardrails
|
|
102
|
+
|
|
103
|
+
Treat `.with(item => ...)` as a declarative Path DSL builder:
|
|
104
|
+
|
|
105
|
+
- the callback receives a proxy, not a hydrated runtime record
|
|
106
|
+
- do not place side effects or command calls inside `.with(...)`
|
|
107
|
+
- do not use imperative branching like `if(item.type === '...')` in the callback
|
|
108
|
+
|
|
109
|
+
Use `$switch` for conditional path branching:
|
|
110
|
+
|
|
111
|
+
```javascript
|
|
112
|
+
const settlementsPath = computed(() =>
|
|
113
|
+
path.accounting.settlementsByTransaction({
|
|
114
|
+
transactionType: 'bankAccount_BankTransaction',
|
|
115
|
+
transaction: transactionId,
|
|
116
|
+
range: { limit: 256 }
|
|
117
|
+
}).with(settlement => settlement.subjectType.$switch({
|
|
118
|
+
invoice_CostInvoice: path.invoice.costInvoice({ costInvoice: settlement.subject }),
|
|
119
|
+
invoice_IncomeInvoice: path.invoice.incomeInvoice({ incomeInvoice: settlement.subject }),
|
|
120
|
+
hr_CivilContract: path.hr.civilContract({ civilContract: settlement.subject })
|
|
121
|
+
}).$bind('subjectDoc'))
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Production-style pattern reference:
|
|
126
|
+
|
|
127
|
+
```javascript
|
|
128
|
+
p.url.urlsByTargetAndPath({ targetType, domain, path: urlPath })
|
|
129
|
+
.with(url => url.type.$switch({
|
|
130
|
+
canonical: null,
|
|
131
|
+
redirect: p.url.canonical({ targetType, target: url.target })
|
|
132
|
+
}).$bind('canonical'))
|
|
133
|
+
```
|
|
134
|
+
|
|
65
135
|
Access in template:
|
|
66
136
|
|
|
67
137
|
```vue
|
|
@@ -86,7 +156,7 @@ const eventPath = computed(() =>
|
|
|
86
156
|
)
|
|
87
157
|
```
|
|
88
158
|
|
|
89
|
-
## Step
|
|
159
|
+
## Step 4 – Conditional loading with `useClient`
|
|
90
160
|
|
|
91
161
|
Use `useClient()` to check authentication state and conditionally build paths:
|
|
92
162
|
|
|
@@ -132,7 +202,7 @@ When the path is `null` / `false` / `undefined`, `live()` returns a ref with `nu
|
|
|
132
202
|
</template>
|
|
133
203
|
```
|
|
134
204
|
|
|
135
|
-
## Step
|
|
205
|
+
## Step 5 – Dependent paths
|
|
136
206
|
|
|
137
207
|
When one path depends on data from another, load them sequentially:
|
|
138
208
|
|
|
@@ -152,7 +222,7 @@ const [author] = await Promise.all([live(authorPath)])
|
|
|
152
222
|
|
|
153
223
|
Or use `.with()` to combine them in a single query (preferred when possible).
|
|
154
224
|
|
|
155
|
-
## Step
|
|
225
|
+
## Step 6 – Props-based paths in components
|
|
156
226
|
|
|
157
227
|
When building reusable components that receive IDs as props:
|
|
158
228
|
|
|
@@ -177,6 +247,7 @@ When building reusable components that receive IDs as props:
|
|
|
177
247
|
| Pattern | When to use |
|
|
178
248
|
|---|---|
|
|
179
249
|
| `computed(() => path.xxx(...))` | Path depends on reactive values |
|
|
250
|
+
| `useFetch(path.xxx(...))` | One-time data fetch (not reactive) |
|
|
180
251
|
| `.with(item => path.yyy(...).bind('field'))` | Attach related objects |
|
|
181
252
|
| `client.value.user && path.xxx(...)` | Load only when authenticated |
|
|
182
253
|
| `client.value.roles.includes('admin')` | Load/show only for specific roles |
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: live-change-frontend-e2e-lifecycle
|
|
3
|
+
description: Set up stable node:test E2E lifecycle with env, withBrowser and e2eSuite
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill: live-change-frontend-e2e-lifecycle
|
|
7
|
+
|
|
8
|
+
Use this skill when creating or refactoring LiveChange frontend E2E tests.
|
|
9
|
+
|
|
10
|
+
## Goal
|
|
11
|
+
|
|
12
|
+
Avoid flaky teardown where only the first test file runs because shared env teardown calls `process.exit`.
|
|
13
|
+
|
|
14
|
+
## Step 1 - Build shared env helper
|
|
15
|
+
|
|
16
|
+
Create `e2e/env.ts` with:
|
|
17
|
+
|
|
18
|
+
- `getTestEnv()` that starts `TestServer` once and memoizes with `envPromise`
|
|
19
|
+
- `disposeTestEnv()` that resets memoized state and disposes server
|
|
20
|
+
- fallback cleanup:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
process.on('beforeExit', () => {
|
|
24
|
+
void disposeTestEnv()
|
|
25
|
+
})
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Keep startup failure `process.exit(1)` inside `getTestEnv()` catch.
|
|
29
|
+
|
|
30
|
+
Do not add `after(...process.exit(...))` to this file.
|
|
31
|
+
|
|
32
|
+
## Step 2 - Isolate browser per test
|
|
33
|
+
|
|
34
|
+
Create `e2e/withBrowser.ts` with Playwright setup/teardown in `try/finally`:
|
|
35
|
+
|
|
36
|
+
- call `getTestEnv()` once per test execution
|
|
37
|
+
- open browser/context/page
|
|
38
|
+
- close context and browser in `finally`
|
|
39
|
+
|
|
40
|
+
## Step 3 - Add suite-level teardown
|
|
41
|
+
|
|
42
|
+
Create `e2e/e2eSuite.ts`:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { after, describe } from 'node:test'
|
|
46
|
+
import { disposeTestEnv } from './env.js'
|
|
47
|
+
|
|
48
|
+
export function e2eSuite(name: string, define: () => void): void {
|
|
49
|
+
describe(name, () => {
|
|
50
|
+
after(async () => {
|
|
51
|
+
await disposeTestEnv()
|
|
52
|
+
process.exit(0)
|
|
53
|
+
})
|
|
54
|
+
define()
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Step 4 - Wrap tests
|
|
60
|
+
|
|
61
|
+
Each `e2e/*.test.ts` file should use one wrapper:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import test from 'node:test'
|
|
65
|
+
import { e2eSuite } from './e2eSuite.js'
|
|
66
|
+
|
|
67
|
+
e2eSuite('example-suite', () => {
|
|
68
|
+
test('renders page', async () => {
|
|
69
|
+
// test body
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Step 5 - Verify
|
|
75
|
+
|
|
76
|
+
Run from project root with `fnm exec`:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
fnm exec -- npm run e2e
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Confirm multiple test files execute and process exits only after full suite teardown.
|
|
@@ -27,6 +27,19 @@ function articlesPathRange(range) {
|
|
|
27
27
|
}
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
## Step 1a – Hard rules for index-backed ranges
|
|
31
|
+
|
|
32
|
+
For lists loaded with `RangeViewer` / `rangeBuckets`:
|
|
33
|
+
|
|
34
|
+
- backend views should use `sortedIndexRangePath`, not `indexRangePath`,
|
|
35
|
+
- keep `range.gt/gte/lt/lte` for pagination cursor only,
|
|
36
|
+
- never override `gt/lt` in frontend `pathFunction` with ad-hoc filters.
|
|
37
|
+
|
|
38
|
+
Why:
|
|
39
|
+
|
|
40
|
+
- RangeViewer computes next buckets from previous cursor boundaries,
|
|
41
|
+
- replacing cursor fields causes repeated slices and broken infinite loading.
|
|
42
|
+
|
|
30
43
|
## Step 2 – Attach related objects with `.with()`
|
|
31
44
|
|
|
32
45
|
Chain `.with()` calls to load related data for each item:
|
|
@@ -47,6 +60,22 @@ Each `.with()` call:
|
|
|
47
60
|
- builds a path to the related data,
|
|
48
61
|
- calls `.bind('fieldName')` to attach the result under that field name.
|
|
49
62
|
|
|
63
|
+
Important:
|
|
64
|
+
- this proxy is Path DSL input, not a hydrated runtime item
|
|
65
|
+
- do not branch with imperative `if/else` on proxy fields inside `.with(...)`
|
|
66
|
+
- for type-based conditional branches, use `$switch(...).$bind(...)`
|
|
67
|
+
|
|
68
|
+
```javascript
|
|
69
|
+
function settlementsPathRange(range) {
|
|
70
|
+
return path.accounting.settlementsByTransaction({ ...range })
|
|
71
|
+
.with(settlement => settlement.subjectType.$switch({
|
|
72
|
+
invoice_CostInvoice: path.invoice.costInvoice({ costInvoice: settlement.subject }),
|
|
73
|
+
invoice_IncomeInvoice: path.invoice.incomeInvoice({ incomeInvoice: settlement.subject }),
|
|
74
|
+
hr_CivilContract: path.hr.civilContract({ civilContract: settlement.subject })
|
|
75
|
+
}).$bind('subjectDoc'))
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
50
79
|
Nested `.with()` is also supported:
|
|
51
80
|
|
|
52
81
|
```javascript
|
|
@@ -127,3 +156,64 @@ Iterate in the template:
|
|
|
127
156
|
</div>
|
|
128
157
|
</template>
|
|
129
158
|
```
|
|
159
|
+
|
|
160
|
+
## Step 5 – Optional filters without breaking range cursor
|
|
161
|
+
|
|
162
|
+
When your list supports optional filtering (for example `month`), do not push raw field values into `gt/gte/lt/lte` from the frontend.
|
|
163
|
+
|
|
164
|
+
Why:
|
|
165
|
+
|
|
166
|
+
- Range boundaries are compared against full index keys, not a single field.
|
|
167
|
+
- `RangeViewer` controls `gt/lt` for pagination; overriding them breaks infinite scroll behavior.
|
|
168
|
+
|
|
169
|
+
Correct pattern:
|
|
170
|
+
|
|
171
|
+
1. Keep RangeViewer cursor in `range` (`...reverseRange(range)`).
|
|
172
|
+
2. Send optional filters as separate params (`month`, `state`, etc.).
|
|
173
|
+
3. Let backend view apply prefix logic (`sortedIndexRangePath` with longer key prefix or `App.utils.prefixRange`).
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
function transactionsPathRange(range) {
|
|
177
|
+
return path.bankAccount.bankTransactionsByBankAccountAndDate({
|
|
178
|
+
bankAccount: accountId,
|
|
179
|
+
month: month.value || undefined,
|
|
180
|
+
...reverseRange(range)
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Step 6 – Reactive filter changes (no hidden bucket bugs)
|
|
186
|
+
|
|
187
|
+
If `pathFunction` depends on reactive filters (for example month/company/status), prefer `ReactiveRangeViewer` over mutating `RangeViewer` input directly.
|
|
188
|
+
|
|
189
|
+
Why:
|
|
190
|
+
|
|
191
|
+
- changing filters can recreate bucket state in subtle ways
|
|
192
|
+
- forcing rerender with ad-hoc `:key` works, but spreads fragile logic across pages
|
|
193
|
+
- `ReactiveRangeViewer` centralizes safe reload behavior
|
|
194
|
+
|
|
195
|
+
```vue
|
|
196
|
+
<ReactiveRangeViewer
|
|
197
|
+
:pathFunction="transactionsPathRange"
|
|
198
|
+
:sourceKey="JSON.stringify({ month: filterByMonth ? month : null, accountId })"
|
|
199
|
+
:preserveHeightOnReload="true"
|
|
200
|
+
:canLoadTop="false"
|
|
201
|
+
canDropBottom
|
|
202
|
+
loadBottomSensorSize="3000px"
|
|
203
|
+
dropBottomSensorSize="8000px"
|
|
204
|
+
>
|
|
205
|
+
<template #default="{ item }">
|
|
206
|
+
<BankTransactionListItem :transaction="item" />
|
|
207
|
+
</template>
|
|
208
|
+
</ReactiveRangeViewer>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Use `sourceKey` as the explicit reload trigger when filter inputs change.
|
|
212
|
+
|
|
213
|
+
## Checklist – range pagination safety
|
|
214
|
+
|
|
215
|
+
- [ ] backend index view is based on `sortedIndexRangePath`
|
|
216
|
+
- [ ] frontend `pathFunction` forwards `range` unchanged (`...range` or `...reverseRange(range)`)
|
|
217
|
+
- [ ] domain filters (`month`, `year`, `status`) are separate view params
|
|
218
|
+
- [ ] no manual cursor overrides (`gt/gte/lt/lte`) in frontend code
|
|
219
|
+
- [ ] if narrowing is needed, backend uses index prefix design first, `prefixRange` only as fallback
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: live-change-frontend-synchronized
|
|
3
|
+
description: Use synchronized and synchronizedList for autosave editing in LiveChange Vue frontends, including object vs list decision flow, identifiers mapping, and save/delete patterns
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill: live-change-frontend-synchronized (Claude Code)
|
|
7
|
+
|
|
8
|
+
Use this skill when implementing or refactoring frontend editing flows that should keep local form state synchronized with backend data.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
- Editing one object loaded from `live(...)` with autosave or manual save.
|
|
13
|
+
- Editing list items inline (for example access roles) with per-row identifiers.
|
|
14
|
+
- Replacing custom `watch + api.command` autosave logic with a standard helper.
|
|
15
|
+
- Choosing between `synchronized`, `synchronizedList`, and `editorData`.
|
|
16
|
+
|
|
17
|
+
## Decision flow
|
|
18
|
+
|
|
19
|
+
1. **One editable object** (`profile`, `note`, `settings`) -> use `synchronized`.
|
|
20
|
+
2. **Editable list rows** (`accesses`, `invitations`, `requests`) -> use `synchronizedList`.
|
|
21
|
+
3. **Definition-driven CRUD form with validation UI** -> use `editorData` from auto-form stack.
|
|
22
|
+
|
|
23
|
+
## What counts as "editable list rows"
|
|
24
|
+
|
|
25
|
+
Treat the feature as a list flow (`synchronizedList`) when most of these are true:
|
|
26
|
+
|
|
27
|
+
- The UI renders rows with `v-for` and each row has editable fields.
|
|
28
|
+
- Users can edit many rows inline in one screen (table/config/admin list).
|
|
29
|
+
- Autosave should happen per row while the list stays reactive.
|
|
30
|
+
- Each row needs its own action payload keys in addition to shared list context.
|
|
31
|
+
|
|
32
|
+
In this case, wire one `synchronizedList(...)` and edit fields directly on `syncList.value`.
|
|
33
|
+
Do not build a parallel `id -> synchronized(...)` map for rows.
|
|
34
|
+
|
|
35
|
+
## `synchronized` pattern (object)
|
|
36
|
+
|
|
37
|
+
```javascript
|
|
38
|
+
import { synchronized } from '@live-change/vue3-components'
|
|
39
|
+
|
|
40
|
+
const sync = synchronized({
|
|
41
|
+
source: sourceRef,
|
|
42
|
+
update: actions.service.updateThing,
|
|
43
|
+
identifiers: computed(() => ({ thing: thingId.value })),
|
|
44
|
+
recursive: true,
|
|
45
|
+
autoSave: true,
|
|
46
|
+
debounce: 600
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const { value: editable, changed, saving, save } = sync
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Rules
|
|
53
|
+
|
|
54
|
+
- `source` should come from `live(...)` or a computed source.
|
|
55
|
+
- Keep identifiers stable and pass them through `identifiers` (plain object or `computed`).
|
|
56
|
+
- Use `updateDataProperty: 'data'` for draft-like payloads where backend expects nested data.
|
|
57
|
+
- Use `autoSave: false` when save must happen only after explicit confirmation.
|
|
58
|
+
|
|
59
|
+
## `synchronizedList` pattern (list)
|
|
60
|
+
|
|
61
|
+
```javascript
|
|
62
|
+
import { synchronizedList } from '@live-change/vue3-components'
|
|
63
|
+
|
|
64
|
+
const syncList = synchronizedList({
|
|
65
|
+
source: accesses,
|
|
66
|
+
update: actions.accessControl.updateSessionOrUserAndObjectOwnedAccess,
|
|
67
|
+
delete: actions.accessControl.resetSessionOrUserAndObjectOwnedAccess,
|
|
68
|
+
identifiers: { object, objectType },
|
|
69
|
+
objectIdentifiers: ({ to, sessionOrUser, sessionOrUserType }) => ({
|
|
70
|
+
access: to, sessionOrUser, sessionOrUserType, object, objectType
|
|
71
|
+
}),
|
|
72
|
+
recursive: true
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const editableRows = syncList.value
|
|
76
|
+
await syncList.delete(editableRows.value[0])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Rules
|
|
80
|
+
|
|
81
|
+
- `source` should be a list from `live(...)`.
|
|
82
|
+
- Every item should have stable `id`.
|
|
83
|
+
- Put shared context in `identifiers`, and row-specific keys in `objectIdentifiers`.
|
|
84
|
+
- Edit rows through `syncList.value`; call `syncList.delete(...)` / `syncList.insert(...)` on the helper object.
|
|
85
|
+
- Prefer this pattern for configuration lists, permission tables, dictionaries, and other multi-row settings editors.
|
|
86
|
+
|
|
87
|
+
## Project examples to follow
|
|
88
|
+
|
|
89
|
+
- `speed-dating/front/src/components/notes/NoteEditor.vue` (`synchronized` with autosave)
|
|
90
|
+
- `speed-dating/front/src/components/profile/ProfileSettings.vue` (`synchronized` + `updateDataProperty: 'data'`)
|
|
91
|
+
- `family-tree/front/src/components/AgreementDialog.vue` (`synchronized` with manual save)
|
|
92
|
+
- `rcstreamer/front/src/configuration/AccessRequests.vue` (`synchronizedList`)
|
|
93
|
+
- `rcstreamer/front/src/configuration/AccessInvitations.vue` (`synchronizedList`)
|
|
94
|
+
- `rcstreamer/front/src/configuration/AccessList.vue` (`synchronizedList`)
|
|
95
|
+
|
|
96
|
+
## Common mistakes
|
|
97
|
+
|
|
98
|
+
- Using `synchronized` for a list of rows instead of `synchronizedList`.
|
|
99
|
+
- Forgetting `objectIdentifiers` when backend update/delete actions need per-row keys.
|
|
100
|
+
- Mutating raw `live(...)` list data instead of `synchronizedList(...).value`.
|
|
101
|
+
- Mixing autosave helper patterns with unrelated one-shot action form patterns.
|