@conduction/nextcloud-vue 2.75.0 → 2.77.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 (158) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +18 -3
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +49 -3
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  5. package/dist/esm/components/CnCardGrid/CnCardGrid.vue2.js +6 -0
  6. package/dist/esm/components/CnCardGrid/CnCardGrid.vue2.js.map +1 -1
  7. package/dist/esm/components/CnCardGrid/CnCardGrid.vue3.js +3 -2
  8. package/dist/esm/components/CnCardGrid/CnCardGrid.vue3.js.map +1 -1
  9. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +42 -1
  10. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  11. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +228 -203
  12. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  13. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue.js +9 -0
  14. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue.js.map +1 -0
  15. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue2.js +50 -0
  16. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue2.js.map +1 -0
  17. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue3.js +13 -0
  18. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue3.js.map +1 -0
  19. package/dist/esm/components/CnCredentials/CnCredentials.vue2.js +11 -10
  20. package/dist/esm/components/CnCredentials/CnCredentials.vue2.js.map +1 -1
  21. package/dist/esm/components/CnCredentials/CnCredentials.vue3.js.map +1 -1
  22. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +41 -4
  23. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  24. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +90 -51
  25. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  26. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +24 -33
  27. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  28. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +12 -24
  29. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  30. package/dist/esm/components/CnFavouriteToggle/CnFavouriteToggle.vue2.js +7 -2
  31. package/dist/esm/components/CnFavouriteToggle/CnFavouriteToggle.vue2.js.map +1 -1
  32. package/dist/esm/components/CnFavouriteToggle/CnFavouriteToggle.vue3.js.map +1 -1
  33. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +114 -1
  34. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  35. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +115 -62
  36. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  37. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue2.js +89 -7
  38. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue2.js.map +1 -1
  39. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue3.js +62 -30
  40. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue3.js.map +1 -1
  41. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +242 -19
  42. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  43. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +25 -20
  44. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  45. package/dist/esm/components/CnIndexPage/selfModeActions.js +90 -14
  46. package/dist/esm/components/CnIndexPage/selfModeActions.js.map +1 -1
  47. package/dist/esm/components/CnIndexPage/useSelfFetchList.js +15 -3
  48. package/dist/esm/components/CnIndexPage/useSelfFetchList.js.map +1 -1
  49. package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js +26 -0
  50. package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js.map +1 -1
  51. package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js +26 -9
  52. package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js.map +1 -1
  53. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +199 -7
  54. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  55. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js +155 -119
  56. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  57. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +19 -3
  58. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  59. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  60. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +6 -0
  61. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  62. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +4 -1
  63. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  64. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue2.js +153 -38
  65. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue2.js.map +1 -1
  66. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue3.js +74 -56
  67. package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue3.js.map +1 -1
  68. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +13 -0
  69. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
  70. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
  71. package/dist/esm/composables/useListView.js +4 -0
  72. package/dist/esm/composables/useListView.js.map +1 -1
  73. package/dist/esm/composables/useObjectLock.js +41 -10
  74. package/dist/esm/composables/useObjectLock.js.map +1 -1
  75. package/dist/esm/index.js +3 -1
  76. package/dist/esm/index.js.map +1 -1
  77. package/dist/esm/l10n/en.json.js +31 -1
  78. package/dist/esm/l10n/en.json.js.map +1 -1
  79. package/dist/esm/l10n/nl.json.js +32 -2
  80. package/dist/esm/l10n/nl.json.js.map +1 -1
  81. package/dist/esm/nextcloud-vue.css +376 -75
  82. package/dist/esm/store/plugins/interactions.js +20 -10
  83. package/dist/esm/store/plugins/interactions.js.map +1 -1
  84. package/dist/esm/store/useObjectStore.js +54 -9
  85. package/dist/esm/store/useObjectStore.js.map +1 -1
  86. package/dist/esm/utils/builtInFormatters.js +15 -7
  87. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  88. package/dist/esm/utils/groupAutocomplete.js +63 -1
  89. package/dist/esm/utils/groupAutocomplete.js.map +1 -1
  90. package/dist/esm/utils/lensAvailability.js +90 -0
  91. package/dist/esm/utils/lensAvailability.js.map +1 -0
  92. package/dist/esm/utils/objectSignals.js +44 -0
  93. package/dist/esm/utils/objectSignals.js.map +1 -0
  94. package/dist/esm/utils/personalLenses.js +13 -6
  95. package/dist/esm/utils/personalLenses.js.map +1 -1
  96. package/dist/esm/utils/recordInteractions.js +31 -13
  97. package/dist/esm/utils/recordInteractions.js.map +1 -1
  98. package/dist/esm/utils/resolveFilterTokens.js +46 -3
  99. package/dist/esm/utils/resolveFilterTokens.js.map +1 -1
  100. package/dist/esm/utils/schema.js +1 -1
  101. package/dist/esm/utils/schema.js.map +1 -1
  102. package/dist/esm/utils/sentinelTokens.js +4 -4
  103. package/dist/esm/utils/sentinelTokens.js.map +1 -1
  104. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  105. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  106. package/dist/esm/utils/widgetVisibility.js +46 -1
  107. package/dist/esm/utils/widgetVisibility.js.map +1 -1
  108. package/dist/nextcloud-vue.cjs.js +4732 -2990
  109. package/dist/nextcloud-vue.cjs.js.map +2 -2
  110. package/dist/nextcloud-vue.css +376 -75
  111. package/l10n/en.json +31 -1
  112. package/l10n/nl.json +32 -2
  113. package/package.json +1 -1
  114. package/src/components/CnAppNav/CnAppNav.vue +44 -5
  115. package/src/components/CnCardGrid/CnCardGrid.md +1 -0
  116. package/src/components/CnCardGrid/CnCardGrid.vue +7 -0
  117. package/src/components/CnCellRenderer/CnCellRenderer.vue +56 -1
  118. package/src/components/CnCellRenderer/CnGroupNameCell.vue +61 -0
  119. package/src/components/CnCredentials/CnCredentials.vue +17 -16
  120. package/src/components/CnDataTable/CnDataTable.md +9 -0
  121. package/src/components/CnDataTable/CnDataTable.vue +80 -20
  122. package/src/components/CnDetailPage/CnDetailPage.vue +18 -32
  123. package/src/components/CnFavouriteToggle/CnFavouriteToggle.vue +7 -2
  124. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +204 -5
  125. package/src/components/CnFollowToggle/CnFollowToggle.vue +118 -13
  126. package/src/components/CnIndexPage/CnIndexPage.md +51 -0
  127. package/src/components/CnIndexPage/CnIndexPage.vue +261 -35
  128. package/src/components/CnIndexPage/selfModeActions.js +89 -13
  129. package/src/components/CnIndexPage/useSelfFetchList.js +15 -3
  130. package/src/components/CnObjectCard/CnObjectCard.md +1 -0
  131. package/src/components/CnObjectCard/CnObjectCard.vue +89 -1
  132. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +226 -2
  133. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +19 -3
  134. package/src/components/CnObjectSidebar/CnFilesTab.vue +9 -0
  135. package/src/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue +181 -38
  136. package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +13 -0
  137. package/src/composables/__tests__/useObjectLock.endpoints.spec.js +83 -0
  138. package/src/composables/useListView.js +4 -0
  139. package/src/composables/useObjectLock.js +41 -10
  140. package/src/css/look-board-detail.css +40 -2
  141. package/src/css/look-board-index.css +35 -1
  142. package/src/css/table.css +82 -17
  143. package/src/index.js +3 -1
  144. package/src/schemas/app-manifest-v2.schema.json +11 -7
  145. package/src/store/plugins/interactions.js +20 -10
  146. package/src/store/useObjectStore.js +54 -9
  147. package/src/utils/builtInFormatters.js +15 -7
  148. package/src/utils/groupAutocomplete.js +75 -0
  149. package/src/utils/index.js +2 -1
  150. package/src/utils/lensAvailability.js +86 -0
  151. package/src/utils/objectSignals.js +41 -0
  152. package/src/utils/personalLenses.js +13 -6
  153. package/src/utils/recordInteractions.js +31 -13
  154. package/src/utils/resolveFilterTokens.js +45 -2
  155. package/src/utils/schema.js +1 -1
  156. package/src/utils/sentinelTokens.js +4 -4
  157. package/src/utils/validateManifestV2.compiled.js +2 -2
  158. package/src/utils/widgetVisibility.js +45 -0
@@ -13,6 +13,58 @@ function resolveNameField(ctx) {
13
13
 
14
14
  function findSource(ctx, id) {
15
15
  return ctx.effectiveObjects().find((o) => o.id === id || o['@self']?.id === id)
16
+ || (typeof ctx.knownRow === 'function' ? ctx.knownRow(id) : undefined)
17
+ }
18
+
19
+ /**
20
+ * The register/schema of a row that belongs to another pair than the page's,
21
+ * in a list fetched from a `collectionUrl`. Registers a store type for that
22
+ * pair on first use. Returns null for rows of the page's own pair, and always
23
+ * without a `collectionUrl`.
24
+ *
25
+ * @param {object} opts Page context.
26
+ * @param {string} opts.collectionUrl The page's collection endpoint, or ''.
27
+ * @param {string} opts.register The page's register.
28
+ * @param {string} opts.schema The page's schema.
29
+ * @param {object|null} opts.primarySchema The page's resolved schema.
30
+ * @param {object|null} [opts.primaryRegister] The page's resolved register, which matches a slug `register` to `@self.register`.
31
+ * @param {object|null} opts.store The object store.
32
+ * @param {object} row The row.
33
+ * @return {{register: string, schema: string, type: string}|null} The row's own pair.
34
+ */
35
+ export function resolveRowTarget({ collectionUrl, register, schema, primarySchema, primaryRegister, store }, row) {
36
+ if (!collectionUrl || !row || !store) {
37
+ return null
38
+ }
39
+ const self = row['@self'] || {}
40
+ const reg = self.register !== undefined && self.register !== null ? String(self.register) : ''
41
+ const sch = self.schema !== undefined && self.schema !== null ? String(self.schema) : ''
42
+ if (!reg || !sch) {
43
+ return null
44
+ }
45
+ const idsOf = (...values) => values.filter((v) => v !== undefined && v !== null && v !== '').map(String)
46
+ const schemaIds = idsOf(schema, primarySchema && primarySchema.id, primarySchema && primarySchema.slug)
47
+ const registerIds = idsOf(register, primaryRegister && primaryRegister.id, primaryRegister && primaryRegister.slug)
48
+ if (schemaIds.includes(sch) && registerIds.includes(reg)) {
49
+ return null
50
+ }
51
+ const type = `${reg}-${sch}`
52
+ if (!store.objectTypeRegistry?.[type]) {
53
+ store.registerObjectType(type, sch, reg, { registerSlug: reg, schemaSlug: sch })
54
+ }
55
+ return { register: reg, schema: sch, type }
56
+ }
57
+
58
+ /**
59
+ * The store type a row is written through: its own pair's, or the page's.
60
+ *
61
+ * @param {object} ctx Accessor closures.
62
+ * @param {object} [row] The row.
63
+ * @return {string} The type slug.
64
+ */
65
+ function typeFor(ctx, row) {
66
+ const target = row && typeof ctx.rowTarget === 'function' ? ctx.rowTarget(row) : null
67
+ return target ? target.type : ctx.selfObjectType()
16
68
  }
17
69
 
18
70
  /**
@@ -25,6 +77,10 @@ function findSource(ctx, id) {
25
77
  */
26
78
  function copyAddress(ctx, source) {
27
79
  const self = source['@self'] || {}
80
+ const target = typeof ctx.rowTarget === 'function' ? ctx.rowTarget(source) : null
81
+ if (target) {
82
+ return { register: target.register, schema: target.schema, id: String(source.id || self.id) }
83
+ }
28
84
  const reg = ctx.register()
29
85
  const sch = ctx.schema()
30
86
  const register = (typeof reg === 'string' && reg) || self.register
@@ -54,12 +110,12 @@ function refreshList(ctx) {
54
110
  }
55
111
  }
56
112
 
57
- function storeError(ctx) {
58
- return ctx.selfObjectStore()?.getError?.(ctx.selfObjectType())
113
+ function storeError(ctx, type = ctx.selfObjectType()) {
114
+ return ctx.selfObjectStore()?.getError?.(type)
59
115
  }
60
116
 
61
- function storeErrorMessage(ctx, fallback) {
62
- const err = storeError(ctx)
117
+ function storeErrorMessage(ctx, fallback, type) {
118
+ const err = storeError(ctx, type)
63
119
  return (err && err.message) || fallback
64
120
  }
65
121
 
@@ -76,13 +132,14 @@ export function createSelfModeActions(ctx) {
76
132
  return false
77
133
  }
78
134
  try {
79
- const ok = await ctx.selfObjectStore().deleteObject(ctx.selfObjectType(), id)
135
+ const type = typeFor(ctx, findSource(ctx, id))
136
+ const ok = await ctx.selfObjectStore().deleteObject(type, id)
80
137
  if (ok) {
81
138
  ctx.setResults.singleDelete({ success: true })
82
139
  ctx.emit('delete', id)
83
140
  refreshList(ctx)
84
141
  } else {
85
- ctx.setResults.singleDelete({ error: storeErrorMessage(ctx, 'Delete failed') })
142
+ ctx.setResults.singleDelete({ error: storeErrorMessage(ctx, 'Delete failed', type) })
86
143
  }
87
144
  } catch (err) {
88
145
  ctx.setResults.singleDelete({ error: (err && err.message) || 'Delete failed' })
@@ -95,12 +152,28 @@ export function createSelfModeActions(ctx) {
95
152
  return false
96
153
  }
97
154
  try {
98
- const { successfulIds, failedIds } = await ctx.selfObjectStore().deleteObjects(ctx.selfObjectType(), ids)
155
+ // One request per pair: a mixed list holds rows of several types.
156
+ const idsByType = new Map()
157
+ for (const id of ids) {
158
+ const type = typeFor(ctx, findSource(ctx, id))
159
+ idsByType.set(type, [...(idsByType.get(type) || []), id])
160
+ }
161
+ const successfulIds = []
162
+ const failedIds = []
163
+ let failedType = null
164
+ for (const [type, typeIds] of idsByType) {
165
+ const outcome = await ctx.selfObjectStore().deleteObjects(type, typeIds)
166
+ successfulIds.push(...outcome.successfulIds)
167
+ failedIds.push(...outcome.failedIds)
168
+ if (outcome.failedIds.length > 0 && failedType === null) {
169
+ failedType = type
170
+ }
171
+ }
99
172
  if (failedIds.length === 0) {
100
173
  ctx.setResults.massDelete({ success: true, successfulIds })
101
174
  } else {
102
175
  ctx.setResults.massDelete({
103
- error: storeErrorMessage(ctx, `Failed to delete ${failedIds.length} item(s)`),
176
+ error: storeErrorMessage(ctx, `Failed to delete ${failedIds.length} item(s)`, failedType),
104
177
  successfulIds,
105
178
  failedIds,
106
179
  })
@@ -142,13 +215,14 @@ export function createSelfModeActions(ctx) {
142
215
  }
143
216
  }
144
217
  const clone = cloneObjectForCopy(source, newName, resolveNameField(ctx))
145
- const saved = await ctx.selfObjectStore().saveObject(ctx.selfObjectType(), clone)
218
+ const type = typeFor(ctx, source)
219
+ const saved = await ctx.selfObjectStore().saveObject(type, clone)
146
220
  if (saved) {
147
221
  ctx.setResults.singleCopy({ success: true })
148
222
  ctx.emit('copy', payload)
149
223
  refreshList(ctx)
150
224
  } else {
151
- ctx.setResults.singleCopy({ error: storeErrorMessage(ctx, 'Copy failed') })
225
+ ctx.setResults.singleCopy({ error: storeErrorMessage(ctx, 'Copy failed', type) })
152
226
  }
153
227
  } catch (err) {
154
228
  ctx.setResults.singleCopy({ error: (err && err.message) || 'Copy failed' })
@@ -191,7 +265,7 @@ export function createSelfModeActions(ctx) {
191
265
  }
192
266
  const clone = cloneObjectForCopy(source, getName(source), nameField)
193
267
  try {
194
- const saved = await ctx.selfObjectStore().saveObject(ctx.selfObjectType(), clone)
268
+ const saved = await ctx.selfObjectStore().saveObject(typeFor(ctx, source), clone)
195
269
  if (saved) {
196
270
  successfulIds.push(id)
197
271
  } else {
@@ -261,7 +335,9 @@ export function createSelfModeActions(ctx) {
261
335
  return false
262
336
  }
263
337
  try {
264
- const saved = await ctx.selfObjectStore().saveObject(ctx.selfObjectType(), formData)
338
+ const item = ctx.editItem()
339
+ const type = item ? typeFor(ctx, item) : ctx.selfObjectType()
340
+ const saved = await ctx.selfObjectStore().saveObject(type, formData)
265
341
  if (saved) {
266
342
  ctx.setResults.form({ success: true })
267
343
  const isCreate = !ctx.editItem()
@@ -277,7 +353,7 @@ export function createSelfModeActions(ctx) {
277
353
  ctx.afterCreateSuccess(saved)
278
354
  }
279
355
  } else {
280
- const err = storeError(ctx)
356
+ const err = storeError(ctx, type)
281
357
  if (err && err.isValidation) {
282
358
  // Keep the form visible so the user can fix the invalid data.
283
359
  ctx.setResults.formValidation(err.fields, err.message || 'Validation failed')
@@ -106,7 +106,11 @@ export function useSelfFetchList(props, instance, inject, extras = {}) {
106
106
  const register = folder.register || props.register
107
107
  return { register, schema: folder.schema, type: `${register}-${folder.schema}` }
108
108
  }
109
- return { register: props.register, schema: props.schema, type: `${props.register}-${props.schema}` }
109
+ // A collection endpoint gets its own type key, so a plain registration of
110
+ // the same pair elsewhere never resets it.
111
+ const collectionUrl = props.collectionUrl || ''
112
+ const type = `${props.register}-${props.schema}` + (collectionUrl ? `@${collectionUrl}` : '')
113
+ return { register: props.register, schema: props.schema, type, collectionUrl }
110
114
  })
111
115
  const objectType = computed(() => resolvedTarget.value.type)
112
116
  const sidebarState = inject('sidebarState', null) ?? inject('objectSidebarState', null)
@@ -155,9 +159,17 @@ export function useSelfFetchList(props, instance, inject, extras = {}) {
155
159
  // second arg) — that previously made fetch URLs go to `/api/objects/undefined/[object Object]`.
156
160
  // Runs (sync, ahead of the list's own watcher) again whenever the type switches.
157
161
  function registerTarget() {
158
- const { register, schema, type } = resolvedTarget.value
162
+ const { register, schema, type, collectionUrl } = resolvedTarget.value
159
163
  if (typeof objectStore.registerObjectType === 'function') {
160
- objectStore.registerObjectType(type, schema, register, { registerSlug: register, schemaSlug: schema })
164
+ objectStore.registerObjectType(type, schema, register, {
165
+ registerSlug: register,
166
+ schemaSlug: schema,
167
+ ...(collectionUrl ? { collectionUrl } : {}),
168
+ })
169
+ }
170
+ // A mixed list tells its own rows apart by `@self.register`, an id, so a slug register needs resolving.
171
+ if (collectionUrl && !/^\d+$/.test(String(register)) && typeof objectStore.fetchRegister === 'function') {
172
+ objectStore.fetchRegister(type)
161
173
  }
162
174
  }
163
175
  registerTarget()
@@ -137,6 +137,7 @@ export default {
137
137
  | Prop | Type | Default | Description |
138
138
  |---|---|---|---|
139
139
  | `maxMetadata` | `Number` | `4` | Maximum number of schema properties to show in the metadata section. Properties are sorted by their `order` value before slicing |
140
+ | `accent` | `Object` | `null` | Status accent `{ variant, icon?, label? }`: a colored start border and, with `icon`, a colored icon before the title. `variant` is `success`, `warning`, `error`, `info` or `primary`; `label` is the icon's tooltip and accessible name |
140
141
 
141
142
  ## Slots
142
143
 
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div
3
3
  class="cn-object-card"
4
- :class="{ 'cn-object-card--selected': selected, 'cn-object-card--board': isBoardLook }"
4
+ :class="[{ 'cn-object-card--selected': selected, 'cn-object-card--board': isBoardLook }, accentVariant && ['cn-object-card--accent', `cn-object-card--accent-${accentVariant}`]]"
5
5
  @mousedown="onCardMouseDown"
6
6
  @click="onCardClick($event)"
7
7
  @auxclick="onCardAuxClick($event)">
@@ -42,6 +42,16 @@
42
42
  to report. A card whose consumer passes no badges
43
43
  would otherwise show no padlock at all. -->
44
44
  <CnLockIndicator :object="object" :size="16" />
45
+ <span
46
+ v-if="accentVariant && accent.icon"
47
+ class="cn-object-card__accent-icon"
48
+ data-testid="cn-object-card-accent-icon"
49
+ :role="accent.label ? 'img' : null"
50
+ :aria-label="accent.label || null"
51
+ :aria-hidden="accent.label ? null : 'true'"
52
+ :title="accent.label || null">
53
+ <CnIcon :name="accent.icon" :size="16" />
54
+ </span>
45
55
  {{ title }}
46
56
  </h3>
47
57
  <p v-if="description" class="cn-object-card__description">
@@ -128,6 +138,8 @@ import { CnIcon } from '../CnIcon/index.js'
128
138
  import { CnLockIndicator } from '../CnLockIndicator/index.js'
129
139
  import { CnStatusBadge } from '../CnStatusBadge/index.js'
130
140
 
141
+ const ACCENT_VARIANTS = ['success', 'warning', 'error', 'info', 'primary']
142
+
131
143
  /**
132
144
  * CnObjectCard — Schema-configuration-driven card for object display.
133
145
  *
@@ -244,6 +256,20 @@ export default {
244
256
  default: null,
245
257
  },
246
258
 
259
+ /**
260
+ * A status accent: a colored border at the start of the card and,
261
+ * with `icon`, a colored icon before the title. `variant` is one of
262
+ * `success`, `warning`, `error`, `info` or `primary`; `icon` is a
263
+ * name registered with CnIcon; `label` names the status for screen
264
+ * readers and as the icon's tooltip.
265
+ *
266
+ * @type {{variant: string, icon?: string, label?: string}}
267
+ */
268
+ accent: {
269
+ type: Object,
270
+ default: null,
271
+ },
272
+
247
273
  /**
248
274
  * The property keys the facts list shows, in order (manifest key
249
275
  * `config.cardFields`). Without it the card shows the first
@@ -307,6 +333,16 @@ export default {
307
333
  return null
308
334
  },
309
335
 
336
+ /**
337
+ * The accent variant when it is a known one, else ''.
338
+ *
339
+ * @return {string}
340
+ */
341
+ accentVariant() {
342
+ const variant = this.accent?.variant
343
+ return ACCENT_VARIANTS.includes(variant) ? variant : ''
344
+ },
345
+
310
346
  config() {
311
347
  return this.schema?.configuration || {}
312
348
  },
@@ -502,6 +538,58 @@ export default {
502
538
  background: var(--color-primary-element-light);
503
539
  }
504
540
 
541
+ /* The 4px start border takes 3px of padding, so the content stays put. */
542
+ .cn-object-card.cn-object-card--accent {
543
+ border-inline-start-width: 4px;
544
+ padding-inline-start: 13px;
545
+ }
546
+
547
+ .cn-object-card.cn-object-card--accent-success {
548
+ border-inline-start-color: var(--color-element-success);
549
+ }
550
+
551
+ .cn-object-card.cn-object-card--accent-warning {
552
+ border-inline-start-color: var(--color-element-warning);
553
+ }
554
+
555
+ .cn-object-card.cn-object-card--accent-error {
556
+ border-inline-start-color: var(--color-element-error);
557
+ }
558
+
559
+ .cn-object-card.cn-object-card--accent-info {
560
+ border-inline-start-color: var(--color-element-info);
561
+ }
562
+
563
+ .cn-object-card.cn-object-card--accent-primary {
564
+ border-inline-start-color: var(--color-primary-element);
565
+ }
566
+
567
+ .cn-object-card__accent-icon {
568
+ display: inline-flex;
569
+ vertical-align: -2px;
570
+ margin-inline-end: 4px;
571
+ }
572
+
573
+ .cn-object-card--accent-success .cn-object-card__accent-icon {
574
+ color: var(--color-text-success);
575
+ }
576
+
577
+ .cn-object-card--accent-warning .cn-object-card__accent-icon {
578
+ color: var(--color-text-warning);
579
+ }
580
+
581
+ .cn-object-card--accent-error .cn-object-card__accent-icon {
582
+ color: var(--color-text-error);
583
+ }
584
+
585
+ .cn-object-card--accent-info .cn-object-card__accent-icon {
586
+ color: var(--color-element-info);
587
+ }
588
+
589
+ .cn-object-card--accent-primary .cn-object-card__accent-icon {
590
+ color: var(--color-primary-element);
591
+ }
592
+
505
593
  .cn-object-card__checkbox {
506
594
  flex-shrink: 0;
507
595
  padding-top: 2px;
@@ -149,6 +149,28 @@
149
149
  @update:modelValue="onRelationChange(field, $event)"
150
150
  @close="commitEdit" />
151
151
 
152
+ <!-- Nextcloud group or user (`referenceType: nextcloud-group`
153
+ / `nextcloud-user`, one or a list): pick by display name,
154
+ store the group id or uid. The same search the create and
155
+ edit dialogs use. -->
156
+ <component
157
+ :is="isUserPicker(field) ? 'NcSelectUsers' : 'NcSelect'"
158
+ v-else-if="isPersonPicker(field)"
159
+ ref="activeEditor"
160
+ :inputLabel="field.label"
161
+ :labelOutside="true"
162
+ :options="personOptions[field.key] || []"
163
+ :modelValue="personSelected(field)"
164
+ :multiple="isPersonMultiple(field)"
165
+ :keepOpen="isPersonMultiple(field)"
166
+ :loading="personLoading === field.key"
167
+ :filterable="false"
168
+ :clearable="!field.required"
169
+ label="label"
170
+ @search="(q) => searchPeople(field, q)"
171
+ @update:modelValue="onPersonChange(field, $event)"
172
+ @close="isPersonMultiple(field) ? undefined : commitEdit()" />
173
+
152
174
  <!-- Select -->
153
175
  <NcSelect
154
176
  v-else-if="field.widget === 'select'"
@@ -259,7 +281,17 @@
259
281
  :value="displayValues[field.key]"
260
282
  :raw="(objectData || {})[field.key]" />
261
283
  <template v-else>
262
- <img v-if="isImageField(field) && rawOf(field)"
284
+ <!-- A Nextcloud group (or list): its display name, cached
285
+ per id; the id while the name loads or when unknown. -->
286
+ <span
287
+ v-if="isGroupPicker(field) && groupIdsOf(field).length"
288
+ class="cn-object-data-widget__groups">
289
+ <template v-for="(gid, gi) in groupIdsOf(field)" :key="gid">
290
+ <span v-if="gi > 0">, </span>
291
+ <CnGroupNameCell :gid="gid" />
292
+ </template>
293
+ </span>
294
+ <img v-else-if="isImageField(field) && rawOf(field)"
263
295
  :src="rawOf(field)"
264
296
  :alt="field.label"
265
297
  class="cn-object-data-widget__image">
@@ -370,19 +402,22 @@
370
402
  import axios from '@nextcloud/axios'
371
403
  import { translate as t } from '@nextcloud/l10n'
372
404
  import { generateUrl } from '@nextcloud/router'
373
- import { NcActionButton, NcButton, NcCheckboxRadioSwitch, NcLoadingIcon, NcSelect, NcTextField } from '@nextcloud/vue'
405
+ import { NcActionButton, NcButton, NcCheckboxRadioSwitch, NcLoadingIcon, NcSelect, NcSelectUsers, NcTextField } from '@nextcloud/vue'
374
406
  import Check from 'vue-material-design-icons/Check.vue'
375
407
  import Close from 'vue-material-design-icons/Close.vue'
376
408
  import ContentSaveOutline from 'vue-material-design-icons/ContentSaveOutline.vue'
377
409
  import InformationOutline from 'vue-material-design-icons/InformationOutline.vue'
378
410
  import Pencil from 'vue-material-design-icons/Pencil.vue'
411
+ import CnGroupNameCell from '../CnCellRenderer/CnGroupNameCell.vue'
379
412
  import CnFormDialog from '../CnFormDialog/CnFormDialog.vue'
380
413
  import { normalizeLook } from '../../composables/useLook.js'
381
414
  import { useObjectStore } from '../../store/index.js'
415
+ import { groupDisplayName, loadGroupDisplayName, searchNextcloudGroups } from '../../utils/groupAutocomplete.js'
382
416
  import { PANEL_ACTION_SINK } from '../../utils/panelActions.js'
383
417
  import { resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
384
418
  import { fieldsFromSchema, formatValue } from '../../utils/schema.js'
385
419
  import { schemaRefSlug } from '../../utils/schemaRefSlug.js'
420
+ import { resolveNextcloudUser, searchNextcloudUsers } from '../../utils/userAutocomplete.js'
386
421
  import { CnIcon } from '../CnIcon/index.js'
387
422
  import { CnObjectMetadataModal } from '../CnObjectMetadataModal/index.js'
388
423
  import { CnWidgetWrapper } from '../CnWidgetWrapper/index.js'
@@ -427,6 +462,8 @@ export default {
427
462
  NcLoadingIcon,
428
463
  NcTextField,
429
464
  NcSelect,
465
+ NcSelectUsers,
466
+ CnGroupNameCell,
430
467
  NcCheckboxRadioSwitch,
431
468
  NcActionButton,
432
469
  CnWidgetWrapper,
@@ -806,6 +843,12 @@ export default {
806
843
  relationOptions: {},
807
844
  /** Whether relation picker options are being fetched. */
808
845
  relationOptionsLoading: false,
846
+ /** Group and user picker options per field key ({ id, label }[]). */
847
+ personOptions: {},
848
+ /** Key of the group or user field whose options are loading, or null. */
849
+ personLoading: null,
850
+ /** Display names of users seen in a user picker, by uid. */
851
+ userLabels: {},
809
852
  /** Whether the user expanded the widget to see every field. */
810
853
  expanded: false,
811
854
  /** Fields that fit the cell while collapsed; null when all fit. */
@@ -1719,6 +1762,11 @@ export default {
1719
1762
  if (this.isSingleRelationField(field.key)) {
1720
1763
  this.loadRelationOptions(field.key)
1721
1764
  }
1765
+ // Group and user fields pick from Nextcloud's own directory.
1766
+ if (this.isPersonPicker(field)) {
1767
+ this.searchPeople(field, '')
1768
+ this.resolvePersonLabels(field, currentValue)
1769
+ }
1722
1770
 
1723
1771
  this.$nextTick(() => {
1724
1772
  // Focus the editor
@@ -1944,6 +1992,182 @@ export default {
1944
1992
  return options.find((opt) => opt.id === val) || { id: val, label: String(val) }
1945
1993
  },
1946
1994
 
1995
+ /**
1996
+ * Whether a field picks a Nextcloud group (widget `group` or
1997
+ * `group-multiselect`, from `referenceType: nextcloud-group`).
1998
+ *
1999
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-the-inline-editor-picks-a-nextcloud-group-or-user
2000
+ * @param {object} field Field descriptor.
2001
+ * @return {boolean}
2002
+ */
2003
+ isGroupPicker(field) {
2004
+ return !!field && (field.widget === 'group' || field.widget === 'group-multiselect')
2005
+ },
2006
+
2007
+ /**
2008
+ * Whether a field picks a Nextcloud user (widget `user` or
2009
+ * `user-multiselect`, from `referenceType: nextcloud-user`).
2010
+ *
2011
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-the-inline-editor-picks-a-nextcloud-group-or-user
2012
+ * @param {object} field Field descriptor.
2013
+ * @return {boolean}
2014
+ */
2015
+ isUserPicker(field) {
2016
+ return !!field && (field.widget === 'user' || field.widget === 'user-multiselect')
2017
+ },
2018
+
2019
+ /**
2020
+ * Whether a field picks a group or a user.
2021
+ *
2022
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-the-inline-editor-picks-a-nextcloud-group-or-user
2023
+ * @param {object} field Field descriptor.
2024
+ * @return {boolean}
2025
+ */
2026
+ isPersonPicker(field) {
2027
+ return this.isGroupPicker(field) || this.isUserPicker(field)
2028
+ },
2029
+
2030
+ /**
2031
+ * Whether a group or user field holds a list.
2032
+ *
2033
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-the-inline-editor-picks-a-nextcloud-group-or-user
2034
+ * @param {object} field Field descriptor.
2035
+ * @return {boolean}
2036
+ */
2037
+ isPersonMultiple(field) {
2038
+ return !!field && (field.widget === 'group-multiselect' || field.widget === 'user-multiselect')
2039
+ },
2040
+
2041
+ /**
2042
+ * The group ids a group field holds, for the display mode.
2043
+ *
2044
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-the-inline-editor-picks-a-nextcloud-group-or-user
2045
+ * @param {object} field Field descriptor.
2046
+ * @return {string[]}
2047
+ */
2048
+ groupIdsOf(field) {
2049
+ const raw = this.rawOf(field)
2050
+ const list = Array.isArray(raw) ? raw : [raw]
2051
+ return list.filter((v) => typeof v === 'string' && v !== '')
2052
+ },
2053
+
2054
+ /**
2055
+ * The display name of a group id or uid, falling back to the id.
2056
+ *
2057
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-the-inline-editor-picks-a-nextcloud-group-or-user
2058
+ * @param {object} field Field descriptor.
2059
+ * @param {string} id The group id or uid.
2060
+ * @return {string}
2061
+ */
2062
+ personLabel(field, id) {
2063
+ if (this.isGroupPicker(field)) {
2064
+ return groupDisplayName(id) || String(id)
2065
+ }
2066
+ return this.userLabels[id] || String(id)
2067
+ },
2068
+
2069
+ /**
2070
+ * Search Nextcloud groups or users for a picker. Fails soft (the
2071
+ * helpers return `[]`), so the picker stays usable with what it has.
2072
+ *
2073
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-the-inline-editor-picks-a-nextcloud-group-or-user
2074
+ * @param {object} field Field descriptor.
2075
+ * @param {string} query The search term.
2076
+ * @return {Promise<void>}
2077
+ */
2078
+ async searchPeople(field, query) {
2079
+ this.personLoading = field.key
2080
+ try {
2081
+ const options = this.isGroupPicker(field)
2082
+ ? await searchNextcloudGroups(query || '')
2083
+ : await searchNextcloudUsers(query || '')
2084
+ const list = Array.isArray(options) ? options : []
2085
+ if (this.isUserPicker(field)) {
2086
+ const labels = { ...this.userLabels }
2087
+ for (const opt of list) {
2088
+ labels[opt.id] = opt.label
2089
+ }
2090
+ this.userLabels = labels
2091
+ }
2092
+ this.personOptions = { ...this.personOptions, [field.key]: list }
2093
+ } finally {
2094
+ if (this.personLoading === field.key) {
2095
+ this.personLoading = null
2096
+ }
2097
+ }
2098
+ },
2099
+
2100
+ /**
2101
+ * Look up the display names of the value a group or user field holds,
2102
+ * so the open editor shows names rather than ids.
2103
+ *
2104
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-the-inline-editor-picks-a-nextcloud-group-or-user
2105
+ * @param {object} field Field descriptor.
2106
+ * @param {unknown} value The held value (an id or a list of ids).
2107
+ * @return {Promise<void>}
2108
+ */
2109
+ async resolvePersonLabels(field, value) {
2110
+ const ids = (Array.isArray(value) ? value : [value])
2111
+ .filter((v) => typeof v === 'string' && v !== '')
2112
+ if (this.isGroupPicker(field)) {
2113
+ await Promise.all(ids.map((gid) => loadGroupDisplayName(gid)))
2114
+ return
2115
+ }
2116
+ const unknown = ids.filter((uid) => !this.userLabels[uid])
2117
+ const options = await Promise.all(unknown.map((uid) => resolveNextcloudUser(uid)))
2118
+ if (options.length > 0) {
2119
+ const labels = { ...this.userLabels }
2120
+ for (const opt of options) {
2121
+ if (opt && opt.id) {
2122
+ labels[opt.id] = opt.label || opt.id
2123
+ }
2124
+ }
2125
+ this.userLabels = labels
2126
+ }
2127
+ },
2128
+
2129
+ /**
2130
+ * The selected option(s) of a group or user picker.
2131
+ *
2132
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-the-inline-editor-picks-a-nextcloud-group-or-user
2133
+ * @param {object} field Field descriptor.
2134
+ * @return {object|object[]|null}
2135
+ */
2136
+ personSelected(field) {
2137
+ const toOption = (id) => {
2138
+ const label = this.personLabel(field, id)
2139
+ return { id, label, displayName: label }
2140
+ }
2141
+ const v = this.editData[field.key]
2142
+ if (this.isPersonMultiple(field)) {
2143
+ return (Array.isArray(v) ? v : [])
2144
+ .filter((id) => typeof id === 'string' && id !== '')
2145
+ .map(toOption)
2146
+ }
2147
+ return (typeof v === 'string' && v !== '') ? toOption(v) : null
2148
+ },
2149
+
2150
+ /**
2151
+ * Store a group or user choice as the id (or list of ids).
2152
+ *
2153
+ * @spec openspec/changes/nextcloud-group-surfaces/specs/data-display/spec.md#requirement-the-inline-editor-picks-a-nextcloud-group-or-user
2154
+ * @param {object} field Field descriptor.
2155
+ * @param {object|object[]|null} selected The chosen option(s).
2156
+ */
2157
+ onPersonChange(field, selected) {
2158
+ if (this.isPersonMultiple(field)) {
2159
+ const ids = (Array.isArray(selected) ? selected : [])
2160
+ .map((opt) => (opt && typeof opt === 'object' ? opt.id : opt))
2161
+ .filter((id) => typeof id === 'string' && id !== '')
2162
+ this.updateField(field.key, ids)
2163
+ return
2164
+ }
2165
+ if (selected && this.isUserPicker(field) && selected.id) {
2166
+ this.userLabels = { ...this.userLabels, [selected.id]: selected.label || selected.id }
2167
+ }
2168
+ this.updateField(field.key, selected && selected.id ? selected.id : null)
2169
+ },
2170
+
1947
2171
  onSelectChange(field, option) {
1948
2172
  this.updateField(field.key, option ? option.id : null)
1949
2173
  },
@@ -357,7 +357,8 @@ export default {
357
357
  * Host translate function provided by CnAppRoot as
358
358
  * `cnTranslate: this.translate` (bound to the host app's id). The
359
359
  * manifest-authored `content.emptyText` is run through it for this
360
- * component's OWN empty state, and so is `content.prompt`. Defaults to an identity function so
360
+ * component's OWN empty state, and so are `content.prompt` and
361
+ * `content.addLabel`. Defaults to an identity function so
361
362
  * an untranslated key renders as itself.
362
363
  */
363
364
  cnTranslate: { default: () => (key) => key },
@@ -1092,9 +1093,24 @@ export default {
1092
1093
  return (c.formFieldOverrides && typeof c.formFieldOverrides === 'object') ? c.formFieldOverrides : {}
1093
1094
  },
1094
1095
 
1095
- /** Pre-translated Add label (overridable via `content.addLabel`). */
1096
+ /**
1097
+ * The Add label: `content.addLabel` through the app's translate
1098
+ * function, else the library's "Add".
1099
+ *
1100
+ * @spec openspec/changes/r4-object-lock-url-and-credentials-copy/specs/object-list-add-label/spec.md
1101
+ * @return {string}
1102
+ */
1096
1103
  addLabel() {
1097
- return this.content.addLabel || t('nextcloud-vue', 'Add')
1104
+ // `content.addLabel` is manifest copy in the APP's catalogue, so it
1105
+ // goes through the host translate function like `emptyText` and
1106
+ // `prompt`. It used to print raw, so a Dutch user read the English
1107
+ // manifest string. A label an app already translated has no key
1108
+ // of its own in the catalogue and comes back unchanged.
1109
+ if (this.content.addLabel) {
1110
+ const fn = typeof this.cnTranslate === 'function' ? this.cnTranslate : (k) => k
1111
+ return fn(this.content.addLabel)
1112
+ }
1113
+ return t('nextcloud-vue', 'Add')
1098
1114
  },
1099
1115
 
1100
1116
  /** Stable signature of the query so the watcher only refetches on real change. */