@conduction/nextcloud-vue 2.56.0-beta.36 → 2.56.0-beta.37

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 (136) hide show
  1. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue.js +1 -1
  2. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js +131 -3
  3. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue2.js.map +1 -1
  4. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js +54 -18
  5. package/dist/esm/components/CnAdminSettingsShell/CnAdminSettingsShell.vue3.js.map +1 -1
  6. package/dist/esm/components/CnBoardView/CnBoardView.vue.js +1 -1
  7. package/dist/esm/components/CnBoardView/CnBoardView.vue2.js +232 -4
  8. package/dist/esm/components/CnBoardView/CnBoardView.vue2.js.map +1 -1
  9. package/dist/esm/components/CnBoardView/CnBoardView.vue3.js +95 -51
  10. package/dist/esm/components/CnBoardView/CnBoardView.vue3.js.map +1 -1
  11. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue.js +1 -1
  12. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +45 -0
  13. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  14. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +22 -12
  15. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  16. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +23 -4
  17. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  18. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
  20. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +3 -1
  21. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  22. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  23. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  24. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +18 -5
  25. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  26. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  27. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +548 -77
  28. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  29. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +191 -82
  30. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  31. package/dist/esm/components/CnIndexPage/useSelfFetchList.js +11 -2
  32. package/dist/esm/components/CnIndexPage/useSelfFetchList.js.map +1 -1
  33. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  34. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +70 -2
  35. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  36. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  37. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue.js +1 -1
  38. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js +67 -5
  39. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue2.js.map +1 -1
  40. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue3.js +181 -124
  41. package/dist/esm/components/CnRegisterMapping/CnRegisterMapping.vue3.js.map +1 -1
  42. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue.js +1 -1
  43. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js +5 -1
  44. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue2.js.map +1 -1
  45. package/dist/esm/components/CnSettingsPage/CnSettingsPage.vue3.js.map +1 -1
  46. package/dist/esm/components/CnStatWidget/CnStatWidget.vue.js +1 -1
  47. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +134 -10
  48. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  49. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +6 -2
  50. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  51. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  52. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +6 -3
  53. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  54. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  55. package/dist/esm/components/CnToggleTableWidget/CnToggleTableWidget.vue.js +1 -1
  56. package/dist/esm/components/CnToggleTableWidget/CnToggleTableWidget.vue2.js +140 -7
  57. package/dist/esm/components/CnToggleTableWidget/CnToggleTableWidget.vue2.js.map +1 -1
  58. package/dist/esm/components/CnToggleTableWidget/CnToggleTableWidget.vue3.js.map +1 -1
  59. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue.js +1 -1
  60. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue2.js +37 -9
  61. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue2.js.map +1 -1
  62. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue3.js +6 -5
  63. package/dist/esm/components/CnVersionInfoCard/CnVersionInfoCard.vue3.js.map +1 -1
  64. package/dist/esm/components/CnWidgetCardGrid/CnWidgetCardGrid.vue2.js +53 -1
  65. package/dist/esm/components/CnWidgetCardGrid/CnWidgetCardGrid.vue2.js.map +1 -1
  66. package/dist/esm/components/CnWidgetCardGrid/CnWidgetCardGrid.vue3.js +3 -2
  67. package/dist/esm/components/CnWidgetCardGrid/CnWidgetCardGrid.vue3.js.map +1 -1
  68. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue.js +1 -1
  69. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +135 -0
  70. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
  71. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
  72. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue.js +1 -1
  73. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +19 -0
  74. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  75. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +37 -19
  76. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  77. package/dist/esm/composables/useListView.js +21 -0
  78. package/dist/esm/composables/useListView.js.map +1 -1
  79. package/dist/esm/l10n/en.json.js +23 -1
  80. package/dist/esm/l10n/en.json.js.map +1 -1
  81. package/dist/esm/l10n/nl.json.js +41 -1
  82. package/dist/esm/l10n/nl.json.js.map +1 -1
  83. package/dist/esm/library-style.js +1 -1
  84. package/dist/esm/nextcloud-vue.css +622 -332
  85. package/dist/esm/store/useObjectStore.js +16 -2
  86. package/dist/esm/store/useObjectStore.js.map +1 -1
  87. package/dist/esm/utils/boardColumns.js +8 -1
  88. package/dist/esm/utils/boardColumns.js.map +1 -1
  89. package/dist/esm/utils/boardReferenceColumns.js +278 -0
  90. package/dist/esm/utils/boardReferenceColumns.js.map +1 -0
  91. package/dist/esm/utils/buildManifest.js +4 -1
  92. package/dist/esm/utils/buildManifest.js.map +1 -1
  93. package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
  94. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  95. package/dist/esm/utils/widgetDispatch.js +8 -2
  96. package/dist/esm/utils/widgetDispatch.js.map +1 -1
  97. package/dist/nextcloud-vue.cjs.js +27964 -25769
  98. package/dist/nextcloud-vue.cjs.js.map +2 -2
  99. package/dist/nextcloud-vue.css +622 -332
  100. package/l10n/en.json +23 -1
  101. package/l10n/nl.json +41 -1
  102. package/package.json +1 -1
  103. package/src/components/CnAdminSettingsShell/CnAdminSettingsShell.vue +160 -8
  104. package/src/components/CnBoardView/CnBoardView.vue +262 -6
  105. package/src/components/CnDashboardPage/CnDashboardPage.vue +57 -2
  106. package/src/components/CnDetailPage/CnDetailPage.md +1 -1
  107. package/src/components/CnDetailPage/CnDetailPage.vue +23 -4
  108. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +3 -1
  109. package/src/components/CnFactsWidget/CnFactsWidget.vue +18 -5
  110. package/src/components/CnIndexPage/CnIndexPage.vue +586 -25
  111. package/src/components/CnIndexPage/useSelfFetchList.js +10 -1
  112. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +70 -2
  113. package/src/components/CnRegisterMapping/CnRegisterMapping.md +5 -0
  114. package/src/components/CnRegisterMapping/CnRegisterMapping.vue +231 -96
  115. package/src/components/CnSettingsPage/CnSettingsPage.vue +5 -1
  116. package/src/components/CnStatWidget/CnStatWidget.vue +136 -11
  117. package/src/components/CnTabsWidget/CnTabsWidget.vue +6 -3
  118. package/src/components/CnToggleTableWidget/CnToggleTableWidget.vue +140 -7
  119. package/src/components/CnVersionInfoCard/CnVersionInfoCard.md +2 -1
  120. package/src/components/CnVersionInfoCard/CnVersionInfoCard.vue +42 -13
  121. package/src/components/CnWidgetCardGrid/CnWidgetCardGrid.vue +55 -2
  122. package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +135 -0
  123. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +10 -0
  124. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +44 -1
  125. package/src/composables/useListView.js +21 -0
  126. package/src/css/board.css +65 -4
  127. package/src/css/dialog.css +4 -2
  128. package/src/css/index-page.css +49 -0
  129. package/src/css/look-board.css +77 -7
  130. package/src/schemas/app-manifest-v2.schema.json +115 -5
  131. package/src/store/useObjectStore.js +16 -2
  132. package/src/utils/boardColumns.js +8 -1
  133. package/src/utils/boardReferenceColumns.js +274 -0
  134. package/src/utils/buildManifest.js +4 -1
  135. package/src/utils/validateManifestV2.compiled.js +3 -3
  136. package/src/utils/widgetDispatch.js +8 -2
@@ -5,7 +5,7 @@
5
5
  <template>
6
6
  <div
7
7
  class="cn-board-view"
8
- :class="{ 'cn-board-view--board': isBoardLook }"
8
+ :class="{ 'cn-board-view--board': isBoardLook, 'cn-board-view--wrap': wrapColumns, 'cn-board-view--selecting': selectedKeys.length > 0 }"
9
9
  data-testid="cn-board-view">
10
10
  <p v-if="!usable" class="cn-board-view__unusable" data-testid="cn-board-unusable">
11
11
  {{ unusableText }}
@@ -36,9 +36,11 @@
36
36
  v-for="column in lane.columns"
37
37
  :key="`col-${lane.key}-${column.key}`"
38
38
  class="cn-board-view__column"
39
+ :class="{ 'cn-board-view__column--unreachable': isUnreachable(column) }"
39
40
  role="listitem"
40
41
  data-testid="cn-board-column"
41
42
  :data-column="column.key"
43
+ :data-unreachable="isUnreachable(column) ? 'true' : null"
42
44
  @dragover.prevent
43
45
  @drop="onDrop(column, $event)">
44
46
  <!-- Board look: a dot, the label and a white count badge, with an
@@ -116,11 +118,21 @@
116
118
  :data-card-id="cardKey(card)"
117
119
  :draggable="canMove"
118
120
  @dragstart="onDragStart(card, column, $event)"
121
+ @dragend="onDragEnd"
119
122
  @keydown="onCardKeydown(card, column, lane, $event)">
120
123
  <!-- Board look: title link, sub line, pill, footer, and a
121
124
  menu button instead of the Move to select. -->
122
125
  <template v-if="isBoardLook">
123
126
  <div class="cn-board-view__card-head">
127
+ <input
128
+ v-if="selectable"
129
+ type="checkbox"
130
+ class="cn-board-view__select"
131
+ data-testid="cn-board-card-select"
132
+ :data-card-id="cardKey(card)"
133
+ :checked="isSelected(card)"
134
+ :aria-label="selectLabel(card)"
135
+ @change="toggleSelected(card)">
124
136
  <a
125
137
  href="#"
126
138
  class="cn-board-view__card-title"
@@ -164,8 +176,14 @@
164
176
  class="cn-board-view__menu-item"
165
177
  data-testid="cn-board-menu-move"
166
178
  :data-target="target.key"
167
- @click="onMenuMove(card, column, target)">
179
+ :aria-disabled="isMoveAllowed(card, target) ? null : 'true'"
180
+ :class="{ 'cn-board-view__menu-item--blocked': !isMoveAllowed(card, target) }"
181
+ @click="isMoveAllowed(card, target) && onMenuMove(card, column, target)">
168
182
  {{ target.label }}
183
+ <span
184
+ v-if="!isMoveAllowed(card, target)"
185
+ class="cn-board-view__menu-note"
186
+ data-testid="cn-board-menu-blocked">{{ notAllowedLabel }}</span>
169
187
  </button>
170
188
  </div>
171
189
  <button
@@ -216,6 +234,16 @@
216
234
  </span>
217
235
  </template>
218
236
  <template v-else>
237
+ <label v-if="selectable" class="cn-board-view__select-label">
238
+ <input
239
+ type="checkbox"
240
+ class="cn-board-view__select"
241
+ data-testid="cn-board-card-select"
242
+ :data-card-id="cardKey(card)"
243
+ :checked="isSelected(card)"
244
+ :aria-label="selectLabel(card)"
245
+ @change="toggleSelected(card)">
246
+ </label>
219
247
  <span
220
248
  v-if="dueLabel(card)"
221
249
  class="cn-board-view__due"
@@ -354,6 +382,12 @@ export default {
354
382
  * draws the screens' board anatomy (screens-kanban-parity).
355
383
  */
356
384
  cnLook: { default: 'nextcloud' },
385
+ /**
386
+ * The app's cell-formatter registry, provided by CnAppRoot. A card
387
+ * role named in `cardRoles.formatters` shows its value through it, as
388
+ * the table's cells do (screens-board-reference-columns).
389
+ */
390
+ cnFormatters: { default: () => ({}) },
357
391
  },
358
392
 
359
393
  props: {
@@ -494,9 +528,72 @@ export default {
494
528
  type: Number,
495
529
  default: 0,
496
530
  },
531
+
532
+ /**
533
+ * The column a row belongs in, for a status whose raw value is not
534
+ * the column key: a reference whose rows are merged by name
535
+ * (screens-board-reference-columns). Absent, the row's `statusField`
536
+ * value is the key.
537
+ *
538
+ * @type {((row: object) => string)|null}
539
+ * @spec openspec/changes/screens-board-reference-columns/specs/board-view/spec.md#requirement-columns-can-be-the-rows-a-field-references
540
+ */
541
+ columnKeyOf: {
542
+ type: Function,
543
+ default: null,
544
+ },
545
+
546
+ /**
547
+ * Wrap the columns onto more rows, as many to a row as fit at 240px,
548
+ * instead of one row that scrolls sideways (manifest
549
+ * `config.board.wrap`). For a board with more stages than fit.
550
+ *
551
+ * @spec openspec/changes/screens-board-reference-columns/specs/board-view/spec.md#requirement-a-wide-board-can-wrap-its-columns
552
+ */
553
+ wrapColumns: {
554
+ type: Boolean,
555
+ default: false,
556
+ },
557
+
558
+ /**
559
+ * Offer a checkbox on every card, so cards can be selected for the
560
+ * list's bulk actions (the same selection the table holds).
561
+ *
562
+ * @spec openspec/changes/screens-board-reference-columns/specs/board-view/spec.md#requirement-cards-can-be-selected-for-bulk-actions
563
+ */
564
+ selectable: {
565
+ type: Boolean,
566
+ default: false,
567
+ },
568
+
569
+ /**
570
+ * The selected cards, by row key.
571
+ *
572
+ * @type {Array<string|number>}
573
+ */
574
+ selectedIds: {
575
+ type: Array,
576
+ default: () => [],
577
+ },
578
+
579
+ /**
580
+ * The columns one card may move to, asked when its menu opens or its
581
+ * drag starts: `(card) => Promise<Array<string>|null>`, column keys,
582
+ * `null` for every column. A column the card cannot reach is greyed
583
+ * during the drag and marked "not allowed" in the menu, and a drop on
584
+ * it is refused without asking the host. Absent, every column is
585
+ * offered and the host's move decides.
586
+ *
587
+ * @type {((card: object) => Promise<Array<string>|null>)|null}
588
+ * @spec openspec/changes/screens-board-reference-columns/specs/board-view/spec.md#requirement-the-board-shows-where-a-card-can-go
589
+ */
590
+ allowedMoves: {
591
+ type: Function,
592
+ default: null,
593
+ },
497
594
  },
498
595
 
499
- emits: ['card-click', 'card-aux-click', 'moved', 'refused', 'stale', 'load-more'],
596
+ emits: ['card-click', 'card-aux-click', 'moved', 'refused', 'stale', 'load-more', 'select'],
500
597
 
501
598
  data() {
502
599
  return {
@@ -512,6 +609,10 @@ export default {
512
609
  expanded: [],
513
610
  /** The card whose menu is open (board look), by card key. */
514
611
  openMenuKey: null,
612
+ /** Column keys each card may move to (`allowedMoves`), by card key; absent = not asked yet. */
613
+ allowedByCard: {},
614
+ /** The column keys the dragged card may reach, or null for all. */
615
+ dragAllowed: null,
515
616
  }
516
617
  },
517
618
 
@@ -529,6 +630,7 @@ export default {
529
630
  statusField: this.statusField,
530
631
  offBoardLabel: t('nextcloud-vue', 'Elsewhere'),
531
632
  colorField: this.colorField,
633
+ keyOf: this.columnKeyOf,
532
634
  })
533
635
  },
534
636
 
@@ -556,9 +658,20 @@ export default {
556
658
  due: (given && given.due) || '',
557
659
  owner: (given && given.owner) || '',
558
660
  pillColors: (given && given.pillColors) || {},
661
+ formatters: (given && given.formatters && typeof given.formatters === 'object') ? given.formatters : {},
559
662
  }
560
663
  },
561
664
 
665
+ /** @return {string} The note on a menu target the card cannot reach. */
666
+ notAllowedLabel() {
667
+ return t('nextcloud-vue', 'not allowed')
668
+ },
669
+
670
+ /** @return {Array<string>} The selected row keys as strings. */
671
+ selectedKeys() {
672
+ return (Array.isArray(this.selectedIds) ? this.selectedIds : []).map(String)
673
+ },
674
+
562
675
  /** @return {string} The Open in new tab menu item. */
563
676
  openInNewTabLabel() {
564
677
  return t('nextcloud-vue', 'Open in new tab')
@@ -617,6 +730,97 @@ export default {
617
730
 
618
731
  preventMiddleClickAutoscroll,
619
732
 
733
+ /**
734
+ * Whether a card is selected.
735
+ *
736
+ * @param {object} card The card.
737
+ * @return {boolean} True when selected.
738
+ */
739
+ isSelected(card) {
740
+ return this.selectedKeys.includes(this.cardKey(card))
741
+ },
742
+
743
+ /**
744
+ * The checkbox's accessible name.
745
+ *
746
+ * @param {object} card The card.
747
+ * @return {string} "Select <name>".
748
+ */
749
+ selectLabel(card) {
750
+ const name = this.roleText(card, this.roles.title) || this.cardKey(card)
751
+ return t('nextcloud-vue', 'Select {name}', { name })
752
+ },
753
+
754
+ /**
755
+ * Select a card, or unselect it.
756
+ *
757
+ * @param {object} card The card.
758
+ * @return {void}
759
+ * @spec openspec/changes/screens-board-reference-columns/specs/board-view/spec.md#requirement-cards-can-be-selected-for-bulk-actions
760
+ */
761
+ toggleSelected(card) {
762
+ const key = this.cardKey(card)
763
+ const next = this.isSelected(card)
764
+ ? this.selectedIds.filter((id) => String(id) !== key)
765
+ : [...this.selectedIds, card?.[this.rowKey]]
766
+ /**
767
+ * @event select Emitted when a card's checkbox changes. Payload: the selected row keys.
768
+ */
769
+ this.$emit('select', next)
770
+ },
771
+
772
+ /**
773
+ * Ask the host where one card may go (`allowedMoves`), once per card.
774
+ *
775
+ * @param {object} card The card.
776
+ * @return {Promise<Array<string>|null>} The column keys, or null for all.
777
+ * @spec openspec/changes/screens-board-reference-columns/specs/board-view/spec.md#requirement-the-board-shows-where-a-card-can-go
778
+ */
779
+ async loadAllowed(card) {
780
+ if (typeof this.allowedMoves !== 'function') {
781
+ return null
782
+ }
783
+ const key = this.cardKey(card)
784
+ if (Object.hasOwn(this.allowedByCard, key)) {
785
+ return this.allowedByCard[key]
786
+ }
787
+ let allowed
788
+ try {
789
+ const answer = await this.allowedMoves(card)
790
+ allowed = Array.isArray(answer) ? answer.map(String) : null
791
+ } catch {
792
+ // Unknown is not "nowhere": the host's move stays the authority.
793
+ allowed = null
794
+ }
795
+ this.allowedByCard = { ...this.allowedByCard, [key]: allowed }
796
+ return allowed
797
+ },
798
+
799
+ /**
800
+ * Whether a card may move to a column, as far as the board knows.
801
+ *
802
+ * @param {object} card The card.
803
+ * @param {object} target The column.
804
+ * @return {boolean} False only when the host said it cannot.
805
+ */
806
+ isMoveAllowed(card, target) {
807
+ const allowed = this.allowedByCard[this.cardKey(card)]
808
+ return !Array.isArray(allowed) || allowed.includes(String(target.key))
809
+ },
810
+
811
+ /**
812
+ * Whether a column is out of reach for the card being dragged.
813
+ *
814
+ * @param {object} column The column.
815
+ * @return {boolean} True while a drag is on and the card cannot go there.
816
+ */
817
+ isUnreachable(column) {
818
+ return this.dragging !== null
819
+ && Array.isArray(this.dragAllowed)
820
+ && column.key !== this.dragging.fromKey
821
+ && !this.dragAllowed.includes(String(column.key))
822
+ },
823
+
620
824
  /**
621
825
  * A card's due state under `dueRule`.
622
826
  *
@@ -658,11 +862,35 @@ export default {
658
862
  const fields = Array.isArray(role) ? role : [role]
659
863
  return fields
660
864
  .filter((field) => typeof field === 'string' && field !== '')
661
- .map((field) => this.textOf(readPath(card, field)))
865
+ .map((field) => this.fieldText(card, field))
662
866
  .filter((value) => value !== '')
663
867
  .join(' \u00b7 ')
664
868
  },
665
869
 
870
+ /**
871
+ * One card field as text: through the formatter `cardRoles.formatters`
872
+ * names for it, else the value itself.
873
+ *
874
+ * @param {object} card The card's row.
875
+ * @param {string} field The dot-path.
876
+ * @return {string} The text.
877
+ * @spec openspec/changes/screens-board-reference-columns/specs/board-view/spec.md#requirement-a-card-field-can-show-through-a-formatter
878
+ */
879
+ fieldText(card, field) {
880
+ const raw = readPath(card, field)
881
+ const id = this.roles.formatters[field]
882
+ const fn = id && this.cnFormatters ? this.cnFormatters[id] : null
883
+ if (typeof fn === 'function') {
884
+ try {
885
+ const out = fn(raw, card, null)
886
+ return out === null || out === undefined ? '' : String(out).trim()
887
+ } catch {
888
+ return this.textOf(raw)
889
+ }
890
+ }
891
+ return this.textOf(raw)
892
+ },
893
+
666
894
  /**
667
895
  * A value as text: a string or number as is, an object by its label.
668
896
  *
@@ -687,7 +915,7 @@ export default {
687
915
  * @return {string} A CnStatusBadge variant.
688
916
  */
689
917
  pillVariant(card) {
690
- const raw = this.textOf(readPath(card, this.roles.pill))
918
+ const raw = this.fieldText(card, this.roles.pill)
691
919
  const variant = this.roles.pillColors[raw] ?? this.roles.pillColors[raw.toLowerCase()]
692
920
  return isBadgeVariant(variant) ? variant : 'default'
693
921
  },
@@ -727,7 +955,7 @@ export default {
727
955
  * @return {string} The name, '' when there is none.
728
956
  */
729
957
  ownerName(card) {
730
- return this.roles.owner === '' ? '' : this.textOf(readPath(card, this.roles.owner))
958
+ return this.roles.owner === '' ? '' : this.fieldText(card, this.roles.owner)
731
959
  },
732
960
 
733
961
  /**
@@ -879,6 +1107,7 @@ export default {
879
1107
  const key = this.cardKey(card)
880
1108
  this.openMenuKey = this.openMenuKey === key ? null : key
881
1109
  if (this.openMenuKey !== null) {
1110
+ this.loadAllowed(card)
882
1111
  this.focusMenuItem(card, 0)
883
1112
  }
884
1113
  },
@@ -967,6 +1196,7 @@ export default {
967
1196
  event.preventDefault()
968
1197
  if (!this.isMenuOpen(card)) {
969
1198
  this.openMenuKey = this.cardKey(card)
1199
+ this.loadAllowed(card)
970
1200
  this.focusMenuItem(card, 0)
971
1201
  }
972
1202
  }
@@ -1113,8 +1343,27 @@ export default {
1113
1343
  */
1114
1344
  onDragStart(card, column, event) {
1115
1345
  this.dragging = { card, fromKey: column.key }
1346
+ this.dragAllowed = null
1116
1347
  // Firefox drops a drag that carries no data.
1117
1348
  event?.dataTransfer?.setData?.('text/plain', this.cardKey(card))
1349
+ if (typeof this.allowedMoves === 'function') {
1350
+ this.loadAllowed(card).then((allowed) => {
1351
+ if (this.dragging && this.dragging.card === card) {
1352
+ this.dragAllowed = allowed
1353
+ }
1354
+ })
1355
+ }
1356
+ },
1357
+
1358
+ /**
1359
+ * The drag ended (dropped anywhere, or cancelled): no column stays
1360
+ * greyed. A drop on a column has already run by now.
1361
+ *
1362
+ * @return {void}
1363
+ */
1364
+ onDragEnd() {
1365
+ this.dragging = null
1366
+ this.dragAllowed = null
1118
1367
  },
1119
1368
 
1120
1369
  /**
@@ -1129,6 +1378,13 @@ export default {
1129
1378
  }
1130
1379
  const { card, fromKey } = this.dragging
1131
1380
  this.dragging = null
1381
+ this.dragAllowed = null
1382
+ if (column.key !== fromKey && !this.isMoveAllowed(card, column)) {
1383
+ const name = this.roleText(card, this.roles.title) || this.cardKey(card)
1384
+ this.refusal = t('nextcloud-vue', '{name} cannot move to {column}.', { name, column: column.label })
1385
+ this.$emit('refused', { card, message: this.refusal })
1386
+ return
1387
+ }
1132
1388
  await this.move(card, fromKey, column.key)
1133
1389
  },
1134
1390
 
@@ -395,6 +395,7 @@
395
395
  <CnStatsBlockWidget
396
396
  v-bind="getStatsBlockProps(item)"
397
397
  :title="getWidgetTitle(item)"
398
+ :eyebrow="getWidgetEyebrow(item)"
398
399
  :data-source="getWidgetDataSource(item)" />
399
400
  </CnWidgetWrapper>
400
401
  <CnWidgetWrapper
@@ -420,7 +421,7 @@
420
421
  :is="registryRenderer(item)"
421
422
  :widgetId="item.widgetId"
422
423
  :content="registryWidgetBindings(item)"
423
- v-bind="registryWidgetBindings(item)"
424
+ v-bind="{ ...registryWidgetBindings(item), ...containerBindings(item) }"
424
425
  @header="onWidgetHeader(item, $event)" />
425
426
  </CnWidgetWrapper>
426
427
  <CnWidgetWrapper
@@ -433,6 +434,7 @@
433
434
  <CnWidgetWrapper
434
435
  v-else
435
436
  :title="getWidgetTitle(item)"
437
+ :eyebrow="getWidgetEyebrow(item)"
436
438
  :showTitle="false"
437
439
  :showRefresh="false">
438
440
  <div class="cn-dashboard-page__unknown">
@@ -494,6 +496,7 @@
494
496
  <CnWidgetWrapper
495
497
  v-if="missingRequiredApp(item)"
496
498
  :title="getWidgetTitle(item)"
499
+ :eyebrow="getWidgetEyebrow(item)"
497
500
  :showTitle="widgetShowTitle(item)">
498
501
  <!-- Compact on purpose: a two-row tile is about 150px, less
499
502
  a title row. NcEmptyContent's 64px icon, 20px name,
@@ -532,6 +535,7 @@
532
535
  <CnWidgetWrapper
533
536
  :widgetId="item.widgetId"
534
537
  :title="getWidgetTitle(item)"
538
+ :eyebrow="getWidgetEyebrow(item)"
535
539
  :iconUrl="getWidgetIconUrl(item)"
536
540
  :iconClass="getWidgetIconClass(item)"
537
541
  :showTitle="widgetShowTitle(item)"
@@ -613,6 +617,7 @@
613
617
  :widgetId="item.widgetId"
614
618
  :class="{ 'cn-dashboard-page__chart-fit': isChartFitted(item) }"
615
619
  :title="getWidgetTitle(item)"
620
+ :eyebrow="getWidgetEyebrow(item)"
616
621
  :iconUrl="getWidgetIconUrl(item)"
617
622
  :iconClass="getWidgetIconClass(item)"
618
623
  :showTitle="widgetShowTitle(item)"
@@ -691,6 +696,7 @@
691
696
  <CnWidgetWrapper
692
697
  :widgetId="item.widgetId"
693
698
  :title="getWidgetTitle(item)"
699
+ :eyebrow="getWidgetEyebrow(item)"
694
700
  :iconUrl="getWidgetIconUrl(item)"
695
701
  :iconClass="getWidgetIconClass(item)"
696
702
  :showTitle="widgetShowTitle(item)"
@@ -718,6 +724,7 @@
718
724
  <CnWidgetWrapper
719
725
  :widgetId="item.widgetId"
720
726
  :title="getWidgetTitle(item)"
727
+ :eyebrow="getWidgetEyebrow(item)"
721
728
  :iconUrl="getWidgetIconUrl(item)"
722
729
  :iconClass="getWidgetIconClass(item)"
723
730
  :showTitle="widgetShowTitle(item)"
@@ -757,6 +764,7 @@
757
764
  <CnWidgetWrapper
758
765
  :widgetId="item.widgetId"
759
766
  :title="getWidgetTitle(item)"
767
+ :eyebrow="getWidgetEyebrow(item)"
760
768
  :iconUrl="getWidgetIconUrl(item)"
761
769
  :iconClass="getWidgetIconClass(item)"
762
770
  :showTitle="widgetShowTitle(item)"
@@ -782,6 +790,7 @@
782
790
  <CnWidgetWrapper
783
791
  :widgetId="item.widgetId"
784
792
  :title="getWidgetTitle(item)"
793
+ :eyebrow="getWidgetEyebrow(item)"
785
794
  :showTitle="widgetShowTitle(item)"
786
795
  :showActions="widgetShowActions(item)"
787
796
  :showRefresh="getWidgetShowRefresh(item)"
@@ -873,7 +882,7 @@
873
882
  :is="registryRenderer(item)"
874
883
  :widgetId="item.widgetId"
875
884
  :content="registryWidgetBindings(item)"
876
- v-bind="registryWidgetBindings(item)"
885
+ v-bind="{ ...registryWidgetBindings(item), ...containerBindings(item) }"
877
886
  @header="onWidgetHeader(item, $event)" />
878
887
  </CnWidgetWrapper>
879
888
  </template>
@@ -882,6 +891,7 @@
882
891
  <CnWidgetWrapper
883
892
  v-else
884
893
  :title="getWidgetTitle(item)"
894
+ :eyebrow="getWidgetEyebrow(item)"
885
895
  :showTitle="widgetShowTitle(item)"
886
896
  :showRefresh="false">
887
897
  <div class="cn-dashboard-page__unknown">
@@ -2944,6 +2954,7 @@ export default {
2944
2954
  return {
2945
2955
  widgetId: item.widgetId,
2946
2956
  title: this.getWidgetTitle(item),
2957
+ eyebrow: this.getWidgetEyebrow(item),
2947
2958
  showTitle: this.widgetShowTitle(item),
2948
2959
  showActions: this.widgetShowActions(item),
2949
2960
  showRefresh: this.getWidgetShowRefresh(item),
@@ -3630,6 +3641,35 @@ export default {
3630
3641
  return outcome ? { ...content, conditionOutcome: outcome } : content
3631
3642
  },
3632
3643
 
3644
+ /**
3645
+ * What a container widget (registry `container`, such as `tabs`) needs
3646
+ * beside its content: the page's widget definitions, so its tabs find
3647
+ * the widgets they name, and the consumer registry for custom child
3648
+ * types. Empty for every other widget, so a leaf gets no extra
3649
+ * attributes on its root.
3650
+ *
3651
+ * @param {object} item Layout item.
3652
+ * @return {object} The extra bindings.
3653
+ * @spec openspec/changes/dashboard-tabs-children/specs/dashboard-page/spec.md#requirement-a-tabs-widget-on-a-dashboard-renders-the-widgets-its-tabs-name
3654
+ */
3655
+ containerBindings(item) {
3656
+ const def = this.getWidgetDef(item.widgetId)
3657
+ if (!def || !def.type) {
3658
+ return {}
3659
+ }
3660
+ const entry = getWidgetTypeEntry(def.type) || getWidgetTypeEntry(canonicalWidgetType(def.type))
3661
+ if (!entry || entry.container !== true) {
3662
+ return {}
3663
+ }
3664
+ const own = Array.isArray(this.widgets) ? this.widgets.filter(Boolean) : []
3665
+ const fromView = this.activeView && Array.isArray(this.activeView.widgets) ? this.activeView.widgets.filter(Boolean) : []
3666
+ return {
3667
+ availableWidgets: [...own, ...fromView],
3668
+ cnRegistry: this.cnRegistry || {},
3669
+ surface: this.surface,
3670
+ }
3671
+ },
3672
+
3633
3673
  /**
3634
3674
  * A widget's content with the board's quiet collection footer: in the
3635
3675
  * board look a dashboard that drops the widget Actions menu
@@ -4002,6 +4042,21 @@ export default {
4002
4042
  this.showWidgetConfig = false
4003
4043
  },
4004
4044
 
4045
+ /**
4046
+ * The line above a widget's title (`eyebrow` on the widget
4047
+ * definition: "Saldo per week" above "Prognose 13 weken"), through the
4048
+ * host translate, or '' for none.
4049
+ *
4050
+ * @param {object} item Layout item.
4051
+ * @return {string}
4052
+ * @spec openspec/changes/widget-title-eyebrow/specs/dashboard-page/spec.md#requirement-a-widget-card-can-carry-a-line-above-its-title
4053
+ */
4054
+ getWidgetEyebrow(item) {
4055
+ const def = this.getWidgetDef(item.widgetId)
4056
+ const eyebrow = def && typeof def.eyebrow === 'string' ? def.eyebrow : ''
4057
+ return eyebrow ? this.effectiveTranslate(eyebrow) : ''
4058
+ },
4059
+
4005
4060
  /**
4006
4061
  * The widget's title as AUTHORED — the untranslated manifest source
4007
4062
  * string — resolved by widget id rather than by item, so a descendant
@@ -460,4 +460,4 @@ When the schema-driven fetch answers 404 the page shows a not-found state whose
460
460
 
461
461
  ### The board look
462
462
 
463
- `look` (String, default `''`): `nextcloud` or `board`; empty takes the app's look. `headerMeta` (String): the meta line on row 2 of the board header, a field template such as `via {channel}` (manifest `config.headerMeta`). `tabsLabel` (String): the accessible name of the tab list (manifest `config.tabsLabel`). `identifierField` (String): the field named in the last line of the History side card (manifest `config.identifierField`). See `docs/components/cn-detail-page.md`.
463
+ `look` (String, default `''`): `nextcloud` or `board`; empty takes the app's look. `headerMeta` (String): the meta line on row 2 of the board header, a field template such as `via {channel}` (manifest `config.headerMeta`). `tabsLabel` (String): the accessible name of the tab list (manifest `config.tabsLabel`). `identifierField` (String): the field named in the last line of the History side card (manifest `config.identifierField`). `titleField` (String): the record field the header names the record by, such as an invoice number (manifest `config.titleField`); an empty value falls back to the display name. See `docs/components/cn-detail-page.md`.
@@ -2664,6 +2664,19 @@ export default {
2664
2664
  type: String,
2665
2665
  default: '',
2666
2666
  },
2667
+
2668
+ /**
2669
+ * The record field the header names the record by (manifest
2670
+ * `config.titleField`, a dotted path such as `number`), as an invoice
2671
+ * page names the invoice number. An empty value falls back to the
2672
+ * display name. Empty (the default) changes nothing.
2673
+ *
2674
+ * @spec openspec/changes/detail-title-field/specs/detail-page/spec.md#requirement-a-detail-page-can-name-the-record-by-a-field
2675
+ */
2676
+ titleField: {
2677
+ type: String,
2678
+ default: '',
2679
+ },
2667
2680
  },
2668
2681
 
2669
2682
  emits: [
@@ -4102,10 +4115,10 @@ export default {
4102
4115
  },
4103
4116
 
4104
4117
  /**
4105
- * The resolved record's human display name, drawn from the loaded
4106
- * object via the same fallback chain the relation resolver uses:
4107
- * `@self.name` → `name` / `title` / `displayName` → `firstName
4108
- * lastName`. Empty string while the object is still loading or when no
4118
+ * The resolved record's human display name: the `titleField` value
4119
+ * when the page names one and it is filled, else the same fallback
4120
+ * chain the relation resolver uses: `@self.name` → `name` / `title` /
4121
+ * `displayName` → `firstName lastName`. Empty string while the object is still loading or when no
4109
4122
  * name-like field exists (a raw id is never used as a name — ADR-062).
4110
4123
  *
4111
4124
  * @return {string}
@@ -4115,6 +4128,12 @@ export default {
4115
4128
  if (!obj || typeof obj !== 'object') {
4116
4129
  return ''
4117
4130
  }
4131
+ if (this.titleField) {
4132
+ const named = stageOf(obj, this.titleField)
4133
+ if (named !== '') {
4134
+ return named
4135
+ }
4136
+ }
4118
4137
  const self = obj['@self'] || {}
4119
4138
  const id = this.objectId !== null && this.objectId !== undefined ? String(this.objectId) : ''
4120
4139
  const candidates = [self.name, self.title, obj.name, obj.title, obj.displayName]
@@ -725,7 +725,9 @@ export default {
725
725
  if (this.widget && this.widget.type === 'files' && !(this.cnRegistry && this.cnRegistry.files) && this.register && this.schema) {
726
726
  return CnObjectFilesWidget
727
727
  }
728
- return resolveRegistryRenderer(this.widget, this.cnRegistry)
728
+ // Inside a dashboard's tabs the dashboard's resolution holds.
729
+ const onDashboard = this.surface === 'app-dashboard' || this.surface === 'user-dashboard'
730
+ return resolveRegistryRenderer(this.widget, this.cnRegistry, { typeAsWritten: onDashboard })
729
731
  },
730
732
 
731
733
  /**