@conduction/nextcloud-vue 2.119.0 → 2.121.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 (179) hide show
  1. package/dist/esm/components/CnAiFeaturesWidget/CnAiFeaturesWidget.vue.js +9 -0
  2. package/dist/esm/components/CnAiFeaturesWidget/CnAiFeaturesWidget.vue.js.map +1 -0
  3. package/dist/esm/components/CnAiFeaturesWidget/CnAiFeaturesWidget.vue2.js +226 -0
  4. package/dist/esm/components/CnAiFeaturesWidget/CnAiFeaturesWidget.vue2.js.map +1 -0
  5. package/dist/esm/components/CnAiFeaturesWidget/CnAiFeaturesWidget.vue3.js +211 -0
  6. package/dist/esm/components/CnAiFeaturesWidget/CnAiFeaturesWidget.vue3.js.map +1 -0
  7. package/dist/esm/components/CnAiFeaturesWidget/CnAiSuggestionDecision.vue.js +9 -0
  8. package/dist/esm/components/CnAiFeaturesWidget/CnAiSuggestionDecision.vue.js.map +1 -0
  9. package/dist/esm/components/CnAiFeaturesWidget/CnAiSuggestionDecision.vue2.js +64 -0
  10. package/dist/esm/components/CnAiFeaturesWidget/CnAiSuggestionDecision.vue2.js.map +1 -0
  11. package/dist/esm/components/CnAiFeaturesWidget/CnAiSuggestionDecision.vue3.js +89 -0
  12. package/dist/esm/components/CnAiFeaturesWidget/CnAiSuggestionDecision.vue3.js.map +1 -0
  13. package/dist/esm/components/CnIcon/semanticIcons.js +2 -2
  14. package/dist/esm/components/CnIcon/semanticIcons.js.map +1 -1
  15. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +53 -1
  16. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  17. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +546 -523
  18. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  19. package/dist/esm/components/CnIndexPage/useNamedSource.js +9 -0
  20. package/dist/esm/components/CnIndexPage/useNamedSource.js.map +1 -1
  21. package/dist/esm/components/CnObjectPermissionsTab/CnObjectPermissionsTab.vue.js +9 -0
  22. package/dist/esm/components/CnObjectPermissionsTab/CnObjectPermissionsTab.vue.js.map +1 -0
  23. package/dist/esm/components/CnObjectPermissionsTab/CnObjectPermissionsTab.vue2.js +257 -0
  24. package/dist/esm/components/CnObjectPermissionsTab/CnObjectPermissionsTab.vue2.js.map +1 -0
  25. package/dist/esm/components/CnObjectPermissionsTab/CnObjectPermissionsTab.vue3.js +266 -0
  26. package/dist/esm/components/CnObjectPermissionsTab/CnObjectPermissionsTab.vue3.js.map +1 -0
  27. package/dist/esm/components/CnObjectSidebar/CnObjectSidebar.vue.js +1 -1
  28. package/dist/esm/components/CnObjectSidebar/CnObjectSidebar.vue2.js +14 -7
  29. package/dist/esm/components/CnObjectSidebar/CnObjectSidebar.vue2.js.map +1 -1
  30. package/dist/esm/components/CnObjectSidebar/CnObjectSidebar.vue3.js.map +1 -1
  31. package/dist/esm/components/CnObjectTranslationsWidget/CnObjectTranslationsWidget.vue.js +9 -0
  32. package/dist/esm/components/CnObjectTranslationsWidget/CnObjectTranslationsWidget.vue.js.map +1 -0
  33. package/dist/esm/components/CnObjectTranslationsWidget/CnObjectTranslationsWidget.vue2.js +278 -0
  34. package/dist/esm/components/CnObjectTranslationsWidget/CnObjectTranslationsWidget.vue2.js.map +1 -0
  35. package/dist/esm/components/CnObjectTranslationsWidget/CnObjectTranslationsWidget.vue3.js +128 -0
  36. package/dist/esm/components/CnObjectTranslationsWidget/CnObjectTranslationsWidget.vue3.js.map +1 -0
  37. package/dist/esm/components/CnReadinessChecklist/CnReadinessChecklist.vue.js +9 -0
  38. package/dist/esm/components/CnReadinessChecklist/CnReadinessChecklist.vue.js.map +1 -0
  39. package/dist/esm/components/CnReadinessChecklist/CnReadinessChecklist.vue2.js +214 -0
  40. package/dist/esm/components/CnReadinessChecklist/CnReadinessChecklist.vue2.js.map +1 -0
  41. package/dist/esm/components/CnReadinessChecklist/CnReadinessChecklist.vue3.js +125 -0
  42. package/dist/esm/components/CnReadinessChecklist/CnReadinessChecklist.vue3.js.map +1 -0
  43. package/dist/esm/components/CnSchemaRulesWidget/CnSchemaRulesWidget.vue.js +9 -0
  44. package/dist/esm/components/CnSchemaRulesWidget/CnSchemaRulesWidget.vue.js.map +1 -0
  45. package/dist/esm/components/CnSchemaRulesWidget/CnSchemaRulesWidget.vue2.js +163 -0
  46. package/dist/esm/components/CnSchemaRulesWidget/CnSchemaRulesWidget.vue2.js.map +1 -0
  47. package/dist/esm/components/CnSchemaRulesWidget/CnSchemaRulesWidget.vue3.js +131 -0
  48. package/dist/esm/components/CnSchemaRulesWidget/CnSchemaRulesWidget.vue3.js.map +1 -0
  49. package/dist/esm/components/CnSchemaRulesWidget/schemaRules.js +127 -0
  50. package/dist/esm/components/CnSchemaRulesWidget/schemaRules.js.map +1 -0
  51. package/dist/esm/components/CnSearchIndexStatus/CnSearchIndexStatus.vue.js +9 -0
  52. package/dist/esm/components/CnSearchIndexStatus/CnSearchIndexStatus.vue.js.map +1 -0
  53. package/dist/esm/components/CnSearchIndexStatus/CnSearchIndexStatus.vue2.js +82 -0
  54. package/dist/esm/components/CnSearchIndexStatus/CnSearchIndexStatus.vue2.js.map +1 -0
  55. package/dist/esm/components/CnSearchIndexStatus/CnSearchIndexStatus.vue3.js +61 -0
  56. package/dist/esm/components/CnSearchIndexStatus/CnSearchIndexStatus.vue3.js.map +1 -0
  57. package/dist/esm/components/CnSearchIndexStatus/searchIndexStatus.js +60 -0
  58. package/dist/esm/components/CnSearchIndexStatus/searchIndexStatus.js.map +1 -0
  59. package/dist/esm/components/CnShippedConfigurationSection/CnShippedConfigurationSection.vue.js +10 -0
  60. package/dist/esm/components/CnShippedConfigurationSection/CnShippedConfigurationSection.vue.js.map +1 -0
  61. package/dist/esm/components/CnShippedConfigurationSection/CnShippedConfigurationSection.vue2.js +226 -0
  62. package/dist/esm/components/CnShippedConfigurationSection/CnShippedConfigurationSection.vue2.js.map +1 -0
  63. package/dist/esm/components/CnShippedConfigurationSection/CnShippedConfigurationSection.vue3.js +126 -0
  64. package/dist/esm/components/CnShippedConfigurationSection/CnShippedConfigurationSection.vue3.js.map +1 -0
  65. package/dist/esm/components/CnStateMachineEditor/CnStateMachineEditor.vue.js +10 -0
  66. package/dist/esm/components/CnStateMachineEditor/CnStateMachineEditor.vue.js.map +1 -0
  67. package/dist/esm/components/CnStateMachineEditor/CnStateMachineEditor.vue2.js +944 -0
  68. package/dist/esm/components/CnStateMachineEditor/CnStateMachineEditor.vue2.js.map +1 -0
  69. package/dist/esm/components/CnStateMachineEditor/CnStateMachineEditor.vue3.js +276 -0
  70. package/dist/esm/components/CnStateMachineEditor/CnStateMachineEditor.vue3.js.map +1 -0
  71. package/dist/esm/components/CnStateMachineEditor/CnStateMachineFields.vue.js +9 -0
  72. package/dist/esm/components/CnStateMachineEditor/CnStateMachineFields.vue.js.map +1 -0
  73. package/dist/esm/components/CnStateMachineEditor/CnStateMachineFields.vue2.js +354 -0
  74. package/dist/esm/components/CnStateMachineEditor/CnStateMachineFields.vue2.js.map +1 -0
  75. package/dist/esm/components/CnStateMachineEditor/CnStateMachineFields.vue3.js +392 -0
  76. package/dist/esm/components/CnStateMachineEditor/CnStateMachineFields.vue3.js.map +1 -0
  77. package/dist/esm/components/CnStateMachineEditor/CnStateMachineNode.vue.js +9 -0
  78. package/dist/esm/components/CnStateMachineEditor/CnStateMachineNode.vue.js.map +1 -0
  79. package/dist/esm/components/CnStateMachineEditor/CnStateMachineNode.vue2.js +180 -0
  80. package/dist/esm/components/CnStateMachineEditor/CnStateMachineNode.vue2.js.map +1 -0
  81. package/dist/esm/components/CnStateMachineEditor/CnStateMachineNode.vue3.js +208 -0
  82. package/dist/esm/components/CnStateMachineEditor/CnStateMachineNode.vue3.js.map +1 -0
  83. package/dist/esm/components/CnStateMachineEditor/CnStateMachinePanel.vue.js +9 -0
  84. package/dist/esm/components/CnStateMachineEditor/CnStateMachinePanel.vue.js.map +1 -0
  85. package/dist/esm/components/CnStateMachineEditor/CnStateMachinePanel.vue2.js +244 -0
  86. package/dist/esm/components/CnStateMachineEditor/CnStateMachinePanel.vue2.js.map +1 -0
  87. package/dist/esm/components/CnStateMachineEditor/CnStateMachinePanel.vue3.js +76 -0
  88. package/dist/esm/components/CnStateMachineEditor/CnStateMachinePanel.vue3.js.map +1 -0
  89. package/dist/esm/components/CnStateMachineEditor/stateMachineFields.js +290 -0
  90. package/dist/esm/components/CnStateMachineEditor/stateMachineFields.js.map +1 -0
  91. package/dist/esm/components/CnTimelineWidget/CnTimelineManage.vue.js +9 -0
  92. package/dist/esm/components/CnTimelineWidget/CnTimelineManage.vue.js.map +1 -0
  93. package/dist/esm/components/CnTimelineWidget/CnTimelineManage.vue2.js +448 -0
  94. package/dist/esm/components/CnTimelineWidget/CnTimelineManage.vue2.js.map +1 -0
  95. package/dist/esm/components/CnTimelineWidget/CnTimelineManage.vue3.js +303 -0
  96. package/dist/esm/components/CnTimelineWidget/CnTimelineManage.vue3.js.map +1 -0
  97. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue.js +1 -1
  98. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +19 -2
  99. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -1
  100. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js +73 -62
  101. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -1
  102. package/dist/esm/components/CnWidgetGrid/builtInWidgets.js +6 -0
  103. package/dist/esm/components/CnWidgetGrid/builtInWidgets.js.map +1 -1
  104. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +42 -32
  105. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  106. package/dist/esm/composables/indexSources.js +116 -16
  107. package/dist/esm/composables/indexSources.js.map +1 -1
  108. package/dist/esm/index.js +9 -0
  109. package/dist/esm/index.js.map +1 -1
  110. package/dist/esm/l10n/en.json.js +207 -1
  111. package/dist/esm/l10n/en.json.js.map +1 -1
  112. package/dist/esm/l10n/nl.json.js +207 -1
  113. package/dist/esm/l10n/nl.json.js.map +1 -1
  114. package/dist/esm/library-style.js +1 -1
  115. package/dist/esm/nextcloud-vue.css +951 -16
  116. package/dist/esm/utils/aiFeatures.js +245 -0
  117. package/dist/esm/utils/aiFeatures.js.map +1 -0
  118. package/dist/esm/utils/aiSuggestionTracker.js +182 -0
  119. package/dist/esm/utils/aiSuggestionTracker.js.map +1 -0
  120. package/dist/esm/utils/libraryWidgetKeys.js +2 -0
  121. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  122. package/dist/esm/utils/objectPermissions.js +552 -0
  123. package/dist/esm/utils/objectPermissions.js.map +1 -0
  124. package/dist/esm/utils/objectTranslations.js +180 -0
  125. package/dist/esm/utils/objectTranslations.js.map +1 -0
  126. package/dist/esm/utils/stateMachineValidation.js +254 -0
  127. package/dist/esm/utils/stateMachineValidation.js.map +1 -0
  128. package/dist/esm/utils/widgetDispatch.js +3 -1
  129. package/dist/esm/utils/widgetDispatch.js.map +1 -1
  130. package/dist/nextcloud-vue.cjs.js +23038 -14567
  131. package/dist/nextcloud-vue.cjs.js.map +2 -2
  132. package/dist/nextcloud-vue.css +951 -16
  133. package/l10n/en.json +207 -1
  134. package/l10n/nl.json +207 -1
  135. package/package.json +1 -1
  136. package/src/components/CnAiFeaturesWidget/CnAiFeaturesWidget.vue +416 -0
  137. package/src/components/CnAiFeaturesWidget/CnAiSuggestionDecision.vue +136 -0
  138. package/src/components/CnAiFeaturesWidget/__tests__/CnAiFeaturesWidget.spec.js +213 -0
  139. package/src/components/CnAiFeaturesWidget/index.js +14 -0
  140. package/src/components/CnIndexPage/CnIndexPage.vue +74 -3
  141. package/src/components/CnIndexPage/useNamedSource.js +9 -0
  142. package/src/components/CnObjectPermissionsTab/CnObjectPermissionsTab.vue +499 -0
  143. package/src/components/CnObjectPermissionsTab/__tests__/CnObjectPermissionsTab.spec.js +186 -0
  144. package/src/components/CnObjectPermissionsTab/index.js +13 -0
  145. package/src/components/CnObjectSidebar/CnObjectSidebar.vue +8 -1
  146. package/src/components/CnObjectTranslationsWidget/CnObjectTranslationsWidget.vue +424 -0
  147. package/src/components/CnObjectTranslationsWidget/__tests__/CnObjectTranslationsWidget.spec.js +189 -0
  148. package/src/components/CnObjectTranslationsWidget/index.js +13 -0
  149. package/src/components/CnReadinessChecklist/CnReadinessChecklist.vue +335 -0
  150. package/src/components/CnReadinessChecklist/index.js +9 -0
  151. package/src/components/CnSchemaRulesWidget/CnSchemaRulesWidget.vue +300 -0
  152. package/src/components/CnSchemaRulesWidget/index.js +10 -0
  153. package/src/components/CnSchemaRulesWidget/schemaRules.js +123 -0
  154. package/src/components/CnSearchIndexStatus/CnSearchIndexStatus.vue +160 -0
  155. package/src/components/CnSearchIndexStatus/index.js +10 -0
  156. package/src/components/CnSearchIndexStatus/searchIndexStatus.js +56 -0
  157. package/src/components/CnShippedConfigurationSection/CnShippedConfigurationSection.vue +328 -0
  158. package/src/components/CnShippedConfigurationSection/index.js +9 -0
  159. package/src/components/CnStateMachineEditor/CnStateMachineEditor.vue +1332 -0
  160. package/src/components/CnStateMachineEditor/CnStateMachineFields.vue +791 -0
  161. package/src/components/CnStateMachineEditor/CnStateMachineNode.vue +442 -0
  162. package/src/components/CnStateMachineEditor/CnStateMachinePanel.vue +326 -0
  163. package/src/components/CnStateMachineEditor/index.js +8 -0
  164. package/src/components/CnStateMachineEditor/stateMachineFields.js +287 -0
  165. package/src/components/CnTimelineWidget/CnTimelineManage.vue +827 -0
  166. package/src/components/CnTimelineWidget/CnTimelineWidget.vue +29 -3
  167. package/src/components/CnWidgetGrid/builtInWidgets.js +6 -0
  168. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +10 -0
  169. package/src/components/index.js +8 -0
  170. package/src/composables/indexSources.js +116 -16
  171. package/src/css/index-page.css +7 -0
  172. package/src/index.js +9 -0
  173. package/src/utils/aiFeatures.js +241 -0
  174. package/src/utils/aiSuggestionTracker.js +178 -0
  175. package/src/utils/libraryWidgetKeys.js +2 -0
  176. package/src/utils/objectPermissions.js +548 -0
  177. package/src/utils/objectTranslations.js +176 -0
  178. package/src/utils/stateMachineValidation.js +250 -0
  179. package/src/utils/widgetDispatch.js +3 -1
@@ -231,16 +231,20 @@ import { useIntegrationRegistry } from '../../composables/useIntegrationRegistry
231
231
  import { useObjectSubscription } from '../../composables/useObjectSubscription.js'
232
232
  import { pickDeclaredProps } from '../../utils/declaredProps.js'
233
233
  import { labelLang } from '../../utils/manifestTranslate.js'
234
+ import { CnAiFeaturesWidget } from '../CnAiFeaturesWidget/index.js'
234
235
  import { CnIcon } from '../CnIcon/index.js'
235
236
  import { CnLeafMountHost } from '../CnLeafMountHost/index.js'
236
237
  import { CnObjectDataWidget } from '../CnObjectDataWidget/index.js'
237
238
  import { CnObjectMetadataWidget } from '../CnObjectMetadataWidget/index.js'
239
+ import { CnObjectPermissionsTab } from '../CnObjectPermissionsTab/index.js'
238
240
 
239
241
  /**
240
242
  * Built-in widget registry used by the open-enum `tabs` prop.
241
243
  * - `data` → CnObjectDataWidget (schema-driven editable grid)
242
244
  * - `metadata` → CnObjectMetadataWidget (read-only system metadata)
243
245
  * - `audit` → CnAuditTrailTab (the object's change log / audit trail)
246
+ * - `object-permissions` → CnObjectPermissionsTab (read-only: who holds which
247
+ * right on the object, where each grant came from, and on a past date)
244
248
  * - `object-table` → CnWidgetObjectTable (a declarative list scoped to the
245
249
  * parent object — the full source / endpointSource / columns / actions
246
250
  * contract). Resolves `@objectId` / `@object.<field>` filter tokens against
@@ -252,6 +256,8 @@ import { CnObjectMetadataWidget } from '../CnObjectMetadataWidget/index.js'
252
256
  * customComponents registry (prop, then injected `cnCustomComponents`).
253
257
  */
254
258
  const BUILTIN_WIDGETS = {
259
+ // The hermiq AI features a record's type switches on (CnAiFeaturesWidget).
260
+ 'ai-features': CnAiFeaturesWidget,
255
261
  data: CnObjectDataWidget,
256
262
  metadata: CnObjectMetadataWidget,
257
263
  audit: CnAuditTrailTab,
@@ -264,6 +270,7 @@ const BUILTIN_WIDGETS = {
264
270
  // a sidebar tab can render a related-object list scoped to the parent
265
271
  // object via `@objectId` / `@object.<field>` tokens in its `source.filter`.
266
272
  'object-table': CnWidgetObjectTable,
273
+ 'object-permissions': CnObjectPermissionsTab,
267
274
  }
268
275
 
269
276
  /**
@@ -1009,7 +1016,7 @@ export default {
1009
1016
  return reg[type]
1010
1017
  }
1011
1018
  // eslint-disable-next-line no-console
1012
- console.warn(`[CnObjectSidebar] Unknown widget type "${type}" — not in built-ins (data, metadata, audit, audit-trail, object-table), the registry or customComponents.`)
1019
+ console.warn(`[CnObjectSidebar] Unknown widget type "${type}" — not in built-ins (data, metadata, audit, audit-trail, object-table, object-permissions), the registry or customComponents.`)
1013
1020
  return null
1014
1021
  },
1015
1022
 
@@ -0,0 +1,424 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ SPDX-License-Identifier: EUPL-1.2
4
+
5
+ CnObjectTranslationsWidget — an object's translatable properties in every
6
+ language its register serves: one completeness chip per language (a stale
7
+ value counts as missing), a language tab strip, the source language read-only,
8
+ a stale mark per field, and one save that writes each edited property as a
9
+ whole language map. It translates nothing itself.
10
+ -->
11
+ <template>
12
+ <div class="cn-object-translations" data-testid="cn-object-translations">
13
+ <NcLoadingIcon v-if="loading" :size="24" />
14
+
15
+ <p v-else-if="error" class="cn-object-translations__empty" role="alert">
16
+ {{ t('nextcloud-vue', 'The translations could not be read. Reload the page to try again.') }}
17
+ </p>
18
+
19
+ <p v-else-if="propertiesShown.length === 0"
20
+ class="cn-object-translations__empty"
21
+ data-testid="cn-object-translations-none">
22
+ {{ emptyText || t('nextcloud-vue', 'This item has no labels that can be translated.') }}
23
+ </p>
24
+
25
+ <template v-else>
26
+ <ul class="cn-object-translations__chips" data-testid="cn-object-translations-chips">
27
+ <li v-for="chip in chips"
28
+ :key="chip.language"
29
+ class="cn-object-translations__chip cn-status-badge cn-status-badge--default"
30
+ :class="{ 'cn-object-translations__chip--complete': chip.complete }"
31
+ :data-language="chip.language">
32
+ <span class="cn-object-translations__chip-name">{{ chip.name }}</span>
33
+ <span class="cn-object-translations__chip-count">{{ chip.count }}</span>
34
+ </li>
35
+ </ul>
36
+
37
+ <p class="cn-object-translations__source">
38
+ {{ sourceNote }}
39
+ </p>
40
+
41
+ <div class="cn-object-translations__tabs"
42
+ role="tablist"
43
+ :aria-label="t('nextcloud-vue', 'Languages')"
44
+ @keydown="onTabKeydown">
45
+ <NcButton v-for="language in languages"
46
+ :id="tabId(language)"
47
+ :key="language"
48
+ :variant="language === activeLanguage ? 'primary' : 'tertiary'"
49
+ role="tab"
50
+ :aria-selected="String(language === activeLanguage)"
51
+ :aria-controls="panelId"
52
+ :tabindex="language === activeLanguage ? 0 : -1"
53
+ :data-language="language"
54
+ @click="activeLanguage = language">
55
+ {{ nameOf(language) }}
56
+ </NcButton>
57
+ </div>
58
+
59
+ <div :id="panelId"
60
+ role="tabpanel"
61
+ class="cn-object-translations__panel"
62
+ :aria-labelledby="tabId(activeLanguage)">
63
+ <div v-for="property in propertiesShown"
64
+ :key="property"
65
+ class="cn-object-translations__field"
66
+ :data-property="property">
67
+ <NcTextField :label="labelOf(property)"
68
+ :modelValue="draftOf(property)"
69
+ :disabled="activeLanguage === sourceLanguage"
70
+ @update:modelValue="setDraft(property, $event)" />
71
+
72
+ <span v-if="isStale(property)"
73
+ class="cn-object-translations__stale"
74
+ :data-property="property">
75
+ {{ t('nextcloud-vue', 'Out of date') }}
76
+ </span>
77
+ </div>
78
+
79
+ <NcButton variant="primary"
80
+ :disabled="saving || !hasEdits"
81
+ data-testid="cn-object-translations-save"
82
+ @click="save">
83
+ {{ t('nextcloud-vue', 'Save') }}
84
+ </NcButton>
85
+ </div>
86
+ </template>
87
+ </div>
88
+ </template>
89
+
90
+ <script>
91
+ import { showError, showSuccess } from '@nextcloud/dialogs'
92
+ import { subscribe, unsubscribe } from '@nextcloud/event-bus'
93
+ import { getLanguage, translate as t } from '@nextcloud/l10n'
94
+ import { NcButton, NcLoadingIcon, NcTextField } from '@nextcloud/vue'
95
+ import {
96
+ completenessOf,
97
+ DEFAULT_API_BASE,
98
+ objectWithTranslations,
99
+ registerLanguages,
100
+ saveTranslatedProperty,
101
+ schemaProperties,
102
+ STATUS_OUTDATED,
103
+ translationRows,
104
+ } from '../../utils/objectTranslations.js'
105
+
106
+ const PAGE_REFRESH = 'cn:page:refresh'
107
+
108
+ let instanceCount = 0
109
+
110
+ export default {
111
+ name: 'CnObjectTranslationsWidget',
112
+
113
+ components: { NcButton, NcLoadingIcon, NcTextField },
114
+
115
+ props: {
116
+ /** Register slug or id (detail-context merge). */
117
+ register: { type: String, default: '' },
118
+ /** Schema slug, or the schema object the detail-context merge supplies. */
119
+ schema: { type: [String, Object], default: '' },
120
+ /** The object id (detail-context merge). Falls back to the route's `id`. */
121
+ objectId: { type: String, default: '' },
122
+ /** OpenRegister API root. */
123
+ apiBase: { type: String, default: DEFAULT_API_BASE },
124
+ /** Optional whitelist of properties to offer; empty offers every translatable one. */
125
+ properties: { type: Array, default: () => [] },
126
+ /** Text when nothing is translatable; empty uses the translated default. */
127
+ emptyText: { type: String, default: '' },
128
+ /** Stored widget content blob; its keys are read as the props above. */
129
+ content: { type: Object, default: () => ({}) },
130
+ },
131
+
132
+ data() {
133
+ instanceCount += 1
134
+ return {
135
+ uid: `cn-object-translations-${instanceCount}`,
136
+ loading: true,
137
+ error: false,
138
+ saving: false,
139
+ languages: [],
140
+ sourceLanguage: '',
141
+ translatable: [],
142
+ titles: {},
143
+ values: {},
144
+ statuses: {},
145
+ drafts: {},
146
+ activeLanguage: '',
147
+ }
148
+ },
149
+
150
+ computed: {
151
+ target() {
152
+ return {
153
+ register: this.register || this.content.register || '',
154
+ schema: this.schema || this.content.schema || '',
155
+ apiBase: this.content.apiBase || this.apiBase,
156
+ }
157
+ },
158
+
159
+ resolvedId() {
160
+ return String(this.objectId || this.content.objectId || this.$route?.params?.id || '')
161
+ },
162
+
163
+ whitelist() {
164
+ const list = this.properties.length > 0 ? this.properties : (this.content.properties || [])
165
+ return Array.isArray(list) ? list.map(String) : []
166
+ },
167
+
168
+ /** The translatable properties OpenRegister reported, narrowed by the whitelist. */
169
+ propertiesShown() {
170
+ return this.whitelist.length === 0
171
+ ? this.translatable
172
+ : this.translatable.filter((property) => this.whitelist.includes(property))
173
+ },
174
+
175
+ panelId() {
176
+ return `${this.uid}-panel`
177
+ },
178
+
179
+ /**
180
+ * One chip per language the REGISTER declares (so a language nobody has
181
+ * started reads "0 of 5"), counting a stale value as missing.
182
+ *
183
+ * @return {Array<object>} The chips, source first.
184
+ */
185
+ chips() {
186
+ return this.languages.map((language) => {
187
+ const { translated, total } = completenessOf({
188
+ properties: this.propertiesShown,
189
+ values: this.values,
190
+ statuses: this.statuses,
191
+ language,
192
+ sourceLanguage: this.sourceLanguage,
193
+ })
194
+ return {
195
+ language,
196
+ name: this.nameOf(language),
197
+ // TRANSLATORS: how many of a record's labels exist in one language
198
+ count: t('nextcloud-vue', '{translated} of {total}', { translated, total }),
199
+ complete: translated === total,
200
+ }
201
+ })
202
+ },
203
+
204
+ sourceNote() {
205
+ return t('nextcloud-vue', '{language} is the source. Change a {language} label and its translations are marked out of date.', { language: this.nameOf(this.sourceLanguage) })
206
+ },
207
+
208
+ hasEdits() {
209
+ return this.propertiesShown.some((property) => this.draftOf(property) !== this.storedOf(property))
210
+ },
211
+ },
212
+
213
+ mounted() {
214
+ subscribe(PAGE_REFRESH, this.load)
215
+ this.load()
216
+ },
217
+
218
+ beforeUnmount() {
219
+ unsubscribe(PAGE_REFRESH, this.load)
220
+ },
221
+
222
+ methods: {
223
+ t,
224
+
225
+ /**
226
+ * A language's name in the reader's own language, from the browser
227
+ * (keeps 38 language names out of the catalogue).
228
+ *
229
+ * @param {string} code The BCP 47 tag.
230
+ * @return {string} The name, or the tag.
231
+ */
232
+ nameOf(code) {
233
+ try {
234
+ return new Intl.DisplayNames([getLanguage()], { type: 'language' }).of(code) ?? code
235
+ } catch {
236
+ return code
237
+ }
238
+ },
239
+
240
+ tabId(language) {
241
+ return `${this.uid}-tab-${language}`
242
+ },
243
+
244
+ /**
245
+ * Arrow keys, Home and End move between the language tabs.
246
+ *
247
+ * @param {KeyboardEvent} event The key event.
248
+ */
249
+ onTabKeydown(event) {
250
+ const index = this.languages.indexOf(this.activeLanguage)
251
+ const last = this.languages.length - 1
252
+ const next = {
253
+ ArrowRight: index >= last ? 0 : index + 1,
254
+ ArrowLeft: index <= 0 ? last : index - 1,
255
+ Home: 0,
256
+ End: last,
257
+ }[event.key]
258
+ if (next === undefined || last < 0) {
259
+ return
260
+ }
261
+ event.preventDefault()
262
+ this.activeLanguage = this.languages[next]
263
+ this.$nextTick(() => {
264
+ const el = document.getElementById(this.tabId(this.activeLanguage))
265
+ if (el && typeof el.focus === 'function') {
266
+ el.focus()
267
+ }
268
+ })
269
+ },
270
+
271
+ labelOf(property) {
272
+ return this.titles[property] ?? property
273
+ },
274
+
275
+ storedOf(property) {
276
+ return this.values?.[property]?.[this.activeLanguage] ?? ''
277
+ },
278
+
279
+ draftOf(property) {
280
+ const typed = this.drafts?.[this.activeLanguage]?.[property]
281
+ return typed === undefined ? this.storedOf(property) : typed
282
+ },
283
+
284
+ setDraft(property, value) {
285
+ const language = this.activeLanguage
286
+ this.drafts = { ...this.drafts, [language]: { ...(this.drafts[language] ?? {}), [property]: value } }
287
+ },
288
+
289
+ isStale(property) {
290
+ return (this.statuses?.[property]?.[this.activeLanguage] ?? '') === STATUS_OUTDATED
291
+ },
292
+
293
+ /**
294
+ * Read the register languages, the object with every language, the
295
+ * property titles and the sidecar statuses.
296
+ *
297
+ * @return {Promise<void>}
298
+ */
299
+ async load() {
300
+ if (!this.resolvedId) {
301
+ this.loading = false
302
+ this.error = true
303
+ return
304
+ }
305
+
306
+ this.loading = true
307
+ try {
308
+ const [languages, read, definitions] = await Promise.all([
309
+ registerLanguages(this.target),
310
+ objectWithTranslations(this.target, this.resolvedId),
311
+ schemaProperties(this.target),
312
+ ])
313
+
314
+ const meta = read.languageMeta ?? {}
315
+ this.translatable = Object.keys(meta)
316
+ const first = this.propertiesShown[0] ?? this.translatable[0]
317
+ this.sourceLanguage = meta[first]?.sourceLanguage ?? languages[0] ?? 'nl'
318
+ this.languages = languages.length > 0 ? languages : [this.sourceLanguage]
319
+ this.activeLanguage = this.languages.find((code) => code !== this.sourceLanguage) ?? this.sourceLanguage
320
+
321
+ this.values = Object.fromEntries(this.translatable.map((property) => {
322
+ const stored = read.object?.[property]
323
+ return [property, { ...(stored !== null && typeof stored === 'object' ? stored : {}) }]
324
+ }))
325
+ this.titles = Object.fromEntries(this.translatable.map((property) => [property, definitions[property]?.title ?? property]))
326
+
327
+ const uuid = String(read.object?.['@self']?.uuid ?? read.object?.id ?? this.resolvedId)
328
+ const statuses = {}
329
+ for (const row of await translationRows(this.target, uuid)) {
330
+ const property = String(row.property ?? '')
331
+ const language = String(row.language ?? '')
332
+ if (property === '' || language === '') {
333
+ continue
334
+ }
335
+ statuses[property] = { ...(statuses[property] ?? {}), [language]: row.status }
336
+ }
337
+ this.statuses = statuses
338
+
339
+ this.drafts = {}
340
+ this.error = false
341
+ } catch {
342
+ this.error = true
343
+ } finally {
344
+ this.loading = false
345
+ }
346
+ },
347
+
348
+ /**
349
+ * Write every edited property back, each as a whole language map.
350
+ *
351
+ * @return {Promise<void>}
352
+ */
353
+ async save() {
354
+ this.saving = true
355
+ try {
356
+ for (const property of this.propertiesShown) {
357
+ const draft = this.draftOf(property)
358
+ if (draft === this.storedOf(property)) {
359
+ continue
360
+ }
361
+ const map = { ...(this.values[property] ?? {}), [this.activeLanguage]: draft }
362
+ await saveTranslatedProperty(this.target, this.resolvedId, property, map)
363
+ this.values = { ...this.values, [property]: map }
364
+ }
365
+ this.drafts = { ...this.drafts, [this.activeLanguage]: {} }
366
+ showSuccess(t('nextcloud-vue', 'The labels are saved.'))
367
+ } catch {
368
+ showError(t('nextcloud-vue', 'The labels could not be saved.'))
369
+ } finally {
370
+ this.saving = false
371
+ }
372
+ },
373
+ },
374
+ }
375
+ </script>
376
+
377
+ <style scoped>
378
+ .cn-object-translations__chips {
379
+ display: flex;
380
+ flex-wrap: wrap;
381
+ gap: 8px;
382
+ list-style: none;
383
+ margin: 0 0 8px;
384
+ padding: 0;
385
+ }
386
+
387
+ .cn-object-translations__chip {
388
+ align-items: center;
389
+ background: var(--color-background-dark);
390
+ display: flex;
391
+ gap: 6px;
392
+ }
393
+
394
+ .cn-object-translations__chip--complete {
395
+ background: var(--color-success, var(--color-background-dark));
396
+ --cn-status-badge-ink: var(--color-primary-text);
397
+ color: var(--cn-status-badge-ink);
398
+ }
399
+
400
+ .cn-object-translations__source,
401
+ .cn-object-translations__empty {
402
+ color: var(--color-text-maxcontrast);
403
+ margin: 0 0 8px;
404
+ }
405
+
406
+ .cn-object-translations__tabs {
407
+ display: flex;
408
+ flex-wrap: wrap;
409
+ gap: 4px;
410
+ margin-bottom: 8px;
411
+ }
412
+
413
+ .cn-object-translations__field {
414
+ align-items: center;
415
+ display: flex;
416
+ gap: 8px;
417
+ margin-bottom: 8px;
418
+ }
419
+
420
+ .cn-object-translations__stale {
421
+ color: var(--color-warning-text, var(--color-main-text));
422
+ white-space: nowrap;
423
+ }
424
+ </style>
@@ -0,0 +1,189 @@
1
+ /**
2
+ * SPDX-License-Identifier: EUPL-1.2
3
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
4
+ *
5
+ * CnObjectTranslationsWidget edits a property in a second language and says
6
+ * what is still missing. Axios is mocked, not the helpers, so the helpers'
7
+ * own reading is under test with the widget.
8
+ *
9
+ * Pinned: the chips come from the register's languages; a stale translation
10
+ * counts as missing (OpenRegister's own completeness counts it as done); the
11
+ * PATCH carries the WHOLE language map; only properties OpenRegister holds as
12
+ * translatable are offered; a failed read says so; the language strip is a
13
+ * keyboard-operable tablist with a tabpanel.
14
+ */
15
+
16
+ import axios from '@nextcloud/axios'
17
+ import { mount } from '@vue/test-utils'
18
+ import CnObjectTranslationsWidget from '../CnObjectTranslationsWidget.vue'
19
+ import { completenessOf } from '../../../utils/objectTranslations.js'
20
+
21
+ jest.mock('@nextcloud/axios', () => ({ __esModule: true, default: { get: jest.fn(), patch: jest.fn() } }))
22
+ jest.mock('@nextcloud/router', () => ({ __esModule: true, generateUrl: jest.fn((p) => `/index.php${p}`) }))
23
+ jest.mock('@nextcloud/dialogs', () => ({ __esModule: true, showError: jest.fn(), showSuccess: jest.fn() }))
24
+ jest.mock('@nextcloud/event-bus', () => ({ __esModule: true, subscribe: jest.fn(), unsubscribe: jest.fn(), emit: jest.fn() }))
25
+
26
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
27
+
28
+ const stubs = {
29
+ NcButton: { name: 'NcButton', props: ['disabled', 'variant'], emits: ['click'], template: '<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\')"><slot /></button>' },
30
+ NcLoadingIcon: true,
31
+ NcTextField: { name: 'NcTextField', props: ['label', 'modelValue', 'disabled'], emits: ['update:modelValue'], template: '<input :aria-label="label" :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target.value)">' },
32
+ }
33
+
34
+ let object = {}
35
+ let rows = []
36
+
37
+ async function mountWidget(props = {}) {
38
+ const wrapper = mount(CnObjectTranslationsWidget, {
39
+ props: { register: 'reg', schema: 'kind', objectId: 'o-1', ...props },
40
+ global: { stubs },
41
+ })
42
+ for (let i = 0; i < 5; i++) {
43
+ await flush()
44
+ }
45
+ await wrapper.vm.$nextTick()
46
+ return wrapper
47
+ }
48
+
49
+ beforeEach(() => {
50
+ jest.clearAllMocks()
51
+ rows = []
52
+ object = {
53
+ '@self': { uuid: 'o-1' },
54
+ title: { nl: 'Omgevingsvergunning' },
55
+ description: { nl: 'Een vergunning voor bouwen' },
56
+ _meta: {
57
+ languageMeta: {
58
+ title: { sourceLanguage: 'nl', served: 'nl', isSource: true, status: 'source' },
59
+ description: { sourceLanguage: 'nl', served: 'nl', isSource: true, status: 'source' },
60
+ },
61
+ },
62
+ }
63
+ axios.get.mockImplementation((url) => {
64
+ if (url.endsWith('/registers/reg')) {
65
+ return Promise.resolve({ data: { languages: ['nl', 'en'] } })
66
+ }
67
+ if (url.endsWith('/registers/reg/schemas')) {
68
+ return Promise.resolve({ data: { results: [{ slug: 'kind', properties: { title: { title: 'Title' }, description: { title: 'Description' } } }] } })
69
+ }
70
+ if (url.includes('/translations/object/')) {
71
+ return Promise.resolve({ data: { translations: rows } })
72
+ }
73
+ return Promise.resolve({ data: { object } })
74
+ })
75
+ axios.patch.mockResolvedValue({ data: { object } })
76
+ })
77
+
78
+ describe('an object says what is still missing', () => {
79
+ it('draws one chip per declared language, not per language present', async () => {
80
+ const wrapper = await mountWidget()
81
+ const chips = wrapper.findAll('.cn-object-translations__chip')
82
+ expect(chips).toHaveLength(2)
83
+ expect(chips[1].attributes('data-language')).toBe('en')
84
+ expect(chips[1].text()).toContain('0 of 2')
85
+ })
86
+
87
+ it('counts a stale translation as missing', () => {
88
+ const values = { title: { nl: 'A', en: 'A!' }, description: { nl: 'B', en: 'B!' } }
89
+ expect(completenessOf({ properties: ['title', 'description'], values, statuses: { title: { en: 'human_reviewed' }, description: { en: 'human_reviewed' } }, language: 'en', sourceLanguage: 'nl' })).toEqual({ translated: 2, total: 2 })
90
+ expect(completenessOf({ properties: ['title', 'description'], values, statuses: { title: { en: 'outdated' }, description: { en: 'human_reviewed' } }, language: 'en', sourceLanguage: 'nl' })).toEqual({ translated: 1, total: 2 })
91
+ })
92
+
93
+ it('reports one of two when one of two is translated', async () => {
94
+ object.title.en = 'Environmental permit'
95
+ rows = [{ property: 'title', language: 'en', status: 'human_reviewed' }]
96
+ const wrapper = await mountWidget()
97
+ expect(wrapper.find('.cn-object-translations__chip[data-language="en"]').text()).toContain('1 of 2')
98
+ })
99
+
100
+ it('drops a translation back out of the count when the source moves', async () => {
101
+ object.title.en = 'Environmental permit'
102
+ rows = [{ property: 'title', language: 'en', status: 'outdated' }]
103
+ const wrapper = await mountWidget()
104
+ expect(wrapper.find('.cn-object-translations__chip[data-language="en"]').text()).toContain('0 of 2')
105
+ expect(wrapper.find('.cn-object-translations__stale').exists()).toBe(true)
106
+ })
107
+
108
+ it('reads the object under the register and schema it was given', async () => {
109
+ await mountWidget()
110
+ expect(axios.get.mock.calls.some(([url, opts]) => url === '/index.php/apps/openregister/api/objects/reg/kind/o-1' && opts.params._translations === 'all')).toBe(true)
111
+ expect(axios.get.mock.calls.some(([url, opts]) => url.endsWith('/translations/object/o-1') && opts.params.schema === 'kind')).toBe(true)
112
+ })
113
+
114
+ it('takes the slug of a schema object from the detail context', async () => {
115
+ await mountWidget({ schema: { slug: 'kind', id: 7 } })
116
+ expect(axios.get.mock.calls.some(([url]) => url.endsWith('/objects/reg/kind/o-1'))).toBe(true)
117
+ })
118
+ })
119
+
120
+ describe('an admin adds an English title', () => {
121
+ it('writes the whole language map through a typed edit, so the Dutch title survives', async () => {
122
+ const wrapper = await mountWidget()
123
+ expect(wrapper.vm.activeLanguage).toBe('en')
124
+
125
+ const field = wrapper.find('.cn-object-translations__field[data-property="title"] input')
126
+ await field.setValue('Environmental permit')
127
+ await wrapper.find('[data-testid="cn-object-translations-save"]').trigger('click')
128
+ await flush()
129
+
130
+ expect(axios.patch).toHaveBeenCalledTimes(1)
131
+ const [url, body] = axios.patch.mock.calls[0]
132
+ expect(url).toBe('/index.php/apps/openregister/api/objects/reg/kind/o-1')
133
+ expect(body).toEqual({ title: { nl: 'Omgevingsvergunning', en: 'Environmental permit' } })
134
+ expect(axios.patch.mock.calls[0][2]).toBeUndefined()
135
+ })
136
+
137
+ it('keeps the source language read-only', async () => {
138
+ const wrapper = await mountWidget()
139
+ wrapper.vm.activeLanguage = 'nl'
140
+ await wrapper.vm.$nextTick()
141
+ expect(wrapper.find('.cn-object-translations__field input').attributes('disabled')).toBeDefined()
142
+ })
143
+
144
+ it('offers only the properties OpenRegister holds as translatable', async () => {
145
+ delete object._meta.languageMeta.description
146
+ const wrapper = await mountWidget()
147
+ expect(wrapper.findAll('.cn-object-translations__field')).toHaveLength(1)
148
+ })
149
+
150
+ it('narrows to a whitelist when one is configured', async () => {
151
+ const wrapper = await mountWidget({ content: { properties: ['description'] } })
152
+ const fields = wrapper.findAll('.cn-object-translations__field')
153
+ expect(fields).toHaveLength(1)
154
+ expect(fields[0].attributes('data-property')).toBe('description')
155
+ })
156
+
157
+ it('says so rather than drawing an empty page when the read fails', async () => {
158
+ axios.get.mockRejectedValue(new Error('503'))
159
+ const wrapper = await mountWidget()
160
+ expect(wrapper.text()).toContain('could not be read')
161
+ expect(wrapper.findAll('.cn-object-translations__chip')).toHaveLength(0)
162
+ })
163
+ })
164
+
165
+ describe('the language strip is a tablist', () => {
166
+ it('links each tab to the panel and moves with the arrow keys', async () => {
167
+ const wrapper = await mountWidget()
168
+ const panel = wrapper.find('[role="tabpanel"]')
169
+ expect(panel.exists()).toBe(true)
170
+ const tabs = wrapper.findAll('[role="tab"]')
171
+ expect(tabs).toHaveLength(2)
172
+ expect(tabs[0].attributes('aria-controls')).toBe(panel.attributes('id'))
173
+ expect(panel.attributes('aria-labelledby')).toBe(tabs[1].attributes('id'))
174
+ expect(tabs[1].attributes('aria-selected')).toBe('true')
175
+ expect(tabs[0].attributes('tabindex')).toBe('-1')
176
+
177
+ await wrapper.find('[role="tablist"]').trigger('keydown', { key: 'ArrowRight' })
178
+ expect(wrapper.vm.activeLanguage).toBe('nl')
179
+ await wrapper.find('[role="tablist"]').trigger('keydown', { key: 'End' })
180
+ expect(wrapper.vm.activeLanguage).toBe('en')
181
+ })
182
+ })
183
+
184
+ describe('registration', () => {
185
+ it('is the detail-page widget type object-translations', () => {
186
+ const { BUILT_IN_WIDGETS } = require('../../CnWidgetGrid/builtInWidgets.js')
187
+ expect(BUILT_IN_WIDGETS['object-translations']).toBe(CnObjectTranslationsWidget)
188
+ })
189
+ })
@@ -0,0 +1,13 @@
1
+ /**
2
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ * SPDX-License-Identifier: EUPL-1.2
4
+ *
5
+ * CnObjectTranslationsWidget — an object's translatable properties per
6
+ * language, with completeness chips and one save. The detail-page widget
7
+ * type `object-translations`.
8
+ */
9
+
10
+ import CnObjectTranslationsWidget from './CnObjectTranslationsWidget.vue'
11
+
12
+ export { CnObjectTranslationsWidget }
13
+ export default CnObjectTranslationsWidget