@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.
- package/dist/esm/components/CnAiFeaturesWidget/CnAiFeaturesWidget.vue.js +9 -0
- package/dist/esm/components/CnAiFeaturesWidget/CnAiFeaturesWidget.vue.js.map +1 -0
- package/dist/esm/components/CnAiFeaturesWidget/CnAiFeaturesWidget.vue2.js +226 -0
- package/dist/esm/components/CnAiFeaturesWidget/CnAiFeaturesWidget.vue2.js.map +1 -0
- package/dist/esm/components/CnAiFeaturesWidget/CnAiFeaturesWidget.vue3.js +211 -0
- package/dist/esm/components/CnAiFeaturesWidget/CnAiFeaturesWidget.vue3.js.map +1 -0
- package/dist/esm/components/CnAiFeaturesWidget/CnAiSuggestionDecision.vue.js +9 -0
- package/dist/esm/components/CnAiFeaturesWidget/CnAiSuggestionDecision.vue.js.map +1 -0
- package/dist/esm/components/CnAiFeaturesWidget/CnAiSuggestionDecision.vue2.js +64 -0
- package/dist/esm/components/CnAiFeaturesWidget/CnAiSuggestionDecision.vue2.js.map +1 -0
- package/dist/esm/components/CnAiFeaturesWidget/CnAiSuggestionDecision.vue3.js +89 -0
- package/dist/esm/components/CnAiFeaturesWidget/CnAiSuggestionDecision.vue3.js.map +1 -0
- package/dist/esm/components/CnIcon/semanticIcons.js +2 -2
- package/dist/esm/components/CnIcon/semanticIcons.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +53 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +546 -523
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/useNamedSource.js +9 -0
- package/dist/esm/components/CnIndexPage/useNamedSource.js.map +1 -1
- package/dist/esm/components/CnObjectPermissionsTab/CnObjectPermissionsTab.vue.js +9 -0
- package/dist/esm/components/CnObjectPermissionsTab/CnObjectPermissionsTab.vue.js.map +1 -0
- package/dist/esm/components/CnObjectPermissionsTab/CnObjectPermissionsTab.vue2.js +257 -0
- package/dist/esm/components/CnObjectPermissionsTab/CnObjectPermissionsTab.vue2.js.map +1 -0
- package/dist/esm/components/CnObjectPermissionsTab/CnObjectPermissionsTab.vue3.js +266 -0
- package/dist/esm/components/CnObjectPermissionsTab/CnObjectPermissionsTab.vue3.js.map +1 -0
- package/dist/esm/components/CnObjectSidebar/CnObjectSidebar.vue.js +1 -1
- package/dist/esm/components/CnObjectSidebar/CnObjectSidebar.vue2.js +14 -7
- package/dist/esm/components/CnObjectSidebar/CnObjectSidebar.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectSidebar/CnObjectSidebar.vue3.js.map +1 -1
- package/dist/esm/components/CnObjectTranslationsWidget/CnObjectTranslationsWidget.vue.js +9 -0
- package/dist/esm/components/CnObjectTranslationsWidget/CnObjectTranslationsWidget.vue.js.map +1 -0
- package/dist/esm/components/CnObjectTranslationsWidget/CnObjectTranslationsWidget.vue2.js +278 -0
- package/dist/esm/components/CnObjectTranslationsWidget/CnObjectTranslationsWidget.vue2.js.map +1 -0
- package/dist/esm/components/CnObjectTranslationsWidget/CnObjectTranslationsWidget.vue3.js +128 -0
- package/dist/esm/components/CnObjectTranslationsWidget/CnObjectTranslationsWidget.vue3.js.map +1 -0
- package/dist/esm/components/CnReadinessChecklist/CnReadinessChecklist.vue.js +9 -0
- package/dist/esm/components/CnReadinessChecklist/CnReadinessChecklist.vue.js.map +1 -0
- package/dist/esm/components/CnReadinessChecklist/CnReadinessChecklist.vue2.js +214 -0
- package/dist/esm/components/CnReadinessChecklist/CnReadinessChecklist.vue2.js.map +1 -0
- package/dist/esm/components/CnReadinessChecklist/CnReadinessChecklist.vue3.js +125 -0
- package/dist/esm/components/CnReadinessChecklist/CnReadinessChecklist.vue3.js.map +1 -0
- package/dist/esm/components/CnSchemaRulesWidget/CnSchemaRulesWidget.vue.js +9 -0
- package/dist/esm/components/CnSchemaRulesWidget/CnSchemaRulesWidget.vue.js.map +1 -0
- package/dist/esm/components/CnSchemaRulesWidget/CnSchemaRulesWidget.vue2.js +163 -0
- package/dist/esm/components/CnSchemaRulesWidget/CnSchemaRulesWidget.vue2.js.map +1 -0
- package/dist/esm/components/CnSchemaRulesWidget/CnSchemaRulesWidget.vue3.js +131 -0
- package/dist/esm/components/CnSchemaRulesWidget/CnSchemaRulesWidget.vue3.js.map +1 -0
- package/dist/esm/components/CnSchemaRulesWidget/schemaRules.js +127 -0
- package/dist/esm/components/CnSchemaRulesWidget/schemaRules.js.map +1 -0
- package/dist/esm/components/CnSearchIndexStatus/CnSearchIndexStatus.vue.js +9 -0
- package/dist/esm/components/CnSearchIndexStatus/CnSearchIndexStatus.vue.js.map +1 -0
- package/dist/esm/components/CnSearchIndexStatus/CnSearchIndexStatus.vue2.js +82 -0
- package/dist/esm/components/CnSearchIndexStatus/CnSearchIndexStatus.vue2.js.map +1 -0
- package/dist/esm/components/CnSearchIndexStatus/CnSearchIndexStatus.vue3.js +61 -0
- package/dist/esm/components/CnSearchIndexStatus/CnSearchIndexStatus.vue3.js.map +1 -0
- package/dist/esm/components/CnSearchIndexStatus/searchIndexStatus.js +60 -0
- package/dist/esm/components/CnSearchIndexStatus/searchIndexStatus.js.map +1 -0
- package/dist/esm/components/CnShippedConfigurationSection/CnShippedConfigurationSection.vue.js +10 -0
- package/dist/esm/components/CnShippedConfigurationSection/CnShippedConfigurationSection.vue.js.map +1 -0
- package/dist/esm/components/CnShippedConfigurationSection/CnShippedConfigurationSection.vue2.js +226 -0
- package/dist/esm/components/CnShippedConfigurationSection/CnShippedConfigurationSection.vue2.js.map +1 -0
- package/dist/esm/components/CnShippedConfigurationSection/CnShippedConfigurationSection.vue3.js +126 -0
- package/dist/esm/components/CnShippedConfigurationSection/CnShippedConfigurationSection.vue3.js.map +1 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineEditor.vue.js +10 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineEditor.vue.js.map +1 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineEditor.vue2.js +944 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineEditor.vue2.js.map +1 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineEditor.vue3.js +276 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineEditor.vue3.js.map +1 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineFields.vue.js +9 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineFields.vue.js.map +1 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineFields.vue2.js +354 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineFields.vue2.js.map +1 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineFields.vue3.js +392 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineFields.vue3.js.map +1 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineNode.vue.js +9 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineNode.vue.js.map +1 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineNode.vue2.js +180 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineNode.vue2.js.map +1 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineNode.vue3.js +208 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachineNode.vue3.js.map +1 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachinePanel.vue.js +9 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachinePanel.vue.js.map +1 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachinePanel.vue2.js +244 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachinePanel.vue2.js.map +1 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachinePanel.vue3.js +76 -0
- package/dist/esm/components/CnStateMachineEditor/CnStateMachinePanel.vue3.js.map +1 -0
- package/dist/esm/components/CnStateMachineEditor/stateMachineFields.js +290 -0
- package/dist/esm/components/CnStateMachineEditor/stateMachineFields.js.map +1 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineManage.vue.js +9 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineManage.vue.js.map +1 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineManage.vue2.js +448 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineManage.vue2.js.map +1 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineManage.vue3.js +303 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineManage.vue3.js.map +1 -0
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue.js +1 -1
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +19 -2
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js +73 -62
- package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnWidgetGrid/builtInWidgets.js +6 -0
- package/dist/esm/components/CnWidgetGrid/builtInWidgets.js.map +1 -1
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +42 -32
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
- package/dist/esm/composables/indexSources.js +116 -16
- package/dist/esm/composables/indexSources.js.map +1 -1
- package/dist/esm/index.js +9 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/l10n/en.json.js +207 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +207 -1
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/library-style.js +1 -1
- package/dist/esm/nextcloud-vue.css +951 -16
- package/dist/esm/utils/aiFeatures.js +245 -0
- package/dist/esm/utils/aiFeatures.js.map +1 -0
- package/dist/esm/utils/aiSuggestionTracker.js +182 -0
- package/dist/esm/utils/aiSuggestionTracker.js.map +1 -0
- package/dist/esm/utils/libraryWidgetKeys.js +2 -0
- package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
- package/dist/esm/utils/objectPermissions.js +552 -0
- package/dist/esm/utils/objectPermissions.js.map +1 -0
- package/dist/esm/utils/objectTranslations.js +180 -0
- package/dist/esm/utils/objectTranslations.js.map +1 -0
- package/dist/esm/utils/stateMachineValidation.js +254 -0
- package/dist/esm/utils/stateMachineValidation.js.map +1 -0
- package/dist/esm/utils/widgetDispatch.js +3 -1
- package/dist/esm/utils/widgetDispatch.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +23038 -14567
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +951 -16
- package/l10n/en.json +207 -1
- package/l10n/nl.json +207 -1
- package/package.json +1 -1
- package/src/components/CnAiFeaturesWidget/CnAiFeaturesWidget.vue +416 -0
- package/src/components/CnAiFeaturesWidget/CnAiSuggestionDecision.vue +136 -0
- package/src/components/CnAiFeaturesWidget/__tests__/CnAiFeaturesWidget.spec.js +213 -0
- package/src/components/CnAiFeaturesWidget/index.js +14 -0
- package/src/components/CnIndexPage/CnIndexPage.vue +74 -3
- package/src/components/CnIndexPage/useNamedSource.js +9 -0
- package/src/components/CnObjectPermissionsTab/CnObjectPermissionsTab.vue +499 -0
- package/src/components/CnObjectPermissionsTab/__tests__/CnObjectPermissionsTab.spec.js +186 -0
- package/src/components/CnObjectPermissionsTab/index.js +13 -0
- package/src/components/CnObjectSidebar/CnObjectSidebar.vue +8 -1
- package/src/components/CnObjectTranslationsWidget/CnObjectTranslationsWidget.vue +424 -0
- package/src/components/CnObjectTranslationsWidget/__tests__/CnObjectTranslationsWidget.spec.js +189 -0
- package/src/components/CnObjectTranslationsWidget/index.js +13 -0
- package/src/components/CnReadinessChecklist/CnReadinessChecklist.vue +335 -0
- package/src/components/CnReadinessChecklist/index.js +9 -0
- package/src/components/CnSchemaRulesWidget/CnSchemaRulesWidget.vue +300 -0
- package/src/components/CnSchemaRulesWidget/index.js +10 -0
- package/src/components/CnSchemaRulesWidget/schemaRules.js +123 -0
- package/src/components/CnSearchIndexStatus/CnSearchIndexStatus.vue +160 -0
- package/src/components/CnSearchIndexStatus/index.js +10 -0
- package/src/components/CnSearchIndexStatus/searchIndexStatus.js +56 -0
- package/src/components/CnShippedConfigurationSection/CnShippedConfigurationSection.vue +328 -0
- package/src/components/CnShippedConfigurationSection/index.js +9 -0
- package/src/components/CnStateMachineEditor/CnStateMachineEditor.vue +1332 -0
- package/src/components/CnStateMachineEditor/CnStateMachineFields.vue +791 -0
- package/src/components/CnStateMachineEditor/CnStateMachineNode.vue +442 -0
- package/src/components/CnStateMachineEditor/CnStateMachinePanel.vue +326 -0
- package/src/components/CnStateMachineEditor/index.js +8 -0
- package/src/components/CnStateMachineEditor/stateMachineFields.js +287 -0
- package/src/components/CnTimelineWidget/CnTimelineManage.vue +827 -0
- package/src/components/CnTimelineWidget/CnTimelineWidget.vue +29 -3
- package/src/components/CnWidgetGrid/builtInWidgets.js +6 -0
- package/src/components/CnWidgetGrid/registerDashboardWidgets.js +10 -0
- package/src/components/index.js +8 -0
- package/src/composables/indexSources.js +116 -16
- package/src/css/index-page.css +7 -0
- package/src/index.js +9 -0
- package/src/utils/aiFeatures.js +241 -0
- package/src/utils/aiSuggestionTracker.js +178 -0
- package/src/utils/libraryWidgetKeys.js +2 -0
- package/src/utils/objectPermissions.js +548 -0
- package/src/utils/objectTranslations.js +176 -0
- package/src/utils/stateMachineValidation.js +250 -0
- 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>
|
package/src/components/CnObjectTranslationsWidget/__tests__/CnObjectTranslationsWidget.spec.js
ADDED
|
@@ -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
|