@conduction/nextcloud-vue 2.67.0 → 2.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js +37 -26
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js.map +1 -1
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js +3 -3
- package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +87 -18
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +6 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +58 -48
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/defaultActions.js +6 -7
- package/dist/esm/components/CnIndexPage/defaultActions.js.map +1 -1
- package/dist/esm/components/CnRowActions/CnRowActions.vue2.js +33 -34
- package/dist/esm/components/CnRowActions/CnRowActions.vue2.js.map +1 -1
- package/dist/esm/components/CnRowActions/CnRowActions.vue3.js +3 -3
- package/dist/esm/components/CnRowActions/CnRowActions.vue3.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +8 -2
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
- package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +3 -2
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -1
- package/dist/esm/composables/useContextMenu.js +4 -2
- package/dist/esm/composables/useContextMenu.js.map +1 -1
- package/dist/esm/utils/resolveRowActions.js +130 -0
- package/dist/esm/utils/resolveRowActions.js.map +1 -0
- package/dist/esm/utils/rowActionItem.js +104 -0
- package/dist/esm/utils/rowActionItem.js.map +1 -0
- package/dist/esm/utils/timelineEvents.js +44 -1
- package/dist/esm/utils/timelineEvents.js.map +1 -1
- package/dist/esm/utils/validateManifest.js +59 -3
- package/dist/esm/utils/validateManifest.js.map +1 -1
- package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +575 -151
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/package.json +1 -1
- package/scripts/.jsdoc-baselines.json +2 -2
- package/src/components/CnContextMenu/CnContextMenu.vue +40 -29
- package/src/components/CnFormDialog/CnFormDialog.vue +92 -18
- package/src/components/CnIndexPage/CnIndexPage.md +21 -0
- package/src/components/CnIndexPage/CnIndexPage.vue +61 -51
- package/src/components/CnIndexPage/defaultActions.js +6 -7
- package/src/components/CnRowActions/CnRowActions.vue +36 -37
- package/src/components/CnSetupWizard/CnSetupWizard.vue +8 -2
- package/src/components/CnTimelineWidget/CnTimelineWidget.vue +3 -2
- package/src/composables/useContextMenu.js +4 -2
- package/src/schemas/app-manifest-v2.schema.json +46 -8
- package/src/utils/resolveRowActions.js +127 -0
- package/src/utils/rowActionItem.js +100 -0
- package/src/utils/timelineEvents.js +43 -0
- package/src/utils/validateManifest.js +60 -3
- package/src/utils/validateManifestV2.compiled.js +2 -2
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
:primary="primary"
|
|
4
4
|
:menuName="menuName"
|
|
5
5
|
data-testid="cn-row-actions">
|
|
6
|
-
<template v-for="{ action, link } in renderedActions" :key="action
|
|
6
|
+
<template v-for="{ action, link } in renderedActions" :key="actionKey(action)">
|
|
7
7
|
<!-- A navigate-only action is a real link, so it can be middle-clicked, opened in a new tab or copied. -->
|
|
8
8
|
<NcActionLink
|
|
9
9
|
v-if="link"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
:target="link.target"
|
|
12
12
|
:title="getTitle(action)"
|
|
13
13
|
:class="{ 'cn-row-action--destructive': action.destructive }"
|
|
14
|
-
:data-testid="
|
|
14
|
+
:data-testid="actionTestId(action)"
|
|
15
15
|
closeAfterClick
|
|
16
16
|
@click="onLinkAction(action, link, $event)">
|
|
17
17
|
<template v-if="action.icon" #icon>
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
:title="getTitle(action)"
|
|
26
26
|
:disabled="isDisabled(action)"
|
|
27
27
|
:class="{ 'cn-row-action--destructive': action.destructive }"
|
|
28
|
-
:data-testid="
|
|
28
|
+
:data-testid="actionTestId(action)"
|
|
29
29
|
closeAfterClick
|
|
30
30
|
@click="onAction(action)">
|
|
31
31
|
<template v-if="action.icon" #icon>
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
import { NcActionButton, NcActionLink, NcActions } from '@nextcloud/vue'
|
|
43
43
|
import { followItemActionLink, resolveItemActionLink } from '../../utils/actionLink.js'
|
|
44
44
|
import { isModifiedClick } from '../../utils/linkNavigation.js'
|
|
45
|
-
import {
|
|
45
|
+
import { isRowActionVisible, rowActionKey, rowActionPayload, rowActionTestId, slugifyActionLabel } from '../../utils/rowActionItem.js'
|
|
46
46
|
import { CnIcon } from '../CnIcon/index.js'
|
|
47
47
|
|
|
48
48
|
/**
|
|
@@ -103,11 +103,13 @@ export default {
|
|
|
103
103
|
* - `to` (string | object | (row) => string | object) — render the entry as a link to this
|
|
104
104
|
* vue-router location; a plain click routes in place. Ignored when the router cannot resolve it.
|
|
105
105
|
* - `linkTarget` (string) — the link's `target`, e.g. `_blank`
|
|
106
|
+
* - `id` (string) — the action's id, passed in the `action` payload
|
|
107
|
+
* - `builtin` (boolean) — set by CnIndexPage on its built-in View / Edit / Copy / Delete; a built-in's testid is `cn-action-item-<id>` in every locale and its key `builtin:<id>`
|
|
106
108
|
*
|
|
107
109
|
* A link entry still emits `action`, but its `handler` is not called: the link is the
|
|
108
110
|
* navigation. A disabled entry, or a `to` without a resolvable route, stays a button.
|
|
109
111
|
*
|
|
110
|
-
* @type {Array<{label: string, icon: object | string, handler: (targetItem: object) => void, disabled: boolean | ((targetItem: object) => boolean), visible: boolean | ((targetItem: object) => boolean), title: string | ((targetItem: object) => string), destructive: boolean, href: string | ((targetItem: object) => string), to: string | object | ((targetItem: object) => string | object), linkTarget: string}>}
|
|
112
|
+
* @type {Array<{label: string, id: string, builtin: boolean, icon: object | string, handler: (targetItem: object) => void, disabled: boolean | ((targetItem: object) => boolean), visible: boolean | ((targetItem: object) => boolean), title: string | ((targetItem: object) => string), destructive: boolean, href: string | ((targetItem: object) => string), to: string | object | ((targetItem: object) => string | object), linkTarget: string}>}
|
|
111
113
|
*/
|
|
112
114
|
actions: {
|
|
113
115
|
type: Array,
|
|
@@ -137,31 +139,13 @@ export default {
|
|
|
137
139
|
|
|
138
140
|
computed: {
|
|
139
141
|
/**
|
|
140
|
-
* Filter actions by their `visible`
|
|
141
|
-
*
|
|
142
|
+
* Filter actions by their `visibleWhen` and `visible` gates, with the same rule CnContextMenu uses.
|
|
143
|
+
* An action without either always shows.
|
|
142
144
|
*
|
|
143
145
|
* @return {Array} Visible actions for the current row.
|
|
144
146
|
*/
|
|
145
147
|
visibleActions() {
|
|
146
|
-
return this.actions.filter((action) =>
|
|
147
|
-
// A manifest is JSON and cannot hold a function, so `visibleWhen` is
|
|
148
|
-
// the only per-row gate an app configured from one can express. Only
|
|
149
|
-
// a locally decidable condition is gated on: an endpoint/source one is
|
|
150
|
-
// not this evaluator's question, and nothing gated a row action at all
|
|
151
|
-
// before, so answering `false` to it would delete the entry outright.
|
|
152
|
-
if (action.visibleWhen
|
|
153
|
-
&& isLocallyDecidableVisibleWhen(action.visibleWhen)
|
|
154
|
-
&& evaluateVisibleWhenLocal(action.visibleWhen, this.row) === false) {
|
|
155
|
-
return false
|
|
156
|
-
}
|
|
157
|
-
if (action.visible === undefined) {
|
|
158
|
-
return true
|
|
159
|
-
}
|
|
160
|
-
if (typeof action.visible === 'function') {
|
|
161
|
-
return !!action.visible(this.row)
|
|
162
|
-
}
|
|
163
|
-
return !!action.visible
|
|
164
|
-
})
|
|
148
|
+
return this.actions.filter((action) => isRowActionVisible(action, this.row))
|
|
165
149
|
},
|
|
166
150
|
|
|
167
151
|
/**
|
|
@@ -210,7 +194,7 @@ export default {
|
|
|
210
194
|
if (action.handler && typeof action.handler === 'function') {
|
|
211
195
|
action.handler(this.row)
|
|
212
196
|
}
|
|
213
|
-
this.$emit('action',
|
|
197
|
+
this.$emit('action', rowActionPayload(action, this.row))
|
|
214
198
|
},
|
|
215
199
|
|
|
216
200
|
/**
|
|
@@ -229,26 +213,41 @@ export default {
|
|
|
229
213
|
}
|
|
230
214
|
followItemActionLink(event, link, this.$router)
|
|
231
215
|
/**
|
|
232
|
-
* @event action User picked an entry. Payload: the action's
|
|
233
|
-
* @type {{ action: string, row: object|null }}
|
|
216
|
+
* @event action User picked an entry. Payload: `action` (the label), the row, the action's `id` when it has one, and `builtin: true` for a CnIndexPage built-in. A button entry has already run its `handler`; a link entry navigates instead, and a modified (new-tab) click on a link emits nothing.
|
|
217
|
+
* @type {{ action: string, row: object|null, id?: string, builtin?: boolean }}
|
|
234
218
|
*/
|
|
235
|
-
this.$emit('action',
|
|
219
|
+
this.$emit('action', rowActionPayload(action, this.row))
|
|
236
220
|
},
|
|
237
221
|
|
|
238
222
|
/**
|
|
239
223
|
* Slugify an action label for use in stable `data-testid` selectors.
|
|
240
|
-
* Lowercase, kebab-case, strip non-alphanumeric.
|
|
241
|
-
* `:data-testid` binding on NcActionButton — does not affect runtime
|
|
242
|
-
* behaviour or rendered text.
|
|
224
|
+
* Lowercase, kebab-case, strip non-alphanumeric.
|
|
243
225
|
*
|
|
244
226
|
* @param {string} label - The action's display label
|
|
245
227
|
* @return {string} kebab-case slug suitable for a testid suffix.
|
|
246
228
|
*/
|
|
247
229
|
slugifyLabel(label) {
|
|
248
|
-
return
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
230
|
+
return slugifyActionLabel(label)
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The entry's `data-testid`: `cn-action-item-<id>` for a built-in, the label slug otherwise.
|
|
235
|
+
*
|
|
236
|
+
* @param {object} action The action definition.
|
|
237
|
+
* @return {string} The testid.
|
|
238
|
+
*/
|
|
239
|
+
actionTestId(action) {
|
|
240
|
+
return rowActionTestId(action)
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The entry's render key: `builtin:<id>` for a built-in, the label otherwise.
|
|
245
|
+
*
|
|
246
|
+
* @param {object} action The action definition.
|
|
247
|
+
* @return {string} The key.
|
|
248
|
+
*/
|
|
249
|
+
actionKey(action) {
|
|
250
|
+
return rowActionKey(action)
|
|
252
251
|
},
|
|
253
252
|
},
|
|
254
253
|
}
|
|
@@ -641,9 +641,15 @@ export default {
|
|
|
641
641
|
value = this.choiceLabel(step, raw)
|
|
642
642
|
}
|
|
643
643
|
} else if (step.type === 'config-fields') {
|
|
644
|
+
// Only the fields the reader filled in: a row of empty
|
|
645
|
+
// "Name: , KvK: ," reads as broken, not as "not set".
|
|
644
646
|
value = this.fieldsFor(step)
|
|
645
|
-
.
|
|
646
|
-
|
|
647
|
+
.filter((f) => {
|
|
648
|
+
const v = this.configModel[f.key]
|
|
649
|
+
return v !== null && v !== undefined && String(v).trim() !== ''
|
|
650
|
+
})
|
|
651
|
+
.map((f) => `${f.label}: ${this.configModel[f.key]}`)
|
|
652
|
+
.join(', ') || t('nextcloud-vue', 'Not set')
|
|
647
653
|
}
|
|
648
654
|
if (this.isOnDemand(step)) {
|
|
649
655
|
const ran = this.ranThisSession(step.id)
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
<script>
|
|
54
54
|
import { getLanguage, translate as t } from '@nextcloud/l10n'
|
|
55
55
|
import { NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'
|
|
56
|
-
import { auditEvents, fieldEvents, relatedEvents, sortEvents, timelineEntryEvents } from '../../utils/timelineEvents.js'
|
|
56
|
+
import { auditEvents, fieldEvents, listEvents, relatedEvents, sortEvents, timelineEntryEvents } from '../../utils/timelineEvents.js'
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* CnTimelineWidget — an object's dated events in time order (`timeline`
|
|
@@ -193,6 +193,7 @@ export default {
|
|
|
193
193
|
const now = new Date()
|
|
194
194
|
const all = [
|
|
195
195
|
...fieldEvents(this.resolvedObject || {}, this.fieldsConfig, now),
|
|
196
|
+
...listEvents(this.resolvedObject || {}, this.content.lists, now),
|
|
196
197
|
...this.relatedConfig.flatMap((cfg, i) => relatedEvents(this.relatedRows[i] || [], cfg, now)),
|
|
197
198
|
...auditEvents(this.auditEntries, this.describeAudit, now),
|
|
198
199
|
...timelineEntryEvents(this.timelineEntries, t('nextcloud-vue', 'Note'), now),
|
|
@@ -347,7 +348,7 @@ export default {
|
|
|
347
348
|
})
|
|
348
349
|
|
|
349
350
|
if (this.content.auditTrail === true) {
|
|
350
|
-
jobs.push(axios.get(generateUrl(`${base}/audit-
|
|
351
|
+
jobs.push(axios.get(generateUrl(`${base}/audit-trails`), { params: { limit: this.content.auditLimit || 50 } })
|
|
351
352
|
.then((r) => {
|
|
352
353
|
const data = r && r.data
|
|
353
354
|
this.auditEntries = (data && (data.results || data)) || []
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { onBeforeUnmount, ref } from 'vue'
|
|
2
|
+
import { rowActionPayload } from '../utils/rowActionItem.js'
|
|
2
3
|
|
|
3
4
|
export const CTX_MENU_CSS_VAR_X = '--cn-ctx-menu-x'
|
|
4
5
|
export const CTX_MENU_CSS_VAR_Y = '--cn-ctx-menu-y'
|
|
@@ -135,15 +136,16 @@ export function useContextMenu() {
|
|
|
135
136
|
*
|
|
136
137
|
* Calls `action.handler(targetItem)` if a handler exists, then returns a
|
|
137
138
|
* payload object the caller can pass to `$emit('action', payload)`.
|
|
139
|
+
* The payload is the one CnRowActions and CnContextMenu emit: the label as `action`, plus `id` and `builtin: true` when the action carries them.
|
|
138
140
|
*
|
|
139
141
|
* @param {object} action Action definition with `label` and optional `handler`
|
|
140
|
-
* @return {{ action: string, row: object }}
|
|
142
|
+
* @return {{ action: string, row: object, id?: string, builtin?: boolean }}
|
|
141
143
|
*/
|
|
142
144
|
function triggerAction(action) {
|
|
143
145
|
if (action.handler && typeof action.handler === 'function') {
|
|
144
146
|
action.handler(targetItem.value)
|
|
145
147
|
}
|
|
146
|
-
return
|
|
148
|
+
return rowActionPayload(action, targetItem.value)
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
// Clean up DOM if the consumer unmounts while the menu is open — nothing
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
|
|
4
4
|
"title": "Conduction App Manifest v2",
|
|
5
5
|
"description": "v2 schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Introduces a uniform widgets[] array on every page type with a per-slot grid coordinate system, a typed actions[] discriminator, and the required $schema field for version detection. v1 manifests continue to validate against app-manifest.schema.json (unchanged).",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.50.0",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"required": ["$schema", "version"],
|
|
9
9
|
"allOf": [
|
|
@@ -3458,14 +3458,30 @@
|
|
|
3458
3458
|
"actions": {
|
|
3459
3459
|
"type": "array",
|
|
3460
3460
|
"items": {
|
|
3461
|
-
"
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3461
|
+
"anyOf": [
|
|
3462
|
+
{
|
|
3463
|
+
"type": "object",
|
|
3464
|
+
"required": ["label"],
|
|
3465
|
+
"additionalProperties": true,
|
|
3466
|
+
"properties": {
|
|
3467
|
+
"label": { "type": "string", "minLength": 1 },
|
|
3468
|
+
"id": { "type": "string", "not": { "pattern": "^builtin:" } },
|
|
3469
|
+
"builtin": false
|
|
3470
|
+
}
|
|
3471
|
+
},
|
|
3472
|
+
{
|
|
3473
|
+
"type": "string",
|
|
3474
|
+
"enum": ["builtin:view", "builtin:edit", "builtin:copy", "builtin:delete"]
|
|
3475
|
+
}
|
|
3476
|
+
]
|
|
3467
3477
|
},
|
|
3468
|
-
"
|
|
3478
|
+
"allOf": [
|
|
3479
|
+
{ "contains": { "const": "builtin:view" }, "minContains": 0, "maxContains": 1 },
|
|
3480
|
+
{ "contains": { "const": "builtin:edit" }, "minContains": 0, "maxContains": 1 },
|
|
3481
|
+
{ "contains": { "const": "builtin:copy" }, "minContains": 0, "maxContains": 1 },
|
|
3482
|
+
{ "contains": { "const": "builtin:delete" }, "minContains": 0, "maxContains": 1 }
|
|
3483
|
+
],
|
|
3484
|
+
"description": "Declarative actions on a page. An object entry is governed ONLY by \"an object carrying a non-empty label\", deliberately: this key is polymorphic by page type (index rows dispatch $defs/action; detail pages also carry type='lifecycle-transition' entries), so one closed grammar here would reject working manifests. A label is what every dialect needs — CnRowActions renders an entry from its label and icon, so an entry without one became a full-height, clickable, EMPTY row in the overflow menu. An object is always an app action whatever its id: an id, when present, must be a string that does not start with 'builtin:', and the object may not set a 'builtin' key. On a type='index' page an entry may also be one of the placeholders \"builtin:view\", \"builtin:edit\", \"builtin:copy\" or \"builtin:delete\", each at most once, which puts that built-in row action at its position in the menu. A placeholder only places a built-in: config.actionToggles / the show*Action keys still decide whether it renders. Enabled built-ins the array does not place are appended after everything else in the order View, Edit, Copy, Delete. Any other string (a bare \"edit\", an unknown \"builtin:archive\") is refused, and placeholders are refused on every page type other than index."
|
|
3469
3485
|
},
|
|
3470
3486
|
"rowIndicators": {
|
|
3471
3487
|
"type": "array",
|
|
@@ -3732,6 +3748,10 @@
|
|
|
3732
3748
|
"type": "boolean",
|
|
3733
3749
|
"description": "Whether the built-in create / edit form dialog is enabled. Maps to CnIndexPage `showFormDialog`."
|
|
3734
3750
|
},
|
|
3751
|
+
"showViewAction": {
|
|
3752
|
+
"type": "boolean",
|
|
3753
|
+
"description": "Whether to add a View action to row actions. Maps to CnIndexPage `showViewAction`."
|
|
3754
|
+
},
|
|
3735
3755
|
"showEditAction": {
|
|
3736
3756
|
"type": "boolean",
|
|
3737
3757
|
"description": "Whether to add an Edit action to row actions. Maps to CnIndexPage `showEditAction`."
|
|
@@ -3824,6 +3844,24 @@
|
|
|
3824
3844
|
}
|
|
3825
3845
|
}
|
|
3826
3846
|
},
|
|
3847
|
+
{
|
|
3848
|
+
"description": "The \"builtin:<id>\" placeholders in config.actions place CnIndexPage's built-in row actions, so only a type='index' page may use them. On every other page type config.actions holds objects only; a placeholder there would validate and then render nothing (row-action-builtin-placement).",
|
|
3849
|
+
"if": {
|
|
3850
|
+
"not": {
|
|
3851
|
+
"properties": { "type": { "const": "index" } },
|
|
3852
|
+
"required": ["type"]
|
|
3853
|
+
}
|
|
3854
|
+
},
|
|
3855
|
+
"then": {
|
|
3856
|
+
"properties": {
|
|
3857
|
+
"config": {
|
|
3858
|
+
"properties": {
|
|
3859
|
+
"actions": { "items": { "type": "object" } }
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
}
|
|
3864
|
+
},
|
|
3827
3865
|
{
|
|
3828
3866
|
"description": "tabInAddress puts the active tab of a record in the address. Only a detail page has a record and a tab strip, so the key is refused elsewhere rather than validating into a no-op (case-page-and-list-as-a-place).",
|
|
3829
3867
|
"if": {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
|
|
3
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The built-in row action ids, in their default order.
|
|
8
|
+
*
|
|
9
|
+
* @type {ReadonlyArray<string>}
|
|
10
|
+
*/
|
|
11
|
+
export const BUILTIN_ROW_ACTION_IDS = Object.freeze(['view', 'edit', 'copy', 'delete'])
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The prefix that marks a row action entry as a placeholder for a built-in.
|
|
15
|
+
*
|
|
16
|
+
* @type {string}
|
|
17
|
+
*/
|
|
18
|
+
export const BUILTIN_PLACEHOLDER_PREFIX = 'builtin:'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether an entry is one of the four built-in placeholders (`"builtin:view"`, `"builtin:edit"`, `"builtin:copy"`, `"builtin:delete"`).
|
|
22
|
+
*
|
|
23
|
+
* @param {object|string} entry A declared row action entry.
|
|
24
|
+
* @return {boolean} True for a known placeholder string.
|
|
25
|
+
*/
|
|
26
|
+
export function isBuiltinPlaceholder(entry) {
|
|
27
|
+
return typeof entry === 'string'
|
|
28
|
+
&& entry.startsWith(BUILTIN_PLACEHOLDER_PREFIX)
|
|
29
|
+
&& BUILTIN_ROW_ACTION_IDS.includes(entry.slice(BUILTIN_PLACEHOLDER_PREFIX.length))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A short name for an entry in a warning message.
|
|
34
|
+
*
|
|
35
|
+
* @param {object} action An app action object.
|
|
36
|
+
* @return {string} Its id, else its label, quoted.
|
|
37
|
+
*/
|
|
38
|
+
function describe(action) {
|
|
39
|
+
const name = (typeof action.id === 'string' && action.id !== '') ? action.id : action.label
|
|
40
|
+
return JSON.stringify(name === undefined ? '' : name)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a page's declared row actions and its enabled built-ins into the one ordered list every row surface renders.
|
|
45
|
+
*
|
|
46
|
+
* - An object is an app action, whatever its `id`; a `builtin` key on it is dropped, since only real built-ins carry that marker.
|
|
47
|
+
* - A placeholder (`"builtin:edit"`) puts that built-in at its position when it is enabled, and renders nothing when it is not: the toggle wins.
|
|
48
|
+
* - A repeated placeholder keeps its first position.
|
|
49
|
+
* - Any other entry is dropped.
|
|
50
|
+
* - Enabled built-ins the entries do not place are appended in the order `builtins` gives them.
|
|
51
|
+
*
|
|
52
|
+
* Every dropped or questionable entry is reported in `warnings`; the caller decides whether to log them.
|
|
53
|
+
*
|
|
54
|
+
* @param {Array<object|string>} declared The declared entries: app action objects and placeholders.
|
|
55
|
+
* @param {Array<{id: string}>} builtins The enabled built-in actions, in default order.
|
|
56
|
+
* @param {object} [options] Options.
|
|
57
|
+
* @param {(action: object) => object} [options.prepare] Maps an app action to the object rendered for it.
|
|
58
|
+
* @return {{actions: Array<object>, warnings: Array<{code: string, message: string}>, deleteNotLast: boolean}} The ordered actions, the warnings, and whether the built-in Delete is enabled but not last.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveRowActions(declared, builtins, { prepare = (action) => action } = {}) {
|
|
61
|
+
const enabled = new Map()
|
|
62
|
+
for (const builtin of (Array.isArray(builtins) ? builtins : [])) {
|
|
63
|
+
if (builtin && typeof builtin.id === 'string') {
|
|
64
|
+
enabled.set(builtin.id, builtin)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const placed = new Set()
|
|
68
|
+
const actions = []
|
|
69
|
+
const warnings = []
|
|
70
|
+
|
|
71
|
+
for (const entry of (Array.isArray(declared) ? declared : [])) {
|
|
72
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
73
|
+
let action = entry
|
|
74
|
+
if (Object.hasOwn(entry, 'builtin')) {
|
|
75
|
+
const { builtin, ...rest } = entry
|
|
76
|
+
action = rest
|
|
77
|
+
warnings.push({
|
|
78
|
+
code: 'builtin-key-ignored',
|
|
79
|
+
message: `Action ${describe(entry)} sets "builtin", which only built-in actions carry; the key is ignored and the entry stays an app action.`,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
actions.push(prepare(action))
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (isBuiltinPlaceholder(entry)) {
|
|
86
|
+
const id = entry.slice(BUILTIN_PLACEHOLDER_PREFIX.length)
|
|
87
|
+
if (placed.has(id)) {
|
|
88
|
+
warnings.push({
|
|
89
|
+
code: 'repeated-placeholder',
|
|
90
|
+
message: `"${entry}" is placed more than once; only its first position is used.`,
|
|
91
|
+
})
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
placed.add(id)
|
|
95
|
+
if (!enabled.has(id)) {
|
|
96
|
+
warnings.push({
|
|
97
|
+
code: 'disabled-placeholder',
|
|
98
|
+
message: `"${entry}" is placed but that built-in action is turned off, so it renders nothing. Its show*Action toggle decides whether it renders.`,
|
|
99
|
+
})
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
actions.push(enabled.get(id))
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
warnings.push({
|
|
106
|
+
code: 'invalid-entry',
|
|
107
|
+
message: `Ignoring action ${JSON.stringify(entry)}: actions must be objects with an id and a label, or one of ${BUILTIN_ROW_ACTION_IDS.map((id) => `"${BUILTIN_PLACEHOLDER_PREFIX}${id}"`).join(', ')}. To turn a built-in action on or off use the showViewAction / showEditAction / showCopyAction / showDeleteAction props.`,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const builtin of enabled.values()) {
|
|
112
|
+
if (!placed.has(builtin.id)) {
|
|
113
|
+
actions.push(builtin)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const deleteIndex = actions.findIndex((action) => action && action.builtin === true && action.id === 'delete')
|
|
118
|
+
const deleteNotLast = deleteIndex !== -1 && deleteIndex !== actions.length - 1
|
|
119
|
+
if (deleteNotLast) {
|
|
120
|
+
warnings.push({
|
|
121
|
+
code: 'delete-not-last',
|
|
122
|
+
message: `"${BUILTIN_PLACEHOLDER_PREFIX}delete" is not the last row action; it renders before ${actions.slice(deleteIndex + 1).map((a) => (a.builtin === true ? `"${BUILTIN_PLACEHOLDER_PREFIX}${a.id}"` : describe(a))).join(', ')}. Delete is normally last.`,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { actions, warnings, deleteNotLast }
|
|
127
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
|
|
3
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
*
|
|
5
|
+
* How a row action renders as a menu entry.
|
|
6
|
+
* CnRowActions and CnContextMenu both use these, so a row's actions menu and its right-click menu agree on which entries show and on each entry's key, testid and event payload.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { evaluateVisibleWhenLocal, isLocallyDecidableVisibleWhen } from './visibleWhen.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether an action is a built-in row action (View, Edit, Copy, Delete).
|
|
13
|
+
*
|
|
14
|
+
* @param {object} action The action.
|
|
15
|
+
* @return {boolean} True for a built-in carrying an id.
|
|
16
|
+
*/
|
|
17
|
+
function isBuiltin(action) {
|
|
18
|
+
return !!action && action.builtin === true && typeof action.id === 'string' && action.id !== ''
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether an action shows for an item.
|
|
23
|
+
* A locally decidable `visibleWhen` that is false hides it; an endpoint or source condition is not decided here.
|
|
24
|
+
* Then `visible` (a boolean or `(item) => boolean`) applies; without it the action shows.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} action The action.
|
|
27
|
+
* @param {object|string|number|null} item The row or right-clicked item.
|
|
28
|
+
* @return {boolean} Whether the entry renders.
|
|
29
|
+
*/
|
|
30
|
+
export function isRowActionVisible(action, item) {
|
|
31
|
+
if (action.visibleWhen
|
|
32
|
+
&& isLocallyDecidableVisibleWhen(action.visibleWhen)
|
|
33
|
+
&& evaluateVisibleWhenLocal(action.visibleWhen, item) === false) {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
if (action.visible === undefined) {
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
if (typeof action.visible === 'function') {
|
|
40
|
+
return !!action.visible(item)
|
|
41
|
+
}
|
|
42
|
+
return !!action.visible
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Slugify a label for a `data-testid` suffix: lowercase, kebab-case, non-alphanumerics stripped.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} label The label.
|
|
49
|
+
* @return {string} The slug.
|
|
50
|
+
*/
|
|
51
|
+
export function slugifyActionLabel(label) {
|
|
52
|
+
// One pass collapses every run of other characters to a single '-', so at
|
|
53
|
+
// most one '-' can sit at either end; trimming it by index keeps this
|
|
54
|
+
// linear. `/^-+|-+$/g` here was a ReDoS on long runs of '-' (CodeQL).
|
|
55
|
+
const slug = String(label || '')
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
58
|
+
const start = slug.startsWith('-') ? 1 : 0
|
|
59
|
+
const end = slug.length > start && slug.endsWith('-') ? slug.length - 1 : slug.length
|
|
60
|
+
return slug.slice(start, end)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The render key of an entry: `builtin:<id>` for a built-in, the label for an app action.
|
|
65
|
+
* An app action with `id: "edit"` therefore never shares a key with the built-in Edit.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} action The action.
|
|
68
|
+
* @return {string} The key.
|
|
69
|
+
*/
|
|
70
|
+
export function rowActionKey(action) {
|
|
71
|
+
return isBuiltin(action) ? `builtin:${action.id}` : action.label
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The `data-testid` of an entry: `cn-action-item-<id>` for a built-in, in every locale, and the slug of the label for an app action.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} action The action.
|
|
78
|
+
* @return {string} The testid.
|
|
79
|
+
*/
|
|
80
|
+
export function rowActionTestId(action) {
|
|
81
|
+
return `cn-action-item-${isBuiltin(action) ? action.id : slugifyActionLabel(action.label)}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The `action` event payload for an entry: `action` is the label, `id` is the action's id when it has one, and `builtin: true` marks a built-in.
|
|
86
|
+
*
|
|
87
|
+
* @param {object} action The action.
|
|
88
|
+
* @param {object|string|number|null} row The row or right-clicked item.
|
|
89
|
+
* @return {{action: string, row: (object|string|number|null), id?: string, builtin?: true}} The payload.
|
|
90
|
+
*/
|
|
91
|
+
export function rowActionPayload(action, row) {
|
|
92
|
+
const payload = { action: action.label, row }
|
|
93
|
+
if (typeof action.id === 'string' && action.id !== '') {
|
|
94
|
+
payload.id = action.id
|
|
95
|
+
}
|
|
96
|
+
if (isBuiltin(action)) {
|
|
97
|
+
payload.builtin = true
|
|
98
|
+
}
|
|
99
|
+
return payload
|
|
100
|
+
}
|
|
@@ -97,6 +97,49 @@ export function fieldEvents(object, fields, now = new Date()) {
|
|
|
97
97
|
* @param {Date} [now] The reference moment.
|
|
98
98
|
* @return {Array<object>} The events.
|
|
99
99
|
*/
|
|
100
|
+
/**
|
|
101
|
+
* The text at a path of a list entry, or '' when the path is unset or empty.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} row The list entry.
|
|
104
|
+
* @param {string|undefined} path The path to read.
|
|
105
|
+
* @return {string} The text.
|
|
106
|
+
*/
|
|
107
|
+
function textAt(row, path) {
|
|
108
|
+
if (!path) {
|
|
109
|
+
return ''
|
|
110
|
+
}
|
|
111
|
+
const value = readPath(row, path)
|
|
112
|
+
return value === null || value === undefined ? '' : String(value)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Events from a list held on the object itself, such as a status history:
|
|
117
|
+
* `[{ field, dateField, labelField?, label?, detailField? }]`. Each entry of the
|
|
118
|
+
* list at `field` becomes one event, dated by `dateField`, labelled by its
|
|
119
|
+
* `labelField` value (else `label`), with `detailField` as the line under it.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} object The object.
|
|
122
|
+
* @param {Array<object>} lists The list configs.
|
|
123
|
+
* @param {Date} now The moment that separates past from upcoming.
|
|
124
|
+
* @return {Array<object>} The events.
|
|
125
|
+
* @spec openspec/changes/timeline-audit-trails-url/specs/timeline-widget/spec.md#requirement-a-list-on-the-object-becomes-dated-events
|
|
126
|
+
*/
|
|
127
|
+
export function listEvents(object, lists, now = new Date()) {
|
|
128
|
+
return (Array.isArray(lists) ? lists : [])
|
|
129
|
+
.filter((l) => l && typeof l.field === 'string' && typeof l.dateField === 'string')
|
|
130
|
+
.flatMap((l) => {
|
|
131
|
+
const rows = readPath(object, l.field)
|
|
132
|
+
return (Array.isArray(rows) ? rows : []).map((row, i) => makeEvent({
|
|
133
|
+
id: `list:${l.field}:${i}`,
|
|
134
|
+
raw: readPath(row, l.dateField),
|
|
135
|
+
label: textAt(row, l.labelField) || l.label || l.field,
|
|
136
|
+
detail: textAt(row, l.detailField),
|
|
137
|
+
source: 'list',
|
|
138
|
+
}, now))
|
|
139
|
+
})
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
}
|
|
142
|
+
|
|
100
143
|
export function relatedEvents(rows, config, now = new Date()) {
|
|
101
144
|
const cfg = config || {}
|
|
102
145
|
const dateField = cfg.dateField || '@self.created'
|