@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
@@ -0,0 +1,827 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ SPDX-License-Identifier: EUPL-1.2
4
+
5
+ CnTimelineManage — the `manage` mode of CnTimelineWidget: one chronological
6
+ read of OpenRegister's timeline on an object (notes, calls, messages), newest
7
+ first with pinned entries on top, plus what a reader who may manage it can do
8
+ there: filter by kind and visibility, pin, close a follow-up, write a note
9
+ (optionally from a standard text block, optionally visible outside), export
10
+ the shown entries as CSV, and run configured entry actions such as a reply
11
+ dialog.
12
+
13
+ The list read is filtered by OpenRegister whether or not a filter is asked
14
+ for; the response says whether the reader may manage (`canManage`), and the
15
+ controls are drawn from that rather than guessed. A failed read is not an
16
+ empty history.
17
+ -->
18
+ <template>
19
+ <div class="cn-timeline-manage" data-testid="cn-timeline-manage">
20
+ <div class="cn-timeline-manage__header">
21
+ <div class="cn-timeline-manage__heading">
22
+ <h3 class="cn-timeline-manage__title">
23
+ {{ titleText }}
24
+ </h3>
25
+ <p class="cn-timeline-manage__subtitle">
26
+ {{ subtitleText }}
27
+ </p>
28
+ </div>
29
+ <div class="cn-timeline-manage__header-actions">
30
+ <NcButton v-if="canManage && composerEnabled"
31
+ data-testid="cn-timeline-add-note"
32
+ @click="focusComposer">
33
+ <template #icon>
34
+ <Plus :size="20" />
35
+ </template>
36
+ {{ t('nextcloud-vue', 'Add a note') }}
37
+ </NcButton>
38
+ <NcButton data-testid="cn-timeline-export" @click="exportEntries">
39
+ <template #icon>
40
+ <Download :size="20" />
41
+ </template>
42
+ {{ t('nextcloud-vue', 'Export') }}
43
+ </NcButton>
44
+ </div>
45
+ </div>
46
+
47
+ <div class="cn-timeline-manage__chips"
48
+ role="group"
49
+ :aria-label="t('nextcloud-vue', 'Kind')"
50
+ data-testid="cn-timeline-kind-filter">
51
+ <button v-for="chip in kindChips"
52
+ :key="chip.key"
53
+ type="button"
54
+ class="cn-timeline-manage__chip"
55
+ :class="{ 'cn-timeline-manage__chip--active': chip.active }"
56
+ :aria-pressed="chip.active ? 'true' : 'false'"
57
+ :data-testid="'cn-timeline-chip-' + chip.key"
58
+ @click="chooseKind(chip.id)">
59
+ {{ chip.label }}
60
+ <span class="cn-timeline-manage__chip-count">{{ chip.count }}</span>
61
+ </button>
62
+ </div>
63
+
64
+ <div class="cn-timeline-manage__controls">
65
+ <NcSelect v-if="canManage"
66
+ v-model="visibilityFilter"
67
+ class="cn-timeline-manage__filter"
68
+ :options="visibilityOptions"
69
+ :clearable="true"
70
+ label="label"
71
+ :inputLabel="t('nextcloud-vue', 'Visibility')"
72
+ :placeholder="t('nextcloud-vue', 'Internal and public')"
73
+ data-testid="cn-timeline-visibility-filter"
74
+ @update:modelValue="load" />
75
+ </div>
76
+
77
+ <NcLoadingIcon v-if="loading" :size="32" />
78
+
79
+ <NcNoteCard v-else-if="error" type="error" data-testid="cn-timeline-error">
80
+ {{ error }}
81
+ </NcNoteCard>
82
+
83
+ <NcEmptyContent v-else-if="entries.length === 0"
84
+ :name="t('nextcloud-vue', 'Nothing recorded yet')"
85
+ :description="t('nextcloud-vue', 'Notes, calls and messages appear here in the order they happened.')"
86
+ data-testid="cn-timeline-empty" />
87
+
88
+ <ul v-else class="cn-timeline-manage__list" data-testid="cn-timeline-list">
89
+ <li v-for="entry in entries"
90
+ :key="entry.id"
91
+ class="cn-timeline-manage__entry"
92
+ :class="{ 'cn-timeline-manage__entry--pinned': entry.pinned }"
93
+ :data-kind="entry.kind || 'note'"
94
+ data-testid="cn-timeline-entry">
95
+ <div class="cn-timeline-manage__head">
96
+ <span class="cn-timeline-manage__kind">{{ kindLabel(entry) }}</span>
97
+ <span class="cn-timeline-manage__author">{{ entry.author }}</span>
98
+ <span class="cn-timeline-manage__moment">{{ moment(entry.created) }}</span>
99
+ <span v-if="entry.visibility === 'public'"
100
+ class="cn-timeline-manage__badge"
101
+ data-testid="cn-timeline-public">
102
+ {{ publicLabel }}
103
+ </span>
104
+ <span v-if="entry.followUp === 'open'"
105
+ class="cn-timeline-manage__badge cn-timeline-manage__badge--open"
106
+ data-testid="cn-timeline-followup-open">
107
+ {{ t('nextcloud-vue', 'Needs an answer') }}
108
+ </span>
109
+ </div>
110
+
111
+ <p class="cn-timeline-manage__message">
112
+ {{ entry.message }}
113
+ </p>
114
+
115
+ <p v-if="entry.siblings && entry.siblings.length"
116
+ class="cn-timeline-manage__siblings"
117
+ data-testid="cn-timeline-siblings">
118
+ {{ n('nextcloud-vue', 'Also written on %n other record', 'Also written on %n other records', entry.siblings.length) }}
119
+ </p>
120
+
121
+ <div v-if="canManage" class="cn-timeline-manage__actions">
122
+ <NcButton variant="tertiary"
123
+ :data-testid="'cn-timeline-pin-' + entry.id"
124
+ @click="togglePin(entry)">
125
+ {{ entry.pinned ? t('nextcloud-vue', 'Unpin') : t('nextcloud-vue', 'Pin') }}
126
+ </NcButton>
127
+ <NcButton v-for="action in actionsFor(entry)"
128
+ :key="action.id"
129
+ variant="tertiary"
130
+ :data-testid="'cn-timeline-action-' + action.id + '-' + entry.id"
131
+ @click="openAction(action, entry)">
132
+ {{ translate(action.label) }}
133
+ </NcButton>
134
+ <NcButton v-if="entry.followUp === 'open'"
135
+ variant="tertiary"
136
+ :data-testid="'cn-timeline-followup-' + entry.id"
137
+ @click="closeFollowUp(entry)">
138
+ {{ t('nextcloud-vue', 'Mark as answered') }}
139
+ </NcButton>
140
+ </div>
141
+ </li>
142
+ </ul>
143
+
144
+ <p v-if="!loading && !error && allEntries.length > 0"
145
+ class="cn-timeline-manage__count"
146
+ data-testid="cn-timeline-count">
147
+ {{ t('nextcloud-vue', '{shown} of {total} events', { shown: entries.length, total: allEntries.length }) }}
148
+ </p>
149
+
150
+ <form v-if="canManage && composerEnabled"
151
+ ref="composer"
152
+ class="cn-timeline-manage__composer"
153
+ data-testid="cn-timeline-composer"
154
+ @submit.prevent="submit">
155
+ <h3 class="cn-timeline-manage__composer-title">
156
+ {{ t('nextcloud-vue', 'Add a note') }}
157
+ </h3>
158
+ <p class="cn-timeline-manage__composer-hint">
159
+ {{ t('nextcloud-vue', 'Goes on the timeline above, internal unless you make it visible') }}
160
+ </p>
161
+ <NcSelect v-if="textBlocks.length"
162
+ v-model="chosenBlock"
163
+ class="cn-timeline-manage__filter"
164
+ :options="blockOptions"
165
+ :clearable="true"
166
+ label="label"
167
+ :inputLabel="t('nextcloud-vue', 'Standard note')"
168
+ :placeholder="t('nextcloud-vue', 'Write your own')"
169
+ data-testid="cn-timeline-block"
170
+ @update:modelValue="pickBlock" />
171
+
172
+ <label class="cn-timeline-manage__draft-label" :for="draftId">
173
+ {{ t('nextcloud-vue', 'Write a note on this record') }}
174
+ </label>
175
+ <textarea :id="draftId"
176
+ v-model="draft"
177
+ class="cn-timeline-manage__draft"
178
+ :placeholder="t('nextcloud-vue', 'Write a note on this record')"
179
+ data-testid="cn-timeline-draft" />
180
+
181
+ <NcCheckboxRadioSwitch v-model="draftIsPublic" data-testid="cn-timeline-draft-public">
182
+ {{ publicLabel }}
183
+ </NcCheckboxRadioSwitch>
184
+
185
+ <NcButton variant="primary"
186
+ type="submit"
187
+ :disabled="saving || !draft.trim()"
188
+ data-testid="cn-timeline-save">
189
+ {{ t('nextcloud-vue', 'Add to the timeline') }}
190
+ </NcButton>
191
+ </form>
192
+
193
+ <component :is="activeModal.component"
194
+ v-if="activeModal"
195
+ v-bind="activeModal.props"
196
+ v-on="activeModal.listeners" />
197
+ </div>
198
+ </template>
199
+
200
+ <script>
201
+ import axios from '@nextcloud/axios'
202
+ import { translatePlural as n, translate as t } from '@nextcloud/l10n'
203
+ import { NcButton, NcCheckboxRadioSwitch, NcEmptyContent, NcLoadingIcon, NcNoteCard, NcSelect } from '@nextcloud/vue'
204
+ import Download from 'vue-material-design-icons/Download.vue'
205
+ import Plus from 'vue-material-design-icons/Plus.vue'
206
+ import { prefixUrl } from '../../utils/headers.js'
207
+
208
+ let instanceCount = 0
209
+
210
+ /**
211
+ * Read a dotted path off a value.
212
+ *
213
+ * @param {object} value The value.
214
+ * @param {string} path The path.
215
+ * @return {unknown} What is there, or undefined.
216
+ */
217
+ function readPath(value, path) {
218
+ return String(path).split('.').reduce((at, key) => (at === null || at === undefined ? undefined : at[key]), value)
219
+ }
220
+
221
+ export default {
222
+ name: 'CnTimelineManage',
223
+
224
+ components: { Download, NcButton, NcCheckboxRadioSwitch, NcEmptyContent, NcLoadingIcon, NcNoteCard, NcSelect, Plus },
225
+
226
+ inject: {
227
+ /** The app's component registry; entry actions resolve their modal here. */
228
+ cnRegistry: { default: () => ({}) },
229
+ /** Consumer translation function; manifest labels are authored in English. */
230
+ cnTranslate: { default: () => (key) => key },
231
+ },
232
+
233
+ props: {
234
+ /** Register slug. */
235
+ register: { type: String, default: '' },
236
+ /** Schema slug. */
237
+ schema: { type: String, default: '' },
238
+ /** The object's id. */
239
+ objectId: { type: String, default: '' },
240
+ /** OpenRegister API base. */
241
+ apiBase: { type: String, default: '/apps/openregister/api' },
242
+ /**
243
+ * The widget content: `{ title?, subtitle?, publicLabel?, exportName?,
244
+ * composer?, textBlocks?, entryActions? }`.
245
+ */
246
+ content: { type: Object, default: () => ({}) },
247
+ },
248
+
249
+ data() {
250
+ instanceCount += 1
251
+ return {
252
+ uid: instanceCount,
253
+ allEntries: [],
254
+ entries: [],
255
+ kinds: [],
256
+ loading: false,
257
+ error: '',
258
+ canManage: false,
259
+ kindFilter: null,
260
+ visibilityFilter: null,
261
+ textBlocks: [],
262
+ chosenBlock: null,
263
+ draft: '',
264
+ draftIsPublic: false,
265
+ saving: false,
266
+ activeModal: null,
267
+ }
268
+ },
269
+
270
+ computed: {
271
+ titleText() {
272
+ return this.content.title ? this.translate(this.content.title) : t('nextcloud-vue', 'History')
273
+ },
274
+
275
+ subtitleText() {
276
+ return this.content.subtitle ? this.translate(this.content.subtitle) : t('nextcloud-vue', 'What happened, who did it and when, newest first')
277
+ },
278
+
279
+ publicLabel() {
280
+ return this.content.publicLabel ? this.translate(this.content.publicLabel) : t('nextcloud-vue', 'Visible to the applicant')
281
+ },
282
+
283
+ composerEnabled() {
284
+ return this.content.composer !== false
285
+ },
286
+
287
+ draftId() {
288
+ return `cn-timeline-draft-${this.uid}`
289
+ },
290
+
291
+ /**
292
+ * The kind options, the plain note first. A note is an entry with NO
293
+ * kind, so "Note" filters on its absence.
294
+ *
295
+ * @return {Array<object>} The options.
296
+ */
297
+ kindOptions() {
298
+ return [{ id: '', label: t('nextcloud-vue', 'Note') }].concat(this.kinds.map((kind) => ({ id: kind.slug, label: kind.title || kind.slug })))
299
+ },
300
+
301
+ /**
302
+ * All, then every kind the object has entries of, each with its count.
303
+ *
304
+ * @return {Array<object>} The chips.
305
+ */
306
+ kindChips() {
307
+ const chosen = this.kindFilter ? (this.kindFilter.id || '') : null
308
+ const counts = new Map()
309
+ for (const entry of this.allEntries) {
310
+ const kind = entry.kind || ''
311
+ counts.set(kind, (counts.get(kind) || 0) + 1)
312
+ }
313
+ const chips = [{ key: 'all', id: null, label: t('nextcloud-vue', 'All'), count: this.allEntries.length, active: chosen === null }]
314
+ for (const option of this.kindOptions) {
315
+ const count = counts.get(option.id) || 0
316
+ if (count > 0) {
317
+ chips.push({ key: option.id || 'note', id: option.id, label: option.label, count, active: chosen === option.id })
318
+ }
319
+ }
320
+ return chips
321
+ },
322
+
323
+ blockOptions() {
324
+ return this.textBlocks.map((block) => ({ id: block.slug, label: block.title || block.slug }))
325
+ },
326
+
327
+ visibilityOptions() {
328
+ return [
329
+ { id: 'internal', label: t('nextcloud-vue', 'Internal only') },
330
+ { id: 'public', label: this.publicLabel },
331
+ ]
332
+ },
333
+
334
+ base() {
335
+ return this.apiBase.replace(/\/$/, '')
336
+ },
337
+
338
+ timelineUrl() {
339
+ if (!this.objectId || !this.register || !this.schema) {
340
+ return ''
341
+ }
342
+ return prefixUrl(`${this.base}/objects/${this.register}/${this.schema}/${this.objectId}/timeline`)
343
+ },
344
+
345
+ entryActions() {
346
+ return Array.isArray(this.content.entryActions) ? this.content.entryActions.filter((action) => action && action.id) : []
347
+ },
348
+ },
349
+
350
+ watch: {
351
+ objectId: 'load',
352
+ },
353
+
354
+ mounted() {
355
+ this.loadKinds()
356
+ if (this.content.textBlocks !== false) {
357
+ this.loadTextBlocks()
358
+ }
359
+ this.load()
360
+ },
361
+
362
+ methods: {
363
+ t,
364
+ n,
365
+
366
+ translate(text) {
367
+ return text ? this.cnTranslate(text) : ''
368
+ },
369
+
370
+ /** Read the declared kinds, so the filter names them as administered. */
371
+ async loadKinds() {
372
+ try {
373
+ const response = await axios.get(prefixUrl(`${this.base}/timeline/kinds`))
374
+ this.kinds = response.data?.results || []
375
+ } catch {
376
+ this.kinds = []
377
+ }
378
+ },
379
+
380
+ /** Read the seeded standard notes. */
381
+ async loadTextBlocks() {
382
+ try {
383
+ const response = await axios.get(prefixUrl(`${this.base}/timeline/text-blocks`))
384
+ this.textBlocks = response.data?.results || []
385
+ } catch {
386
+ this.textBlocks = []
387
+ }
388
+ },
389
+
390
+ /**
391
+ * Note which standard note was picked. The BLOCK is sent, not its body:
392
+ * OpenRegister fills the object's own values into it on the way in.
393
+ *
394
+ * @param {object|null} block The picked option.
395
+ */
396
+ pickBlock(block) {
397
+ this.chosenBlock = block || null
398
+ if (block) {
399
+ const declared = this.textBlocks.find((entry) => entry.slug === block.id)
400
+ this.draft = declared?.title || block.label
401
+ }
402
+ },
403
+
404
+ /**
405
+ * Write the drafted note onto this object.
406
+ *
407
+ * @return {Promise<void>}
408
+ */
409
+ async submit() {
410
+ if (!this.draft.trim() || !this.timelineUrl) {
411
+ return
412
+ }
413
+ this.saving = true
414
+ this.error = ''
415
+ try {
416
+ const payload = { visibility: this.draftIsPublic ? 'public' : 'internal' }
417
+ if (this.chosenBlock?.id) {
418
+ payload.textBlock = this.chosenBlock.id
419
+ } else {
420
+ payload.message = this.draft
421
+ }
422
+ await axios.post(this.timelineUrl, payload)
423
+ this.draft = ''
424
+ this.chosenBlock = null
425
+ this.draftIsPublic = false
426
+ await this.load()
427
+ } catch {
428
+ this.error = t('nextcloud-vue', 'That note could not be saved.')
429
+ } finally {
430
+ this.saving = false
431
+ }
432
+ },
433
+
434
+ /**
435
+ * Read the timeline with the visibility filter, if one is set.
436
+ *
437
+ * @return {Promise<void>}
438
+ */
439
+ async load() {
440
+ if (!this.timelineUrl) {
441
+ return
442
+ }
443
+ this.loading = true
444
+ this.error = ''
445
+ try {
446
+ const params = {}
447
+ if (this.visibilityFilter?.id) {
448
+ params.visibility = this.visibilityFilter.id
449
+ }
450
+ const response = await axios.get(this.timelineUrl, { params })
451
+ this.canManage = response.data?.canManage === true
452
+ this.allEntries = response.data?.results || []
453
+ this.entries = this.applyKindFilter(this.allEntries)
454
+ } catch {
455
+ this.allEntries = []
456
+ this.entries = []
457
+ this.error = t('nextcloud-vue', 'The timeline could not be read. Try again, or ask an administrator to check OpenRegister.')
458
+ } finally {
459
+ this.loading = false
460
+ }
461
+ },
462
+
463
+ /**
464
+ * Show one kind, or every kind for `null`, without reading again.
465
+ *
466
+ * @param {string|null} id The kind slug, '' for a note, null for all.
467
+ */
468
+ chooseKind(id) {
469
+ this.kindFilter = id === null ? null : { id }
470
+ this.entries = this.applyKindFilter(this.allEntries)
471
+ },
472
+
473
+ /**
474
+ * The kind filter is applied in the reading: the list endpoint does not
475
+ * read a kind parameter, so sending one would be dropped in silence.
476
+ *
477
+ * @param {Array<object>} results The entries as read.
478
+ * @return {Array<object>} The entries to show.
479
+ */
480
+ applyKindFilter(results) {
481
+ if (!this.kindFilter) {
482
+ return results
483
+ }
484
+ const wanted = this.kindFilter.id || ''
485
+ return results.filter((entry) => (entry.kind || '') === wanted)
486
+ },
487
+
488
+ /**
489
+ * The shown entries as CSV: when, kind, who, visibility and what.
490
+ *
491
+ * @return {string} The CSV text.
492
+ */
493
+ entriesCsv() {
494
+ const cell = (value) => `"${String(value ?? '').replace(/"/g, '""')}"`
495
+ const rows = [[t('nextcloud-vue', 'When'), t('nextcloud-vue', 'Kind'), t('nextcloud-vue', 'Who'), t('nextcloud-vue', 'Visibility'), t('nextcloud-vue', 'Message')]]
496
+ for (const entry of this.entries) {
497
+ rows.push([entry.created, this.kindLabel(entry), entry.author, entry.visibility, entry.message])
498
+ }
499
+ return rows.map((row) => row.map(cell).join(',')).join('\r\n')
500
+ },
501
+
502
+ /** Download the shown entries as a CSV file. */
503
+ exportEntries() {
504
+ const blob = new Blob([this.entriesCsv()], { type: 'text/csv;charset=utf-8' })
505
+ const url = URL.createObjectURL(blob)
506
+ const link = document.createElement('a')
507
+ link.href = url
508
+ link.download = `${this.content.exportName || 'history'}-${this.objectId || 'record'}.csv`
509
+ document.body.appendChild(link)
510
+ link.click()
511
+ link.remove()
512
+ URL.revokeObjectURL(url)
513
+ },
514
+
515
+ /** Bring the composer into view and focus its text. */
516
+ focusComposer() {
517
+ const form = this.$refs.composer
518
+ if (!form) {
519
+ return
520
+ }
521
+ form.scrollIntoView?.({ behavior: 'smooth', block: 'center' })
522
+ form.querySelector('textarea')?.focus()
523
+ },
524
+
525
+ /**
526
+ * The label of one entry's kind, falling back to "Note".
527
+ *
528
+ * @param {object} entry The entry.
529
+ * @return {string} The label.
530
+ */
531
+ kindLabel(entry) {
532
+ if (!entry.kind) {
533
+ return t('nextcloud-vue', 'Note')
534
+ }
535
+ const declared = this.kinds.find((kind) => kind.slug === entry.kind)
536
+ return declared?.title || entry.kind
537
+ },
538
+
539
+ /**
540
+ * One moment in the reader's locale.
541
+ *
542
+ * @param {string} value An ISO 8601 stamp.
543
+ * @return {string} The moment, or the raw value.
544
+ */
545
+ moment(value) {
546
+ if (!value) {
547
+ return ''
548
+ }
549
+ const parsed = new Date(value)
550
+ return isNaN(parsed.getTime()) ? value : parsed.toLocaleString()
551
+ },
552
+
553
+ async togglePin(entry) {
554
+ await this.patch(entry, { pinned: !entry.pinned })
555
+ },
556
+
557
+ async closeFollowUp(entry) {
558
+ await this.patch(entry, { followUp: 'done' })
559
+ },
560
+
561
+ /**
562
+ * Write one change onto one entry and read the timeline back (pinning
563
+ * changes the order, so patching in place would look like nothing).
564
+ *
565
+ * @param {object} entry The entry.
566
+ * @param {object} changes The fields to write.
567
+ * @return {Promise<void>}
568
+ */
569
+ async patch(entry, changes) {
570
+ try {
571
+ await axios.patch(`${this.timelineUrl}/${entry.id}`, changes)
572
+ await this.load()
573
+ } catch {
574
+ this.error = t('nextcloud-vue', 'That change could not be saved.')
575
+ }
576
+ },
577
+
578
+ /**
579
+ * The configured entry actions that apply to one entry (`kinds` limits
580
+ * an action to entries of those kinds).
581
+ *
582
+ * @param {object} entry The entry.
583
+ * @return {Array<object>} The actions.
584
+ */
585
+ actionsFor(entry) {
586
+ return this.entryActions.filter((action) => !Array.isArray(action.kinds) || action.kinds.includes(entry.kind || ''))
587
+ },
588
+
589
+ /**
590
+ * Resolve `@objectId` and `@row.<path>` tokens in an action's props.
591
+ *
592
+ * @param {object} props The props as declared.
593
+ * @param {object} entry The entry.
594
+ * @return {object} The resolved props.
595
+ */
596
+ resolveProps(props, entry) {
597
+ const out = {}
598
+ for (const [key, value] of Object.entries(props || {})) {
599
+ if (value === '@objectId') {
600
+ out[key] = this.objectId
601
+ } else if (typeof value === 'string' && value.startsWith('@row.')) {
602
+ const found = readPath(entry, value.slice('@row.'.length))
603
+ out[key] = found === undefined || found === null ? '' : found
604
+ } else {
605
+ out[key] = value
606
+ }
607
+ }
608
+ return out
609
+ },
610
+
611
+ /**
612
+ * Open an entry action's modal from the app's registry. On its
613
+ * `doneEvent` (default `done`) the entry's open follow-up is closed when
614
+ * the action says so; otherwise the timeline is read again.
615
+ *
616
+ * @param {object} action The action.
617
+ * @param {object} entry The entry.
618
+ */
619
+ openAction(action, entry) {
620
+ const registered = (this.cnRegistry || {})[action.modal]
621
+ const component = registered && (registered.component || registered)
622
+ if (!component) {
623
+ // eslint-disable-next-line no-console
624
+ console.warn(`[CnTimelineWidget] entry action "${action.id}" names modal "${action.modal}", which the registry does not hold.`)
625
+ return
626
+ }
627
+ const close = () => {
628
+ this.activeModal = null
629
+ }
630
+ const done = async () => {
631
+ this.activeModal = null
632
+ if (action.closesFollowUp === true && entry.followUp === 'open') {
633
+ await this.closeFollowUp(entry)
634
+ return
635
+ }
636
+ await this.load()
637
+ }
638
+ this.activeModal = {
639
+ component,
640
+ props: this.resolveProps(action.props, entry),
641
+ listeners: { [action.doneEvent || 'done']: done, close },
642
+ }
643
+ },
644
+ },
645
+ }
646
+ </script>
647
+
648
+ <style scoped>
649
+ .cn-timeline-manage__header {
650
+ display: flex;
651
+ align-items: flex-start;
652
+ justify-content: space-between;
653
+ gap: 12px;
654
+ margin-block-end: 16px;
655
+ }
656
+
657
+ .cn-timeline-manage__heading {
658
+ display: flex;
659
+ flex-direction: column;
660
+ gap: 4px;
661
+ }
662
+
663
+ .cn-timeline-manage__header-actions {
664
+ display: flex;
665
+ gap: 8px;
666
+ }
667
+
668
+ .cn-timeline-manage__title {
669
+ margin: 0;
670
+ font-size: 17px;
671
+ font-weight: 700;
672
+ line-height: 1.2;
673
+ }
674
+
675
+ .cn-timeline-manage__subtitle {
676
+ margin: 0;
677
+ color: var(--color-text-maxcontrast);
678
+ font-size: 14px;
679
+ }
680
+
681
+ .cn-timeline-manage__chips {
682
+ display: flex;
683
+ flex-wrap: wrap;
684
+ gap: 8px;
685
+ margin-block-end: 12px;
686
+ }
687
+
688
+ .cn-timeline-manage__chip {
689
+ display: inline-flex;
690
+ align-items: center;
691
+ gap: 8px;
692
+ box-sizing: border-box;
693
+ height: 36px;
694
+ padding: 0 12px;
695
+ border: 1px solid var(--color-border-dark);
696
+ border-radius: 18px;
697
+ background: var(--color-main-background);
698
+ color: var(--color-main-text);
699
+ font-size: 14px;
700
+ font-weight: 600;
701
+ cursor: pointer;
702
+ }
703
+
704
+ /* The chosen kind is a filled chip with no outline. */
705
+ .cn-timeline-manage__chip--active {
706
+ border: 0;
707
+ background: var(--color-main-text);
708
+ color: var(--color-main-background);
709
+ }
710
+
711
+ .cn-timeline-manage__chip-count {
712
+ padding: 1px 7px;
713
+ border-radius: 10px;
714
+ background: var(--color-border);
715
+ color: var(--color-main-text);
716
+ font-size: 12px;
717
+ }
718
+
719
+ /* On the filled chip the count is a lighter ink bubble with light text. */
720
+ .cn-timeline-manage__chip--active .cn-timeline-manage__chip-count {
721
+ background: color-mix(in srgb, var(--color-main-background) 18%, var(--color-main-text));
722
+ color: var(--color-main-background);
723
+ }
724
+
725
+ .cn-timeline-manage__count {
726
+ margin: 12px 0 0;
727
+ color: var(--color-text-maxcontrast);
728
+ }
729
+
730
+ .cn-timeline-manage__composer {
731
+ display: flex;
732
+ flex-direction: column;
733
+ gap: 8px;
734
+ margin-block-start: 24px;
735
+ padding-block-start: 16px;
736
+ border-block-start: 1px solid var(--color-border);
737
+ }
738
+
739
+ .cn-timeline-manage__composer-title {
740
+ margin: 0;
741
+ font-size: 16px;
742
+ font-weight: bold;
743
+ }
744
+
745
+ .cn-timeline-manage__composer-hint {
746
+ margin: 0;
747
+ color: var(--color-text-maxcontrast);
748
+ }
749
+
750
+ .cn-timeline-manage__draft-label {
751
+ font-weight: bold;
752
+ }
753
+
754
+ .cn-timeline-manage__draft {
755
+ width: 100%;
756
+ min-height: 70px;
757
+ }
758
+
759
+ .cn-timeline-manage__controls {
760
+ display: flex;
761
+ gap: 12px;
762
+ flex-wrap: wrap;
763
+ margin-block-end: 12px;
764
+ }
765
+
766
+ .cn-timeline-manage__filter {
767
+ min-width: 200px;
768
+ }
769
+
770
+ .cn-timeline-manage__list {
771
+ list-style: none;
772
+ margin: 0;
773
+ padding: 0;
774
+ }
775
+
776
+ .cn-timeline-manage__entry {
777
+ border-block-end: 1px solid var(--color-border);
778
+ padding-block: 12px;
779
+ }
780
+
781
+ .cn-timeline-manage__entry--pinned {
782
+ border-inline-start: 3px solid var(--color-primary-element);
783
+ padding-inline-start: 8px;
784
+ }
785
+
786
+ .cn-timeline-manage__head {
787
+ display: flex;
788
+ gap: 8px;
789
+ flex-wrap: wrap;
790
+ align-items: baseline;
791
+ color: var(--color-text-maxcontrast);
792
+ font-size: 0.9em;
793
+ }
794
+
795
+ .cn-timeline-manage__kind {
796
+ font-weight: bold;
797
+ color: var(--color-main-text);
798
+ }
799
+
800
+ .cn-timeline-manage__badge {
801
+ border: 1px solid var(--color-border);
802
+ border-radius: var(--border-radius);
803
+ padding: 0 6px;
804
+ }
805
+
806
+ .cn-timeline-manage__badge--open {
807
+ border-color: var(--color-warning);
808
+ color: var(--color-warning-text);
809
+ }
810
+
811
+ .cn-timeline-manage__message {
812
+ margin-block: 4px 0;
813
+ white-space: pre-wrap;
814
+ }
815
+
816
+ .cn-timeline-manage__siblings {
817
+ margin-block: 4px 0;
818
+ color: var(--color-text-maxcontrast);
819
+ font-size: 0.9em;
820
+ }
821
+
822
+ .cn-timeline-manage__actions {
823
+ display: flex;
824
+ gap: 8px;
825
+ margin-block-start: 4px;
826
+ }
827
+ </style>