@live-change/frontend-template 0.9.199 → 0.9.200
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 +62 -0
- package/.claude/rules/live-change-backend-event-sourcing.md +186 -0
- package/.claude/rules/live-change-backend-models-and-relations.md +72 -0
- package/.claude/rules/live-change-frontend-vue-primevue.md +26 -0
- package/.claude/settings.json +32 -0
- package/.claude/skills/create-skills-and-rules/SKILL.md +248 -0
- package/.claude/skills/live-change-backend-change-triggers/SKILL.md +186 -0
- package/.claude/skills/live-change-design-actions-views-triggers/SKILL.md +462 -0
- package/.claude/skills/live-change-design-models-relations/SKILL.md +230 -0
- package/.claude/skills/live-change-design-service/SKILL.md +133 -0
- package/.claude/skills/live-change-frontend-accessible-objects/SKILL.md +384 -0
- package/.claude/skills/live-change-frontend-accessible-objects.md +383 -0
- package/.claude/skills/live-change-frontend-action-buttons/SKILL.md +129 -0
- package/.claude/skills/live-change-frontend-action-form/SKILL.md +149 -0
- package/.claude/skills/live-change-frontend-analytics/SKILL.md +147 -0
- package/.claude/skills/live-change-frontend-command-forms/SKILL.md +216 -0
- package/.claude/skills/live-change-frontend-data-views/SKILL.md +183 -0
- package/.claude/skills/live-change-frontend-editor-form/SKILL.md +240 -0
- package/.claude/skills/live-change-frontend-locale-time/SKILL.md +172 -0
- package/.claude/skills/live-change-frontend-page-list-detail/SKILL.md +201 -0
- package/.claude/skills/live-change-frontend-range-list/SKILL.md +129 -0
- package/.claude/skills/live-change-frontend-ssr-setup/SKILL.md +119 -0
- package/.cursor/rules/live-change-backend-actions-views-triggers.mdc +88 -0
- package/.cursor/rules/live-change-backend-event-sourcing.mdc +185 -0
- package/.cursor/rules/live-change-backend-models-and-relations.mdc +62 -0
- package/.cursor/skills/create-skills-and-rules.md +248 -0
- package/.cursor/skills/live-change-backend-change-triggers.md +186 -0
- package/.cursor/skills/live-change-design-actions-views-triggers.md +178 -79
- package/.cursor/skills/live-change-design-models-relations.md +112 -50
- package/.cursor/skills/live-change-design-service.md +1 -0
- package/.cursor/skills/live-change-frontend-accessible-objects.md +384 -0
- package/.cursor/skills/live-change-frontend-action-buttons.md +1 -0
- package/.cursor/skills/live-change-frontend-action-form.md +9 -3
- package/.cursor/skills/live-change-frontend-analytics.md +1 -0
- package/.cursor/skills/live-change-frontend-command-forms.md +1 -0
- package/.cursor/skills/live-change-frontend-data-views.md +1 -0
- package/.cursor/skills/live-change-frontend-editor-form.md +135 -72
- package/.cursor/skills/live-change-frontend-locale-time.md +1 -0
- package/.cursor/skills/live-change-frontend-page-list-detail.md +1 -0
- package/.cursor/skills/live-change-frontend-range-list.md +1 -0
- package/.cursor/skills/live-change-frontend-ssr-setup.md +1 -0
- package/front/src/router.js +2 -1
- package/opencode.json +10 -0
- package/package.json +52 -50
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: List entities accessible via access control views (myAccessibleObjects, myAccessesByObjectType, invitations, roles)
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Skill: live-change-frontend-accessible-objects (Claude Code)
|
|
6
|
+
|
|
7
|
+
Use this skill when you need to **list entities accessible to the current user** using the access-control service:
|
|
8
|
+
|
|
9
|
+
- objects with `entity` access control (not simple `userItem` relations),
|
|
10
|
+
- objects the user has roles on via `accessControl_setAccess` / `accessControl_setPublicAccess`,
|
|
11
|
+
- invitations to objects,
|
|
12
|
+
- role-filtered lists of accessible objects.
|
|
13
|
+
|
|
14
|
+
This skill complements:
|
|
15
|
+
|
|
16
|
+
- backend skill `live-change-design-actions-views-triggers` (how to grant access and enable indexes),
|
|
17
|
+
- frontend skills `live-change-frontend-data-views` and `live-change-frontend-page-list-detail`.
|
|
18
|
+
|
|
19
|
+
## When to use
|
|
20
|
+
|
|
21
|
+
- You want **all objects of a given type** that the current user can access (not just those they created).
|
|
22
|
+
- You are building a list like “My companies”, “My projects”, “Events I can join”.
|
|
23
|
+
- You already grant access through access-control triggers (owner / member / reader roles).
|
|
24
|
+
- The objects are **entities** with access control, not simple `userItem` / `itemOf` relations.
|
|
25
|
+
|
|
26
|
+
If you simply need “records owned by user”, prefer the `userItem` / `itemOf` relation patterns from `live-change-design-models-relations`. Use this skill when access is controlled by roles in the access-control service.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Step 1 – Understand the views you can use
|
|
31
|
+
|
|
32
|
+
There are two main ways to list accessible objects on the frontend.
|
|
33
|
+
|
|
34
|
+
### 1. Indexed pipeline (requires `indexed: true` on access-control)
|
|
35
|
+
|
|
36
|
+
Enabled when the access-control service is configured with:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
// app.server/app.config.js (service config)
|
|
40
|
+
{
|
|
41
|
+
name: 'accessControl',
|
|
42
|
+
createSessionOnUpdate: true,
|
|
43
|
+
contactTypes,
|
|
44
|
+
indexed: true
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then the following views become available (from `access-control-service/indexes.js`):
|
|
49
|
+
|
|
50
|
+
- `myAccessibleObjects({ objectType?, ...range })`
|
|
51
|
+
- `myAccessibleObjectsByRole({ role, objectType?, ...range })`
|
|
52
|
+
- `accessibleObjects(...)` / `accessibleObjectsByRole(...)` – admin-only variants with explicit `sessionOrUserType` + `sessionOrUser`
|
|
53
|
+
- `objectAccesses({ objectType, object, role?, ...range })` – list owners/roles for a specific object
|
|
54
|
+
|
|
55
|
+
Use these when you want:
|
|
56
|
+
|
|
57
|
+
- “all my accessible objects of type X” (`myAccessibleObjects` with `objectType`),
|
|
58
|
+
- “all my objects where I have role Y” (`myAccessibleObjectsByRole` with `role`),
|
|
59
|
+
- admin tools for inspecting who has access to what (`accessibleObjects`, `objectAccesses`).
|
|
60
|
+
|
|
61
|
+
### 2. Non-indexed views (always available)
|
|
62
|
+
|
|
63
|
+
Even without `indexed: true`, `view.js` provides:
|
|
64
|
+
|
|
65
|
+
- `myAccessesByObjectType({ objectType, ...range })`
|
|
66
|
+
- `myAccessesByObjectTypeAndRole({ objectType, role, ...range })`
|
|
67
|
+
- `myAccessInvitationsByObjectType({ objectType, ...range })`
|
|
68
|
+
- `myAccessInvitationsByObjectTypeAndRole({ objectType, role, ...range })`
|
|
69
|
+
|
|
70
|
+
Use these when:
|
|
71
|
+
|
|
72
|
+
- you need invitations in addition to accepted accesses,
|
|
73
|
+
- the project has not enabled `indexed: true` yet,
|
|
74
|
+
- you are building paginated lists with `<RangeViewer>` based on access entries.
|
|
75
|
+
|
|
76
|
+
**Object type format:** always `serviceName_ModelName` (for example `company_Company`, `speedDating_Event`).
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Step 2 – Simple list with `myAccessibleObjects` (indexed)
|
|
81
|
+
|
|
82
|
+
This is the most convenient way to list all entities of one type accessible to the current user, when indexes are enabled.
|
|
83
|
+
|
|
84
|
+
Example: list companies the user can access (similar to auto-firma `companies/index.vue`):
|
|
85
|
+
|
|
86
|
+
```vue
|
|
87
|
+
<route>
|
|
88
|
+
{ "name": "companies", "meta": { "signedIn": true } }
|
|
89
|
+
</route>
|
|
90
|
+
|
|
91
|
+
<template>
|
|
92
|
+
<div class="container mx-auto p-4">
|
|
93
|
+
<div class="flex items-center justify-between mb-6">
|
|
94
|
+
<h1 class="text-2xl font-bold">{{ t('companies.list.title') }}</h1>
|
|
95
|
+
<Button :label="t('companies.list.addButton')" icon="pi pi-plus"
|
|
96
|
+
@click="router.push({ name: 'companiesNew' })" />
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<Card v-if="!accessibleCompanies?.length">
|
|
100
|
+
<template #content>
|
|
101
|
+
<p class="text-center text-gray-500">
|
|
102
|
+
{{ t('companies.list.empty') }}
|
|
103
|
+
</p>
|
|
104
|
+
</template>
|
|
105
|
+
</Card>
|
|
106
|
+
|
|
107
|
+
<div class="grid gap-4">
|
|
108
|
+
<Card v-for="accessible in accessibleCompanies" :key="accessible.id"
|
|
109
|
+
class="cursor-pointer hover:shadow-md transition-shadow">
|
|
110
|
+
<template #content>
|
|
111
|
+
<div class="flex items-center justify-between">
|
|
112
|
+
<div>
|
|
113
|
+
<div class="text-lg font-semibold">{{ accessible.company.name }}</div>
|
|
114
|
+
<div class="text-sm text-gray-500">
|
|
115
|
+
{{ t('companies.list.nipLabel') }} {{ accessible.company.nip }}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<router-link :to="{ name: 'company', params: { company: accessible.company.id } }">
|
|
119
|
+
<Button :label="t('companies.list.details')"
|
|
120
|
+
icon="pi pi-arrow-right" severity="secondary" />
|
|
121
|
+
</router-link>
|
|
122
|
+
</div>
|
|
123
|
+
</template>
|
|
124
|
+
</Card>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</template>
|
|
128
|
+
|
|
129
|
+
<script setup>
|
|
130
|
+
import { live, usePath, useClient } from '@live-change/vue3-ssr'
|
|
131
|
+
import { useRouter } from 'vue-router'
|
|
132
|
+
import { computed } from 'vue'
|
|
133
|
+
import { useI18n } from 'vue-i18n'
|
|
134
|
+
import Button from 'primevue/button'
|
|
135
|
+
import Card from 'primevue/card'
|
|
136
|
+
|
|
137
|
+
const router = useRouter()
|
|
138
|
+
const path = usePath()
|
|
139
|
+
const client = useClient()
|
|
140
|
+
const { t } = useI18n()
|
|
141
|
+
|
|
142
|
+
const accessibleCompaniesPath = computed(() =>
|
|
143
|
+
client.value.user
|
|
144
|
+
? path.accessControl.myAccessibleObjects({ objectType: 'company_Company' })
|
|
145
|
+
.with(accessible =>
|
|
146
|
+
path.company.company({ company: accessible.object }).bind('company')
|
|
147
|
+
)
|
|
148
|
+
: null
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const [accessibleCompanies] = await Promise.all([
|
|
152
|
+
live(accessibleCompaniesPath)
|
|
153
|
+
])
|
|
154
|
+
</script>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Key points:
|
|
158
|
+
|
|
159
|
+
- **Guard with `client.value.user`** so that unauthenticated users do not try to load the view.
|
|
160
|
+
- Pass `objectType: 'service_Model'`, e.g. `'company_Company'`.
|
|
161
|
+
- Use `.with()` to hydrate each access entry with the full entity (`accessible.object` → `company`).
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## Step 3 – Paginated list with `myAccessesByObjectType` + `RangeViewer`
|
|
166
|
+
|
|
167
|
+
When you want an infinite scroll or very large lists, use the non-indexed range views and `<RangeViewer>` (see also `live-change-frontend-range-list` and `speed-dating/front/src/pages/events/index.vue`).
|
|
168
|
+
|
|
169
|
+
Example: “My events” with invitations and accepted accesses:
|
|
170
|
+
|
|
171
|
+
```vue
|
|
172
|
+
<template>
|
|
173
|
+
<div class="w-full max-w-6xl">
|
|
174
|
+
<div class="w-full" v-if="anyInvitations?.length">
|
|
175
|
+
<div class="bg-surface-0 dark:bg-surface-900 rounded-border shadow-sm p-4 mt-4">
|
|
176
|
+
<div class="text-2xl font-medium text-surface-800 dark:text-surface-50">
|
|
177
|
+
{{ t('event.invitations.title') }}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
<range-viewer :pathFunction="myEventsInvitationsPathRange" key="invitations"
|
|
181
|
+
:canLoadTop="false" canDropBottom
|
|
182
|
+
loadBottomSensorSize="4000px" dropBottomSensorSize="3000px">
|
|
183
|
+
<template #empty>
|
|
184
|
+
<div class="text-xl text-surface-800 dark:text-surface-50 my-4 mx-4">
|
|
185
|
+
{{ t('event.invitations.noEventsFound') }}
|
|
186
|
+
</div>
|
|
187
|
+
</template>
|
|
188
|
+
<template #default="{ item: invitation }">
|
|
189
|
+
<EventCard :event="invitation.event" class="my-2" />
|
|
190
|
+
</template>
|
|
191
|
+
</range-viewer>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<div class="w-full">
|
|
195
|
+
<div class="bg-surface-0 dark:bg-surface-900 rounded-border shadow-sm p-4 mt-4">
|
|
196
|
+
<div class="text-2xl font-medium text-surface-800 dark:text-surface-50">
|
|
197
|
+
{{ t('event.yourEvents') }}
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
<range-viewer :pathFunction="myEventsAccessesPathRange" key="myEvents"
|
|
201
|
+
:canLoadTop="false" canDropBottom
|
|
202
|
+
loadBottomSensorSize="4000px" dropBottomSensorSize="3000px">
|
|
203
|
+
<template #empty>
|
|
204
|
+
<div class="text-xl text-surface-800 dark:text-surface-50 my-4 mx-4">
|
|
205
|
+
{{ t('event.invitations.noEventsFound') }}
|
|
206
|
+
</div>
|
|
207
|
+
</template>
|
|
208
|
+
<template #default="{ item: access }">
|
|
209
|
+
<EventCard :event="access.event" class="my-2" />
|
|
210
|
+
</template>
|
|
211
|
+
</range-viewer>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</template>
|
|
215
|
+
|
|
216
|
+
<script setup>
|
|
217
|
+
import EventCard from '../../components/events/EventCard.vue'
|
|
218
|
+
import { RangeViewer } from '@live-change/vue3-components'
|
|
219
|
+
|
|
220
|
+
import { usePath, live, useClient, reverseRange } from '@live-change/vue3-ssr'
|
|
221
|
+
import { useI18n } from 'vue-i18n'
|
|
222
|
+
|
|
223
|
+
const path = usePath()
|
|
224
|
+
const client = useClient()
|
|
225
|
+
const { t } = useI18n()
|
|
226
|
+
|
|
227
|
+
function myEventsAccessesPathRange(range) {
|
|
228
|
+
return path.accessControl.myAccessesByObjectType({
|
|
229
|
+
objectType: 'speedDating_Event',
|
|
230
|
+
...reverseRange(range)
|
|
231
|
+
}).with(access =>
|
|
232
|
+
path.speedDating.event({ event: access.object }).bind('event')
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function myEventsInvitationsPathRange(range) {
|
|
237
|
+
return path.accessControl.myAccessInvitationsByObjectType({
|
|
238
|
+
objectType: 'speedDating_Event',
|
|
239
|
+
...reverseRange(range)
|
|
240
|
+
}).with(invitation =>
|
|
241
|
+
path.speedDating.event({ event: invitation.object }).bind('event')
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const anyInvitationsPath = myEventsInvitationsPathRange({ limit: 1 })
|
|
246
|
+
|
|
247
|
+
const [anyInvitations] = await Promise.all([
|
|
248
|
+
live(anyInvitationsPath)
|
|
249
|
+
])
|
|
250
|
+
</script>
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Notes:
|
|
254
|
+
|
|
255
|
+
- `myAccessesByObjectType` returns access entries with `objectType`, `object`, `role`, etc.
|
|
256
|
+
- You hydrate each access with the real entity using `.with(...)`.
|
|
257
|
+
- `<RangeViewer>` takes a `pathFunction(range)` that must handle `gt` / `lt` / `limit` (here we use `reverseRange(range)` from `@live-change/vue3-ssr`).
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Step 4 – Add invitations next to accepted accesses
|
|
262
|
+
|
|
263
|
+
Invitations are separate records that share the same `objectType` / `object` fields. To show invitations together with accepted accesses:
|
|
264
|
+
|
|
265
|
+
1. Use `myAccessInvitationsByObjectType` (and optionally `...ByObjectTypeAndRole`) for pending invites.
|
|
266
|
+
2. Use `myAccessesByObjectType` (and optionally `...ByObjectTypeAndRole`) for accepted access.
|
|
267
|
+
3. Display them in separate sections or merge in the UI.
|
|
268
|
+
|
|
269
|
+
Example outline:
|
|
270
|
+
|
|
271
|
+
```js
|
|
272
|
+
function myObjectsAccessesPathRange(range) {
|
|
273
|
+
return path.accessControl.myAccessesByObjectType({
|
|
274
|
+
objectType: 'myService_MyEntity',
|
|
275
|
+
...reverseRange(range)
|
|
276
|
+
}).with(access =>
|
|
277
|
+
path.myService.myEntity({ entity: access.object }).bind('entity')
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function myObjectsInvitationsPathRange(range) {
|
|
282
|
+
return path.accessControl.myAccessInvitationsByObjectType({
|
|
283
|
+
objectType: 'myService_MyEntity',
|
|
284
|
+
...reverseRange(range)
|
|
285
|
+
}).with(invitation =>
|
|
286
|
+
path.myService.myEntity({ entity: invitation.object }).bind('entity')
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Use two `<RangeViewer>` blocks or one section with two lists, depending on UX.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Step 5 – Filter by role with `myAccessibleObjectsByRole`
|
|
296
|
+
|
|
297
|
+
When indexes are enabled, you can query only objects where the current user has a specific role (for example `owner`, `admin`, `member`).
|
|
298
|
+
|
|
299
|
+
Example:
|
|
300
|
+
|
|
301
|
+
```js
|
|
302
|
+
import { computed } from 'vue'
|
|
303
|
+
import { usePath, useClient, live } from '@live-change/vue3-ssr'
|
|
304
|
+
|
|
305
|
+
const path = usePath()
|
|
306
|
+
const client = useClient()
|
|
307
|
+
|
|
308
|
+
const ownerProjectsPath = computed(() =>
|
|
309
|
+
client.value.user
|
|
310
|
+
? path.accessControl.myAccessibleObjectsByRole({
|
|
311
|
+
role: 'owner',
|
|
312
|
+
objectType: 'project_Project'
|
|
313
|
+
}).with(accessible =>
|
|
314
|
+
path.project.project({ project: accessible.object }).bind('project')
|
|
315
|
+
)
|
|
316
|
+
: null
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
const [ownerProjects] = await Promise.all([
|
|
320
|
+
live(ownerProjectsPath)
|
|
321
|
+
])
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Use this pattern for:
|
|
325
|
+
|
|
326
|
+
- “Projects I own” vs. “Projects where I am a member”.
|
|
327
|
+
- “Companies where I am accountant” vs. general access.
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## Step 6 – Admin tools with `accessibleObjects` and `objectAccesses`
|
|
332
|
+
|
|
333
|
+
For administrative UIs you may need to:
|
|
334
|
+
|
|
335
|
+
- list all objects accessible to a given user,
|
|
336
|
+
- list all users who have access to a specific object and their roles.
|
|
337
|
+
|
|
338
|
+
Use these views (available when `indexed: true`):
|
|
339
|
+
|
|
340
|
+
- `accessibleObjects({ sessionOrUserType, sessionOrUser, objectType?, ...range })`
|
|
341
|
+
- `accessibleObjectsByRole({ sessionOrUserType, sessionOrUser, role, objectType?, ...range })`
|
|
342
|
+
- `objectAccesses({ objectType, object, role?, ...range })`
|
|
343
|
+
|
|
344
|
+
Important:
|
|
345
|
+
|
|
346
|
+
- These views require **admin** role (checked in `indexes.js`).
|
|
347
|
+
- `sessionOrUserType` is `'user_User'` for users or `'session_Session'` for anonymous sessions.
|
|
348
|
+
- `sessionOrUser` is the user id or session id.
|
|
349
|
+
|
|
350
|
+
Example skeleton for an admin panel:
|
|
351
|
+
|
|
352
|
+
```js
|
|
353
|
+
const usersProjectsPath = computed(() =>
|
|
354
|
+
client.value.roles.includes('admin')
|
|
355
|
+
? path.accessControl.accessibleObjectsByRole({
|
|
356
|
+
sessionOrUserType: 'user_User',
|
|
357
|
+
sessionOrUser: inspectedUserId,
|
|
358
|
+
role: 'member',
|
|
359
|
+
objectType: 'project_Project'
|
|
360
|
+
}).with(accessible =>
|
|
361
|
+
path.project.project({ project: accessible.object }).bind('project')
|
|
362
|
+
)
|
|
363
|
+
: null
|
|
364
|
+
)
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
## Summary
|
|
370
|
+
|
|
371
|
+
| Need | View | Notes |
|
|
372
|
+
|---|---|---|
|
|
373
|
+
| All entities of one type I can access | `myAccessibleObjects({ objectType })` | Requires `indexed: true`, simplest pattern |
|
|
374
|
+
| All entities where I have specific role | `myAccessibleObjectsByRole({ role, objectType? })` | Use for “owner”, “member”, etc. |
|
|
375
|
+
| Paginated “my X” list without indexes | `myAccessesByObjectType` + `<RangeViewer>` | Always available, hydrate with `.with()` |
|
|
376
|
+
| Pending invitations | `myAccessInvitationsByObjectType` | Often shown in a separate section |
|
|
377
|
+
| Admin: what objects a user can access | `accessibleObjects` / `accessibleObjectsByRole` | Requires admin role, explicit user id |
|
|
378
|
+
| Admin: who has access to object | `objectAccesses` | Good for audit / sharing dialogs |
|
|
379
|
+
|
|
380
|
+
Remember:
|
|
381
|
+
|
|
382
|
+
- `objectType` is always `service_Model`.
|
|
383
|
+
- For relations like `userItem`, use relation-based views instead; this skill is for **access-control based entities**.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: live-change-frontend-action-buttons
|
|
3
|
+
description: Build async action buttons with workingZone, toast and confirm dialogs
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill: live-change-frontend-action-buttons (Claude Code)
|
|
7
|
+
|
|
8
|
+
Use this skill when you build **buttons that trigger async actions** outside of forms, using `workingZone`, `toast`, and optionally `confirm`.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
- A button triggers a backend action (delete, approve, toggle, etc.) — **no form fields**.
|
|
13
|
+
- You want the global loading spinner to appear while the action runs.
|
|
14
|
+
- Destructive actions need a confirmation dialog before executing.
|
|
15
|
+
|
|
16
|
+
**Need a form with fields?** Use `editorData` (model editing) or `actionData` (one-shot actions) instead.
|
|
17
|
+
|
|
18
|
+
## Step 1 – Inject workingZone, set up toast/confirm
|
|
19
|
+
|
|
20
|
+
```javascript
|
|
21
|
+
import { inject } from 'vue'
|
|
22
|
+
import { useToast } from 'primevue/usetoast'
|
|
23
|
+
import { useConfirm } from 'primevue/useconfirm'
|
|
24
|
+
import { useApi, useActions } from '@live-change/vue3-ssr'
|
|
25
|
+
|
|
26
|
+
const workingZone = inject('workingZone')
|
|
27
|
+
const toast = useToast()
|
|
28
|
+
const confirm = useConfirm()
|
|
29
|
+
const api = useApi()
|
|
30
|
+
const actions = useActions()
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`workingZone` is provided by `ViewRoot` (which wraps every page in `<WorkingZone>`). When you call `workingZone.addPromise(name, promise)`, the global spinner/blur activates until the promise resolves.
|
|
34
|
+
|
|
35
|
+
## Step 2 – Simple action button
|
|
36
|
+
|
|
37
|
+
Wrap the async operation in `workingZone.addPromise()`:
|
|
38
|
+
|
|
39
|
+
```javascript
|
|
40
|
+
function createItem() {
|
|
41
|
+
workingZone.addPromise('createItem', (async () => {
|
|
42
|
+
const result = await actions.blog.createArticle({})
|
|
43
|
+
toast.add({ severity: 'success', summary: 'Article created', life: 2000 })
|
|
44
|
+
router.push({ name: 'article:edit', params: { article: result } })
|
|
45
|
+
})())
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Important:** Note the `(async () => { ... })()` pattern – you must invoke the async IIFE immediately so `addPromise` receives a Promise, not a function.
|
|
50
|
+
|
|
51
|
+
Template:
|
|
52
|
+
|
|
53
|
+
```vue
|
|
54
|
+
<Button label="Create article" icon="pi pi-plus" @click="createItem" />
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Step 3 – Destructive action with confirm
|
|
58
|
+
|
|
59
|
+
Use `confirm.require()` before the action:
|
|
60
|
+
|
|
61
|
+
```javascript
|
|
62
|
+
function deleteItem(item) {
|
|
63
|
+
confirm.require({
|
|
64
|
+
message: 'Are you sure you want to delete this article?',
|
|
65
|
+
header: 'Confirmation',
|
|
66
|
+
icon: 'pi pi-trash',
|
|
67
|
+
acceptClass: 'p-button-danger',
|
|
68
|
+
accept: async () => {
|
|
69
|
+
workingZone.addPromise('deleteArticle', (async () => {
|
|
70
|
+
await actions.blog.deleteArticle({ article: item.id })
|
|
71
|
+
toast.add({ severity: 'success', summary: 'Deleted', life: 2000 })
|
|
72
|
+
})())
|
|
73
|
+
},
|
|
74
|
+
reject: () => {
|
|
75
|
+
toast.add({ severity: 'info', summary: 'Cancelled', life: 1500 })
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Template:
|
|
82
|
+
|
|
83
|
+
```vue
|
|
84
|
+
<Button label="Delete" icon="pi pi-trash" severity="danger" @click="deleteItem(article)" />
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Step 4 – Error handling
|
|
88
|
+
|
|
89
|
+
Add try/catch inside the async IIFE:
|
|
90
|
+
|
|
91
|
+
```javascript
|
|
92
|
+
function toggleStatus(item) {
|
|
93
|
+
workingZone.addPromise('toggleStatus', (async () => {
|
|
94
|
+
try {
|
|
95
|
+
await actions.blog.toggleArticleStatus({ article: item.id })
|
|
96
|
+
toast.add({ severity: 'success', summary: 'Status updated', life: 2000 })
|
|
97
|
+
} catch(e) {
|
|
98
|
+
toast.add({ severity: 'error', summary: 'Error', detail: e?.message ?? e, life: 5000 })
|
|
99
|
+
}
|
|
100
|
+
})())
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Step 5 – Using api.command instead of actions
|
|
105
|
+
|
|
106
|
+
Both work. `actions` is shorthand for typed service actions:
|
|
107
|
+
|
|
108
|
+
```javascript
|
|
109
|
+
// Using actions (preferred when available):
|
|
110
|
+
await actions.blog.deleteArticle({ article: id })
|
|
111
|
+
|
|
112
|
+
// Using api.command (always works):
|
|
113
|
+
await api.command(['blog', 'deleteArticle'], { article: id })
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Pattern summary
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
Button click
|
|
120
|
+
→ confirm.require() (if destructive)
|
|
121
|
+
→ workingZone.addPromise('name', (async () => {
|
|
122
|
+
try {
|
|
123
|
+
await actions.service.action({ ... })
|
|
124
|
+
toast.add({ severity: 'success', ... })
|
|
125
|
+
} catch(e) {
|
|
126
|
+
toast.add({ severity: 'error', ... })
|
|
127
|
+
}
|
|
128
|
+
})())
|
|
129
|
+
```
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: live-change-frontend-action-form
|
|
3
|
+
description: Build one-shot action forms with actionData, AutoField and ActionButtons
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill: live-change-frontend-action-form (Claude Code)
|
|
7
|
+
|
|
8
|
+
Use this skill when you build **one-shot action forms** with `actionData` and `AutoField` in a LiveChange frontend.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
- You need a form for a command/action (not CRUD model editing).
|
|
13
|
+
- The form submits once, then shows a "done" state.
|
|
14
|
+
- You want draft auto-save while the user fills in the form.
|
|
15
|
+
|
|
16
|
+
**Editing a model record instead?** Use `editorData` (see `live-change-frontend-editor-form` skill).
|
|
17
|
+
**No form fields, just a button?** Use `api.command` (see `live-change-frontend-command-forms` skill).
|
|
18
|
+
|
|
19
|
+
## Step 1 – Set up actionData
|
|
20
|
+
|
|
21
|
+
```javascript
|
|
22
|
+
import { AutoField, actionData, ActionButtons } from '@live-change/frontend-auto-form'
|
|
23
|
+
|
|
24
|
+
const formData = await actionData({
|
|
25
|
+
service: 'blog',
|
|
26
|
+
action: 'publishArticle',
|
|
27
|
+
parameters: { article: props.articleId }, // fixed params (not shown as fields)
|
|
28
|
+
initialValue: { scheduleTime: null }, // initial values for editable fields
|
|
29
|
+
draft: true,
|
|
30
|
+
doneToast: 'Article published!',
|
|
31
|
+
onDone: (result) => {
|
|
32
|
+
router.push({ name: 'articles' })
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For reactive parameters, use `computedAsync`:
|
|
38
|
+
|
|
39
|
+
```javascript
|
|
40
|
+
import { computedAsync } from '@vueuse/core'
|
|
41
|
+
|
|
42
|
+
const formData = computedAsync(() =>
|
|
43
|
+
actionData({
|
|
44
|
+
service: 'blog',
|
|
45
|
+
action: 'publishArticle',
|
|
46
|
+
parameters: { article: props.articleId },
|
|
47
|
+
})
|
|
48
|
+
)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Step 2 – Build the template with AutoField
|
|
52
|
+
|
|
53
|
+
**Every field must show validation errors.** Always pass `:error="formData.propertiesErrors?.fieldName"` to AutoField — or use a manual `Message` if not using AutoField (see `live-change-frontend-editor-form` skill for all 3 approaches).
|
|
54
|
+
|
|
55
|
+
**Always wrap in `<form>`** with `@submit.prevent` and `@reset.prevent` handlers. `ActionButtons` uses `type="submit"` / `type="reset"` internally — without a parent `<form>`, the buttons do nothing.
|
|
56
|
+
|
|
57
|
+
Use `formData.action.properties.*` as definitions and `formData.value.*` as v-model:
|
|
58
|
+
|
|
59
|
+
```vue
|
|
60
|
+
<template>
|
|
61
|
+
<form @submit.prevent="formData.submit()" @reset.prevent="formData.reset()">
|
|
62
|
+
<AutoField
|
|
63
|
+
:definition="formData.action.properties.scheduleTime"
|
|
64
|
+
v-model="formData.data.value.scheduleTime"
|
|
65
|
+
:error="formData.propertiesErrors?.scheduleTime"
|
|
66
|
+
label="Schedule time"
|
|
67
|
+
/>
|
|
68
|
+
<AutoField
|
|
69
|
+
:definition="formData.action.properties.message"
|
|
70
|
+
v-model="formData.data.value.message"
|
|
71
|
+
:error="formData.propertiesErrors?.message"
|
|
72
|
+
label="Message"
|
|
73
|
+
/>
|
|
74
|
+
|
|
75
|
+
<ActionButtons :actionFormData="formData" :resetButton="true" />
|
|
76
|
+
</form>
|
|
77
|
+
</template>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Step 3 – Handle done state
|
|
81
|
+
|
|
82
|
+
After successful submission, `formData.done` becomes `true`:
|
|
83
|
+
|
|
84
|
+
```vue
|
|
85
|
+
<template>
|
|
86
|
+
<div v-if="formData.done" class="text-center">
|
|
87
|
+
<i class="pi pi-check-circle text-4xl text-green-500 mb-2"></i>
|
|
88
|
+
<p>Article published successfully!</p>
|
|
89
|
+
</div>
|
|
90
|
+
<form v-else @submit.prevent="formData.submit()" @reset.prevent="formData.reset()">
|
|
91
|
+
<!-- fields + ActionButtons -->
|
|
92
|
+
</form>
|
|
93
|
+
</template>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Step 4 – Manual buttons (alternative to ActionButtons)
|
|
97
|
+
|
|
98
|
+
```vue
|
|
99
|
+
<div class="flex gap-2 justify-end">
|
|
100
|
+
<Button
|
|
101
|
+
type="submit"
|
|
102
|
+
:label="formData.submitting ? 'Executing...' : 'Execute'"
|
|
103
|
+
:icon="formData.submitting ? 'pi pi-spin pi-spinner' : 'pi pi-play'"
|
|
104
|
+
:disabled="formData.submitting"
|
|
105
|
+
/>
|
|
106
|
+
<Button type="reset" label="Reset" :disabled="!formData.changed" icon="pi pi-eraser" />
|
|
107
|
+
</div>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## editorData vs actionData
|
|
111
|
+
|
|
112
|
+
| Aspect | `editorData` | `actionData` |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| Purpose | CRUD model editing | One-shot command form |
|
|
115
|
+
| Identifier | `model` + `identifiers` | `action` + `parameters` |
|
|
116
|
+
| Create/update | Detects automatically | Always "execute" |
|
|
117
|
+
| After submit | Record is saved | `done` becomes `true` |
|
|
118
|
+
| Definition source | `editor.model` | `formData.action` |
|
|
119
|
+
| `parameters` | Extra params on save | Fixed fields excluded from form |
|
|
120
|
+
|
|
121
|
+
## Key options
|
|
122
|
+
|
|
123
|
+
| Option | Default | Description |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| `service` | required | Service name |
|
|
126
|
+
| `action` | required | Action name |
|
|
127
|
+
| `parameters` | `{}` | Fixed identifier fields (not editable) |
|
|
128
|
+
| `initialValue` | `{}` | Initial values for editable fields |
|
|
129
|
+
| `draft` | `true` | Auto-save draft while filling |
|
|
130
|
+
| `debounce` | `600` | Debounce delay in ms |
|
|
131
|
+
| `doneToast` | `"Action done"` | Toast after success |
|
|
132
|
+
| `onDone` | – | Callback after success |
|
|
133
|
+
|
|
134
|
+
## Key returned properties
|
|
135
|
+
|
|
136
|
+
| Property | Description |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `data` | Editable form data (recommended), it is a ref, it should be used with value - editor.data.value |
|
|
139
|
+
| `value` | Editable form data (obsolete), it is a ref, it should be used with value - editor.value.value |
|
|
140
|
+
| `action` | Action definition (`.properties.*` for `AutoField`) |
|
|
141
|
+
| `editableProperties` | Properties not fixed by `parameters` |
|
|
142
|
+
| `changed` | Form differs from initial value |
|
|
143
|
+
| `submit()` | Execute the action |
|
|
144
|
+
| `submitting` | Action call in progress |
|
|
145
|
+
| `done` | `true` after success |
|
|
146
|
+
| `reset()` | Discard draft, restore initial value |
|
|
147
|
+
| `propertiesErrors` | Server validation errors per property |
|
|
148
|
+
| `draftChanged` | Draft auto-saved but not submitted |
|
|
149
|
+
| `savingDraft` | Draft auto-save in progress |
|