@conduction/nextcloud-vue 2.65.0 → 2.66.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/CnDashboardPage/CnDashboardPage.vue2.js +99 -2
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +438 -383
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +9 -3
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +1 -1
- package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +78 -21
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +129 -96
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js +3 -1
- package/dist/esm/components/CnFkResolveCell/CnFkResolveCell.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +144 -4
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
- package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +63 -7
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +2 -1
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js +11 -0
- package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue2.js.map +1 -1
- package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js +2 -1
- package/dist/esm/components/CnSegmentedControl/CnSegmentedControl.vue3.js.map +1 -1
- package/dist/esm/l10n/en.json.js +3 -0
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +3 -0
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/mixins/pageViews.js +400 -0
- package/dist/esm/mixins/pageViews.js.map +1 -0
- package/dist/esm/nextcloud-vue.css +18 -0
- package/dist/esm/utils/actionsDispatcher.js +11 -2
- package/dist/esm/utils/actionsDispatcher.js.map +1 -1
- package/dist/esm/utils/columnFilters.js +27 -3
- package/dist/esm/utils/columnFilters.js.map +1 -1
- package/dist/esm/utils/schema.js +40 -2
- package/dist/esm/utils/schema.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 +1521 -596
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +18 -0
- package/l10n/en.json +3 -0
- package/l10n/nl.json +3 -0
- package/package.json +1 -1
- package/src/components/CnDashboardPage/CnDashboardPage.vue +165 -8
- package/src/components/CnDataTable/CnColumnFilterPopover.vue +11 -5
- package/src/components/CnDetailPage/CnDetailPage.vue +94 -5
- package/src/components/CnFkResolveCell/CnFkResolveCell.vue +3 -1
- package/src/components/CnFormDialog/CnFormDialog.vue +144 -4
- package/src/components/CnHeaderWidget/CnHeaderWidget.vue +67 -8
- package/src/components/CnSegmentedControl/CnSegmentedControl.md +1 -1
- package/src/components/CnSegmentedControl/CnSegmentedControl.vue +12 -0
- package/src/css/detail-page.css +5 -0
- package/src/mixins/pageViews.js +396 -0
- package/src/schemas/app-manifest-v2.schema.json +29 -1
- package/src/utils/actionsDispatcher.js +11 -2
- package/src/utils/columnFilters.js +29 -2
- package/src/utils/schema.js +40 -2
- package/src/utils/validateManifestV2.compiled.js +2 -2
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V.
|
|
3
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
*
|
|
5
|
+
* Page views: a few views of one page, each its own widget grid, behind one
|
|
6
|
+
* segmented control ("My work | My team").
|
|
7
|
+
*
|
|
8
|
+
* A page that mixes this in accepts `views` (each `{ id, label, icon?,
|
|
9
|
+
* widgets, layout, emptyText? }`), `defaultView` and `viewsLabel`. It keeps
|
|
10
|
+
* the chosen view, writes it to the address (`?view=<id>`) and to the
|
|
11
|
+
* user's preferences (the server, mirrored in the browser), and provides `cnPageViews` so a greeting header widget on
|
|
12
|
+
* the page can draw the switch instead of the page.
|
|
13
|
+
*
|
|
14
|
+
* Which view opens: the address, then the stored view, then `defaultView`,
|
|
15
|
+
* then the first view. An id that names no view is ignored at every step.
|
|
16
|
+
*
|
|
17
|
+
* Used by CnDashboardPage and CnDetailPage. Each renders the chosen view's
|
|
18
|
+
* `layout` with its own grid, so a view looks exactly like the page's own
|
|
19
|
+
* grid.
|
|
20
|
+
*
|
|
21
|
+
* @mixin pageViews
|
|
22
|
+
* @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
|
|
23
|
+
*/
|
|
24
|
+
import { translate as t } from '@nextcloud/l10n'
|
|
25
|
+
import { readUserPreference, USER_PREFERENCE_STORAGE_PREFIX, writeUserPreference } from '../composables/useUserPreferences.js'
|
|
26
|
+
|
|
27
|
+
/** The query parameter that carries the chosen view. */
|
|
28
|
+
export const PAGE_VIEW_QUERY_KEY = 'view'
|
|
29
|
+
|
|
30
|
+
/** Prefix of the user-preference key; the page id follows it. */
|
|
31
|
+
export const PAGE_VIEW_PREFERENCE_PREFIX = 'cn_page_view:'
|
|
32
|
+
|
|
33
|
+
let regionSeq = 0
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read the browser mirror of a user preference synchronously, so the stored
|
|
37
|
+
* view is on screen from the first frame. Errors read as nothing stored.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} appId The app id.
|
|
40
|
+
* @param {string} key The preference key.
|
|
41
|
+
* @return {string|null} The stored id, or null.
|
|
42
|
+
* @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
|
|
43
|
+
*/
|
|
44
|
+
function readStoredViewMirror(appId, key) {
|
|
45
|
+
try {
|
|
46
|
+
if (typeof localStorage === 'undefined') {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
const raw = localStorage.getItem(`${USER_PREFERENCE_STORAGE_PREFIX}${appId}:${key}`)
|
|
50
|
+
if (raw === null || raw === undefined) {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
const value = JSON.parse(raw)
|
|
54
|
+
return typeof value === 'string' && value !== '' ? value : null
|
|
55
|
+
} catch {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const pageViews = {
|
|
61
|
+
inject: {
|
|
62
|
+
/** The host app id, provided by CnAppRoot; keys the stored view. */
|
|
63
|
+
cnPageViewsAppId: { from: 'cnAppId', default: '' },
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
props: {
|
|
67
|
+
/**
|
|
68
|
+
* Views of this page, each its own widget grid behind one segmented
|
|
69
|
+
* control. Each is `{ id, label, icon?, widgets, layout, emptyText? }`:
|
|
70
|
+
* `widgets` and `layout` have the same shape as the page's own. The
|
|
71
|
+
* chosen view renders in a region below the page's own grid. Empty
|
|
72
|
+
* (the default) draws no switch and no region.
|
|
73
|
+
*
|
|
74
|
+
* @type {Array<{id: string, label: string, icon?: string, widgets?: Array<object>, layout?: Array<object>, emptyText?: string}>}
|
|
75
|
+
*/
|
|
76
|
+
views: {
|
|
77
|
+
type: Array,
|
|
78
|
+
default: () => [],
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The id of the view chosen when neither the address nor the browser
|
|
83
|
+
* store names one. Empty chooses the first view.
|
|
84
|
+
*
|
|
85
|
+
* @type {string}
|
|
86
|
+
*/
|
|
87
|
+
defaultView: {
|
|
88
|
+
type: String,
|
|
89
|
+
default: '',
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Accessible name of the view switch (through the host translate
|
|
94
|
+
* function). Empty reads "View".
|
|
95
|
+
*
|
|
96
|
+
* @type {string}
|
|
97
|
+
*/
|
|
98
|
+
viewsLabel: {
|
|
99
|
+
type: String,
|
|
100
|
+
default: '',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
data() {
|
|
105
|
+
regionSeq += 1
|
|
106
|
+
return {
|
|
107
|
+
/** The view the user chose in this session, or the stored one. */
|
|
108
|
+
selectedViewId: null,
|
|
109
|
+
/** Whether the user chose a view in this session (a late server read then leaves it alone). */
|
|
110
|
+
viewChosen: false,
|
|
111
|
+
/** Id of the view region, the target of the options' `aria-controls`. */
|
|
112
|
+
viewRegionId: `cn-page-view-region-${regionSeq}`,
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Provide the views to descendants, so a greeting header widget in the
|
|
118
|
+
* page's grid can draw the switch. Getters keep it reactive.
|
|
119
|
+
*
|
|
120
|
+
* @return {object} The provided `cnPageViews`.
|
|
121
|
+
*/
|
|
122
|
+
provide() {
|
|
123
|
+
const vm = this
|
|
124
|
+
return {
|
|
125
|
+
cnPageViews: {
|
|
126
|
+
get views() { return vm.normalizedViews },
|
|
127
|
+
get activeId() { return vm.activeViewId },
|
|
128
|
+
get regionId() { return vm.viewRegionId },
|
|
129
|
+
select: (id) => vm.selectView(id),
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
computed: {
|
|
135
|
+
/**
|
|
136
|
+
* The declared views in one shape, labels translated. Entries without
|
|
137
|
+
* an id or a label are dropped, as are repeated ids.
|
|
138
|
+
*
|
|
139
|
+
* @return {Array<{id: string, label: string, icon: string, widgets: Array<object>, layout: Array<object>, emptyText: string}>}
|
|
140
|
+
* @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-page-declares-views-that-each-hold-a-widget-grid
|
|
141
|
+
*/
|
|
142
|
+
normalizedViews() {
|
|
143
|
+
const translate = typeof this.cnTranslate === 'function' ? this.cnTranslate : (key) => key
|
|
144
|
+
const seen = new Set()
|
|
145
|
+
return (Array.isArray(this.views) ? this.views : [])
|
|
146
|
+
.filter((view) => {
|
|
147
|
+
if (!view || typeof view !== 'object' || view.id === undefined || view.id === null || view.id === '') {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
if (typeof view.label !== 'string' || view.label === '' || seen.has(String(view.id))) {
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
seen.add(String(view.id))
|
|
154
|
+
return true
|
|
155
|
+
})
|
|
156
|
+
.map((view) => ({
|
|
157
|
+
id: String(view.id),
|
|
158
|
+
label: translate(view.label),
|
|
159
|
+
icon: typeof view.icon === 'string' ? view.icon : '',
|
|
160
|
+
widgets: Array.isArray(view.widgets) ? view.widgets : [],
|
|
161
|
+
layout: Array.isArray(view.layout) ? view.layout : [],
|
|
162
|
+
emptyText: typeof view.emptyText === 'string' && view.emptyText !== '' ? translate(view.emptyText) : '',
|
|
163
|
+
}))
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Whether this page has views.
|
|
168
|
+
*
|
|
169
|
+
* @return {boolean}
|
|
170
|
+
*/
|
|
171
|
+
hasViews() {
|
|
172
|
+
return this.normalizedViews.length > 0
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The chosen view's id: the address, then this session's or the
|
|
177
|
+
* stored choice, then `defaultView`, then the first view.
|
|
178
|
+
*
|
|
179
|
+
* @return {string|null} The id, or null without views.
|
|
180
|
+
* @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
|
|
181
|
+
*/
|
|
182
|
+
activeViewId() {
|
|
183
|
+
const views = this.normalizedViews
|
|
184
|
+
if (views.length === 0) {
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
const known = (id) => id !== null && id !== undefined && views.some((view) => view.id === String(id))
|
|
188
|
+
const fromAddress = this.$route && this.$route.query ? this.$route.query[PAGE_VIEW_QUERY_KEY] : undefined
|
|
189
|
+
if (known(fromAddress)) {
|
|
190
|
+
return String(fromAddress)
|
|
191
|
+
}
|
|
192
|
+
if (known(this.selectedViewId)) {
|
|
193
|
+
return String(this.selectedViewId)
|
|
194
|
+
}
|
|
195
|
+
if (known(this.defaultView)) {
|
|
196
|
+
return this.defaultView
|
|
197
|
+
}
|
|
198
|
+
return views[0].id
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The chosen view, or null without views.
|
|
203
|
+
*
|
|
204
|
+
* @return {object|null}
|
|
205
|
+
*/
|
|
206
|
+
activeView() {
|
|
207
|
+
return this.normalizedViews.find((view) => view.id === this.activeViewId) || null
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The switch's options, for CnSegmentedControl.
|
|
212
|
+
*
|
|
213
|
+
* @return {Array<{value: string, label: string}>}
|
|
214
|
+
*/
|
|
215
|
+
viewSwitchOptions() {
|
|
216
|
+
return this.normalizedViews.map((view) => ({ value: view.id, label: view.label }))
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The switch's accessible name: `viewsLabel` translated, else "View".
|
|
221
|
+
*
|
|
222
|
+
* @return {string}
|
|
223
|
+
* @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-view-switch-is-accessible
|
|
224
|
+
*/
|
|
225
|
+
viewSwitchLabel() {
|
|
226
|
+
const translate = typeof this.cnTranslate === 'function' ? this.cnTranslate : (key) => key
|
|
227
|
+
return this.viewsLabel ? translate(this.viewsLabel) : t('nextcloud-vue', 'Views')
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Whether a greeting header in the page's own widgets draws the
|
|
232
|
+
* switch (an option with a `view`), so the page draws none.
|
|
233
|
+
*
|
|
234
|
+
* @return {boolean}
|
|
235
|
+
* @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-greeting-header-can-switch-the-pages-views
|
|
236
|
+
*/
|
|
237
|
+
viewSwitchInHeaderWidget() {
|
|
238
|
+
const widgets = Array.isArray(this.widgets) ? this.widgets : []
|
|
239
|
+
return widgets.some((def) => {
|
|
240
|
+
const options = def && def.content && def.content.views && def.content.views.options
|
|
241
|
+
return Array.isArray(options) && options.some((option) => option && typeof option.view === 'string' && option.view !== '')
|
|
242
|
+
})
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Whether the page draws the switch itself.
|
|
247
|
+
*
|
|
248
|
+
* @return {boolean}
|
|
249
|
+
*/
|
|
250
|
+
showsPageViewSwitch() {
|
|
251
|
+
return this.hasViews && !this.viewSwitchInHeaderWidget
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The user-preference key for this page's chosen view.
|
|
256
|
+
*
|
|
257
|
+
* @return {string}
|
|
258
|
+
*/
|
|
259
|
+
viewPreferenceKey() {
|
|
260
|
+
// The page id, else the route name: a detail page's fallback id is
|
|
261
|
+
// its record's title, which would remember a view per record.
|
|
262
|
+
const id = this.pageId || (this.$route && this.$route.name) || this.resolvedPageId || 'page'
|
|
263
|
+
return `${PAGE_VIEW_PREFERENCE_PREFIX}${id}`
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The app id the stored view is kept under: the page's `appId` prop
|
|
268
|
+
* when it has one, else the app root's.
|
|
269
|
+
*
|
|
270
|
+
* @return {string}
|
|
271
|
+
*/
|
|
272
|
+
viewPreferenceAppId() {
|
|
273
|
+
const own = typeof this.appId === 'string' ? this.appId : ''
|
|
274
|
+
return own || (typeof this.cnPageViewsAppId === 'string' ? this.cnPageViewsAppId : '')
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The message of the chosen view's empty state.
|
|
279
|
+
*
|
|
280
|
+
* @return {string}
|
|
281
|
+
* @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-a-view-with-nothing-to-draw-says-so
|
|
282
|
+
*/
|
|
283
|
+
viewEmptyText() {
|
|
284
|
+
if (this.activeView && this.activeView.emptyText) {
|
|
285
|
+
return this.activeView.emptyText
|
|
286
|
+
}
|
|
287
|
+
return t('nextcloud-vue', 'This view has no widgets yet.')
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
created() {
|
|
292
|
+
this.loadStoredView()
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
methods: {
|
|
296
|
+
/**
|
|
297
|
+
* Read the stored view: the browser mirror at once, then the user
|
|
298
|
+
* preference on the server, which wins unless the user already chose.
|
|
299
|
+
*
|
|
300
|
+
* @return {Promise<void>}
|
|
301
|
+
* @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
|
|
302
|
+
*/
|
|
303
|
+
async loadStoredView() {
|
|
304
|
+
if (!this.hasViews) {
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
const appId = this.viewPreferenceAppId
|
|
308
|
+
const key = this.viewPreferenceKey
|
|
309
|
+
this.selectedViewId = readStoredViewMirror(appId, key)
|
|
310
|
+
if (!appId) {
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
const stored = await readUserPreference(appId, key, null)
|
|
314
|
+
if (!this.viewChosen && typeof stored === 'string' && stored !== '') {
|
|
315
|
+
this.selectedViewId = stored
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Choose a view: keep it, store it, and write it to the address
|
|
321
|
+
* (replacing the history entry, keeping the other query keys).
|
|
322
|
+
*
|
|
323
|
+
* @param {string} id The view id.
|
|
324
|
+
* @return {void}
|
|
325
|
+
* @spec openspec/changes/view-switch-containers/specs/view-switch-containers/spec.md#requirement-the-chosen-view-is-linkable-and-remembered
|
|
326
|
+
*/
|
|
327
|
+
selectView(id) {
|
|
328
|
+
const view = this.normalizedViews.find((candidate) => candidate.id === String(id))
|
|
329
|
+
if (!view) {
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
this.selectedViewId = view.id
|
|
333
|
+
this.viewChosen = true
|
|
334
|
+
writeUserPreference(this.viewPreferenceAppId, this.viewPreferenceKey, view.id)
|
|
335
|
+
if (!this.$router || !this.$route) {
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
const query = this.$route.query || {}
|
|
339
|
+
if (query[PAGE_VIEW_QUERY_KEY] === view.id) {
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
const result = this.$router.replace({ query: { ...query, [PAGE_VIEW_QUERY_KEY]: view.id } })
|
|
343
|
+
if (result && typeof result.catch === 'function') {
|
|
344
|
+
result.catch(() => {})
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Look a widget definition up in the views, for a layout item of the
|
|
350
|
+
* chosen view.
|
|
351
|
+
*
|
|
352
|
+
* @param {string} widgetId The widget id.
|
|
353
|
+
* @return {object|null} The definition, or null.
|
|
354
|
+
*/
|
|
355
|
+
findViewWidget(widgetId) {
|
|
356
|
+
const view = this.activeView
|
|
357
|
+
if (!view) {
|
|
358
|
+
return null
|
|
359
|
+
}
|
|
360
|
+
return view.widgets.find((def) => def && def.id === widgetId) || null
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Write a drag or resize in the chosen view's grid back onto its
|
|
365
|
+
* layout items in place, as the page does for its own layout, so the
|
|
366
|
+
* in-app manifest editor sees it.
|
|
367
|
+
*
|
|
368
|
+
* @param {Array<object>} updated The layout from the grid.
|
|
369
|
+
* @return {void}
|
|
370
|
+
*/
|
|
371
|
+
onViewLayoutChange(updated) {
|
|
372
|
+
const view = this.activeView
|
|
373
|
+
if (!view || !Array.isArray(updated)) {
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
for (const u of updated) {
|
|
377
|
+
const item = view.layout.find((l) => String(l.id) === String(u.id))
|
|
378
|
+
if (!item) {
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
for (const key of ['gridX', 'gridY', 'gridWidth', 'gridHeight']) {
|
|
382
|
+
if (u[key] !== undefined) {
|
|
383
|
+
item[key] = u[key]
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Emitted when a widget in a view's grid is dragged or resized.
|
|
389
|
+
*
|
|
390
|
+
* @event view-layout-change
|
|
391
|
+
* @type {{view: string, layout: Array<object>}}
|
|
392
|
+
*/
|
|
393
|
+
this.$emit('view-layout-change', { view: view.id, layout: view.layout })
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
}
|
|
@@ -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.48.0",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"required": ["$schema", "version"],
|
|
9
9
|
"allOf": [
|
|
@@ -3608,6 +3608,34 @@
|
|
|
3608
3608
|
"default": true,
|
|
3609
3609
|
"description": "type='dashboard': `false` drops the header row (title, description, header actions, edit toggle) for a page that opens with its own heading, such as a greeting widget; the title stays as a visually hidden heading (zuiddrecht-pixel-gaps). Maps to CnDashboardPage `showHeader`."
|
|
3610
3610
|
},
|
|
3611
|
+
"views": {
|
|
3612
|
+
"type": "array",
|
|
3613
|
+
"minItems": 1,
|
|
3614
|
+
"description": "type='dashboard' | type='detail': views of the page, each its own widget grid behind one segmented control (\"My work | My team\") (view-switch-containers). The chosen view renders in a region below the page's own grid, drawn with the same grid and widgets; the page's own `widgets` and `layout` stay above it. The switch sits in the page header, or in a greeting header widget whose `content.views.options[]` carry `view` instead of `route`. The chosen view is in the address (`?view=<id>`) and in the user's preferences; the address wins. A view with nothing to draw shows `emptyText`, else a default sentence. On a detail page, views replace the automatic Data and Related body. Maps to CnDashboardPage / CnDetailPage `views`.",
|
|
3615
|
+
"items": {
|
|
3616
|
+
"type": "object",
|
|
3617
|
+
"required": ["id", "label"],
|
|
3618
|
+
"additionalProperties": false,
|
|
3619
|
+
"properties": {
|
|
3620
|
+
"id": { "type": "string", "minLength": 1, "pattern": "^[A-Za-z0-9_-]+$", "description": "The view id, written to the address as `?view=<id>`." },
|
|
3621
|
+
"label": { "type": "string", "minLength": 1, "description": "The option label and the region's accessible name (through the app's translations)." },
|
|
3622
|
+
"icon": { "type": "string", "minLength": 1, "description": "Optional MDI icon name, reserved for an icon-bearing switch." },
|
|
3623
|
+
"emptyText": { "type": "string", "minLength": 1, "description": "The sentence shown when the view has nothing to draw." },
|
|
3624
|
+
"widgets": { "type": "array", "description": "The view's widget definitions, the same shape as the page's `widgets`.", "items": { "type": "object", "additionalProperties": true } },
|
|
3625
|
+
"layout": { "type": "array", "description": "The view's grid layout, the same shape as the page's `layout`.", "items": { "type": "object", "additionalProperties": true } }
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
},
|
|
3629
|
+
"defaultView": {
|
|
3630
|
+
"type": "string",
|
|
3631
|
+
"minLength": 1,
|
|
3632
|
+
"description": "type='dashboard' | type='detail': the id of the view chosen when neither the address nor the user's stored choice names one; without it the first view (view-switch-containers). Maps to `defaultView`."
|
|
3633
|
+
},
|
|
3634
|
+
"viewsLabel": {
|
|
3635
|
+
"type": "string",
|
|
3636
|
+
"minLength": 1,
|
|
3637
|
+
"description": "type='dashboard' | type='detail': the accessible name of the page's view switch, e.g. \"Whose work\"; defaults to \"View\" (view-switch-containers). Maps to `viewsLabel`."
|
|
3638
|
+
},
|
|
3611
3639
|
"showWidgetActions": {
|
|
3612
3640
|
"type": "boolean",
|
|
3613
3641
|
"default": true,
|
|
@@ -100,6 +100,14 @@ export function isExternalActionTarget(target) {
|
|
|
100
100
|
* would otherwise register — and fetch — a 404 (defect 7, learniq round
|
|
101
101
|
* 1). Idempotent for a caller that already passes a correct slug.
|
|
102
102
|
*
|
|
103
|
+
* A caller that already holds the schema SLUG (not a `$ref` title) passes
|
|
104
|
+
* `{ exactSchema: true }`: the slug is then used exactly as given. Kebab-casing
|
|
105
|
+
* a real camelCase slug (`productCategory` -> `product-category`) asks the
|
|
106
|
+
* objects API for a schema that does not exist and 404s (pipelinq review,
|
|
107
|
+
* round two).
|
|
108
|
+
*
|
|
109
|
+
* @spec openspec/changes/review-round-two/specs/schema-utilities/spec.md
|
|
110
|
+
*
|
|
103
111
|
* A title is not always registered under its kebab form: learniq holds
|
|
104
112
|
* `LearniqSettings` and `SubjectTeacherAssignment` as written. So a type
|
|
105
113
|
* registered here for a value the kebab rule CHANGED also carries the value
|
|
@@ -110,11 +118,12 @@ export function isExternalActionTarget(target) {
|
|
|
110
118
|
* @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-schema-title-falls-back-to-its-written-form
|
|
111
119
|
* @param {object} store The object store instance (useObjectStore shape).
|
|
112
120
|
* @param {{register: (string|number), schema: (string|number)}} source The widget source.
|
|
121
|
+
* @param {{exactSchema: (boolean|undefined)}} [options] `exactSchema: true` skips the title-to-slug step.
|
|
113
122
|
* @return {string} The type slug to use for store CRUD calls.
|
|
114
123
|
*/
|
|
115
|
-
export function resolveObjectOpType(store, source) {
|
|
124
|
+
export function resolveObjectOpType(store, source, options = {}) {
|
|
116
125
|
const register = String(source.register)
|
|
117
|
-
const schema = String(schemaRefSlug(source.schema))
|
|
126
|
+
const schema = options.exactSchema === true ? String(source.schema) : String(schemaRefSlug(source.schema))
|
|
118
127
|
const written = typeof source.schema === 'string'
|
|
119
128
|
? source.schema.substring(source.schema.lastIndexOf('/') + 1)
|
|
120
129
|
: ''
|
|
@@ -15,13 +15,19 @@
|
|
|
15
15
|
* Parameter shapes, as OpenRegister reads them:
|
|
16
16
|
* - equality and "any of": `key=value`, `key[]=a&key[]=b`
|
|
17
17
|
* - ranges: `key[gte]=from`, `key[lte]=to`
|
|
18
|
+
* - contains (text): `key[like]=term`, case-insensitive (openregister#4430);
|
|
19
|
+
* the server escapes `%`, `_` and `\`, so the raw term goes out as typed
|
|
18
20
|
*
|
|
19
21
|
* @module utils/columnFilters
|
|
20
22
|
* @spec openspec/changes/table-header-sort-and-filter/specs/cn-data-table/spec.md
|
|
23
|
+
* @spec openspec/changes/header-filter-contains/specs/cn-data-table/spec.md#requirement-a-text-header-filter-matches-on-contains
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
import { schemaRefSlug } from './schemaRefSlug.js'
|
|
24
27
|
|
|
28
|
+
/** OpenRegister's case-insensitive contains operator, used by the text filter. */
|
|
29
|
+
export const CONTAINS_OPERATOR = 'like'
|
|
30
|
+
|
|
25
31
|
/** Column keys that mark a value computed on the client, never stored. */
|
|
26
32
|
const COMPUTED_MARKERS = ['aggregate', 'compute', 'computed', 'virtual']
|
|
27
33
|
|
|
@@ -160,7 +166,7 @@ function referenceTarget(column, prop) {
|
|
|
160
166
|
*
|
|
161
167
|
* Kinds: `enum` (checkbox list), `boolean` (yes, no, any), `reference`
|
|
162
168
|
* (searchable list of the referenced objects), `number` and `date` (from and
|
|
163
|
-
* to), and `string` (
|
|
169
|
+
* to), and `string` (contains, through OpenRegister's `[like]`). `filterable: false` on the column turns it
|
|
164
170
|
* off. A column with no schema property behind it only filters when it
|
|
165
171
|
* carries an enum hint (`enum`, or a badge widget's colour map), because the
|
|
166
172
|
* server cannot filter on a value it does not store.
|
|
@@ -234,9 +240,25 @@ export function columnFilterParamKeys(def) {
|
|
|
234
240
|
if (def.kind === 'number' || def.kind === 'date') {
|
|
235
241
|
return [`${def.key}[gte]`, `${def.key}[lte]`]
|
|
236
242
|
}
|
|
243
|
+
if (def.kind === 'string') {
|
|
244
|
+
return [containsKey(def)]
|
|
245
|
+
}
|
|
237
246
|
return [def.key]
|
|
238
247
|
}
|
|
239
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The parameter a text filter writes: `{key}[like]`. An exact `key=value`
|
|
251
|
+
* from the facet sidebar or a fixed filter is a different key, so the header
|
|
252
|
+
* neither shows nor clears it.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} def A filter definition of kind `string`.
|
|
255
|
+
* @return {string} The parameter key.
|
|
256
|
+
* @spec openspec/changes/header-filter-contains/specs/cn-data-table/spec.md#requirement-a-text-header-filter-matches-on-contains
|
|
257
|
+
*/
|
|
258
|
+
function containsKey(def) {
|
|
259
|
+
return `${def.key}[${CONTAINS_OPERATOR}]`
|
|
260
|
+
}
|
|
261
|
+
|
|
240
262
|
/**
|
|
241
263
|
* A column filter's current state, read from the active-filter map.
|
|
242
264
|
*
|
|
@@ -262,6 +284,9 @@ export function columnFilterState(def, activeFilters) {
|
|
|
262
284
|
to: def.kind === 'date' ? to.slice(0, 10) : to,
|
|
263
285
|
}
|
|
264
286
|
}
|
|
287
|
+
if (def.kind === 'string') {
|
|
288
|
+
return { value: asList(filters[containsKey(def)])[0] || '' }
|
|
289
|
+
}
|
|
265
290
|
return { value: asList(filters[def.key])[0] || '' }
|
|
266
291
|
}
|
|
267
292
|
|
|
@@ -309,8 +334,10 @@ export function columnFilterParams(def, state) {
|
|
|
309
334
|
if (def.kind === 'boolean') {
|
|
310
335
|
return { [def.key]: (s.value === 'true' || s.value === 'false') ? [s.value] : [] }
|
|
311
336
|
}
|
|
337
|
+
// Text: the raw term under `[like]`. No wildcards: OpenRegister wraps
|
|
338
|
+
// the term itself and escapes `%`, `_` and `\` in it.
|
|
312
339
|
const value = s.value === undefined || s.value === null ? '' : String(s.value).trim()
|
|
313
|
-
return { [def
|
|
340
|
+
return { [containsKey(def)]: value === '' ? [] : [value] }
|
|
314
341
|
}
|
|
315
342
|
|
|
316
343
|
/**
|
package/src/utils/schema.js
CHANGED
|
@@ -367,7 +367,7 @@ function isUserProp(prop) {
|
|
|
367
367
|
if (!prop || typeof prop !== 'object') {
|
|
368
368
|
return false
|
|
369
369
|
}
|
|
370
|
-
if (prop.referenceType === 'nextcloud-user') {
|
|
370
|
+
if (prop.referenceType === 'nextcloud-user' || prop.widget === 'user') {
|
|
371
371
|
return true
|
|
372
372
|
}
|
|
373
373
|
const format = prop.format || ''
|
|
@@ -389,7 +389,44 @@ function isGroupProp(prop) {
|
|
|
389
389
|
if (!prop || typeof prop !== 'object') {
|
|
390
390
|
return false
|
|
391
391
|
}
|
|
392
|
-
return prop.referenceType === 'nextcloud-group' || prop.format === 'nc-group'
|
|
392
|
+
return prop.referenceType === 'nextcloud-group' || prop.format === 'nc-group' || prop.widget === 'group'
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Make a manifest field override that names a picker widget behave exactly
|
|
397
|
+
* like the schema format that triggers it.
|
|
398
|
+
*
|
|
399
|
+
* OpenRegister refuses formats it does not know (`nc-group`, `language`,
|
|
400
|
+
* `timezone`) at import, so an app cannot always put them in its schema. It
|
|
401
|
+
* declares `fieldOverrides.<key>.widget: 'group' | 'language' | 'timezone'`
|
|
402
|
+
* (or `'user'`) instead. The widget alone is not enough for the user and
|
|
403
|
+
* group pickers, which CnFormDialog recognises by `userPicker` /
|
|
404
|
+
* `groupPicker`, so set those here. An override may also carry its own
|
|
405
|
+
* `x-default` (`current-language`, `current-timezone`), which becomes the
|
|
406
|
+
* field's `defaultToken` just as the schema key does.
|
|
407
|
+
*
|
|
408
|
+
* Mutates `field` in place.
|
|
409
|
+
*
|
|
410
|
+
* @spec openspec/changes/review-round-two/specs/schema-utilities/spec.md
|
|
411
|
+
* @param {object} field The field descriptor, overrides already merged in.
|
|
412
|
+
* @param {object} override The raw override for this key.
|
|
413
|
+
* @return {void}
|
|
414
|
+
*/
|
|
415
|
+
function applyPickerOverride(field, override) {
|
|
416
|
+
if (!override || typeof override !== 'object') {
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
const widget = override.widget
|
|
420
|
+
if (widget === 'group' || widget === 'group-multiselect') {
|
|
421
|
+
field.groupPicker = { multiple: widget === 'group-multiselect' }
|
|
422
|
+
field.userPicker = null
|
|
423
|
+
} else if (widget === 'user' || widget === 'user-multiselect') {
|
|
424
|
+
field.userPicker = { multiple: widget === 'user-multiselect' }
|
|
425
|
+
field.groupPicker = null
|
|
426
|
+
}
|
|
427
|
+
if (typeof override['x-default'] === 'string' && override['x-default'] !== '') {
|
|
428
|
+
field.defaultToken = override['x-default']
|
|
429
|
+
}
|
|
393
430
|
}
|
|
394
431
|
|
|
395
432
|
/**
|
|
@@ -844,6 +881,7 @@ export function fieldsFromSchema(schema, options = {}) {
|
|
|
844
881
|
// Apply per-field overrides
|
|
845
882
|
if (overrides[key]) {
|
|
846
883
|
Object.assign(field, overrides[key])
|
|
884
|
+
applyPickerOverride(field, overrides[key])
|
|
847
885
|
}
|
|
848
886
|
|
|
849
887
|
return field
|