@conduction/nextcloud-vue 2.66.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.
Files changed (95) hide show
  1. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +19 -0
  2. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +10 -3
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +2 -2
  6. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  8. package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js +37 -26
  9. package/dist/esm/components/CnContextMenu/CnContextMenu.vue2.js.map +1 -1
  10. package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js +3 -3
  11. package/dist/esm/components/CnContextMenu/CnContextMenu.vue3.js.map +1 -1
  12. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +4 -4
  13. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  14. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  15. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +87 -18
  16. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  17. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +6 -1
  18. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  19. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +89 -49
  20. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  21. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +337 -315
  22. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  23. package/dist/esm/components/CnIndexPage/defaultActions.js +6 -7
  24. package/dist/esm/components/CnIndexPage/defaultActions.js.map +1 -1
  25. package/dist/esm/components/CnRowActions/CnRowActions.vue2.js +33 -34
  26. package/dist/esm/components/CnRowActions/CnRowActions.vue2.js.map +1 -1
  27. package/dist/esm/components/CnRowActions/CnRowActions.vue3.js +3 -3
  28. package/dist/esm/components/CnRowActions/CnRowActions.vue3.js.map +1 -1
  29. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +13 -13
  30. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  31. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  32. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +3 -2
  33. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -1
  34. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -1
  35. package/dist/esm/composables/useContextMenu.js +4 -2
  36. package/dist/esm/composables/useContextMenu.js.map +1 -1
  37. package/dist/esm/composables/useDependencyCheck.js +44 -1
  38. package/dist/esm/composables/useDependencyCheck.js.map +1 -1
  39. package/dist/esm/composables/useListView.js +43 -1
  40. package/dist/esm/composables/useListView.js.map +1 -1
  41. package/dist/esm/composables/useSetupStatus.js +26 -3
  42. package/dist/esm/composables/useSetupStatus.js.map +1 -1
  43. package/dist/esm/l10n/en.json.js +4 -1
  44. package/dist/esm/l10n/en.json.js.map +1 -1
  45. package/dist/esm/l10n/nl.json.js +4 -1
  46. package/dist/esm/l10n/nl.json.js.map +1 -1
  47. package/dist/esm/nextcloud-vue.css +20 -2
  48. package/dist/esm/store/plugins/liveUpdates.js +19 -9
  49. package/dist/esm/store/plugins/liveUpdates.js.map +1 -1
  50. package/dist/esm/store/useObjectStore.js +20 -9
  51. package/dist/esm/store/useObjectStore.js.map +1 -1
  52. package/dist/esm/utils/resolveRowActions.js +130 -0
  53. package/dist/esm/utils/resolveRowActions.js.map +1 -0
  54. package/dist/esm/utils/rowActionItem.js +104 -0
  55. package/dist/esm/utils/rowActionItem.js.map +1 -0
  56. package/dist/esm/utils/timelineEvents.js +44 -1
  57. package/dist/esm/utils/timelineEvents.js.map +1 -1
  58. package/dist/esm/utils/validateManifest.js +59 -3
  59. package/dist/esm/utils/validateManifest.js.map +1 -1
  60. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  61. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  62. package/dist/nextcloud-vue.cjs.js +1431 -802
  63. package/dist/nextcloud-vue.cjs.js.map +2 -2
  64. package/dist/nextcloud-vue.css +20 -2
  65. package/l10n/en.json +4 -1
  66. package/l10n/nl.json +4 -1
  67. package/package.json +1 -1
  68. package/scripts/.jsdoc-baselines.json +2 -2
  69. package/src/components/CnActionsBar/CnActionsBar.md +2 -1
  70. package/src/components/CnActionsBar/CnActionsBar.vue +25 -1
  71. package/src/components/CnAppNav/CnAppNav.vue +2 -2
  72. package/src/components/CnContextMenu/CnContextMenu.vue +40 -29
  73. package/src/components/CnDataTable/CnDataTable.md +1 -1
  74. package/src/components/CnDataTable/CnDataTable.vue +4 -4
  75. package/src/components/CnFormDialog/CnFormDialog.vue +92 -18
  76. package/src/components/CnIndexPage/CnIndexPage.md +23 -1
  77. package/src/components/CnIndexPage/CnIndexPage.vue +108 -52
  78. package/src/components/CnIndexPage/defaultActions.js +6 -7
  79. package/src/components/CnRowActions/CnRowActions.vue +36 -37
  80. package/src/components/CnSetupWizard/CnSetupWizard.vue +13 -13
  81. package/src/components/CnTimelineWidget/CnTimelineWidget.vue +3 -2
  82. package/src/composables/useContextMenu.js +4 -2
  83. package/src/composables/useDependencyCheck.js +43 -0
  84. package/src/composables/useListView.js +43 -1
  85. package/src/composables/useSetupStatus.js +26 -3
  86. package/src/css/actions-bar.css +20 -2
  87. package/src/schemas/app-manifest-v2.schema.json +48 -10
  88. package/src/schemas/app-manifest.schema.json +1 -1
  89. package/src/store/plugins/liveUpdates.js +19 -9
  90. package/src/store/useObjectStore.js +19 -8
  91. package/src/utils/resolveRowActions.js +127 -0
  92. package/src/utils/rowActionItem.js +100 -0
  93. package/src/utils/timelineEvents.js +43 -0
  94. package/src/utils/validateManifest.js +60 -3
  95. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -3,6 +3,7 @@
3
3
  * SPDX-License-Identifier: EUPL-1.2
4
4
  */
5
5
 
6
+ import { loadState } from '@nextcloud/initial-state'
6
7
  import { useAppStatus } from './useAppStatus.js'
7
8
 
8
9
  /**
@@ -59,3 +60,45 @@ export function checkDependencies(entries, serverStatuses = {}) {
59
60
  export function isDependencyResolved(row) {
60
61
  return !!row && row.installed === true && row.enabled === true
61
62
  }
63
+
64
+ /**
65
+ * Read the `dependency_statuses` initial state an app may inject from PHP.
66
+ *
67
+ * Keyed by app id: `{ installed, enabled }`. Returns `{}` when the app injects
68
+ * none or the state cannot be read, so callers fall back to `useAppStatus`.
69
+ *
70
+ * @param {string} appId The app whose initial state to read.
71
+ * @return {object} Server-reported statuses keyed by app id.
72
+ *
73
+ * @spec openspec/changes/optional-step-requires/specs/cn-setup-wizard/spec.md#requirement-a-step-whose-required-apps-are-absent-is-not-applicable
74
+ */
75
+ export function readServerAppStatuses(appId) {
76
+ try {
77
+ const statuses = loadState(appId, 'dependency_statuses', {})
78
+ return (statuses && typeof statuses === 'object') ? statuses : {}
79
+ } catch {
80
+ return {}
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The apps in a setup step's `requires` list that are not installed and
86
+ * enabled.
87
+ *
88
+ * This is the one resolution every setup surface uses: `CnSetupWizard` skips a
89
+ * step when it is non-empty, and `useSetupStatus` then counts that step as not
90
+ * applicable rather than unmet. Keeping both on one function is what stops the
91
+ * wizard and the status from disagreeing about the same step.
92
+ *
93
+ * @param {Array<string>} requires The step's `requires` app ids.
94
+ * @param {object} [serverStatuses] Server-reported statuses keyed by app id.
95
+ * @return {Array<{id: string, name: string, required: boolean, installed: boolean, enabled: boolean}>} The missing apps.
96
+ *
97
+ * @spec openspec/changes/optional-step-requires/specs/cn-setup-wizard/spec.md#requirement-a-step-whose-required-apps-are-absent-is-not-applicable
98
+ */
99
+ export function missingRequiredApps(requires, serverStatuses = {}) {
100
+ if (!Array.isArray(requires) || requires.length === 0) {
101
+ return []
102
+ }
103
+ return checkDependencies(requires, serverStatuses).filter((row) => !isDependencyResolved(row))
104
+ }
@@ -94,6 +94,13 @@ export function useListView(objectTypeOrOptions, options) {
94
94
  // field (a folder pane grouping by it, say) reads them here rather than
95
95
  // deriving them from `objects`, which is only the current page.
96
96
  const facets = computed(() => objectStore.facets[objectType] || {})
97
+ // Outcome of the latest `refresh()`: the store's error for it, or null once
98
+ // it succeeded or a later fetch stored new rows. The store keeps the previous
99
+ // rows on a failed fetch, so this is how a consumer tells stale rows from
100
+ // current ones.
101
+ const error = ref(null)
102
+ let refreshSeq = 0
103
+ let pendingRefreshes = 0
97
104
 
98
105
  let searchTimeout = null
99
106
 
@@ -178,14 +185,48 @@ export function useListView(objectTypeOrOptions, options) {
178
185
 
179
186
  /**
180
187
  * Fetch the collection using current state params and update sidebar facet data.
188
+ * Only the most recently started refresh sets `error`, so an older response
189
+ * settling late cannot override a newer one's outcome.
181
190
  *
182
191
  * @param {number} [page] Page to fetch
183
192
  * @return {Promise<void>}
184
193
  */
185
194
  async function refresh(page = 1) {
186
- await objectStore.fetchCollection(objectType, buildParams(page))
195
+ const seq = ++refreshSeq
196
+ // This call's own result. `errors[objectType]` is shared by every call
197
+ // for the type and is not cleared on success, so it cannot say which
198
+ // request failed; it is only the fallback for a store that ignores
199
+ // `options.outcome`.
200
+ const outcome = {}
201
+ pendingRefreshes++
202
+ try {
203
+ await objectStore.fetchCollection(objectType, buildParams(page), { outcome })
204
+ } catch (e) {
205
+ if (seq === refreshSeq) {
206
+ error.value = e
207
+ }
208
+ throw e
209
+ } finally {
210
+ pendingRefreshes--
211
+ }
212
+ if (seq === refreshSeq) {
213
+ error.value = ('error' in outcome) ? outcome.error : (objectStore.errors?.[objectType] || null)
214
+ }
187
215
  }
188
216
 
217
+ // A fetch outside `refresh()` (e.g. the live-updates refetch) that stores
218
+ // new rows recovers the list from an earlier failure. Rows written while
219
+ // a refresh is pending are left to that refresh, which decides `error`.
220
+ watch(
221
+ () => objectStore.collections[objectType],
222
+ () => {
223
+ if (pendingRefreshes === 0) {
224
+ error.value = null
225
+ }
226
+ },
227
+ { flush: 'sync' },
228
+ )
229
+
189
230
  // ── Event handlers ───────────────────────────────────────────────────
190
231
 
191
232
  /**
@@ -323,6 +364,7 @@ export function useListView(objectTypeOrOptions, options) {
323
364
  loading,
324
365
  pagination,
325
366
  facets,
367
+ error,
326
368
  // Local state
327
369
  searchTerm,
328
370
  sortKey,
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { computed, ref } from 'vue'
7
+ import { missingRequiredApps, readServerAppStatuses } from './useDependencyCheck.js'
7
8
 
8
9
  /**
9
10
  * Per-`appId` cache of setup-status results. The Vue refs are stored so all
@@ -27,6 +28,14 @@ const cache = new Map()
27
28
  * every non-admin user of an app with a `setup` block met a wizard they had no
28
29
  * permission to complete instead of the app itself.
29
30
  *
31
+ * A step that declares `requires: [appId, ...]` is **not applicable** while any
32
+ * of those apps is not installed and enabled: it is neither met nor unmet, so
33
+ * it never lands in `requiredUnmet` or `optionalUnmet`. `CnSetupWizard` skips
34
+ * such a step, and counting it as unmet kept the wizard reopening (optional
35
+ * step) or gating the app (required step) for a step it would never show. App
36
+ * status is resolved exactly as the wizard does it: the `dependency_statuses`
37
+ * initial state first, then `useAppStatus`.
38
+ *
30
39
  * @param {string} appId Nextcloud app id (e.g. `"procest"`).
31
40
  * @param {object} manifest The app manifest (reads `manifest.setup`).
32
41
  * @return {{
@@ -35,6 +44,7 @@ const cache = new Map()
35
44
  * requiredUnmet: import('vue').ComputedRef<Array<object>>,
36
45
  * optionalUnmet: import('vue').ComputedRef<Array<object>>,
37
46
  * optionalUnmetReported: import('vue').ComputedRef<Array<object>>,
47
+ * notApplicable: import('vue').ComputedRef<Array<object>>,
38
48
  * completed: import('vue').ComputedRef<boolean>,
39
49
  * enabled: boolean,
40
50
  * loading: import('vue').Ref<boolean>,
@@ -45,6 +55,7 @@ const cache = new Map()
45
55
  *
46
56
  * @example
47
57
  * const { requiredUnmet, completed, loading } = useSetupStatus('procest', manifest)
58
+ * @spec openspec/changes/optional-step-requires/specs/cn-setup-wizard/spec.md#requirement-a-step-whose-required-apps-are-absent-is-not-applicable
48
59
  */
49
60
  export function useSetupStatus(appId, manifest) {
50
61
  const setup = (manifest && manifest.setup) || {}
@@ -68,6 +79,9 @@ export function useSetupStatus(appId, manifest) {
68
79
  cache.set(appId, entry)
69
80
  }
70
81
  const { status, loading, error, forbidden } = entry
82
+ // Same source the wizard's dependency gate reads. Initial state is fixed
83
+ // for the page lifetime, so one read per call is enough.
84
+ const serverAppStatuses = readServerAppStatuses(appId)
71
85
 
72
86
  const steps = computed(() => stepDefs.map((s) => {
73
87
  const bag = status.value.steps || {}
@@ -81,7 +95,12 @@ export function useSetupStatus(appId, manifest) {
81
95
  // that act on an unmet step — CnAppRoot auto-opening the non-gating
82
96
  // wizard — must act only on steps the server actually reported, or an
83
97
  // unknowable step parks the wizard over the app for ever.
84
- return { ...s, done: st.done === true, reported, detail: st.detail }
98
+ //
99
+ // `applicable` is false when the step `requires` an app that is not
100
+ // installed and enabled. The wizard skips such a step, so it can never
101
+ // be done here; it is not applicable rather than unmet.
102
+ const missingApps = missingRequiredApps(s.requires, serverAppStatuses).map((row) => row.id)
103
+ return { ...s, done: st.done === true, reported, detail: st.detail, applicable: missingApps.length === 0, missingApps }
85
104
  }))
86
105
  // Presentational step types carry no work, so the server never reports a
87
106
  // `done` flag for them. Counting them as "unmet" made `optionalUnmet`
@@ -89,7 +108,9 @@ export function useSetupStatus(appId, manifest) {
89
108
  // which kept CnAppRoot's non-gating setup wizard auto-opening over the
90
109
  // app on every fresh browser profile no matter how complete setup was.
91
110
  // Only actionable steps can be unmet.
92
- const isActionable = (s) => s.type !== 'info' && s.type !== 'summary'
111
+ // A step whose required apps are absent is skipped by the wizard, so it
112
+ // is neither met nor unmet (see `applicable` above).
113
+ const isActionable = (s) => s.type !== 'info' && s.type !== 'summary' && s.applicable !== false
93
114
  // `forbidden` empties BOTH lists, and that is what actually suppresses the
94
115
  // wizard: CnAppRoot gates on `requiredUnmet.length > 0` (blocking) and on
95
116
  // `requiredUnmet.length === 0 && optionalUnmet.length > 0` (auto-open) —
@@ -110,6 +131,8 @@ export function useSetupStatus(appId, manifest) {
110
131
  // suppressing it is what left dossiq's `seed` step (and with it the app's
111
132
  // only demo-data affordance) permanently unreachable.
112
133
  const optionalUnmetReported = computed(() => optionalUnmet.value.filter((s) => s.reported === true))
134
+ // Steps the wizard skips because an app in their `requires` is absent.
135
+ const notApplicable = computed(() => steps.value.filter((s) => s.applicable === false))
113
136
  const completed = computed(() => {
114
137
  if (!enabled) {
115
138
  return true
@@ -170,7 +193,7 @@ export function useSetupStatus(appId, manifest) {
170
193
  refresh()
171
194
  }
172
195
 
173
- return { steps, status, requiredUnmet, optionalUnmet, optionalUnmetReported, completed, enabled, loading, error, forbidden, refresh }
196
+ return { steps, status, requiredUnmet, optionalUnmet, optionalUnmetReported, notApplicable, completed, enabled, loading, error, forbidden, refresh }
174
197
  }
175
198
 
176
199
  /**
@@ -35,6 +35,23 @@
35
35
  color: var(--color-text-maxcontrast);
36
36
  }
37
37
 
38
+ /* Beside the inline search (`showCountWithSearch`): the search field shrinks
39
+ first and the counter stays on one line. */
40
+ .cn-actions-bar__count--beside-search {
41
+ flex-shrink: 0;
42
+ white-space: nowrap;
43
+ }
44
+
45
+ /* Taken out of the flex row so it adds no gap, but kept in the accessibility
46
+ tree so the live region exists before a total appears. */
47
+ .cn-actions-bar__count--empty {
48
+ position: absolute;
49
+ width: 1px;
50
+ height: 1px;
51
+ overflow: hidden;
52
+ clip-path: inset(50%);
53
+ }
54
+
38
55
  /* Inline search field */
39
56
  .cn-actions-bar__search {
40
57
  position: relative;
@@ -294,8 +311,9 @@
294
311
  display: contents;
295
312
  }
296
313
 
297
- /* #after-search filter buttons + the view toggle ride beside the
298
- search field (DOM order — search first via its own order). */
314
+ /* #after-search filter buttons, the beside-search counter and the view
315
+ toggle ride beside the search field (DOM order — search first via its
316
+ own order). */
299
317
  .cn-actions-bar__info > * {
300
318
  order: 1;
301
319
  }
@@ -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.48.0",
6
+ "version": "2.50.0",
7
7
  "type": "object",
8
8
  "required": ["$schema", "version"],
9
9
  "allOf": [
@@ -98,7 +98,7 @@
98
98
  "suggestMap": { "type": "object", "description": "choice: map of suggestFrom value → suggested option value. Used to auto-suggest a default (e.g. RGS template suggested from organisation type).", "additionalProperties": { "type": "string" } },
99
99
  "multiple": { "type": "boolean", "default": false, "description": "choice: allow multi-select." },
100
100
  "loadAction": { "type": "string", "minLength": 1, "description": "choice with display cards: an actionId each option card loads itself with. Every card except the one whose value is `none` gets its own Load button, which POSTs { dataset: <value> } to /api/setup/action/{loadAction}, spins while it runs and shows the result on that card. A successful load also selects the card. With loadAction the separate run-action step that loaded the picked dataset is no longer needed; manifests that keep it still work." },
101
- "requires": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true, "description": "App ids this step needs. When any of them is not installed and enabled, the wizard skips the step and the summary shows it as skipped, naming the missing apps. Do not combine with required: true; setup status would keep reporting the skipped step as unmet." },
101
+ "requires": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true, "description": "App ids this step needs. When any of them is not installed and enabled, the wizard skips the step and the summary shows it as skipped, naming the missing apps. Setup status then counts the step as not applicable, neither met nor unmet, so it never reopens the wizard or gates the app. This holds for a required step too." },
102
102
  "action": { "type": "string", "description": "run-action: actionId POSTed to /api/setup/action/{action}." },
103
103
  "onDemand": { "type": "boolean", "default": false, "description": "run-action: the step runs only when the user clicks Run, never automatically, and is never outstanding work (it does not open the wizard or decide where a resumed wizard starts). The summary ticks it only when its action succeeded in the current session and shows it as not run otherwise, whatever the server reports. For destructive steps an app reports as done on purpose so they never auto-run, such as removing example data." },
104
104
  "healthCheck": { "type": "boolean", "default": false, "description": "summary: render a health recap." },
@@ -236,7 +236,7 @@
236
236
  },
237
237
  "settingsLabel": {
238
238
  "type": "string",
239
- "description": "Override label for the settings foldout's gear button. Defaults to 'Settings'."
239
+ "description": "Override label for the settings foldout's gear button. Defaults to 'Advanced'."
240
240
  },
241
241
  "card": {
242
242
  "type": "object",
@@ -3458,14 +3458,30 @@
3458
3458
  "actions": {
3459
3459
  "type": "array",
3460
3460
  "items": {
3461
- "type": "object",
3462
- "required": ["label"],
3463
- "additionalProperties": true,
3464
- "properties": {
3465
- "label": { "type": "string", "minLength": 1 }
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
- "description": "Declarative actions on a page. 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. Two shapes hit this, both meaning \"show the built-in\": a bare string (\"edit\") and a key-only object ({\"key\": \"edit\"}). Built-ins are enabled with config.actionToggles / the show*Action keys, never by naming them here."
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": {
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "settingsLabel": {
52
52
  "type": "string",
53
- "description": "Override label for the settings foldout's gear button. Defaults to 'Settings'."
53
+ "description": "Override label for the settings foldout's gear button. Defaults to 'Advanced'."
54
54
  },
55
55
  "primaryAction": {
56
56
  "$ref": "#/$defs/primaryAction",
@@ -432,19 +432,29 @@ export function liveUpdatesPlugin(opts = {}) {
432
432
  const collectionInFlight = new Map()
433
433
 
434
434
  const originalFetchCollection = store.fetchCollection.bind(store)
435
- store.fetchCollection = async function dedupedFetchCollection(type, params = {}) {
435
+ store.fetchCollection = async function dedupedFetchCollection(type, params = {}, options = {}) {
436
436
  if (!store.__liveDedupActive) {
437
- return originalFetchCollection(type, params)
437
+ return originalFetchCollection(type, params, options)
438
438
  }
439
439
  const key = collectionDedupKey(type, params)
440
- if (collectionInFlight.has(key)) {
441
- return collectionInFlight.get(key)
440
+ let entry = collectionInFlight.get(key)
441
+ if (!entry) {
442
+ // The shared request reports into its own sink, copied to
443
+ // each caller's `options.outcome` below, so a caller that
444
+ // joined it learns that request's result too.
445
+ const outcome = {}
446
+ const promise = originalFetchCollection(type, params, { ...options, outcome }).finally(() => {
447
+ collectionInFlight.delete(key)
448
+ })
449
+ entry = { promise, outcome }
450
+ collectionInFlight.set(key, entry)
442
451
  }
443
- const promise = originalFetchCollection(type, params).finally(() => {
444
- collectionInFlight.delete(key)
445
- })
446
- collectionInFlight.set(key, promise)
447
- return promise
452
+ const results = await entry.promise
453
+ const sink = options && options.outcome
454
+ if (sink && typeof sink === 'object' && 'error' in entry.outcome) {
455
+ sink.error = entry.outcome.error
456
+ }
457
+ return results
448
458
  }
449
459
  },
450
460
  }
@@ -656,9 +656,12 @@ const baseActions = {
656
656
  *
657
657
  * @param {string} type The registered type slug
658
658
  * @param {object} [params] Query parameters (_limit, _page, _search, _order, filters)
659
+ * @param {object} [options] Call options
660
+ * @param {{error?: object|null}} [options.outcome] Opt-in sink for THIS call's own result: `error` is set to the error it records on `errors[type]`, or `null` on success. Unlike `errors[type]`, which every call for the type shares, a later call cannot overwrite it.
659
661
  * @return {Promise<Array>} The fetched collection (also stored in state)
660
662
  */
661
- async fetchCollection(type, params = {}) {
663
+ async fetchCollection(type, params = {}, options = {}) {
664
+ const outcome = (options && typeof options.outcome === 'object' && options.outcome) || null
662
665
  this.loading = { ...this.loading, [type]: true }
663
666
  this.errors = { ...this.errors, [type]: null }
664
667
 
@@ -685,9 +688,13 @@ const baseActions = {
685
688
  )
686
689
 
687
690
  if (!response.ok) {
688
- this.errors = { ...this.errors, [type]: await parseResponseError(response, type) }
691
+ const failure = await parseResponseError(response, type)
692
+ this.errors = { ...this.errors, [type]: failure }
693
+ if (outcome) {
694
+ outcome.error = failure
695
+ }
689
696
  // eslint-disable-next-line no-console -- diagnostic for a failure this code already degrades from
690
- console.error(`Error fetching ${type} collection:`, this.errors[type])
697
+ console.error(`Error fetching ${type} collection:`, failure)
691
698
  return []
692
699
  }
693
700
 
@@ -711,13 +718,17 @@ const baseActions = {
711
718
  this.facets = { ...this.facets, [type]: transformed }
712
719
  }
713
720
 
721
+ if (outcome) {
722
+ outcome.error = null
723
+ }
714
724
  return results
715
725
  } catch (error) {
716
- this.errors = {
717
- ...this.errors,
718
- [type]: error.name === 'TypeError'
719
- ? networkError(error)
720
- : genericError(error),
726
+ const failure = error.name === 'TypeError'
727
+ ? networkError(error)
728
+ : genericError(error)
729
+ this.errors = { ...this.errors, [type]: failure }
730
+ if (outcome) {
731
+ outcome.error = failure
721
732
  }
722
733
  // eslint-disable-next-line no-console -- diagnostic for a failure this code already degrades from
723
734
  console.error(`Error fetching ${type} collection:`, error)
@@ -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
+ }