@conduction/nextcloud-vue 2.56.0-beta.14 → 2.56.0-beta.15

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.
Files changed (46) hide show
  1. package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js +37 -26
  2. package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js.map +1 -1
  3. package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js +3 -3
  4. package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js.map +1 -1
  5. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +58 -48
  6. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  7. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  8. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  9. package/dist/esm/components/CnIndexPage/defaultActions.js +6 -7
  10. package/dist/esm/components/CnIndexPage/defaultActions.js.map +1 -1
  11. package/dist/esm/components/CnRowActions/CnRowActions.vue2.js +33 -34
  12. package/dist/esm/components/CnRowActions/CnRowActions.vue2.js.map +1 -1
  13. package/dist/esm/components/CnRowActions/CnRowActions.vue3.js +3 -3
  14. package/dist/esm/components/CnRowActions/CnRowActions.vue3.js.map +1 -1
  15. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +3 -2
  16. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -1
  17. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -1
  18. package/dist/esm/composables/useContextMenu.js +4 -2
  19. package/dist/esm/composables/useContextMenu.js.map +1 -1
  20. package/dist/esm/utils/resolveRowActions.js +130 -0
  21. package/dist/esm/utils/resolveRowActions.js.map +1 -0
  22. package/dist/esm/utils/rowActionItem.js +99 -0
  23. package/dist/esm/utils/rowActionItem.js.map +1 -0
  24. package/dist/esm/utils/timelineEvents.js +44 -1
  25. package/dist/esm/utils/timelineEvents.js.map +1 -1
  26. package/dist/esm/utils/validateManifest.js +59 -3
  27. package/dist/esm/utils/validateManifest.js.map +1 -1
  28. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  29. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  30. package/dist/nextcloud-vue.cjs.js +469 -130
  31. package/dist/nextcloud-vue.cjs.js.map +2 -2
  32. package/package.json +1 -1
  33. package/scripts/.jsdoc-baselines.json +2 -2
  34. package/src/components/CnContextMenu/CnContextMenu.vue +40 -29
  35. package/src/components/CnIndexPage/CnIndexPage.md +21 -0
  36. package/src/components/CnIndexPage/CnIndexPage.vue +61 -51
  37. package/src/components/CnIndexPage/defaultActions.js +6 -7
  38. package/src/components/CnRowActions/CnRowActions.vue +36 -37
  39. package/src/components/CnTimelineWidget/CnTimelineWidget.vue +3 -2
  40. package/src/composables/useContextMenu.js +4 -2
  41. package/src/schemas/app-manifest-v2.schema.json +46 -8
  42. package/src/utils/resolveRowActions.js +127 -0
  43. package/src/utils/rowActionItem.js +95 -0
  44. package/src/utils/timelineEvents.js +43 -0
  45. package/src/utils/validateManifest.js +60 -3
  46. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/dist/nextcloud-vue.cjs.js.map
3
- size: 35372297 bytes
4
- sha256: 7065df25e721d338e4612aac2ee75c58c87fb5e17477c811e7bcf66cee41783d
3
+ size: 35426060 bytes
4
+ sha256: 84895efa944d4151e7030482714b1c85ffc1a2d24cb225657e3deb7d45bd2432
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.56.0-beta.14",
3
+ "version": "2.56.0-beta.15",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
6
6
  "bugs": {
@@ -111,7 +111,7 @@
111
111
  "CnIconPicker": 1,
112
112
  "CnImageWidget": 1,
113
113
  "CnImageWidgetForm": 1,
114
- "CnIndexPage": 0.74,
114
+ "CnIndexPage": 0.79,
115
115
  "CnIndexSidebar": 0.69,
116
116
  "CnInfoWidget": 1,
117
117
  "CnIntegrationCard": 1,
@@ -202,7 +202,7 @@
202
202
  "CnRichSubmitDialog": 1,
203
203
  "CnRoadmapItem": 1,
204
204
  "CnRoadmapTab": 1,
205
- "CnRowActions": 0.8,
205
+ "CnRowActions": 1,
206
206
  "CnSavedViewsControl": 1,
207
207
  "CnSaveViewDialog": 1,
208
208
  "CnSchemaFormDialog": 0.79,
@@ -12,7 +12,7 @@
12
12
  @close="onClose"
13
13
  @closed="onClosed">
14
14
  <!-- Dynamic actions from array prop -->
15
- <template v-for="{ action, link } in renderedActions" :key="action.label">
15
+ <template v-for="{ action, link } in renderedActions" :key="actionKey(action)">
16
16
  <!-- A navigate-only action is a real link, so it can be middle-clicked, opened in a new tab or copied. -->
17
17
  <NcActionLink
18
18
  v-if="link"
@@ -20,7 +20,7 @@
20
20
  :target="link.target"
21
21
  :title="resolveTitle(action)"
22
22
  :class="{ 'cn-row-action--destructive': action.destructive }"
23
- :data-testid="`cn-action-item-${slugifyLabel(action.label)}`"
23
+ :data-testid="actionTestId(action)"
24
24
  closeAfterClick
25
25
  @click="onLinkAction(action, link, $event)">
26
26
  <template v-if="action.icon" #icon>
@@ -34,7 +34,7 @@
34
34
  :title="resolveTitle(action)"
35
35
  :disabled="resolveDisabled(action)"
36
36
  :class="{ 'cn-row-action--destructive': action.destructive }"
37
- :data-testid="`cn-action-item-${slugifyLabel(action.label)}`"
37
+ :data-testid="actionTestId(action)"
38
38
  closeAfterClick
39
39
  @click="onAction(action)">
40
40
  <template v-if="action.icon" #icon>
@@ -95,6 +95,7 @@ import { NcActionButton, NcActionLink, NcActions } from '@nextcloud/vue'
95
95
  import { CTX_MENU_DATA_ATTR, CTX_MENU_POPPER_ATTR } from '../../composables/useContextMenu.js'
96
96
  import { followItemActionLink, resolveItemActionLink } from '../../utils/actionLink.js'
97
97
  import { isModifiedClick } from '../../utils/linkNavigation.js'
98
+ import { isRowActionVisible, rowActionKey, rowActionPayload, rowActionTestId, slugifyActionLabel } from '../../utils/rowActionItem.js'
98
99
  import { CnIcon } from '../CnIcon/index.js'
99
100
 
100
101
  /**
@@ -109,12 +110,13 @@ import { CnIcon } from '../CnIcon/index.js'
109
110
  * cursor positioning). The composable handles the DOM attributes; this component
110
111
  * handles the NcActions template boilerplate.
111
112
  *
112
- * Dynamic actions (CnIndexPage pattern)
113
+ * Dynamic actions (CnIndexPage pattern).
114
+ * CnIndexPage passes the same per-row list its row actions menu renders, so the two menus always match.
113
115
  * ```vue
114
116
  * <CnContextMenu
115
117
  * v-model:open="contextMenuOpen"
116
- * :actions="mergedActions"
117
- * :target-item="contextMenuRow"
118
+ * :actions="rowActionsFor(contextMenuShownRow)"
119
+ * :target-item="contextMenuShownRow"
118
120
  * @action="$emit('action', $event)"
119
121
  * @close="closeContextMenu" />
120
122
  * ```
@@ -161,13 +163,15 @@ export default {
161
163
  * `visible` (boolean | (targetItem) => boolean) hides the entry when falsy
162
164
  * (default: shown). `title` (string | (targetItem) => string) renders as
163
165
  * a native tooltip — useful for explaining why an entry is disabled.
166
+ * A locally decidable `visibleWhen` that is false for the target hides the entry too, as in CnRowActions.
167
+ * `id` is passed in the `action` payload; `builtin` marks a CnIndexPage built-in, whose testid is `cn-action-item-<id>` in every locale.
164
168
  * `href` (URL) or `to` (vue-router location), each a value or a
165
169
  * `(targetItem) => …` function, render the entry as a real link, with
166
170
  * `linkTarget` as its `target`; a link emits `action` but does not call
167
171
  * `handler`. When the entire array is empty (or all entries are filtered
168
172
  * out), only the default slot content is rendered.
169
173
  *
170
- * @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}>}
174
+ * @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), visibleWhen: object, title: string | ((targetItem: object) => string), destructive: boolean, href: string | ((targetItem: object) => string), to: string | object | ((targetItem: object) => string | object), linkTarget: string}>}
171
175
  */
172
176
  actions: {
173
177
  type: Array,
@@ -209,21 +213,13 @@ export default {
209
213
 
210
214
  computed: {
211
215
  /**
212
- * Filter actions by their `visible` predicate. Entries without
213
- * `visible` are always shown (backwards compatible).
216
+ * Filter actions by their `visibleWhen` and `visible` gates, with the same rule CnRowActions uses.
217
+ * Entries without either always show.
214
218
  *
215
219
  * @return {Array} Visible actions for the current targetItem.
216
220
  */
217
221
  visibleActions() {
218
- return this.actions.filter((action) => {
219
- if (action.visible === undefined) {
220
- return true
221
- }
222
- if (typeof action.visible === 'function') {
223
- return !!action.visible(this.targetItem)
224
- }
225
- return !!action.visible
226
- })
222
+ return this.actions.filter((action) => isRowActionVisible(action, this.targetItem))
227
223
  },
228
224
 
229
225
  /**
@@ -466,18 +462,33 @@ export default {
466
462
 
467
463
  /**
468
464
  * Slugify an action label for use in stable `data-testid` selectors.
469
- * Lowercase, kebab-case, strip non-alphanumeric. Used solely by the
470
- * `:data-testid` binding on NcActionButton — does not affect runtime
471
- * behaviour or rendered text.
465
+ * Lowercase, kebab-case, strip non-alphanumeric.
472
466
  *
473
467
  * @param {string} label The action's display label.
474
468
  * @return {string} kebab-case slug suitable for a testid suffix.
475
469
  */
476
470
  slugifyLabel(label) {
477
- return String(label || '')
478
- .toLowerCase()
479
- .replace(/[^a-z0-9]+/g, '-')
480
- .replace(/^-+|-+$/g, '')
471
+ return slugifyActionLabel(label)
472
+ },
473
+
474
+ /**
475
+ * The entry's `data-testid`: `cn-action-item-<id>` for a built-in, the label slug otherwise.
476
+ *
477
+ * @param {object} action The action descriptor.
478
+ * @return {string} The testid.
479
+ */
480
+ actionTestId(action) {
481
+ return rowActionTestId(action)
482
+ },
483
+
484
+ /**
485
+ * The entry's render key: `builtin:<id>` for a built-in, the label otherwise.
486
+ *
487
+ * @param {object} action The action descriptor.
488
+ * @return {string} The key.
489
+ */
490
+ actionKey(action) {
491
+ return rowActionKey(action)
481
492
  },
482
493
 
483
494
  onAction(action) {
@@ -485,10 +496,10 @@ export default {
485
496
  action.handler(this.targetItem)
486
497
  }
487
498
  /**
488
- * @event action User picked an entry from the menu. The action's own `handler(targetItem)` (when present) ran synchronously before this event fires; the event lets parents observe / log the choice.
489
- * @type {{ action: string, row: object|null }}
499
+ * @event action User picked an entry from the menu. The action's own `handler(targetItem)` (when present) ran synchronously before this event fires; the event lets parents observe / log the choice. Payload: `action` (the label), the target as `row`, the action's `id` when it has one, and `builtin: true` for a CnIndexPage built-in.
500
+ * @type {{ action: string, row: object|null, id?: string, builtin?: boolean }}
490
501
  */
491
- this.$emit('action', { action: action.label, row: this.targetItem })
502
+ this.$emit('action', rowActionPayload(action, this.targetItem))
492
503
  },
493
504
 
494
505
  /**
@@ -506,7 +517,7 @@ export default {
506
517
  return
507
518
  }
508
519
  followItemActionLink(event, link, this.$router)
509
- this.$emit('action', { action: action.label, row: this.targetItem })
520
+ this.$emit('action', rowActionPayload(action, this.targetItem))
510
521
  },
511
522
 
512
523
  onClose() {
@@ -866,6 +866,27 @@ actions, and the page's declaration stands as before. Wire the field on one
866
866
  list first, then check `rowActionsNotDeclared` to see what the server is
867
867
  offering that you have not declared yet.
868
868
 
869
+ The built-in View, Edit, Copy and Delete carry the ids `view`, `edit`, `copy` and `delete`, and match this block by id only, never by their label.
870
+ The right-click menu reads the same per-row list as the actions menu, so it drops the same refused actions.
871
+
872
+ ## Placing built-in row actions
873
+
874
+ A `"builtin:view"`, `"builtin:edit"`, `"builtin:copy"` or `"builtin:delete"` string in `actions` puts that built-in at its position; enabled built-ins the array does not place are appended in the default order, and the `show*Action` toggles still decide whether each renders.
875
+
876
+ ```json
877
+ "actionToggles": { "showViewAction": false },
878
+ "actions": [
879
+ "builtin:edit",
880
+ "builtin:copy",
881
+ { "id": "file-list", "label": "File list", "handler": "openPublicationFiles" },
882
+ "builtin:delete"
883
+ ]
884
+ ```
885
+
886
+ This renders Edit, Copy, File list, Delete, and makes Edit the keyboard primary action.
887
+ The keyboard primary action is the first entry the menu shows and enables, so a hidden or disabled entry is skipped.
888
+ See the CnIndexPage docs page for the full rules.
889
+
869
890
  ## State indicators on a row
870
891
 
871
892
  Declare `rowIndicators` and a row carries the flags a handler triages on. Each
@@ -692,11 +692,11 @@
692
692
  </template>
693
693
  </CnCardGrid>
694
694
 
695
- <!-- Right-click context menu (positioned at cursor via CSS) -->
695
+ <!-- Right-click context menu (positioned at cursor via CSS). Same per-row list as the row actions menu, so the two never drift. -->
696
696
  <CnContextMenu
697
697
  v-model:open="contextMenuOpen"
698
- :actions="mergedActions"
699
- :targetItem="contextMenuRow"
698
+ :actions="rowActionsFor(contextMenuShownRow)"
699
+ :targetItem="contextMenuShownRow"
700
700
  @action="onRowAction"
701
701
  @close="closeContextMenu" />
702
702
 
@@ -812,8 +812,10 @@ import { resolveClaimedTeams, resolveClaimTokens, splitViewsIntoTabs, viewAsTab,
812
812
  import { LIST_SHORTCUTS, listPaletteCommands, shortcutFor } from '../../utils/listShortcuts.js'
813
813
  import { multiKeySort } from '../../utils/multiKeySort.js'
814
814
  import { resolveDeepTokens, resolveFilterValue } from '../../utils/resolveFilterTokens.js'
815
+ import { resolveRowActions } from '../../utils/resolveRowActions.js'
815
816
  import { resolveFilterMap } from '../../utils/routeFilters.js'
816
817
  import { availableRowActions, DEFAULT_ROW_ACTION_FIELD, refusalReasonFor, undeclaredRowActions } from '../../utils/rowActionAvailability.js'
818
+ import { isRowActionVisible, rowActionPayload } from '../../utils/rowActionItem.js'
817
819
  import { isNewTabClick, isNewTabHandled, isRowMiddleClick, markNewTabHandled, preventMiddleClickAutoscroll } from '../../utils/rowAuxClick.js'
818
820
  import { DEFAULT_ROW_INDICATOR_CAP } from '../../utils/rowIndicators.js'
819
821
  import { buildRouteQueryFromViewState, buildViewCreatePayload, extractViewState, extractViewStateFromRouteQuery, savedViewScope, viewMatchesScope } from '../../utils/savedViewHelpers.js'
@@ -1687,7 +1689,11 @@ export default {
1687
1689
  default: () => ({}),
1688
1690
  },
1689
1691
 
1690
- /** Row action definitions (app-provided, merged with built-in actions) */
1692
+ /**
1693
+ * Row action definitions, merged with the built-in View / Edit / Copy / Delete.
1694
+ * Objects are app actions.
1695
+ * A `"builtin:view"`, `"builtin:edit"`, `"builtin:copy"` or `"builtin:delete"` string places that built-in at its position when its `show*Action` toggle is on; enabled built-ins not placed are appended in that order.
1696
+ */
1691
1697
  actions: {
1692
1698
  type: Array,
1693
1699
  default: () => [],
@@ -2761,6 +2767,8 @@ export default {
2761
2767
  folderRowLayouts: {},
2762
2768
  /** The row the keyboard is on, or -1. */
2763
2769
  focusedRowIndex: -1,
2770
+ // The row the context menu shows: set on open, never cleared on close, so a closing menu keeps its entries and a late close cannot null a reopened one.
2771
+ contextMenuShownRow: null,
2764
2772
  /** The row a quick edit is open on, or null. */
2765
2773
  quickEditRow: null,
2766
2774
  /** The row as the server holds it, after a stale save. */
@@ -3473,7 +3481,7 @@ export default {
3473
3481
  if (this.quickEditFields.length > 0) {
3474
3482
  handlers['row-quick-edit'] = () => this.focusedRow && this.openQuickEdit(this.focusedRow)
3475
3483
  }
3476
- if (this.focusedRow && this.rowActionsFor(this.focusedRow).length > 0) {
3484
+ if (this.focusedRow && this.primaryRowActionFor(this.focusedRow)) {
3477
3485
  handlers['row-primary'] = () => this.runPrimaryRowAction()
3478
3486
  }
3479
3487
  return handlers
@@ -4154,7 +4162,8 @@ export default {
4154
4162
  },
4155
4163
 
4156
4164
  /**
4157
- * Merged actions: app-provided first, then built-in defaults.
4165
+ * Merged actions: the declared entries in order, each `"builtin:<id>"` placeholder replaced by that built-in when its toggle is on, then the enabled built-ins not placed.
4166
+ * See `resolveRowActions`.
4158
4167
  *
4159
4168
  * REQ-MAD-3 / REQ-MAD-4 / REQ-MAD-5 / REQ-MAD-6 / REQ-MAD-7
4160
4169
  * (manifest-actions-dispatch) — for any action whose `handler`
@@ -4170,58 +4179,41 @@ export default {
4170
4179
  registry: this.effectiveRegistry,
4171
4180
  customComponents: this.effectiveCustomComponents,
4172
4181
  }
4173
- // Drop anything that is not an action OBJECT. A bare string — the
4174
- // shorthand `"edit"` an author reasonably expects to mean "show the
4175
- // built-in Edit" — has no `label` and no `icon`, so it rendered as a
4176
- // full-height NcActionButton with nothing in it: an invisible,
4177
- // clickable, inert row in the overflow menu. Observed on dossiq,
4178
- // where `config.actions: ["create", "edit", "delete", {…}]` put three
4179
- // blank rows above the real ones on four different index pages.
4180
- //
4181
- // Named, not silently swallowed: a dropped action is a capability the
4182
- // author asked for and did not get, and the manifest schema now
4183
- // rejects the shape at authoring time (`config.actions` is governed by
4184
- // the same `action` definition as `pages[].actions`). Built-ins are
4185
- // turned on with the `show*Action` toggles, not by naming them here.
4186
4182
  // A named source may supply its own row actions, on the same
4187
4183
  // precedence as its columns: what the manifest declares wins, and the
4188
- // source is the DEFAULT for a page that declares none. Without this
4189
- // the source's `rowActions` were declared and never read — the third
4190
- // field of the same adapter to be wired late, after `columns` and
4191
- // `addLabel`.
4192
- //
4193
- // Note what this still cannot do: these are MERGED with the built-ins
4194
- // below, so a source can add an action but cannot say "these and no
4195
- // others". Suppressing a built-in is the `show*Action` toggles' job.
4184
+ // source is the DEFAULT for a page that declares none.
4185
+ // Either list may place built-ins with `"builtin:<id>"`; turning one on or off stays the `show*Action` toggles' job.
4196
4186
  const manifestDeclared = this.actions && this.actions.length > 0
4197
4187
  const declaredActions = manifestDeclared
4198
4188
  ? this.actions
4199
4189
  : ((this.isNamedSource && this.namedSource && this.namedSource.rowActions) || [])
4200
4190
 
4201
- const declared = []
4202
- for (const a of declaredActions) {
4203
- if (a && typeof a === 'object' && !Array.isArray(a)) {
4191
+ const { actions, warnings } = resolveRowActions(declaredActions, this.defaultActions, {
4192
+ prepare: (a) => {
4204
4193
  // A SOURCE action may say `action: 'open'`: open this row the
4205
4194
  // way a row click does — the source's own navigation
4206
4195
  // (`openRow`/`detailRoute`), with `edit-open` emitted so a
4207
4196
  // host that configured a row target (`config.rowRoute`) wins.
4208
- // Resolved here because a source is a composable with no
4209
- // router; the flows source declared exactly this action and
4210
- // it rendered as a dead menu entry, because `action` was a
4211
- // key nothing read (dispatch reads `handler` and `type`).
4212
- // Source-declared actions only: the manifest grammar for
4213
- // navigation is `type`/`handler`, and stays so.
4197
+ // Resolved here because a source is a composable with no router.
4198
+ // Source-declared actions only: the manifest grammar for navigation is `type`/`handler`, and stays so.
4214
4199
  if (!manifestDeclared && a.action === 'open' && typeof a.handler !== 'function') {
4215
- declared.push({ ...a, handler: (row) => this.openSourceRow(row) })
4216
- continue
4200
+ return { ...a, handler: (row) => this.openSourceRow(row) }
4217
4201
  }
4218
- declared.push(dispatchAction(a, ctx))
4202
+ return dispatchAction(a, ctx)
4203
+ },
4204
+ })
4205
+ for (const warning of warnings) {
4206
+ // A toggle turned off at runtime (permissions, readOnly) legitimately silences a placeholder; the validator flags a statically-off one.
4207
+ if (warning.code === 'disabled-placeholder') {
4219
4208
  continue
4220
4209
  }
4221
- // eslint-disable-next-line no-console
4222
- console.warn(`[CnIndexPage] Ignoring action ${JSON.stringify(a)}: actions must be objects with an id and a label. To show a built-in action use the showViewAction / showEditAction / showCopyAction / showDeleteAction props.`)
4210
+ // A dropped entry is always named; the placement hints are dev-only.
4211
+ if (warning.code === 'invalid-entry' || process.env.NODE_ENV !== 'production') {
4212
+ // eslint-disable-next-line no-console
4213
+ console.warn(`[CnIndexPage] "${this.title}": ${warning.message}`)
4214
+ }
4223
4215
  }
4224
- return [...declared, ...this.defaultActions]
4216
+ return actions
4225
4217
  },
4226
4218
 
4227
4219
  hasRowActions() {
@@ -5467,9 +5459,20 @@ export default {
5467
5459
  },
5468
5460
 
5469
5461
  /**
5470
- * Run the first action the focused row offers. "First" is the page's
5471
- * order, narrowed by what the server allows on that record, so the
5472
- * primary action is never one this caller may not run.
5462
+ * The first action a row's menu shows and enables: the page's order, narrowed by what the server allows, by `visible` / `visibleWhen`, and by `disabled`.
5463
+ * The keyboard therefore never runs what the menu hides or greys out.
5464
+ *
5465
+ * @param {object} row The row.
5466
+ * @return {(object|null)} The action, or null.
5467
+ * @spec openspec/changes/row-action-builtin-placement/specs/index-page/spec.md
5468
+ */
5469
+ primaryRowActionFor(row) {
5470
+ return this.rowActionsFor(row).find((action) => isRowActionVisible(action, row)
5471
+ && !(typeof action.disabled === 'function' ? action.disabled(row) : action.disabled)) || null
5472
+ },
5473
+
5474
+ /**
5475
+ * Run the focused row's primary action (see `primaryRowActionFor`).
5473
5476
  *
5474
5477
  * @return {void}
5475
5478
  * @spec openspec/changes/working-list-row-actions/specs/index-page/spec.md
@@ -5479,14 +5482,14 @@ export default {
5479
5482
  if (!row) {
5480
5483
  return
5481
5484
  }
5482
- const [first] = this.rowActionsFor(row)
5485
+ const first = this.primaryRowActionFor(row)
5483
5486
  if (!first) {
5484
5487
  return
5485
5488
  }
5486
5489
  if (typeof first.handler === 'function') {
5487
5490
  first.handler(row)
5488
5491
  }
5489
- this.$emit('action', { action: first.label, row })
5492
+ this.onRowAction(rowActionPayload(first, row))
5490
5493
  },
5491
5494
 
5492
5495
  /**
@@ -5803,16 +5806,22 @@ export default {
5803
5806
  },
5804
5807
 
5805
5808
  /**
5806
- * Intercepts CnRowActions' bubbled `@action` so `handler: "none"`
5807
- * actions are dropped before re-emit.
5809
+ * Intercepts CnRowActions' and CnContextMenu's bubbled `@action` so `handler: "none"` actions are dropped before re-emit.
5810
+ * A built-in is matched by its id, an app action by its label.
5808
5811
  *
5809
- * @param {{action: string, row: object}} payload The bubbled action payload.
5812
+ * @param {{action: string, row: object, id?: string, builtin?: boolean}} payload The bubbled action payload.
5810
5813
  */
5811
5814
  onRowAction(payload) {
5812
- const matched = this.mergedActions.find((a) => a.label === payload.action)
5815
+ const matched = payload.builtin === true
5816
+ ? this.mergedActions.find((a) => a.builtin === true && a.id === payload.id)
5817
+ : this.mergedActions.find((a) => a.builtin !== true && a.label === payload.action)
5813
5818
  if (matched && matched._dispatchSuppress) {
5814
5819
  return
5815
5820
  }
5821
+ /**
5822
+ * @event action A row action was chosen from a row's actions menu, its right-click menu or the keyboard primary action. Payload: `action` (the label), the row, the action's `id` when it has one, and `builtin: true` for a built-in View / Edit / Copy / Delete. Its handler has already run.
5823
+ * @type {{ action: string, row: object, id?: string, builtin?: boolean }}
5824
+ */
5816
5825
  this.$emit('action', payload)
5817
5826
  },
5818
5827
 
@@ -6758,6 +6767,7 @@ export default {
6758
6767
  // --- Context menu handlers ---
6759
6768
 
6760
6769
  onRowContextMenu({ row, event }) {
6770
+ this.contextMenuShownRow = row
6761
6771
  this.openContextMenu({ item: row, event })
6762
6772
  },
6763
6773
 
@@ -14,9 +14,8 @@ import TrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
14
14
  * without it, or when it returns null, View is a button.
15
15
  * @param {{ onView: (row: object) => void, onEdit: (row: object) => void, onCopy: (row: object) => void, onDelete: (row: object) => void }} opts.handlers
16
16
  * Click handlers bound to each emitted action, in the same order as `flags`.
17
- * @return {Array<object>} The enabled actions in menu order (view, edit, copy,
18
- * delete), each `{label, icon, handler}` — delete additionally `destructive`,
19
- * view additionally `to` when `viewTo` is given.
17
+ * @return {Array<object>} The enabled actions in menu order (view, edit, copy, delete), each `{id, builtin: true, label, icon, handler}`.
18
+ * The id is `view`, `edit`, `copy` or `delete`; delete additionally carries `destructive`, view additionally `to` when `viewTo` is given.
20
19
  */
21
20
  export function buildDefaultActions({ flags, viewIcon, viewTo = null, handlers }) {
22
21
  // t() at BUILD-actions time, not module time: the labels were string
@@ -26,20 +25,20 @@ export function buildDefaultActions({ flags, viewIcon, viewTo = null, handlers }
26
25
  // consuming app's language is already resolved.
27
26
  const out = []
28
27
  if (flags.view) {
29
- const view = { label: t('nextcloud-vue', 'View'), icon: viewIcon, handler: handlers.onView }
28
+ const view = { id: 'view', builtin: true, label: t('nextcloud-vue', 'View'), icon: viewIcon, handler: handlers.onView }
30
29
  if (typeof viewTo === 'function') {
31
30
  view.to = viewTo
32
31
  }
33
32
  out.push(view)
34
33
  }
35
34
  if (flags.edit) {
36
- out.push({ label: t('nextcloud-vue', 'Edit'), icon: Pencil, handler: handlers.onEdit })
35
+ out.push({ id: 'edit', builtin: true, label: t('nextcloud-vue', 'Edit'), icon: Pencil, handler: handlers.onEdit })
37
36
  }
38
37
  if (flags.copy) {
39
- out.push({ label: t('nextcloud-vue', 'Copy'), icon: ContentCopy, handler: handlers.onCopy })
38
+ out.push({ id: 'copy', builtin: true, label: t('nextcloud-vue', 'Copy'), icon: ContentCopy, handler: handlers.onCopy })
40
39
  }
41
40
  if (flags.del) {
42
- out.push({ label: t('nextcloud-vue', 'Delete'), icon: TrashCanOutline, destructive: true, handler: handlers.onDelete })
41
+ out.push({ id: 'delete', builtin: true, label: t('nextcloud-vue', 'Delete'), icon: TrashCanOutline, destructive: true, handler: handlers.onDelete })
43
42
  }
44
43
  return out
45
44
  }
@@ -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.label">
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="`cn-action-item-${slugifyLabel(action.label)}`"
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="`cn-action-item-${slugifyLabel(action.label)}`"
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 { evaluateVisibleWhenLocal, isLocallyDecidableVisibleWhen } from '../../utils/visibleWhen.js'
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` predicate. An action without a
141
- * `visible` field is always shown (backwards compatible).
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', { action: action.label, row: this.row })
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 label and the row. A button entry has already run its `handler`; a link entry navigates instead, and a modified (new-tab) click on a link emits nothing.
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', { action: action.label, row: this.row })
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. Used solely by the
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 String(label || '')
249
- .toLowerCase()
250
- .replace(/[^a-z0-9]+/g, '-')
251
- .replace(/^-+|-+$/g, '')
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
  }
@@ -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-trail`), { params: { limit: this.content.auditLimit || 50 } })
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)) || []