@jskit-ai/users-web 0.1.107 → 0.1.109

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.
@@ -3,7 +3,7 @@ import { HOME_COG_OUTLET } from "./src/shared/toolsOutletContracts.js";
3
3
  export default Object.freeze({
4
4
  packageVersion: 1,
5
5
  packageId: "@jskit-ai/users-web",
6
- version: "0.1.107",
6
+ version: "0.1.109",
7
7
  kind: "runtime",
8
8
  description: "Users web module: account/profile UI plus shared users web widgets.",
9
9
  dependsOn: [
@@ -78,6 +78,10 @@ export default Object.freeze({
78
78
  subpath: "./client/filters",
79
79
  summary: "Exports client-side CRUD list filter definition helpers."
80
80
  },
81
+ {
82
+ subpath: "./client/rowActions",
83
+ summary: "Exports client-side CRUD list row-action definition helpers."
84
+ },
81
85
  {
82
86
  subpath: "./client/components/ProfileClientElement",
83
87
  summary: "Exports profile settings client element scaffold component."
@@ -114,6 +118,10 @@ export default Object.freeze({
114
118
  subpath: "./client/composables/useCrudListBulkActions",
115
119
  summary: "Exports selected-record state and execution runtime for generated CRUD list bulk actions."
116
120
  },
121
+ {
122
+ subpath: "./client/composables/useCrudListRowActions",
123
+ summary: "Exports per-record action state and execution runtime for generated CRUD list row actions."
124
+ },
117
125
  {
118
126
  subpath: "./client/composables/useCrudListScreen",
119
127
  summary: "Exports the package-owned list screen runtime for generated list pages."
@@ -278,12 +286,12 @@ export default Object.freeze({
278
286
  dependencies: {
279
287
  runtime: {
280
288
  "@mdi/js": "^7.4.47",
281
- "@jskit-ai/http-runtime": "0.1.91",
282
- "@jskit-ai/realtime": "0.1.91",
283
- "@jskit-ai/kernel": "0.1.92",
284
- "@jskit-ai/shell-web": "0.1.91",
285
- "@jskit-ai/uploads-image-web": "0.1.70",
286
- "@jskit-ai/users-core": "0.1.102"
289
+ "@jskit-ai/http-runtime": "0.1.93",
290
+ "@jskit-ai/realtime": "0.1.93",
291
+ "@jskit-ai/kernel": "0.1.94",
292
+ "@jskit-ai/shell-web": "0.1.93",
293
+ "@jskit-ai/uploads-image-web": "0.1.72",
294
+ "@jskit-ai/users-core": "0.1.104"
287
295
  },
288
296
  dev: {}
289
297
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-web",
3
- "version": "0.1.107",
3
+ "version": "0.1.109",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -16,12 +16,14 @@
16
16
  "./client/account-settings/sections": "./src/client/account-settings/sections.js",
17
17
  "./client/bulkActions": "./src/client/bulkActions.js",
18
18
  "./client/filters": "./src/client/filters.js",
19
+ "./client/rowActions": "./src/client/rowActions.js",
19
20
  "./client/composables/useAddEdit": "./src/client/composables/records/useAddEdit.js",
20
21
  "./client/composables/useAccess": "./src/client/composables/useAccess.js",
21
22
  "./client/composables/useCommand": "./src/client/composables/useCommand.js",
22
23
  "./client/composables/useCrudAddEdit": "./src/client/composables/records/useCrudAddEdit.js",
23
24
  "./client/composables/useCrudAddEditScreen": "./src/client/composables/useCrudAddEditScreen.js",
24
25
  "./client/composables/useCrudListBulkActions": "./src/client/composables/useCrudListBulkActions.js",
26
+ "./client/composables/useCrudListRowActions": "./src/client/composables/useCrudListRowActions.js",
25
27
  "./client/composables/crudLookupFieldRuntime": "./src/client/composables/crud/crudLookupFieldRuntime.js",
26
28
  "./client/composables/useCrudListFilterLookups": "./src/client/composables/useCrudListFilterLookups.js",
27
29
  "./client/composables/useCrudListFilters": "./src/client/composables/useCrudListFilters.js",
@@ -44,12 +46,12 @@
44
46
  },
45
47
  "dependencies": {
46
48
  "@mdi/js": "^7.4.47",
47
- "@jskit-ai/http-runtime": "0.1.91",
48
- "@jskit-ai/kernel": "0.1.92",
49
- "@jskit-ai/realtime": "0.1.91",
50
- "@jskit-ai/shell-web": "0.1.91",
51
- "@jskit-ai/uploads-image-web": "0.1.70",
52
- "@jskit-ai/users-core": "0.1.102"
49
+ "@jskit-ai/http-runtime": "0.1.93",
50
+ "@jskit-ai/kernel": "0.1.94",
51
+ "@jskit-ai/realtime": "0.1.93",
52
+ "@jskit-ai/shell-web": "0.1.93",
53
+ "@jskit-ai/uploads-image-web": "0.1.72",
54
+ "@jskit-ai/users-core": "0.1.104"
53
55
  },
54
56
  "peerDependencies": {
55
57
  "@tanstack/vue-query": "^5.90.5",
@@ -0,0 +1,120 @@
1
+ <script setup>
2
+ import { computed } from "vue";
3
+
4
+ const props = defineProps({
5
+ record: {
6
+ type: Object,
7
+ default: () => ({})
8
+ },
9
+ index: {
10
+ type: Number,
11
+ default: 0
12
+ },
13
+ rowActions: {
14
+ type: Object,
15
+ default: null
16
+ },
17
+ viewLocation: {
18
+ type: Object,
19
+ default: null
20
+ },
21
+ editLocation: {
22
+ type: Object,
23
+ default: null
24
+ },
25
+ showView: {
26
+ type: Boolean,
27
+ default: false
28
+ },
29
+ showEdit: {
30
+ type: Boolean,
31
+ default: false
32
+ },
33
+ buttonLabel: {
34
+ type: String,
35
+ default: "Actions"
36
+ },
37
+ buttonVariant: {
38
+ type: String,
39
+ default: "text"
40
+ },
41
+ buttonSize: {
42
+ type: String,
43
+ default: "small"
44
+ }
45
+ });
46
+
47
+ const visibleRowActions = computed(() => {
48
+ if (typeof props.rowActions?.visibleActionsFor !== "function") {
49
+ return [];
50
+ }
51
+ return props.rowActions.visibleActionsFor(props.record, props.index);
52
+ });
53
+ const hasViewAction = computed(() => Boolean(props.showView && props.viewLocation));
54
+ const hasEditAction = computed(() => Boolean(props.showEdit && props.editLocation));
55
+ const hasActions = computed(() =>
56
+ hasViewAction.value || hasEditAction.value || visibleRowActions.value.length > 0
57
+ );
58
+
59
+ function isRowActionLoading(action = {}) {
60
+ return typeof props.rowActions?.isActionLoading === "function" &&
61
+ props.rowActions.isActionLoading(action, props.record, props.index);
62
+ }
63
+
64
+ function isRowActionDisabled(action = {}) {
65
+ return typeof props.rowActions?.isActionDisabled !== "function" ||
66
+ props.rowActions.isActionDisabled(action, props.record, props.index);
67
+ }
68
+
69
+ function runRowAction(action = {}) {
70
+ if (typeof props.rowActions?.execute !== "function") {
71
+ return;
72
+ }
73
+ void props.rowActions.execute(action, props.record, props.index);
74
+ }
75
+ </script>
76
+
77
+ <template>
78
+ <v-menu v-if="hasActions" location="bottom end">
79
+ <template #activator="{ props: menuProps }">
80
+ <v-btn
81
+ v-bind="menuProps"
82
+ :variant="buttonVariant"
83
+ :size="buttonSize"
84
+ >
85
+ {{ buttonLabel }}
86
+ </v-btn>
87
+ </template>
88
+ <v-list density="compact" min-width="150">
89
+ <v-list-item
90
+ v-if="hasViewAction"
91
+ title="Open"
92
+ :to="viewLocation"
93
+ />
94
+ <v-list-item
95
+ v-if="hasEditAction"
96
+ title="Edit"
97
+ :to="editLocation"
98
+ />
99
+ <v-divider v-if="(hasViewAction || hasEditAction) && visibleRowActions.length > 0" />
100
+ <v-list-item
101
+ v-for="action in visibleRowActions"
102
+ :key="action.key"
103
+ :title="action.label"
104
+ :base-color="action.color"
105
+ :disabled="isRowActionDisabled(action)"
106
+ @click="runRowAction(action)"
107
+ >
108
+ <template v-if="isRowActionLoading(action) || action.icon" #prepend>
109
+ <v-progress-circular
110
+ v-if="isRowActionLoading(action)"
111
+ indeterminate
112
+ size="18"
113
+ width="2"
114
+ />
115
+ <v-icon v-else :icon="action.icon" />
116
+ </template>
117
+ </v-list-item>
118
+ </v-list>
119
+ </v-menu>
120
+ </template>
@@ -3,6 +3,7 @@ import { computed, unref } from "vue";
3
3
  import { useRoute } from "vue-router";
4
4
  import CrudListBulkActionSurface from "./CrudListBulkActionSurface.vue";
5
5
  import CrudListFilterSurface from "./CrudListFilterSurface.vue";
6
+ import CrudListRecordActionMenu from "./CrudListRecordActionMenu.vue";
6
7
 
7
8
  const props = defineProps({
8
9
  screen: {
@@ -46,10 +47,14 @@ const props = defineProps({
46
47
  const route = useRoute();
47
48
  const records = computed(() => props.screen?.records || {});
48
49
  const bulkActions = computed(() => props.screen?.bulkActions || {});
50
+ const rowActions = computed(() => props.screen?.rowActions || {});
49
51
  const listFilters = computed(() => props.screen?.listFilters || {});
50
52
  const filterRuntime = computed(() => props.screen?.filterRuntime || null);
53
+ const displayRows = computed(() => unref(props.screen?.displayRows) || []);
54
+ const selectableRows = computed(() => unref(props.screen?.selectableRows) || []);
51
55
  const listPrimaryAction = computed(() => unref(props.screen?.listPrimaryAction) || "");
52
56
  const hasBulkActions = computed(() => Boolean(unref(bulkActions.value?.hasActions)));
57
+ const hasRowActions = computed(() => Boolean(unref(rowActions.value?.hasActions)));
53
58
  const hasViewUrl = computed(() => Boolean(props.screen?.hasViewUrl));
54
59
  const hasEditUrl = computed(() => Boolean(props.screen?.hasEditUrl));
55
60
  const resolvedHeadingTitle = computed(() => String(props.headingTitle || props.titleLabel || "").trim());
@@ -84,6 +89,40 @@ function resolveEditLocation(record) {
84
89
  : "";
85
90
  return path ? { path, query: route.query } : null;
86
91
  }
92
+
93
+ function hasRowActionsFor(record, index) {
94
+ return typeof rowActions.value?.hasVisibleActionsFor === "function" &&
95
+ rowActions.value.hasVisibleActionsFor(record, index);
96
+ }
97
+
98
+ function isBulkRowSelected(row = {}) {
99
+ return typeof bulkActions.value?.isRecordSelected === "function" &&
100
+ bulkActions.value.isRecordSelected(row.record, row.index);
101
+ }
102
+
103
+ function setBulkRowSelected(row = {}, selected = true) {
104
+ if (typeof bulkActions.value?.setRecordSelected !== "function") {
105
+ return;
106
+ }
107
+ bulkActions.value.setRecordSelected(row.record, row.index, selected);
108
+ }
109
+
110
+ function allSelectableRowsSelected() {
111
+ return selectableRows.value.length > 0 && selectableRows.value.every((row) => isBulkRowSelected(row));
112
+ }
113
+
114
+ function someSelectableRowsSelected() {
115
+ return selectableRows.value.some((row) => isBulkRowSelected(row));
116
+ }
117
+
118
+ function setSelectableRowsSelected(selected = true) {
119
+ if (typeof bulkActions.value?.setRecordSelected !== "function") {
120
+ return;
121
+ }
122
+ for (const row of selectableRows.value) {
123
+ bulkActions.value.setRecordSelected(row.record, row.index, selected);
124
+ }
125
+ }
87
126
  </script>
88
127
 
89
128
  <template>
@@ -143,7 +182,7 @@ function resolveEditLocation(record) {
143
182
  <v-btn color="primary" variant="tonal" :loading="records.isFetching" @click="records.reload">Retry</v-btn>
144
183
  </div>
145
184
 
146
- <div v-else-if="records.items.length < 1" class="ui-generator-list-state">
185
+ <div v-else-if="displayRows.length < 1" class="ui-generator-list-state">
147
186
  <h2 class="text-h6 mb-2">{{ emptyTitle }}</h2>
148
187
  <p class="text-body-2 text-medium-emphasis mb-4">{{ emptyBody }}</p>
149
188
  <v-btn v-if="listPrimaryAction" color="primary" variant="flat" :to="listPrimaryAction">
@@ -154,52 +193,44 @@ function resolveEditLocation(record) {
154
193
  <template v-else>
155
194
  <div class="ui-generator-list-cards d-md-none">
156
195
  <v-sheet
157
- v-for="(record, index) in records.items"
158
- :key="records.resolveRowKey(record, index)"
196
+ v-for="row in displayRows"
197
+ :key="row.key"
159
198
  rounded="lg"
160
199
  border
161
200
  class="ui-generator-list-card"
162
201
  >
163
202
  <div class="ui-generator-list-card__header">
164
203
  <v-checkbox-btn
165
- v-if="hasBulkActions"
166
- :model-value="bulkActions.isRecordSelected(record, index)"
167
- :aria-label="`Select ${resolveListRecordTitle(record)}`"
204
+ v-if="hasBulkActions && row.selectable !== false"
205
+ :model-value="isBulkRowSelected(row)"
206
+ :aria-label="`Select ${resolveListRecordTitle(row.record)}`"
168
207
  class="ui-generator-list-card__select"
169
- @update:model-value="bulkActions.setRecordSelected(record, index, $event)"
208
+ @update:model-value="setBulkRowSelected(row, $event)"
170
209
  />
171
210
  <div class="min-w-0">
172
- <div class="ui-generator-list-card__title">{{ resolveListRecordTitle(record) }}</div>
211
+ <div class="ui-generator-list-card__title">{{ resolveListRecordTitle(row.record) }}</div>
173
212
  <div class="text-caption text-medium-emphasis">
174
- {{ records.resolveRowKey(record, index) }}
213
+ {{ row.recordKey }}
175
214
  </div>
176
215
  </div>
177
- <v-menu v-if="hasViewUrl || hasEditUrl" location="bottom end">
178
- <template #activator="{ props: menuProps }">
179
- <v-btn v-bind="menuProps" variant="text" size="small">Actions</v-btn>
180
- </template>
181
- <v-list density="compact" min-width="140">
182
- <v-list-item
183
- v-if="hasViewUrl"
184
- title="Open"
185
- :to="resolveViewLocation(record)"
186
- :disabled="!resolveViewLocation(record)"
187
- />
188
- <v-list-item
189
- v-if="hasEditUrl"
190
- title="Edit"
191
- :to="resolveEditLocation(record)"
192
- :disabled="!resolveEditLocation(record)"
193
- />
194
- </v-list>
195
- </v-menu>
216
+ <CrudListRecordActionMenu
217
+ v-if="hasViewUrl || hasEditUrl || hasRowActionsFor(row.record, row.index)"
218
+ :record="row.record"
219
+ :index="row.index"
220
+ :row-actions="rowActions"
221
+ :show-view="hasViewUrl && !row.synthetic"
222
+ :show-edit="hasEditUrl && !row.synthetic"
223
+ :view-location="resolveViewLocation(row.record)"
224
+ :edit-location="resolveEditLocation(row.record)"
225
+ />
196
226
  </div>
197
227
  <div class="ui-generator-list-card__fields">
198
228
  <slot
199
229
  name="card-fields"
200
- :record="record"
230
+ :record="row.record"
201
231
  :records="records"
202
- :index="index"
232
+ :index="row.index"
233
+ :row="row"
203
234
  :format-list-card-value="formatListCardValue"
204
235
  />
205
236
  </div>
@@ -212,52 +243,72 @@ function resolveEditLocation(record) {
212
243
  <tr>
213
244
  <th v-if="hasBulkActions" class="ui-generator-list-table__select">
214
245
  <v-checkbox-btn
215
- :model-value="bulkActions.allVisibleSelected(records.items)"
246
+ :model-value="allSelectableRowsSelected()"
216
247
  :indeterminate="
217
- bulkActions.someVisibleSelected(records.items) &&
218
- !bulkActions.allVisibleSelected(records.items)
248
+ someSelectableRowsSelected() &&
249
+ !allSelectableRowsSelected()
219
250
  "
220
251
  aria-label="Select visible rows"
221
- @update:model-value="bulkActions.setVisibleSelected(records.items, $event)"
252
+ @update:model-value="setSelectableRowsSelected($event)"
222
253
  />
223
254
  </th>
224
255
  <slot name="table-header" />
225
256
  <th v-if="hasViewUrl" class="text-right" />
226
257
  <th v-if="hasEditUrl" class="text-right" />
258
+ <th v-if="hasRowActions" class="text-right" />
227
259
  </tr>
228
260
  </thead>
229
261
  <tbody>
230
- <tr v-for="(record, index) in records.items" :key="records.resolveRowKey(record, index)">
262
+ <tr v-for="row in displayRows" :key="row.key">
231
263
  <td v-if="hasBulkActions" class="ui-generator-list-table__select">
232
264
  <v-checkbox-btn
233
- :model-value="bulkActions.isRecordSelected(record, index)"
234
- :aria-label="`Select ${resolveListRecordTitle(record)}`"
235
- @update:model-value="bulkActions.setRecordSelected(record, index, $event)"
265
+ v-if="row.selectable !== false"
266
+ :model-value="isBulkRowSelected(row)"
267
+ :aria-label="`Select ${resolveListRecordTitle(row.record)}`"
268
+ @update:model-value="setBulkRowSelected(row, $event)"
236
269
  />
237
270
  </td>
238
- <slot name="table-row" :record="record" :records="records" :index="index" />
271
+ <slot
272
+ name="table-row"
273
+ :record="row.record"
274
+ :records="records"
275
+ :index="row.index"
276
+ :row="row"
277
+ />
239
278
  <td v-if="hasViewUrl" class="text-right">
240
279
  <v-btn
280
+ v-if="!row.synthetic"
241
281
  size="small"
242
282
  color="primary"
243
283
  variant="outlined"
244
- :to="resolveViewLocation(record)"
245
- :disabled="!resolveViewLocation(record)"
284
+ :to="resolveViewLocation(row.record)"
285
+ :disabled="!resolveViewLocation(row.record)"
246
286
  >
247
287
  Open
248
288
  </v-btn>
249
289
  </td>
250
290
  <td v-if="hasEditUrl" class="text-right">
251
291
  <v-btn
292
+ v-if="!row.synthetic"
252
293
  size="small"
253
294
  color="primary"
254
295
  variant="tonal"
255
- :to="resolveEditLocation(record)"
256
- :disabled="!resolveEditLocation(record)"
296
+ :to="resolveEditLocation(row.record)"
297
+ :disabled="!resolveEditLocation(row.record)"
257
298
  >
258
299
  Edit
259
300
  </v-btn>
260
301
  </td>
302
+ <td v-if="hasRowActions" class="text-right">
303
+ <CrudListRecordActionMenu
304
+ v-if="hasRowActionsFor(row.record, row.index)"
305
+ :record="row.record"
306
+ :index="row.index"
307
+ :row-actions="rowActions"
308
+ button-label="More"
309
+ button-variant="outlined"
310
+ />
311
+ </td>
261
312
  </tr>
262
313
  </tbody>
263
314
  </v-table>
@@ -97,12 +97,18 @@ const resolvedDescription = computed(() =>
97
97
  <template v-else>
98
98
  <v-progress-linear v-if="view.isRefetching" indeterminate />
99
99
  <div class="pa-4">
100
+ <slot name="before-fields" :view="view" />
100
101
  <v-row class="ui-generator-view-fields">
101
102
  <slot name="fields" :view="view" />
102
103
  </v-row>
104
+ <slot name="after-fields" :view="view" />
103
105
  </div>
104
106
  </template>
105
107
  </v-sheet>
108
+
109
+ <div v-if="$slots['supporting-content']" class="ui-generator-view-supporting-content">
110
+ <slot name="supporting-content" :view="view" />
111
+ </div>
106
112
  </section>
107
113
  </template>
108
114
 
@@ -165,6 +171,10 @@ const resolvedDescription = computed(() =>
165
171
  min-width: 0;
166
172
  }
167
173
 
174
+ .ui-generator-view-supporting-content {
175
+ min-width: 0;
176
+ }
177
+
168
178
  @media (max-width: 960px) {
169
179
  .ui-generator-view-header {
170
180
  flex-direction: column;
@@ -0,0 +1,144 @@
1
+ import { computed, ref } from "vue";
2
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import { defineCrudListRowActions } from "../rowActions.js";
4
+
5
+ function normalizeRowActionKey(actionOrKey = "") {
6
+ return normalizeText(
7
+ actionOrKey && typeof actionOrKey === "object"
8
+ ? actionOrKey.key
9
+ : actionOrKey
10
+ );
11
+ }
12
+
13
+ function normalizeRowId(value = "") {
14
+ return normalizeText(value);
15
+ }
16
+
17
+ function createExecutionKey(actionKey = "", recordId = "", index = 0) {
18
+ return [
19
+ actionKey,
20
+ recordId || `index-${index}`
21
+ ].join(":");
22
+ }
23
+
24
+ function asContextObject(value = null) {
25
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
26
+ }
27
+
28
+ function useCrudListRowActions(actions = [], {
29
+ resolveRecordId = null,
30
+ resolveContext = null
31
+ } = {}) {
32
+ const normalizedActions = defineCrudListRowActions(actions);
33
+ const executingKeys = ref([]);
34
+ const hasActions = computed(() => normalizedActions.length > 0);
35
+
36
+ function getRecordId(record = {}, index = 0) {
37
+ const resolvedId = typeof resolveRecordId === "function"
38
+ ? resolveRecordId(record, index)
39
+ : record?.id ?? record?.attributes?.id ?? index;
40
+ return normalizeRowId(resolvedId);
41
+ }
42
+
43
+ function createActionContext(action = {}, record = {}, index = 0) {
44
+ const baseContext = typeof resolveContext === "function" ? resolveContext() : {};
45
+ return {
46
+ ...asContextObject(baseContext),
47
+ action,
48
+ record,
49
+ index,
50
+ recordId: getRecordId(record, index)
51
+ };
52
+ }
53
+
54
+ function findAction(actionOrKey = "") {
55
+ const actionKey = normalizeRowActionKey(actionOrKey);
56
+ return normalizedActions.find((action) => action.key === actionKey) || null;
57
+ }
58
+
59
+ function resolveActionFlag(action = {}, flagKey = "", record = {}, index = 0, fallback = false) {
60
+ const flag = action?.[flagKey];
61
+ if (typeof flag === "function") {
62
+ return Boolean(flag(createActionContext(action, record, index)));
63
+ }
64
+ if (flag === undefined || flag === null) {
65
+ return fallback;
66
+ }
67
+ return Boolean(flag);
68
+ }
69
+
70
+ function isActionVisible(actionOrKey = "", record = {}, index = 0) {
71
+ const action = findAction(actionOrKey);
72
+ return action ? resolveActionFlag(action, "visible", record, index, true) : false;
73
+ }
74
+
75
+ function isActionExecuting(actionOrKey = "", record = {}, index = 0) {
76
+ const action = findAction(actionOrKey);
77
+ if (!action) {
78
+ return false;
79
+ }
80
+
81
+ const executionKey = createExecutionKey(action.key, getRecordId(record, index), index);
82
+ return executingKeys.value.includes(executionKey);
83
+ }
84
+
85
+ function isActionLoading(actionOrKey = "", record = {}, index = 0) {
86
+ const action = findAction(actionOrKey);
87
+ if (!action) {
88
+ return false;
89
+ }
90
+
91
+ return isActionExecuting(action, record, index) ||
92
+ resolveActionFlag(action, "loading", record, index, false);
93
+ }
94
+
95
+ function isActionDisabled(actionOrKey = "", record = {}, index = 0) {
96
+ const action = findAction(actionOrKey);
97
+ if (!action || !isActionVisible(action, record, index) || !action.run) {
98
+ return true;
99
+ }
100
+
101
+ return isActionLoading(action, record, index) ||
102
+ resolveActionFlag(action, "disabled", record, index, false);
103
+ }
104
+
105
+ function visibleActionsFor(record = {}, index = 0) {
106
+ return normalizedActions.filter((action) => isActionVisible(action, record, index));
107
+ }
108
+
109
+ function hasVisibleActionsFor(record = {}, index = 0) {
110
+ return visibleActionsFor(record, index).length > 0;
111
+ }
112
+
113
+ async function execute(actionOrKey = "", record = {}, index = 0) {
114
+ const action = findAction(actionOrKey);
115
+ if (!action || typeof action.run !== "function" || isActionDisabled(action, record, index)) {
116
+ return null;
117
+ }
118
+
119
+ const executionKey = createExecutionKey(action.key, getRecordId(record, index), index);
120
+ executingKeys.value = [...executingKeys.value, executionKey];
121
+ try {
122
+ return await action.run(createActionContext(action, record, index));
123
+ } finally {
124
+ executingKeys.value = executingKeys.value.filter((key) => key !== executionKey);
125
+ }
126
+ }
127
+
128
+ return Object.freeze({
129
+ actions: normalizedActions,
130
+ hasActions,
131
+ executingKeys,
132
+ getRecordId,
133
+ findAction,
134
+ isActionVisible,
135
+ isActionDisabled,
136
+ isActionLoading,
137
+ isActionExecuting,
138
+ visibleActionsFor,
139
+ hasVisibleActionsFor,
140
+ execute
141
+ });
142
+ }
143
+
144
+ export { useCrudListRowActions };
@@ -1,7 +1,8 @@
1
- import { computed } from "vue";
1
+ import { computed, unref } from "vue";
2
2
  import { useCrudList } from "./records/useCrudList.js";
3
3
  import { useCrudListBulkActions } from "./useCrudListBulkActions.js";
4
4
  import { useCrudListFilters } from "./useCrudListFilters.js";
5
+ import { useCrudListRowActions } from "./useCrudListRowActions.js";
5
6
 
6
7
  function formatCrudListCardValue(value) {
7
8
  if (value === null || value === undefined || value === "") {
@@ -16,6 +17,89 @@ function formatCrudListCardValue(value) {
16
17
  return value;
17
18
  }
18
19
 
20
+ function asList(value = []) {
21
+ const resolved = unref(value);
22
+ if (resolved == null) {
23
+ return [];
24
+ }
25
+ return Array.isArray(resolved) ? resolved : [resolved];
26
+ }
27
+
28
+ function hasSyntheticRowGroups(value = null) {
29
+ const source = unref(value);
30
+ return Boolean(
31
+ source &&
32
+ typeof source === "object" &&
33
+ !Array.isArray(source) &&
34
+ (Object.hasOwn(source, "prepend") || Object.hasOwn(source, "append"))
35
+ );
36
+ }
37
+
38
+ function normalizeSyntheticDisplayRow(source = null, index = 0, placement = "prepend") {
39
+ const rawRow = unref(source);
40
+ if (!rawRow || typeof rawRow !== "object" || Array.isArray(rawRow)) {
41
+ return null;
42
+ }
43
+
44
+ const hasWrappedRecord = Object.hasOwn(rawRow, "record");
45
+ const record = hasWrappedRecord ? unref(rawRow.record) : rawRow;
46
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
47
+ return null;
48
+ }
49
+
50
+ const keySource = hasWrappedRecord
51
+ ? rawRow.key
52
+ : record.id ?? record.attributes?.id;
53
+ const recordKey = String(keySource || `synthetic-${placement}-${index + 1}`).trim();
54
+
55
+ return Object.freeze({
56
+ key: `synthetic:${placement}:${recordKey}`,
57
+ recordKey,
58
+ record,
59
+ index: hasWrappedRecord && Number.isInteger(rawRow.index) ? rawRow.index : -1,
60
+ synthetic: true,
61
+ selectable: rawRow.selectable === true,
62
+ placement
63
+ });
64
+ }
65
+
66
+ function normalizeSyntheticDisplayRows(value = [], placement = "prepend") {
67
+ return asList(value)
68
+ .map((row, index) => normalizeSyntheticDisplayRow(row, index, placement))
69
+ .filter(Boolean);
70
+ }
71
+
72
+ function normalizeSyntheticDisplayRowGroups(syntheticRows = null) {
73
+ const source = unref(syntheticRows);
74
+ if (hasSyntheticRowGroups(source)) {
75
+ return Object.freeze({
76
+ prepend: normalizeSyntheticDisplayRows(source.prepend, "prepend"),
77
+ append: normalizeSyntheticDisplayRows(source.append, "append")
78
+ });
79
+ }
80
+
81
+ return Object.freeze({
82
+ prepend: normalizeSyntheticDisplayRows(source, "prepend"),
83
+ append: []
84
+ });
85
+ }
86
+
87
+ function createCrudListDisplayRow(record = {}, index = 0, records = {}) {
88
+ const resolveRowKey = typeof records.resolveRowKey === "function"
89
+ ? records.resolveRowKey
90
+ : (_record, fallbackIndex) => fallbackIndex;
91
+ const recordKey = String(resolveRowKey(record, index) || index);
92
+ return Object.freeze({
93
+ key: `record:${recordKey}`,
94
+ recordKey,
95
+ record,
96
+ index,
97
+ synthetic: false,
98
+ selectable: true,
99
+ placement: "records"
100
+ });
101
+ }
102
+
19
103
  function useCrudListScreen({
20
104
  adapter = null,
21
105
  resource = null,
@@ -30,7 +114,10 @@ function useCrudListScreen({
30
114
  recordChangedEvents = [],
31
115
  listFilters = {},
32
116
  listBulkActions = [],
117
+ listRowActions = [],
118
+ syntheticRows = null,
33
119
  routeQueryBlacklist = Object.freeze(["include", "cursor", "limit"]),
120
+ requestQueryParams = null,
34
121
  requestRecoveryLabel = "Records",
35
122
  fallbackLoadError = "Unable to load records."
36
123
  } = {}) {
@@ -63,6 +150,7 @@ function useCrudListScreen({
63
150
  queryParamBlacklist: routeQueryBlacklist
64
151
  },
65
152
  placementSource: `ui-generator.${normalizedResourceNamespace}.list`,
153
+ requestQueryParams,
66
154
  requestRecoveryLabel,
67
155
  fallbackLoadError,
68
156
  recordIdParam,
@@ -75,6 +163,20 @@ function useCrudListScreen({
75
163
  }
76
164
  : null
77
165
  });
166
+ const displayRows = computed(() => {
167
+ const syntheticRowGroups = normalizeSyntheticDisplayRowGroups(syntheticRows);
168
+ const recordRows = (Array.isArray(records.items) ? records.items : [])
169
+ .map((record, index) => createCrudListDisplayRow(record, index, records));
170
+
171
+ return [
172
+ ...syntheticRowGroups.prepend,
173
+ ...recordRows,
174
+ ...syntheticRowGroups.append
175
+ ];
176
+ });
177
+ const selectableRows = computed(() =>
178
+ displayRows.value.filter((row) => row.selectable !== false)
179
+ );
78
180
  const bulkActions = useCrudListBulkActions(listBulkActions, {
79
181
  resolveRecordId: (record, index) => records.resolveRowKey(record, index),
80
182
  resolveContext: () => ({
@@ -82,6 +184,13 @@ function useCrudListScreen({
82
184
  reload: records.reload
83
185
  })
84
186
  });
187
+ const rowActions = useCrudListRowActions(listRowActions, {
188
+ resolveRecordId: (record, index) => records.resolveRowKey(record, index),
189
+ resolveContext: () => ({
190
+ records,
191
+ reload: records.reload
192
+ })
193
+ });
85
194
  const listPrimaryAction = computed(() =>
86
195
  newUrlTemplate ? records.resolveParams(newUrlTemplate) : ""
87
196
  );
@@ -98,6 +207,10 @@ function useCrudListScreen({
98
207
  listFilters,
99
208
  filterRuntime,
100
209
  bulkActions,
210
+ listRowActions,
211
+ rowActions,
212
+ displayRows,
213
+ selectableRows,
101
214
  listPrimaryAction,
102
215
  hasViewUrl: Boolean(viewUrlTemplate),
103
216
  hasEditUrl: Boolean(editUrlTemplate),
@@ -12,25 +12,31 @@ function useCrudViewScreen({
12
12
  listUrlTemplate = "",
13
13
  editUrlTemplate = "",
14
14
  recordChangedEvent = "",
15
+ requestQueryParams = null,
16
+ readEnabled = true,
17
+ queryKeyFactory = null,
15
18
  requestRecoveryLabel = "Record",
16
19
  fallbackLoadError = "Unable to load record.",
17
20
  notFoundMessage = "Record not found."
18
21
  } = {}) {
19
22
  const route = useRoute();
20
23
  const normalizedResourceNamespace = String(resourceNamespace || "resource").trim() || "resource";
24
+ const defaultQueryKeyFactory = (surfaceId = "", workspaceSlug = "") => [
25
+ "ui-generator",
26
+ normalizedResourceNamespace,
27
+ "view",
28
+ String(surfaceId || ""),
29
+ String(workspaceSlug || "")
30
+ ];
21
31
  const view = useCrudView({
22
32
  adapter: adapter || undefined,
23
33
  resource,
24
34
  apiUrlTemplate,
25
35
  recordIdParam,
26
36
  includeRecordIdInQueryKey: true,
27
- queryKeyFactory: (surfaceId = "", workspaceSlug = "") => [
28
- "ui-generator",
29
- normalizedResourceNamespace,
30
- "view",
31
- String(surfaceId || ""),
32
- String(workspaceSlug || "")
33
- ],
37
+ queryKeyFactory: typeof queryKeyFactory === "function" ? queryKeyFactory : defaultQueryKeyFactory,
38
+ requestQueryParams,
39
+ readEnabled,
34
40
  placementSource: `ui-generator.${normalizedResourceNamespace}.view`,
35
41
  requestRecoveryLabel,
36
42
  fallbackLoadError,
@@ -0,0 +1,47 @@
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+
3
+ function normalizeCrudListRowAction(rawAction = {}, index = 0) {
4
+ if (!rawAction || typeof rawAction !== "object" || Array.isArray(rawAction)) {
5
+ return null;
6
+ }
7
+
8
+ const key = normalizeText(rawAction.key || rawAction.id || `action-${index + 1}`);
9
+ const label = normalizeText(rawAction.label || rawAction.title);
10
+ if (!key || !label) {
11
+ return null;
12
+ }
13
+
14
+ return Object.freeze({
15
+ key,
16
+ label,
17
+ icon: normalizeText(rawAction.icon),
18
+ color: normalizeText(rawAction.color, { fallback: "primary" }),
19
+ run: typeof rawAction.run === "function" ? rawAction.run : null,
20
+ visible: rawAction.visible,
21
+ disabled: rawAction.disabled,
22
+ loading: rawAction.loading
23
+ });
24
+ }
25
+
26
+ function defineCrudListRowActions(actions = []) {
27
+ if (!Array.isArray(actions)) {
28
+ throw new TypeError("defineCrudListRowActions requires an array.");
29
+ }
30
+
31
+ const normalizedActions = [];
32
+ const seenKeys = new Set();
33
+
34
+ actions.forEach((rawAction, index) => {
35
+ const action = normalizeCrudListRowAction(rawAction, index);
36
+ if (!action || seenKeys.has(action.key)) {
37
+ return;
38
+ }
39
+
40
+ seenKeys.add(action.key);
41
+ normalizedActions.push(action);
42
+ });
43
+
44
+ return Object.freeze(normalizedActions);
45
+ }
46
+
47
+ export { defineCrudListRowActions };
@@ -11,6 +11,10 @@ async function readComponent(name) {
11
11
  return readFile(path.join(PACKAGE_DIR, "src", "client", "components", name), "utf8");
12
12
  }
13
13
 
14
+ async function readClientFile(...parts) {
15
+ return readFile(path.join(PACKAGE_DIR, "src", "client", ...parts), "utf8");
16
+ }
17
+
14
18
  test("CRUD screen components own generated list/view/form chrome centrally", async () => {
15
19
  const listSource = await readComponent("CrudListScreen.vue");
16
20
  const viewSource = await readComponent("CrudViewScreen.vue");
@@ -18,19 +22,25 @@ test("CRUD screen components own generated list/view/form chrome centrally", asy
18
22
 
19
23
  assert.match(listSource, /CrudListBulkActionSurface/);
20
24
  assert.match(listSource, /CrudListFilterSurface/);
25
+ assert.match(listSource, /CrudListRecordActionMenu/);
21
26
  assert.match(listSource, /ui-generator-list-cards d-md-none/);
22
27
  assert.match(listSource, /ui-generator-list-table d-none d-md-block/);
23
28
  assert.match(listSource, /class="ui-generator-list-fab d-md-none"/);
24
- assert.match(listSource, /#activator[\s\S]*Actions/);
29
+ assert.match(listSource, /button-label="More"/);
30
+ assert.match(listSource, /selectableRows/);
25
31
  assert.match(listSource, /min-height:\s*48px/);
26
32
  assert.match(listSource, /<slot[\s\S]*name="card-fields"/);
27
33
  assert.match(listSource, /<slot name="table-header"/);
28
- assert.match(listSource, /<slot name="table-row"/);
34
+ assert.match(listSource, /<slot[\s\S]*name="table-row"/);
35
+ assert.match(listSource, /:row="row"/);
29
36
 
30
37
  assert.match(viewSource, /generated-ui-screen generated-ui-screen--operator ui-generator-view-element/);
31
38
  assert.match(viewSource, /ui-generator-view-panel/);
32
39
  assert.match(viewSource, /@click="view\.refresh"/);
40
+ assert.match(viewSource, /<slot name="before-fields"/);
33
41
  assert.match(viewSource, /<slot name="fields"/);
42
+ assert.match(viewSource, /<slot name="after-fields"/);
43
+ assert.match(viewSource, /supporting-content/);
34
44
 
35
45
  assert.match(addEditSource, /generated-ui-screen generated-ui-screen--operator ui-generator-add-edit-form/);
36
46
  assert.match(addEditSource, /addEdit\.canRetryLoad/);
@@ -38,6 +48,22 @@ test("CRUD screen components own generated list/view/form chrome centrally", asy
38
48
  assert.match(addEditSource, /<slot[\s\S]*name="fields"/);
39
49
  });
40
50
 
51
+ test("CRUD screen composables expose generated page extension inputs", async () => {
52
+ const listSource = await readClientFile("composables", "useCrudListScreen.js");
53
+ const viewSource = await readClientFile("composables", "useCrudViewScreen.js");
54
+
55
+ assert.match(listSource, /listRowActions = \[\]/);
56
+ assert.match(listSource, /syntheticRows = null/);
57
+ assert.match(listSource, /useCrudListRowActions/);
58
+ assert.match(listSource, /selectableRows/);
59
+
60
+ assert.match(viewSource, /requestQueryParams = null/);
61
+ assert.match(viewSource, /readEnabled = true/);
62
+ assert.match(viewSource, /queryKeyFactory = null/);
63
+ assert.match(viewSource, /requestQueryParams/);
64
+ assert.match(viewSource, /readEnabled/);
65
+ });
66
+
41
67
  test("account settings load state exposes a local retry action", async () => {
42
68
  const source = await readComponent("AccountSettingsClientElement.vue");
43
69
 
@@ -49,14 +75,20 @@ test("CRUD screen composables are importable package APIs", async () => {
49
75
  const [
50
76
  listModule,
51
77
  viewModule,
52
- addEditModule
78
+ addEditModule,
79
+ rowActionsModule,
80
+ rowActionsRuntimeModule
53
81
  ] = await Promise.all([
54
82
  import("@jskit-ai/users-web/client/composables/useCrudListScreen"),
55
83
  import("@jskit-ai/users-web/client/composables/useCrudViewScreen"),
56
- import("@jskit-ai/users-web/client/composables/useCrudAddEditScreen")
84
+ import("@jskit-ai/users-web/client/composables/useCrudAddEditScreen"),
85
+ import("@jskit-ai/users-web/client/rowActions"),
86
+ import("@jskit-ai/users-web/client/composables/useCrudListRowActions")
57
87
  ]);
58
88
 
59
89
  assert.equal(typeof listModule.useCrudListScreen, "function");
60
90
  assert.equal(typeof viewModule.useCrudViewScreen, "function");
61
91
  assert.equal(typeof addEditModule.useCrudAddEditScreen, "function");
92
+ assert.equal(typeof rowActionsModule.defineCrudListRowActions, "function");
93
+ assert.equal(typeof rowActionsRuntimeModule.useCrudListRowActions, "function");
62
94
  });
@@ -24,6 +24,7 @@ test("users-web exports are explicit and aligned with production/template usage"
24
24
  "./client/account-settings/sections",
25
25
  "./client/bulkActions",
26
26
  "./client/filters",
27
+ "./client/rowActions",
27
28
  "./client/composables/useAddEdit",
28
29
  "./client/composables/useCommand",
29
30
  "./client/composables/useEndpointResource",
@@ -33,6 +34,7 @@ test("users-web exports are explicit and aligned with production/template usage"
33
34
  "./client/composables/useCrudAddEdit",
34
35
  "./client/composables/useCrudAddEditScreen",
35
36
  "./client/composables/useCrudListBulkActions",
37
+ "./client/composables/useCrudListRowActions",
36
38
  "./client/composables/useCrudListFilterLookups",
37
39
  "./client/composables/useCrudListFilters",
38
40
  "./client/composables/useCrudList",
@@ -0,0 +1,77 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ test("useCrudListRowActions manages per-record state and executes action context", async () => {
5
+ const { defineCrudListRowActions } = await import("@jskit-ai/users-web/client/rowActions");
6
+ const { useCrudListRowActions } = await import("@jskit-ai/users-web/client/composables/useCrudListRowActions");
7
+ const calls = [];
8
+ let finishRun = null;
9
+ const actions = defineCrudListRowActions([
10
+ {
11
+ key: "delete",
12
+ label: "Delete",
13
+ color: "error",
14
+ visible: ({ record }) => record.isOwnerRow !== true,
15
+ disabled: ({ record }) => record.locked === true,
16
+ loading: ({ record }) => record.pending === true,
17
+ async run(context) {
18
+ calls.push(context);
19
+ await new Promise((resolve) => {
20
+ finishRun = resolve;
21
+ });
22
+ return "deleted";
23
+ }
24
+ }
25
+ ]);
26
+ const runtime = useCrudListRowActions(actions, {
27
+ resolveRecordId: (record) => record.id,
28
+ resolveContext: () => ({
29
+ records: "records-token",
30
+ reload: "reload-token"
31
+ })
32
+ });
33
+
34
+ assert.equal(runtime.hasActions.value, true);
35
+ assert.equal(runtime.hasVisibleActionsFor({ id: "owner", isOwnerRow: true }, 0), false);
36
+ assert.equal(runtime.isActionDisabled("delete", { id: "locked", locked: true }, 1), true);
37
+ assert.equal(runtime.isActionLoading("delete", { id: "pending", pending: true }, 2), true);
38
+
39
+ const record = { id: "10", name: "Worker" };
40
+ assert.equal(runtime.isActionVisible("delete", record, 3), true);
41
+ assert.equal(runtime.isActionDisabled("delete", record, 3), false);
42
+
43
+ const result = runtime.execute("delete", record, 3);
44
+ assert.equal(runtime.isActionExecuting("delete", record, 3), true);
45
+ assert.equal(runtime.isActionLoading("delete", record, 3), true);
46
+ finishRun();
47
+
48
+ assert.equal(await result, "deleted");
49
+ assert.equal(runtime.isActionExecuting("delete", record, 3), false);
50
+ assert.equal(calls.length, 1);
51
+ assert.equal(calls[0].action.key, "delete");
52
+ assert.equal(calls[0].record, record);
53
+ assert.equal(calls[0].index, 3);
54
+ assert.equal(calls[0].recordId, "10");
55
+ assert.equal(calls[0].records, "records-token");
56
+ assert.equal(calls[0].reload, "reload-token");
57
+ });
58
+
59
+ test("defineCrudListRowActions skips malformed and duplicate actions", async () => {
60
+ const { defineCrudListRowActions } = await import("@jskit-ai/users-web/client/rowActions");
61
+
62
+ const actions = defineCrudListRowActions([
63
+ null,
64
+ { key: "delete", label: "Delete", color: "error" },
65
+ { key: "delete", label: "Delete again" },
66
+ { key: "missing-label" },
67
+ { label: "Generated key" }
68
+ ]);
69
+
70
+ assert.deepEqual(
71
+ actions.map((action) => [action.key, action.label, action.color]),
72
+ [
73
+ ["delete", "Delete", "error"],
74
+ ["action-5", "Generated key", "primary"]
75
+ ]
76
+ );
77
+ });