@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.
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +19 -0
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +10 -3
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +2 -2
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
- 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/CnDataTable/CnDataTable.vue2.js +4 -4
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.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 +89 -49
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +337 -315
- 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 +13 -13
- 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/composables/useDependencyCheck.js +44 -1
- package/dist/esm/composables/useDependencyCheck.js.map +1 -1
- package/dist/esm/composables/useListView.js +43 -1
- package/dist/esm/composables/useListView.js.map +1 -1
- package/dist/esm/composables/useSetupStatus.js +26 -3
- package/dist/esm/composables/useSetupStatus.js.map +1 -1
- package/dist/esm/l10n/en.json.js +4 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +4 -1
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/nextcloud-vue.css +20 -2
- package/dist/esm/store/plugins/liveUpdates.js +19 -9
- package/dist/esm/store/plugins/liveUpdates.js.map +1 -1
- package/dist/esm/store/useObjectStore.js +20 -9
- package/dist/esm/store/useObjectStore.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 +1431 -802
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +20 -2
- package/l10n/en.json +4 -1
- package/l10n/nl.json +4 -1
- package/package.json +1 -1
- package/scripts/.jsdoc-baselines.json +2 -2
- package/src/components/CnActionsBar/CnActionsBar.md +2 -1
- package/src/components/CnActionsBar/CnActionsBar.vue +25 -1
- package/src/components/CnAppNav/CnAppNav.vue +2 -2
- package/src/components/CnContextMenu/CnContextMenu.vue +40 -29
- package/src/components/CnDataTable/CnDataTable.md +1 -1
- package/src/components/CnDataTable/CnDataTable.vue +4 -4
- package/src/components/CnFormDialog/CnFormDialog.vue +92 -18
- package/src/components/CnIndexPage/CnIndexPage.md +23 -1
- package/src/components/CnIndexPage/CnIndexPage.vue +108 -52
- package/src/components/CnIndexPage/defaultActions.js +6 -7
- package/src/components/CnRowActions/CnRowActions.vue +36 -37
- package/src/components/CnSetupWizard/CnSetupWizard.vue +13 -13
- package/src/components/CnTimelineWidget/CnTimelineWidget.vue +3 -2
- package/src/composables/useContextMenu.js +4 -2
- package/src/composables/useDependencyCheck.js +43 -0
- package/src/composables/useListView.js +43 -1
- package/src/composables/useSetupStatus.js +26 -3
- package/src/css/actions-bar.css +20 -2
- package/src/schemas/app-manifest-v2.schema.json +48 -10
- package/src/schemas/app-manifest.schema.json +1 -1
- package/src/store/plugins/liveUpdates.js +19 -9
- package/src/store/useObjectStore.js +19 -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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/css/actions-bar.css
CHANGED
|
@@ -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
|
|
298
|
-
search field (DOM order — search first via its
|
|
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.
|
|
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.
|
|
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 '
|
|
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
|
-
"
|
|
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": {
|
|
@@ -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 '
|
|
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
|
-
|
|
441
|
-
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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:`,
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
+
}
|