@conduction/nextcloud-vue 2.91.0 → 2.93.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 (149) hide show
  1. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +57 -4
  2. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +44 -17
  4. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  5. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue.js +1 -1
  6. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js +44 -2
  7. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
  8. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js +140 -59
  9. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
  10. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  11. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +20 -6
  12. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  13. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +3 -2
  14. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  15. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +10 -39
  16. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  17. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  18. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +5 -3
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  21. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  22. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +6 -4
  23. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  24. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  25. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js +46 -0
  26. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js.map +1 -1
  27. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue3.js +6 -2
  28. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue3.js.map +1 -1
  29. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue.js +1 -1
  30. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js +82 -9
  31. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js.map +1 -1
  32. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js +104 -90
  33. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js.map +1 -1
  34. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js +9 -0
  35. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue.js.map +1 -0
  36. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js +355 -0
  37. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue2.js.map +1 -0
  38. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js +462 -0
  39. package/dist/esm/components/CnFeaturesBoard/CnFeaturesBoard.vue3.js.map +1 -0
  40. package/dist/esm/components/CnFormDialog/CnFormDialog.vue.js +1 -1
  41. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +39 -10
  42. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  43. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +418 -399
  44. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  45. package/dist/esm/components/CnIcon/semanticIcons.js +2 -2
  46. package/dist/esm/components/CnIcon/semanticIcons.js.map +1 -1
  47. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue.js +1 -1
  48. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js +156 -3
  49. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js.map +1 -1
  50. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js +168 -52
  51. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js.map +1 -1
  52. package/dist/esm/components/CnModuleCard/CnModuleCard.vue.js +9 -0
  53. package/dist/esm/components/CnModuleCard/CnModuleCard.vue.js.map +1 -0
  54. package/dist/esm/components/CnModuleCard/CnModuleCard.vue2.js +83 -0
  55. package/dist/esm/components/CnModuleCard/CnModuleCard.vue2.js.map +1 -0
  56. package/dist/esm/components/CnModuleCard/CnModuleCard.vue3.js +119 -0
  57. package/dist/esm/components/CnModuleCard/CnModuleCard.vue3.js.map +1 -0
  58. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue.js +1 -1
  59. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue2.js +22 -1
  60. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue2.js.map +1 -1
  61. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue3.js +20 -14
  62. package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue3.js.map +1 -1
  63. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue.js +1 -1
  64. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js +2 -0
  65. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue2.js.map +1 -1
  66. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js +15 -5
  67. package/dist/esm/components/CnNotificationPreferences/CnNotificationPreferences.vue3.js.map +1 -1
  68. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue.js +1 -1
  69. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue2.js +8 -30
  70. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue2.js.map +1 -1
  71. package/dist/esm/components/CnRoadmapTab/CnRoadmapTab.vue3.js.map +1 -1
  72. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  73. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +33 -2
  74. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  75. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  76. package/dist/esm/components/CnTextWidget/CnTextWidget.vue.js +1 -1
  77. package/dist/esm/components/CnTextWidget/CnTextWidget.vue2.js +63 -4
  78. package/dist/esm/components/CnTextWidget/CnTextWidget.vue2.js.map +1 -1
  79. package/dist/esm/components/CnTextWidget/CnTextWidget.vue3.js +19 -2
  80. package/dist/esm/components/CnTextWidget/CnTextWidget.vue3.js.map +1 -1
  81. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js +9 -0
  82. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue.js.map +1 -0
  83. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js +164 -0
  84. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue2.js.map +1 -0
  85. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js +93 -0
  86. package/dist/esm/components/CnUserSettingsDialog/CnUserSettingsDialog.vue3.js.map +1 -0
  87. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue.js +1 -1
  88. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +12 -0
  89. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  90. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  91. package/dist/esm/l10n/en.json.js +45 -1
  92. package/dist/esm/l10n/en.json.js.map +1 -1
  93. package/dist/esm/l10n/nl.json.js +44 -1
  94. package/dist/esm/l10n/nl.json.js.map +1 -1
  95. package/dist/esm/library-style.js +1 -1
  96. package/dist/esm/nextcloud-vue.css +1182 -328
  97. package/dist/esm/utils/boardDate.js +35 -1
  98. package/dist/esm/utils/boardDate.js.map +1 -1
  99. package/dist/esm/utils/featureCatalog.js +135 -0
  100. package/dist/esm/utils/featureCatalog.js.map +1 -0
  101. package/dist/esm/utils/fillTemplate.js +84 -4
  102. package/dist/esm/utils/fillTemplate.js.map +1 -1
  103. package/dist/esm/utils/headerMeta.js +62 -12
  104. package/dist/esm/utils/headerMeta.js.map +1 -1
  105. package/dist/esm/utils/roadmapIssues.js +86 -0
  106. package/dist/esm/utils/roadmapIssues.js.map +1 -0
  107. package/dist/esm/utils/userDisplayName.js +47 -0
  108. package/dist/esm/utils/userDisplayName.js.map +1 -0
  109. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  110. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  111. package/dist/nextcloud-vue.cjs.js +15618 -13097
  112. package/dist/nextcloud-vue.cjs.js.map +2 -2
  113. package/dist/nextcloud-vue.css +1182 -328
  114. package/l10n/en.json +45 -1
  115. package/l10n/nl.json +44 -1
  116. package/package.json +1 -1
  117. package/src/components/CnAppRoot/CnAppRoot.md +2 -0
  118. package/src/components/CnAppRoot/CnAppRoot.vue +66 -5
  119. package/src/components/CnChoiceCards/CnChoiceCards.md +27 -0
  120. package/src/components/CnChoiceCards/CnChoiceCards.vue +168 -3
  121. package/src/components/CnDashboardPage/CnDashboardPage.vue +45 -6
  122. package/src/components/CnDataTable/CnDataTable.vue +10 -39
  123. package/src/components/CnDetailPage/CnDetailPage.vue +5 -3
  124. package/src/components/CnFactsWidget/CnFactsWidget.vue +6 -4
  125. package/src/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue +51 -1
  126. package/src/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +98 -13
  127. package/src/components/CnFeaturesBoard/CnFeaturesBoard.vue +1053 -0
  128. package/src/components/CnFormDialog/CnFormDialog.md +1 -0
  129. package/src/components/CnFormDialog/CnFormDialog.vue +619 -562
  130. package/src/components/CnLinkCardsPage/CnLinkCardsPage.vue +338 -5
  131. package/src/components/CnModuleCard/CnModuleCard.vue +258 -0
  132. package/src/components/CnNcWidgetWidget/CnNcWidgetWidget.vue +23 -2
  133. package/src/components/CnNotificationPreferences/CnNotificationPreferences.vue +5 -0
  134. package/src/components/CnRoadmapTab/CnRoadmapTab.vue +6 -28
  135. package/src/components/CnTabsWidget/CnTabsWidget.vue +33 -2
  136. package/src/components/CnTextWidget/CnTextWidget.vue +89 -4
  137. package/src/components/CnUserSettingsDialog/CnUserSettingsDialog.vue +305 -0
  138. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +12 -0
  139. package/src/css/dialog.css +47 -0
  140. package/src/css/look-board-detail.css +8 -0
  141. package/src/css/look-board.css +33 -0
  142. package/src/schemas/app-manifest-v2.schema.json +67 -4
  143. package/src/utils/boardDate.js +34 -0
  144. package/src/utils/featureCatalog.js +132 -0
  145. package/src/utils/fillTemplate.js +83 -3
  146. package/src/utils/headerMeta.js +62 -12
  147. package/src/utils/roadmapIssues.js +82 -0
  148. package/src/utils/userDisplayName.js +54 -0
  149. package/src/utils/validateManifestV2.compiled.js +3 -3
@@ -5,7 +5,7 @@
5
5
 
6
6
  <template>
7
7
  <div class="cn-nc-widget-widget" :class="{ 'cn-nc-widget-widget--horizontal': displayMode === 'horizontal' }">
8
- <header class="cn-nc-widget-widget__header">
8
+ <header v-if="showsHeader" class="cn-nc-widget-widget__header" data-testid="cn-nc-widget-header">
9
9
  <img
10
10
  v-if="widgetIconUrl"
11
11
  class="cn-nc-widget-widget__header-icon"
@@ -130,9 +130,16 @@ function getDashboardGlobal() {
130
130
  export default {
131
131
  name: 'CnNcWidgetWidget',
132
132
 
133
+ inject: {
134
+ /** Whether the hosting card draws a title row (CnWidgetWrapper). */
135
+ cnWidgetTitled: { default: null },
136
+ },
137
+
133
138
  props: {
134
139
  /**
135
- * Persisted widget content `{widgetId, displayMode, limit?, showAllUrl?, showAllLabel?}`.
140
+ * Persisted widget content `{widgetId, displayMode, limit?, showAllUrl?, showAllLabel?, showHeader?}`.
141
+ * `showHeader: false` drops the widget's own header (icon and title);
142
+ * it is dropped too when the hosting card draws a title row.
136
143
  * `limit` is the most items shown (the list then comes from the items
137
144
  * API, so it fits its cell); `showAllUrl` / `showAllLabel` the link to
138
145
  * the rest (default the widget's own page and "Show all").
@@ -207,6 +214,20 @@ export default {
207
214
  return null
208
215
  },
209
216
 
217
+ /**
218
+ * Whether the widget draws its own header: not with
219
+ * `content.showHeader: false`, nor inside a card that draws a title.
220
+ *
221
+ * @spec openspec/changes/nc-widget-header/specs/nc-widget-header/spec.md#requirement-a-nextcloud-widget-draws-no-second-heading
222
+ * @return {boolean}
223
+ */
224
+ showsHeader() {
225
+ if (this.content && this.content.showHeader === false) {
226
+ return false
227
+ }
228
+ return !(typeof this.cnWidgetTitled === 'function' && this.cnWidgetTitled())
229
+ },
230
+
210
231
  /**
211
232
  * The header title.
212
233
  *
@@ -1,5 +1,8 @@
1
1
  <template>
2
2
  <NcAppSettingsSection id="notifications" :name="t('nextcloud-vue', 'Notifications')">
3
+ <template v-if="cnLook === 'board'" #icon>
4
+ <BellOutline :size="18" />
5
+ </template>
3
6
  <NcLoadingIcon v-if="loading" :size="24" />
4
7
 
5
8
  <NcEmptyContent v-else-if="error"
@@ -107,6 +110,8 @@ export default {
107
110
  * than an always-empty pane.
108
111
  */
109
112
  cnAppId: { default: () => '' },
113
+ /** The app's look; `board` gives the section its navigation icon. */
114
+ cnLook: { default: 'nextcloud' },
110
115
  /**
111
116
  * App-supplied labels for its notification rules, provided by the
112
117
  * CnAppRoot ancestor from its `notificationLabels` prop. Keyed
@@ -69,9 +69,7 @@
69
69
  *
70
70
  * Spec: features-roadmap-component — Requirement "RoadmapTab".
71
71
  */
72
- import axios from '@nextcloud/axios'
73
72
  import { translate as t } from '@nextcloud/l10n'
74
- import { generateUrl } from '@nextcloud/router'
75
73
  import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
76
74
  import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
77
75
  import ClockOutline from 'vue-material-design-icons/ClockOutline.vue'
@@ -79,6 +77,7 @@ import InformationOutline from 'vue-material-design-icons/InformationOutline.vue
79
77
  import KeyOutline from 'vue-material-design-icons/KeyOutline.vue'
80
78
  import RoadVariant from 'vue-material-design-icons/RoadVariant.vue'
81
79
  import CnRoadmapItem from '../CnRoadmapItem/CnRoadmapItem.vue'
80
+ import { fetchRoadmapIssues, sortByReactions } from '../../utils/roadmapIssues.js'
82
81
 
83
82
  export default {
84
83
  name: 'CnRoadmapTab',
@@ -124,7 +123,7 @@ export default {
124
123
 
125
124
  computed: {
126
125
  sortedItems() {
127
- return [...this.items].sort((a, b) => (b.reactions?.total_count || 0) - (a.reactions?.total_count || 0))
126
+ return sortByReactions(this.items)
128
127
  },
129
128
 
130
129
  disabledLabel() {
@@ -175,31 +174,10 @@ export default {
175
174
  this.loading = true
176
175
  this.degraded = null
177
176
  this.items = []
178
- try {
179
- const url = generateUrl('/apps/openregister/api/github/issues')
180
- const response = await axios.get(url, {
181
- params: {
182
- repo: this.repo,
183
- labels: 'enhancement,feature',
184
- },
185
- })
186
- const data = response.data || {}
187
- if (data.hint === 'github_pat_not_configured') {
188
- this.degraded = 'pat_not_configured'
189
- } else if (Array.isArray(data.items)) {
190
- this.items = data.items
191
- }
192
- } catch (e) {
193
- const status = e?.response?.status
194
- const code = e?.response?.data?.error
195
- if (status === 429 || code === 'github_rate_limited' || code === 'rate_limited') {
196
- this.degraded = 'rate_limited'
197
- } else {
198
- this.degraded = 'error'
199
- }
200
- } finally {
201
- this.loading = false
202
- }
177
+ const { items, degraded } = await fetchRoadmapIssues(this.repo)
178
+ this.items = items
179
+ this.degraded = degraded
180
+ this.loading = false
203
181
  },
204
182
  },
205
183
  }
@@ -187,7 +187,7 @@ import { resolveTabCount } from '../../utils/detailActionModel.js'
187
187
  import { fetchListTotal } from '../../utils/fetchFilterCounts.js'
188
188
  import { isActivityWidget } from '../../utils/headerMeta.js'
189
189
  import { PANEL_ACTION_SINK } from '../../utils/panelActions.js'
190
- import { evaluateVisibleWhen, evaluateVisibleWhenLocal, isLocallyDecidableVisibleWhen } from '../../utils/visibleWhen.js'
190
+ import { evaluateVisibleWhen, evaluateVisibleWhenLocal, evaluateWorkspaceCondition, isLocallyDecidableVisibleWhen, isWorkspaceCondition } from '../../utils/visibleWhen.js'
191
191
  import { widgetTitleOf } from '../../utils/widgetDispatch.js'
192
192
  import { CnActionsMenu } from '../CnActionsMenu/index.js'
193
193
 
@@ -331,6 +331,13 @@ export default {
331
331
  * `visibleWhen` reads them as `@context.<name>`. Null on any other page.
332
332
  */
333
333
  cnDetailVisibilityRows: { default: null },
334
+ /**
335
+ * The page's workspace bag (CnDashboardPage provides it), which a
336
+ * page control such as a chips filter writes. A widget in a stack is
337
+ * left out while its workspace `visibleWhen` does not hold. Null off a
338
+ * dashboard.
339
+ */
340
+ cnWorkspaceContext: { default: null },
334
341
  /**
335
342
  * The host's translate (CnAppRoot provides `cnTranslate`), so a tab
336
343
  * label written in the manifest reads in the user's language.
@@ -588,7 +595,8 @@ export default {
588
595
  // widget is skipped. The first that resolves is the panel's.
589
596
  const ids = this.tabWidgetIds(tab)
590
597
  const widgets = ids.length > 1
591
- ? ids.map((id) => this.availableWidgets.find((w) => w && w.id === id) || null).filter(Boolean)
598
+ ? ids.map((id) => this.availableWidgets.find((w) => w && w.id === id) || null)
599
+ .filter((w) => w && !this.hiddenByWorkspace(w))
592
600
  : []
593
601
  const widget = ids.length > 1
594
602
  ? (widgets[0] || null)
@@ -837,6 +845,29 @@ export default {
837
845
  },
838
846
 
839
847
  methods: {
848
+ /**
849
+ * Whether a page control hides a widget of a stack right now: its
850
+ * `visibleWhen` (on the widget, its content or its props) is a
851
+ * workspace condition that does not hold. Off a dashboard there is no
852
+ * workspace, and nothing is hidden. Other conditions are not looked at
853
+ * here.
854
+ *
855
+ * @param {object} widget The widget definition.
856
+ * @return {boolean} True when the widget must not draw.
857
+ * @spec openspec/changes/tabs-stack-follows-page-controls/specs/tabs-widget/spec.md#requirement-a-widget-in-a-tab-stack-follows-the-page-controls
858
+ */
859
+ hiddenByWorkspace(widget) {
860
+ const cond = widget.visibleWhen || widget.content?.visibleWhen || widget.props?.visibleWhen || null
861
+ if (!isWorkspaceCondition(cond)) {
862
+ return false
863
+ }
864
+ const bag = this.cnWorkspaceContext
865
+ if (!bag || typeof bag !== 'object') {
866
+ return false
867
+ }
868
+ return !evaluateWorkspaceCondition(cond, 'value' in bag ? bag.value : bag)
869
+ },
870
+
840
871
  /**
841
872
  * The grid a stack lays its widgets out in, as one column span (of 12)
842
873
  * per widget in order, or null for the plain vertical stack. A tab says
@@ -60,6 +60,17 @@
60
60
  :style="contentStyle">
61
61
  {{ placeholderText }}
62
62
  </span>
63
+ <a
64
+ v-if="link"
65
+ class="cn-text-widget__link"
66
+ data-testid="cn-text-widget-link"
67
+ :href="link.url"
68
+ :target="link.external ? '_blank' : null"
69
+ :rel="link.external ? 'noopener noreferrer' : null"
70
+ @click="openLink">
71
+ {{ link.label }}
72
+ <ChevronRight :size="16" />
73
+ </a>
63
74
  </div>
64
75
  </template>
65
76
 
@@ -67,6 +78,8 @@
67
78
  import { translate as t } from '@nextcloud/l10n'
68
79
  import DOMPurify from 'dompurify'
69
80
  import { Marked } from 'marked'
81
+ import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
82
+ import { followLinkClick, resolveHref } from '../../utils/linkNavigation.js'
70
83
  import { isPlaceholderCell } from '../../utils/textTable.js'
71
84
 
72
85
  // Per-module `Marked` instance so configuration is scoped here and does NOT
@@ -120,13 +133,23 @@ function ensureDomPurifyHook() {
120
133
  export default {
121
134
  name: 'CnTextWidget',
122
135
 
136
+ components: { ChevronRight },
137
+
138
+ inject: {
139
+ /** The app's translate function, provided by CnAppRoot. Identity outside one. */
140
+ cnTranslate: { default: () => (key) => key },
141
+ },
142
+
123
143
  props: {
124
144
  /**
125
145
  * Persisted widget content blob: `{text, fontSize, color,
126
- * backgroundColor, textAlign, contentMode, tableMode, tableData}`.
146
+ * backgroundColor, textAlign, contentMode, tableMode, tableData, link}`.
127
147
  * All keys are optional; missing keys collapse to documented defaults.
148
+ * `text` and `link.label` go through the app's translate function.
149
+ * `link` is `{label, route}` (a page id, or a path starting with `/`) or
150
+ * `{label, href}` (an external URL), drawn under the text.
128
151
  *
129
- * @type {{text: string, fontSize: string, color: string, backgroundColor: string, textAlign: string, contentMode: string, tableMode: boolean, tableData: object}}
152
+ * @type {{text: string, fontSize: string, color: string, backgroundColor: string, textAlign: string, contentMode: string, tableMode: boolean, tableData: object, link: {label: string, route: string, href: string}}}
130
153
  */
131
154
  content: {
132
155
  type: Object,
@@ -135,9 +158,34 @@ export default {
135
158
  },
136
159
 
137
160
  computed: {
138
- /** The author text, or '' when absent / non-string. */
161
+ /** The author text through the app's translate function, or '' when absent / non-string. */
139
162
  text() {
140
- return typeof this.content?.text === 'string' ? this.content.text : ''
163
+ return typeof this.content?.text === 'string' ? this.tr(this.content.text) : ''
164
+ },
165
+
166
+ /**
167
+ * The link under the text, from `content.link`: `{ label, route }`
168
+ * (a page id, or a router path when it starts with `/`) or
169
+ * `{ label, href }` (an external URL, opened in a new tab). The label
170
+ * goes through the app's translate function. Null without a label or
171
+ * a target.
172
+ *
173
+ * @return {{label: string, url: string, location: (object|null), external: boolean}|null} The link.
174
+ */
175
+ link() {
176
+ const link = this.content?.link
177
+ if (!link || typeof link !== 'object' || typeof link.label !== 'string' || link.label === '') {
178
+ return null
179
+ }
180
+ if (typeof link.route === 'string' && link.route !== '') {
181
+ const location = link.route.startsWith('/') ? { path: link.route } : { name: link.route }
182
+ const url = resolveHref(location, this.$router)
183
+ return url ? { label: this.tr(link.label), url, location, external: false } : null
184
+ }
185
+ if (typeof link.href === 'string' && link.href !== '') {
186
+ return { label: this.tr(link.label), url: link.href, location: null, external: true }
187
+ }
188
+ return null
141
189
  },
142
190
 
143
191
  /** Whether the text has any non-whitespace content. */
@@ -253,6 +301,7 @@ export default {
253
301
  'justify-content': 'center',
254
302
  overflow: 'auto',
255
303
  'background-color': this.backgroundColor,
304
+ ...(this.link ? { 'flex-direction': 'column', gap: '8px', 'align-items': 'flex-start' } : {}),
256
305
  }
257
306
  },
258
307
 
@@ -287,6 +336,27 @@ export default {
287
336
  },
288
337
 
289
338
  methods: {
339
+ /**
340
+ * A manifest string through the app's translate function.
341
+ *
342
+ * @param {string} text The string.
343
+ * @return {string} The translated string.
344
+ */
345
+ tr(text) {
346
+ return typeof this.cnTranslate === 'function' ? this.cnTranslate(text) : text
347
+ },
348
+
349
+ /**
350
+ * Route a plain click on an in-app link, so the page does not reload.
351
+ *
352
+ * @param {MouseEvent} event The click.
353
+ */
354
+ openLink(event) {
355
+ if (this.link && !this.link.external) {
356
+ followLinkClick(event, this.link.location, this.$router)
357
+ }
358
+ },
359
+
290
360
  /**
291
361
  * Run cell text through DOMPurify so the table-mode render path has
292
362
  * the same XSS-stripping guarantees as the text-mode path.
@@ -518,4 +588,19 @@ export default {
518
588
  color: var(--color-text-maxcontrast);
519
589
  font-style: italic;
520
590
  }
591
+
592
+ .cn-text-widget__link {
593
+ display: inline-flex;
594
+ align-items: center;
595
+ gap: 2px;
596
+ font-size: 14px;
597
+ font-weight: 600;
598
+ color: var(--color-primary-element);
599
+ text-decoration: none;
600
+ }
601
+
602
+ .cn-text-widget__link:hover,
603
+ .cn-text-widget__link:focus-visible {
604
+ text-decoration: underline;
605
+ }
521
606
  </style>
@@ -0,0 +1,305 @@
1
+ <!--
2
+ - SPDX-FileCopyrightText: 2026 Conduction B.V.
3
+ - SPDX-License-Identifier: EUPL-1.2
4
+ -->
5
+ <template>
6
+ <CnDialog
7
+ class="cn-user-settings-dialog"
8
+ :name="title"
9
+ look="board"
10
+ width="wizard"
11
+ :eyebrow="eyebrow"
12
+ :subtitle="subtitle"
13
+ :open="open"
14
+ :style="eyebrowStyle"
15
+ data-testid="cn-user-settings-dialog"
16
+ @update:open="onUpdateOpen"
17
+ @closing="close">
18
+ <div class="cn-user-settings-dialog__body">
19
+ <nav
20
+ v-if="sections.length > 0"
21
+ class="cn-user-settings-dialog__nav"
22
+ :aria-label="navLabel"
23
+ data-testid="cn-user-settings-nav">
24
+ <ul>
25
+ <li v-for="section in sections" :key="section.id">
26
+ <a
27
+ :href="`#settings-section_${section.id}`"
28
+ class="cn-user-settings-dialog__nav-link"
29
+ :class="{ 'cn-user-settings-dialog__nav-link--active': section.id === selected }"
30
+ :aria-current="section.id === selected ? 'true' : undefined"
31
+ :data-testid="`cn-user-settings-nav-${section.id}`"
32
+ @click.prevent="selectSection(section.id)">
33
+ <span v-if="section.icon" class="cn-user-settings-dialog__nav-icon" aria-hidden="true">
34
+ <CnVNodes :vnodes="section.icon" />
35
+ </span>
36
+ <span>{{ section.name }}</span>
37
+ </a>
38
+ </li>
39
+ </ul>
40
+ </nav>
41
+ <div class="cn-user-settings-dialog__sections">
42
+ <!-- @slot default NcAppSettingsSection children; each registers itself in the navigation. -->
43
+ <slot />
44
+ </div>
45
+ </div>
46
+ <template #actions>
47
+ <p class="cn-user-settings-dialog__note" data-testid="cn-user-settings-note">
48
+ {{ footerNote }}
49
+ </p>
50
+ <NcButton variant="secondary" data-testid="cn-user-settings-close" @click="close">
51
+ {{ closeLabel }}
52
+ </NcButton>
53
+ </template>
54
+ </CnDialog>
55
+ </template>
56
+
57
+ <script>
58
+ import { translate as t } from '@nextcloud/l10n'
59
+ import { NcButton } from '@nextcloud/vue'
60
+ import { markRaw } from 'vue'
61
+ import CnDialog from '../CnDialog/CnDialog.vue'
62
+
63
+ /**
64
+ * The key NcAppSettingsSection injects to register itself with its dialog
65
+ * (`Symbol.for`, so the same symbol `@nextcloud/vue` provides).
66
+ */
67
+ const REGISTRATION_KEY = Symbol.for('NcAppSettingsDialog:registration')
68
+
69
+ /** Renders a section's icon vnodes as they were registered. */
70
+ const CnVNodes = {
71
+ name: 'CnVNodes',
72
+ props: { vnodes: { type: [Array, Object], default: null } },
73
+ render() {
74
+ return this.vnodes
75
+ },
76
+ }
77
+
78
+ /**
79
+ * CnUserSettingsDialog - the user settings dialog in the board look (board
80
+ * PqPersoonlijk): eyebrow, title and subtitle, a left navigation listing the
81
+ * sections with their icons, the sections themselves, and a footer with a
82
+ * note that changes save straight away and a Close button.
83
+ *
84
+ * The sections stay the app's: any `NcAppSettingsSection` in the default slot
85
+ * registers itself with this dialog exactly as it does with
86
+ * `NcAppSettingsDialog`, with its name, order and `#icon` slot. CnAppRoot
87
+ * renders this dialog instead of `NcAppSettingsDialog` under the board look.
88
+ *
89
+ * @spec openspec/changes/user-settings-board-dialog/specs/user-settings-board-dialog/spec.md#requirement-the-user-settings-dialog-takes-the-board-look
90
+ *
91
+ * @event update:open Emitted with `false` when the dialog closes.
92
+ */
93
+ export default {
94
+ name: 'CnUserSettingsDialog',
95
+
96
+ components: { CnDialog, CnVNodes, NcButton },
97
+
98
+ provide() {
99
+ return {
100
+ [REGISTRATION_KEY]: {
101
+ registerSection: this.registerSection,
102
+ unregisterSection: this.unregisterSection,
103
+ },
104
+ }
105
+ },
106
+
107
+ props: {
108
+ /** Whether the dialog is open. */
109
+ open: { type: Boolean, default: false },
110
+ /** Dialog title; also its accessible name. */
111
+ title: { type: String, required: true },
112
+ /** Context line above the title. */
113
+ eyebrow: { type: String, default: () => t('nextcloud-vue', 'Your account') },
114
+ /** Sentence under the title. */
115
+ subtitle: { type: String, default: () => t('nextcloud-vue', 'Only for you. Colleagues keep their own settings.') },
116
+ /** The app's name, after the footer note. */
117
+ appName: { type: String, default: '' },
118
+ },
119
+
120
+ emits: ['update:open'],
121
+
122
+ data() {
123
+ return {
124
+ sections: [],
125
+ selected: '',
126
+ picked: false,
127
+ }
128
+ },
129
+
130
+ computed: {
131
+ /** @return {object} The board's eyebrow: muted, in sentence case. */
132
+ eyebrowStyle() {
133
+ return { '--cn-dialog-eyebrow-transform': 'none', '--cn-dialog-eyebrow-color': 'var(--color-text-maxcontrast)' }
134
+ },
135
+
136
+ /** @return {string} The navigation's accessible name. */
137
+ navLabel() {
138
+ return t('nextcloud-vue', 'Sections of the settings')
139
+ },
140
+
141
+ /** @return {string} The footer note, with the app's name when known. */
142
+ footerNote() {
143
+ const note = t('nextcloud-vue', 'Changes are saved straight away')
144
+ return this.appName ? `${note} · ${this.appName}` : note
145
+ },
146
+
147
+ /** @return {string} The Close button label. */
148
+ closeLabel() {
149
+ return t('nextcloud-vue', 'Close')
150
+ },
151
+ },
152
+
153
+ methods: {
154
+ /**
155
+ * Add a section to the navigation, kept in `order`; a section
156
+ * without one goes last. Until the reader picks one, the first
157
+ * section is current.
158
+ *
159
+ * @param {string} id The section id.
160
+ * @param {string} name The section name.
161
+ * @param {number} [order] The section's place.
162
+ * @param {Array} [icon] The section's icon vnodes.
163
+ */
164
+ registerSection(id, name, order, icon) {
165
+ const rest = this.sections.filter((section) => section.id !== id)
166
+ const place = typeof order === 'number' ? order : Math.max(0, ...rest.map((section) => section.order)) + 1
167
+ this.sections = [...rest, { id, name, order: place, icon: icon ? markRaw(icon) : null }].sort((a, b) => a.order - b.order)
168
+ if (!this.picked) {
169
+ this.selected = this.sections[0].id
170
+ }
171
+ },
172
+
173
+ /**
174
+ * Remove a section from the navigation.
175
+ *
176
+ * @param {string} id The section id.
177
+ */
178
+ unregisterSection(id) {
179
+ this.sections = this.sections.filter((section) => section.id !== id)
180
+ if (this.selected === id) {
181
+ this.selected = this.sections.length > 0 ? this.sections[0].id : ''
182
+ }
183
+ },
184
+
185
+ /**
186
+ * Mark a section current and scroll it into view.
187
+ *
188
+ * @param {string} id The section id.
189
+ */
190
+ selectSection(id) {
191
+ this.picked = true
192
+ this.selected = id
193
+ const target = typeof document !== 'undefined' ? document.getElementById(`settings-section_${id}`) : null
194
+ if (target && typeof target.scrollIntoView === 'function') {
195
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' })
196
+ }
197
+ },
198
+
199
+ /**
200
+ * Follow NcDialog's own open state.
201
+ *
202
+ * @param {boolean} value Whether NcDialog is open.
203
+ */
204
+ onUpdateOpen(value) {
205
+ if (!value) {
206
+ this.close()
207
+ }
208
+ },
209
+
210
+ /** Close the dialog. */
211
+ close() {
212
+ /**
213
+ * @event update:open Emitted with `false` when the dialog closes (Close button, close icon, Escape).
214
+ */
215
+ this.$emit('update:open', false)
216
+ },
217
+ },
218
+ }
219
+ </script>
220
+
221
+ <style scoped>
222
+ .cn-user-settings-dialog__body {
223
+ display: flex;
224
+ flex-wrap: wrap;
225
+ gap: 0 24px;
226
+ }
227
+
228
+ .cn-user-settings-dialog__nav {
229
+ flex: 0 0 200px;
230
+ width: 200px;
231
+ padding-top: 18px;
232
+ }
233
+
234
+ .cn-user-settings-dialog__nav ul {
235
+ display: flex;
236
+ flex-direction: column;
237
+ gap: 2px;
238
+ margin: 0;
239
+ padding: 0;
240
+ list-style: none;
241
+ }
242
+
243
+ .cn-user-settings-dialog__nav-link {
244
+ display: flex;
245
+ align-items: center;
246
+ gap: 10px;
247
+ min-height: 40px;
248
+ padding: 0 12px;
249
+ border-radius: var(--border-radius-element, 8px);
250
+ font-size: 15px;
251
+ color: var(--color-main-text);
252
+ text-decoration: none;
253
+ }
254
+
255
+ .cn-user-settings-dialog__nav-link:hover {
256
+ background: var(--color-background-hover);
257
+ }
258
+
259
+ .cn-user-settings-dialog__nav-link:focus-visible {
260
+ outline: 2px solid var(--color-primary-element);
261
+ outline-offset: 2px;
262
+ }
263
+
264
+ .cn-user-settings-dialog__nav-link--active,
265
+ .cn-user-settings-dialog__nav-link--active:hover {
266
+ background: var(--color-primary-element-light);
267
+ color: var(--color-primary-element-light-text);
268
+ font-weight: 600;
269
+ }
270
+
271
+ .cn-user-settings-dialog__nav-icon {
272
+ display: inline-flex;
273
+ flex: none;
274
+ width: 18px;
275
+ height: 18px;
276
+ align-items: center;
277
+ justify-content: center;
278
+ }
279
+
280
+ .cn-user-settings-dialog__sections {
281
+ display: flex;
282
+ flex: 1 1 380px;
283
+ flex-direction: column;
284
+ min-width: 0;
285
+ }
286
+
287
+ .cn-user-settings-dialog__sections :deep(.app-settings-section) {
288
+ margin: 0;
289
+ padding: 18px 0;
290
+ border-top: 1px solid var(--color-border);
291
+ }
292
+
293
+ .cn-user-settings-dialog__sections :deep(.app-settings-section__name) {
294
+ margin: 0 0 12px;
295
+ font-size: 17px;
296
+ font-weight: 700;
297
+ }
298
+
299
+ .cn-user-settings-dialog__note {
300
+ flex: 1 1 auto;
301
+ margin: 0;
302
+ font-size: 14px;
303
+ color: var(--color-text-maxcontrast);
304
+ }
305
+ </style>
@@ -225,6 +225,18 @@ export default {
225
225
  CnActionsMenu,
226
226
  },
227
227
 
228
+ provide() {
229
+ return {
230
+ /**
231
+ * Whether this card draws a title row, so a widget inside it can
232
+ * leave out a heading of its own (nc-widget-header).
233
+ *
234
+ * @return {boolean}
235
+ */
236
+ cnWidgetTitled: () => this.titleVisible && Boolean(this.displayTitle),
237
+ }
238
+ },
239
+
228
240
  inject: {
229
241
  /**
230
242
  * Host translate function provided by CnAppRoot as