@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.
- package/package.descriptor.mjs +15 -7
- package/package.json +9 -7
- package/src/client/components/CrudListRecordActionMenu.vue +120 -0
- package/src/client/components/CrudListScreen.vue +94 -43
- package/src/client/components/CrudViewScreen.vue +10 -0
- package/src/client/composables/useCrudListRowActions.js +144 -0
- package/src/client/composables/useCrudListScreen.js +114 -1
- package/src/client/composables/useCrudViewScreen.js +13 -7
- package/src/client/rowActions.js +47 -0
- package/test/crudScreenComponents.test.js +36 -4
- package/test/exportsContract.test.js +2 -0
- package/test/useCrudListRowActions.test.js +77 -0
package/package.descriptor.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
282
|
-
"@jskit-ai/realtime": "0.1.
|
|
283
|
-
"@jskit-ai/kernel": "0.1.
|
|
284
|
-
"@jskit-ai/shell-web": "0.1.
|
|
285
|
-
"@jskit-ai/uploads-image-web": "0.1.
|
|
286
|
-
"@jskit-ai/users-core": "0.1.
|
|
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.
|
|
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.
|
|
48
|
-
"@jskit-ai/kernel": "0.1.
|
|
49
|
-
"@jskit-ai/realtime": "0.1.
|
|
50
|
-
"@jskit-ai/shell-web": "0.1.
|
|
51
|
-
"@jskit-ai/uploads-image-web": "0.1.
|
|
52
|
-
"@jskit-ai/users-core": "0.1.
|
|
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="
|
|
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="
|
|
158
|
-
:key="
|
|
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="
|
|
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="
|
|
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
|
-
{{
|
|
213
|
+
{{ row.recordKey }}
|
|
175
214
|
</div>
|
|
176
215
|
</div>
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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="
|
|
246
|
+
:model-value="allSelectableRowsSelected()"
|
|
216
247
|
:indeterminate="
|
|
217
|
-
|
|
218
|
-
!
|
|
248
|
+
someSelectableRowsSelected() &&
|
|
249
|
+
!allSelectableRowsSelected()
|
|
219
250
|
"
|
|
220
251
|
aria-label="Select visible rows"
|
|
221
|
-
@update:model-value="
|
|
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="
|
|
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
|
-
|
|
234
|
-
:
|
|
235
|
-
|
|
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
|
|
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:
|
|
28
|
-
|
|
29
|
-
|
|
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,
|
|
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
|
|
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
|
+
});
|