@conduction/nextcloud-vue 2.144.0 → 2.146.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 (133) hide show
  1. package/dist/esm/components/CnActionDialog/CnActionDialog.vue.js +1 -1
  2. package/dist/esm/components/CnActionDialog/CnActionDialog.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionDialog/CnActionDialog.vue3.js +1 -0
  4. package/dist/esm/components/CnActionDialog/CnActionDialog.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAiCompanion/CnAiChatPanel.vue2.js.map +1 -1
  6. package/dist/esm/components/CnAiCompanion/CnAiChatPanel.vue3.js.map +1 -1
  7. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue.js +1 -1
  8. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +55 -2
  9. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
  10. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
  11. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue.js +1 -1
  12. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js +16 -0
  13. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js.map +1 -1
  14. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js +3 -1
  15. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js.map +1 -1
  16. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue.js +1 -1
  17. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue2.js.map +1 -1
  18. package/dist/esm/components/CnChecklistRunWidget/CnChecklistRunWidget.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +12 -2
  20. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +42 -30
  22. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +22 -0
  24. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  25. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +2 -1
  26. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  27. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue.js +1 -1
  28. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue2.js +1 -1
  29. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue2.js.map +1 -1
  30. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue3.js +3 -3
  31. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue3.js.map +1 -1
  32. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
  33. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js +46 -8
  34. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
  35. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js +3 -3
  36. package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
  37. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue.js +1 -1
  38. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js +2 -2
  39. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue2.js.map +1 -1
  40. package/dist/esm/components/CnFieldListWidget/CnFieldListWidget.vue3.js.map +1 -1
  41. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +6 -3
  42. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  43. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  44. package/dist/esm/components/CnMailTab/CnMailTab.vue.js +1 -1
  45. package/dist/esm/components/CnMailTab/CnMailTab.vue2.js.map +1 -1
  46. package/dist/esm/components/CnMailTab/CnMailTab.vue3.js +2 -2
  47. package/dist/esm/components/CnMailTab/CnMailTab.vue3.js.map +1 -1
  48. package/dist/esm/components/CnNotesCard/CnNotesCard.vue.js +1 -1
  49. package/dist/esm/components/CnNotesCard/CnNotesCard.vue2.js.map +1 -1
  50. package/dist/esm/components/CnNotesCard/CnNotesCard.vue3.js +1 -0
  51. package/dist/esm/components/CnNotesCard/CnNotesCard.vue3.js.map +1 -1
  52. package/dist/esm/components/CnNotificationMatrix/CnNotificationMatrix.vue.js +1 -1
  53. package/dist/esm/components/CnNotificationMatrix/CnNotificationMatrix.vue2.js +21 -2
  54. package/dist/esm/components/CnNotificationMatrix/CnNotificationMatrix.vue2.js.map +1 -1
  55. package/dist/esm/components/CnNotificationMatrix/CnNotificationMatrix.vue3.js.map +1 -1
  56. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
  57. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +73 -12
  58. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
  59. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +165 -126
  60. package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
  61. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue.js +1 -1
  62. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue2.js.map +1 -1
  63. package/dist/esm/components/CnRetentionWidget/CnRetentionWidget.vue3.js.map +1 -1
  64. package/dist/esm/components/CnSettingsField/CnSettingsField.vue.js +1 -1
  65. package/dist/esm/components/CnSettingsField/CnSettingsField.vue2.js +14 -0
  66. package/dist/esm/components/CnSettingsField/CnSettingsField.vue2.js.map +1 -1
  67. package/dist/esm/components/CnSettingsField/CnSettingsField.vue3.js +2 -1
  68. package/dist/esm/components/CnSettingsField/CnSettingsField.vue3.js.map +1 -1
  69. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
  70. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  71. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  72. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue.js +1 -1
  73. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +13 -8
  74. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
  75. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
  76. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue.js +1 -1
  77. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue2.js +10 -2
  78. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue2.js.map +1 -1
  79. package/dist/esm/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue3.js.map +1 -1
  80. package/dist/esm/composables/indexSources.js +35 -9
  81. package/dist/esm/composables/indexSources.js.map +1 -1
  82. package/dist/esm/l10n/nl.json.js +2 -0
  83. package/dist/esm/l10n/nl.json.js.map +1 -1
  84. package/dist/esm/library-style.js +1 -1
  85. package/dist/esm/nextcloud-vue.css +1204 -467
  86. package/dist/esm/utils/builtInFormatters.js +18 -1
  87. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  88. package/dist/esm/utils/detailObjectContext.js +7 -2
  89. package/dist/esm/utils/detailObjectContext.js.map +1 -1
  90. package/dist/esm/utils/localHeaderRows.js +95 -0
  91. package/dist/esm/utils/localHeaderRows.js.map +1 -0
  92. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  93. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  94. package/dist/esm/utils/visibleWhen.js +6 -0
  95. package/dist/esm/utils/visibleWhen.js.map +1 -1
  96. package/dist/nextcloud-vue.cjs.js +686 -239
  97. package/dist/nextcloud-vue.cjs.js.map +2 -2
  98. package/dist/nextcloud-vue.css +1204 -467
  99. package/l10n/nl.json +2 -0
  100. package/package.json +1 -1
  101. package/src/components/CnActionDialog/CnActionDialog.vue +8 -1
  102. package/src/components/CnAiCompanion/CnAiChatPanel.vue +3 -0
  103. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +55 -2
  104. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +18 -0
  105. package/src/components/CnChecklistRunWidget/CnChecklistRunWidget.vue +4 -2
  106. package/src/components/CnDataTable/CnDataTable.vue +19 -2
  107. package/src/components/CnDetailPage/CnDetailPage.md +1 -0
  108. package/src/components/CnDetailPage/CnDetailPage.vue +23 -0
  109. package/src/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue +3 -1
  110. package/src/components/CnFactsWidget/CnFactsWidget.vue +74 -9
  111. package/src/components/CnFieldListWidget/CnFieldListWidget.vue +2 -2
  112. package/src/components/CnIndexPage/CnIndexPage.vue +6 -3
  113. package/src/components/CnMailTab/CnMailTab.vue +6 -4
  114. package/src/components/CnNotesCard/CnNotesCard.vue +7 -2
  115. package/src/components/CnNotificationMatrix/CnNotificationMatrix.vue +25 -3
  116. package/src/components/CnRecordListWidget/CnRecordListWidget.vue +132 -20
  117. package/src/components/CnRetentionWidget/CnRetentionWidget.vue +2 -0
  118. package/src/components/CnSettingsField/CnSettingsField.vue +15 -0
  119. package/src/components/CnTabsWidget/CnTabsWidget.vue +6 -1
  120. package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +13 -8
  121. package/src/components/CnWorkspaceFilterWidget/CnWorkspaceFilterWidget.vue +10 -2
  122. package/src/composables/indexSources.js +35 -9
  123. package/src/css/dialog.css +25 -9
  124. package/src/css/look-board-detail.css +370 -30
  125. package/src/css/look-board-index.css +116 -4
  126. package/src/css/look-board.css +168 -8
  127. package/src/css/table.css +17 -1
  128. package/src/schemas/app-manifest-v2.schema.json +2 -2
  129. package/src/utils/builtInFormatters.js +17 -0
  130. package/src/utils/detailObjectContext.js +7 -2
  131. package/src/utils/localHeaderRows.js +91 -0
  132. package/src/utils/validateManifestV2.compiled.js +2 -2
  133. package/src/utils/visibleWhen.js +6 -0
package/l10n/nl.json CHANGED
@@ -1946,6 +1946,8 @@
1946
1946
  "Absent today": "Vandaag afwezig",
1947
1947
  "{message} until {time}": "{message} tot {time}",
1948
1948
  "Away": "Afwezig",
1949
+ "Present": "Aanwezig",
1950
+ "Absent until {date}": "Afwezig tot {date}",
1949
1951
  "Busy": "Bezet",
1950
1952
  "No one in the team": "Niemand in het team",
1951
1953
  "Also replace the criteria with the current filters": "Vervang ook de criteria door de huidige filters",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "2.144.0",
3
+ "version": "2.146.0",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "homepage": "https://github.com/ConductionNL/nextcloud-vue#readme",
6
6
  "bugs": {
@@ -145,7 +145,7 @@
145
145
  :heading="text(config.note.title)"
146
146
  data-testid="cn-action-dialog-note">
147
147
  <template v-if="config.note.icon" #icon>
148
- <CnIcon :name="config.note.icon" :size="20" />
148
+ <CnIcon class="cn-action-dialog__note-icon" :name="config.note.icon" :size="20" />
149
149
  </template>
150
150
  {{ text(config.note.text) }}
151
151
  </NcNoteCard>
@@ -951,6 +951,13 @@ export default {
951
951
 
952
952
  /* A checkbox field is a tinted box with a 2px edge, its label bold and its
953
953
  hint under it (DqArchiveren). */
954
+ /* A note's own icon stands on the heading's line, as the variant's icon
955
+ does (NcNoteCard centres a slotted icon on the whole note). */
956
+ .cn-action-dialog__note-icon {
957
+ align-self: flex-start;
958
+ margin-top: 1px;
959
+ }
960
+
954
961
  .cn-action-dialog__checkbox {
955
962
  padding: 12px 14px;
956
963
  border: 2px solid var(--color-primary-element);
@@ -1584,11 +1584,14 @@ export default {
1584
1584
  border-radius: 10px;
1585
1585
  background: var(--color-primary-element-light, #eaf0f7);
1586
1586
  color: var(--color-primary-element-light-text, #234a78);
1587
+ font-size: 14px;
1587
1588
  line-height: 1.5;
1588
1589
  }
1589
1590
 
1591
+ /* The icon font's own align-self: center would centre it on a long note. */
1590
1592
  .cn-ai-chat-window__note-icon {
1591
1593
  flex: none;
1594
+ align-self: flex-start;
1592
1595
  margin-top: 1px;
1593
1596
  }
1594
1597
 
@@ -69,9 +69,21 @@ import CnDetailCard from '../CnDetailCard/CnDetailCard.vue'
69
69
  import { formatBoardDate, formatTimeOfDay } from '../../utils/boardDate.js'
70
70
  import { buildHeaders, prefixUrl } from '../../utils/index.js'
71
71
  import { leafSourceUrl } from '../../utils/leafSourceUrl.js'
72
+ import { readPath } from '../../utils/readPath.js'
72
73
  import { userDisplayName } from '../../utils/userDisplayName.js'
73
74
  import { userLocale } from '../../utils/userLocale.js'
74
75
 
76
+ /**
77
+ * A changed value as an entry records it: OpenRegister writes `{ old, new }`,
78
+ * other sources the value itself.
79
+ *
80
+ * @param {unknown} value The recorded change.
81
+ * @return {unknown} The value it changed to.
82
+ */
83
+ function newValueOf(value) {
84
+ return value !== null && typeof value === 'object' && Object.hasOwn(value, 'new') ? value.new : value
85
+ }
86
+
75
87
  /**
76
88
  * @param {object} entry An audit entry.
77
89
  * @return {number} When it happened, in ms; 0 when unreadable.
@@ -202,6 +214,18 @@ export default {
202
214
  * @type {{[key: string]: {[value: string]: string}}|null}
203
215
  */
204
216
  valueLabels: { type: Object, default: null },
217
+ /**
218
+ * What happened, in words, by the field an entry changed: rules
219
+ * `[{ action?, field, to?, label }]`, the first whose `field` the entry
220
+ * changed (to `to`, when given) wins over `actionLabels`. A label fills
221
+ * the same tokens, plus `{object.<path>}` from `object`
222
+ * ("Version {object.version} published" for `isDraft` set to false).
223
+ *
224
+ * @type {Array<{action?: string, field: string, to?: unknown, label: string}>}
225
+ */
226
+ changeLabels: { type: Array, default: () => [] },
227
+ /** The audited record, for `{object.<path>}` in a label; null without one. */
228
+ object: { type: Object, default: null },
205
229
  /**
206
230
  * Records beside the object whose events belong in its history:
207
231
  * `[{ as, register, schema, filter, limit? }]`. The card lists the
@@ -394,12 +418,16 @@ export default {
394
418
  * @return {string} The line.
395
419
  */
396
420
  formatAction(entry) {
397
- const label = this.labelFor(entry)
421
+ const label = this.changeLabelFor(entry) ?? this.labelFor(entry)
398
422
  if (label !== null) {
399
423
  return label
400
424
  .replace('{actor}', this.actorName(entry))
401
425
  .replace('{state}', entry.stateAfter || '')
402
426
  .replace(/\{changed\.([\w-]+)\}/g, (match, key) => this.changedValue(entry, key))
427
+ .replace(/\{object\.([\w.-]+)\}/g, (match, path) => {
428
+ const value = this.object ? readPath(this.object, path) : undefined
429
+ return value === null || value === undefined || typeof value === 'object' ? '' : String(value)
430
+ })
403
431
  }
404
432
  const action = entry.action || entry.event || this.actionLabel
405
433
  return entry.stateAfter ? `${action} (${entry.stateAfter})` : action
@@ -416,7 +444,7 @@ export default {
416
444
  */
417
445
  changedValue(entry, key) {
418
446
  const changed = entry && typeof entry.changed === 'object' && entry.changed !== null ? entry.changed : {}
419
- const value = changed[key]
447
+ const value = newValueOf(changed[key])
420
448
  if (value === null || value === undefined || typeof value === 'object') {
421
449
  return ''
422
450
  }
@@ -424,6 +452,31 @@ export default {
424
452
  return words && typeof words[value] === 'string' ? words[value] : String(value)
425
453
  },
426
454
 
455
+ /**
456
+ * The `changeLabels` rule an entry matches: the first whose field the
457
+ * entry changed, for its action and to its value when the rule names them.
458
+ *
459
+ * @param {object} entry The entry.
460
+ * @return {string|null} The label, or null when no rule matches.
461
+ */
462
+ changeLabelFor(entry) {
463
+ const changed = entry && typeof entry.changed === 'object' && entry.changed !== null ? entry.changed : {}
464
+ const action = entry.action || entry.event || ''
465
+ for (const rule of Array.isArray(this.changeLabels) ? this.changeLabels : []) {
466
+ if (!rule || typeof rule.label !== 'string' || rule.label === '' || typeof rule.field !== 'string' || !Object.hasOwn(changed, rule.field)) {
467
+ continue
468
+ }
469
+ if (rule.action && rule.action !== action) {
470
+ continue
471
+ }
472
+ if (Object.hasOwn(rule, 'to') && String(newValueOf(changed[rule.field])) !== String(rule.to)) {
473
+ continue
474
+ }
475
+ return rule.label
476
+ }
477
+ return null
478
+ },
479
+
427
480
  /**
428
481
  * The `actionLabels` entry for an entry: by action and state first,
429
482
  * then by action.
@@ -20,6 +20,8 @@
20
20
  :excludeActions="resolvedExcludeActions"
21
21
  :actionLabels="actionLabels"
22
22
  :valueLabels="valueLabels"
23
+ :changeLabels="changeLabels"
24
+ :object="ctx.object || ctx.objectData || null"
23
25
  :relatedSources="relatedSources"
24
26
  :footnote="footnote"
25
27
  v-bind="emptyText ? { noEntriesLabel: emptyText } : {}" />
@@ -193,6 +195,22 @@ export default {
193
195
  .map(([key, text]) => [key, translate(text)]))
194
196
  },
195
197
 
198
+ /**
199
+ * `content.changeLabels`, each rule's label through the app's translate.
200
+ *
201
+ * @return {Array<object>}
202
+ */
203
+ changeLabels() {
204
+ const rules = this.content.changeLabels
205
+ if (!Array.isArray(rules)) {
206
+ return []
207
+ }
208
+ const translate = typeof this.cnTranslate === 'function' ? this.cnTranslate : (text) => text
209
+ return rules
210
+ .filter((rule) => rule && typeof rule.label === 'string' && rule.label !== '')
211
+ .map((rule) => ({ ...rule, label: translate(rule.label) }))
212
+ },
213
+
196
214
  /**
197
215
  * `content.footnote`, a muted line under the trail with the record's
198
216
  * fields filled in ("Reference {identifier}"); '' when a field is empty.
@@ -1204,6 +1204,7 @@ export default {
1204
1204
  .cn-checklist-run__help {
1205
1205
  color: var(--color-text-maxcontrast);
1206
1206
  font-size: 14px;
1207
+ line-height: 1.2;
1207
1208
  margin: 0;
1208
1209
  }
1209
1210
 
@@ -1215,13 +1216,14 @@ export default {
1215
1216
  font-size: 15px;
1216
1217
  font-weight: 400;
1217
1218
  gap: 10px;
1218
- min-height: 34px;
1219
+ min-height: 24px;
1219
1220
  }
1220
1221
 
1222
+ /* The board draws the radio with the browser's own 3px 3px 0 5px margin. */
1221
1223
  .cn-checklist-run__choice input {
1222
1224
  accent-color: var(--color-primary-element);
1223
1225
  height: 20px;
1224
- margin: 0;
1226
+ margin: 3px 3px 0 5px;
1225
1227
  width: 20px;
1226
1228
  }
1227
1229
 
@@ -307,6 +307,13 @@
307
307
  customising their own data, not opting out of being told
308
308
  the record is locked. It renders nothing when unlocked,
309
309
  so an unlocked table is byte-for-byte what it was. -->
310
+ <!-- A reorderable row shows where to take hold of it, before its name. -->
311
+ <span v-if="reorderable && colIndex === 0"
312
+ class="cn-table-row__handle"
313
+ aria-hidden="true"
314
+ data-testid="cn-row-reorder-handle">
315
+ <DragVertical :size="16" />
316
+ </span>
310
317
  <CnUnreadMarker
311
318
  v-if="colIndex === titleColumnIndex && isUnreadRow(row)" />
312
319
  <CnLockIndicator
@@ -475,6 +482,7 @@ import { generateUrl } from '@nextcloud/router'
475
482
  import { NcCheckboxRadioSwitch, NcLoadingIcon } from '@nextcloud/vue'
476
483
  import ChevronDown from 'vue-material-design-icons/ChevronDown.vue'
477
484
  import ChevronUp from 'vue-material-design-icons/ChevronUp.vue'
485
+ import DragVertical from 'vue-material-design-icons/DragVertical.vue'
478
486
  import FilterOutline from 'vue-material-design-icons/FilterOutline.vue'
479
487
  import CnPagination from '../CnPagination/CnPagination.vue'
480
488
  import CnColumnFilterPopover from './CnColumnFilterPopover.vue'
@@ -585,6 +593,7 @@ export default {
585
593
  CnPagination,
586
594
  CnUnreadMarker,
587
595
  ChevronDown,
596
+ DragVertical,
588
597
  ChevronUp,
589
598
  FilterOutline,
590
599
  },
@@ -630,7 +639,10 @@ export default {
630
639
  * `link: true` makes that column the one that names the row: it carries
631
640
  * the row link and the title look. Without it the first column does.
632
641
  *
633
- * @type {Array<{key: string, label: string, description: string, sortable: boolean, width: string, class: string, cellClass: string, link: boolean, secondary: (string|((row: object) => string)), secondaryLabels: object}|string>}
642
+ * `emptyValue` is what a row without a value holds in that column, so a
643
+ * `badge` column draws a pill on every row.
644
+ *
645
+ * @type {Array<{key: string, label: string, description: string, sortable: boolean, width: string, class: string, cellClass: string, link: boolean, secondary: (string|((row: object) => string)), secondaryLabels: object, emptyValue: unknown}|string>}
634
646
  */
635
647
  columns: {
636
648
  type: Array,
@@ -2225,7 +2237,12 @@ export default {
2225
2237
  }
2226
2238
  return v
2227
2239
  }
2228
- return this.getCellValue(row, this.columnValueField(col))
2240
+ const value = this.getCellValue(row, this.columnValueField(col))
2241
+ // `emptyValue`: what a row without a value holds, so a badge column draws one for every row.
2242
+ if ((value === null || value === undefined || value === '') && col && Object.hasOwn(col, 'emptyValue')) {
2243
+ return col.emptyValue
2244
+ }
2245
+ return value
2229
2246
  },
2230
2247
 
2231
2248
  /**
@@ -181,6 +181,7 @@ export default {
181
181
  |------|------|---------|-------------|
182
182
  | `icon` | String | `''` | Optional MDI icon name rendered in the header via `CnIcon` |
183
183
  | `iconSize` | Number | `28` | Icon size in pixels |
184
+ | `gridFloat` | Boolean | `true` | GridStack's `float` for the body grid (manifest `config.gridFloat`). False packs widgets upward, so a size-to-content widget ends at its content (DqHandhaving). |
184
185
  | `loadingLabel` | String | `'Loading...'` | Message shown below the spinner during loading |
185
186
  | `band` | Array | `[]` | A row of widgets across the whole page, above the body and the side column (manifest `config.band`). Each entry is a widget id from `widgets`, a widget definition, or `{ widgetId, span, chrome }`: `span` is the entry's share of the row in twelfths (default 12), and `chrome` is `card` or `bare` (a banner defaults to bare). Each entry follows its `visibleWhen`. |
186
187
  | `recordLastChange` | Boolean | `false` | Read who changed the record last (as `headerMetaLastChange` does) for the widgets on the page: a facts card reads it as `_lastChange.userName` / `_lastChange.created` (DqContact "Historie"). |
@@ -911,6 +911,7 @@
911
911
  :editable="section.editable"
912
912
  :columns="12"
913
913
  :columnOpts="bodyColumnOpts"
914
+ :float="gridFloat"
914
915
  class="cn-detail-page__grid"
915
916
  @layoutChange="section.view ? onViewLayoutChange($event) : onBodyLayoutChange($event)">
916
917
  <template #widget="{ item }">
@@ -1842,6 +1843,18 @@ export default {
1842
1843
  default: '',
1843
1844
  },
1844
1845
 
1846
+ /**
1847
+ * GridStack's `float` for the body grid (manifest `config.gridFloat`).
1848
+ * True (the default) keeps every widget on its authored row; false
1849
+ * packs widgets upward, so a size-to-content widget ends at its content
1850
+ * and the one below closes up (DqHandhaving: the violation card sits
1851
+ * 20px under the matrix).
1852
+ */
1853
+ gridFloat: {
1854
+ type: Boolean,
1855
+ default: true,
1856
+ },
1857
+
1845
1858
  /**
1846
1859
  * How tall the labelled buttons in the page's card title rows stand in
1847
1860
  * the board look: `compact` (34px, setting and case-type pages) or
@@ -5745,6 +5758,15 @@ export default {
5745
5758
  },
5746
5759
  },
5747
5760
 
5761
+ // Header actions gate on `@context.<name>` through the object context.
5762
+ visibilityRows() {
5763
+ this.syncObjectContext()
5764
+ },
5765
+
5766
+ visibilityRowsPending() {
5767
+ this.syncObjectContext()
5768
+ },
5769
+
5748
5770
  // Endpoint and source conditions on grid widgets are asked once per record.
5749
5771
  remoteWidgetConditionsKey: {
5750
5772
  immediate: true,
@@ -7887,6 +7909,7 @@ export default {
7887
7909
  object: this.currentObject || null,
7888
7910
  register: resolved.register || this.register || this.sidebarProps?.register || '',
7889
7911
  schema: resolved.schema || this.schema || this.resolvedObjectType || this.sidebarProps?.schema || '',
7912
+ context: this.visibilityRowsPending ? null : (this.visibilityRows || null),
7890
7913
  setObject: (object) => this.setCurrentObject(object),
7891
7914
  }
7892
7915
  },
@@ -31,6 +31,7 @@
31
31
  v-else
32
32
  type="button"
33
33
  class="cn-detail-page__catalog-action"
34
+ :class="{ 'cn-detail-page__catalog-action--add': action.add === true }"
34
35
  data-testid="cn-detail-widget-header-action"
35
36
  @click="action.run()">
36
37
  <CnIcon v-if="action.icon" :name="action.icon" :size="16" />
@@ -56,7 +57,7 @@ export default {
56
57
  components: { CnIcon, CnStatusBadge, NcButton },
57
58
 
58
59
  props: {
59
- /** The reported header: `{ pill, note, actions: [{ label, icon?, variant?, run }] }`. */
60
+ /** The reported header: `{ pill, note, actions: [{ label, icon?, variant?, add?, run }] }`; `add: true` marks a list's Add. */
60
61
  header: {
61
62
  type: Object,
62
63
  required: true,
@@ -85,6 +86,7 @@ export default {
85
86
  font-size: 13px;
86
87
  font-weight: 700;
87
88
  gap: 6px;
89
+ margin: 0;
88
90
  min-height: 32px;
89
91
  padding: 0 14px;
90
92
  white-space: nowrap;
@@ -41,11 +41,12 @@
41
41
  <dl
42
42
  v-if="block.kind === 'rows'"
43
43
  class="cn-facts-widget__rows"
44
- :class="{ 'cn-facts-widget__rows--columns': twoColumns }">
44
+ :class="{ 'cn-facts-widget__rows--columns': twoColumns, 'cn-facts-widget__rows--stacked': stacked }">
45
45
  <div
46
46
  v-for="row in block.rows"
47
47
  :key="row.key"
48
48
  class="cn-facts-widget__row"
49
+ :class="{ 'cn-facts-widget__row--inline': row.inline }"
49
50
  :data-testid="`cn-fact-${row.key}`">
50
51
  <dt class="cn-facts-widget__label" :class="{ 'cn-facts-widget__label--icon': row.icon }">
51
52
  <CnIcon
@@ -232,7 +233,7 @@
232
233
  import { getCurrentUser } from '@nextcloud/auth'
233
234
  import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
234
235
  import { formatFileSize } from '@nextcloud/files'
235
- import { translate as t } from '@nextcloud/l10n'
236
+ import { translatePlural as n, translate as t } from '@nextcloud/l10n'
236
237
  import { generateUrl } from '@nextcloud/router'
237
238
  import { NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'
238
239
  import Check from 'vue-material-design-icons/Check.vue'
@@ -251,6 +252,7 @@ import { parseDateValue } from '../../utils/dateVariant.js'
251
252
  import { fetchListTotal } from '../../utils/fetchFilterCounts.js'
252
253
  import { fillTemplate, formatLongDate } from '../../utils/fillTemplate.js'
253
254
  import { formatMoney, metricLocale, unwrapAppConfig } from '../../utils/formatMetric.js'
255
+ import { groupDisplayName, loadGroupDisplayName } from '../../utils/groupAutocomplete.js'
254
256
  import { readPath } from '../../utils/readPath.js'
255
257
  import { hasUnresolvedDeepTokens, resolveDeepTokens } from '../../utils/resolveFilterTokens.js'
256
258
  import { userDisplayName } from '../../utils/userDisplayName.js'
@@ -424,16 +426,17 @@ export default {
424
426
  /**
425
427
  * The widget configuration.
426
428
  *
427
- * - `fields` — the entries, in order. Each is `{ path, label?, as?, from?, format?, map?, itemPath?, parts?, separator?, route?, variant?, variantMap?, colorMap?, icon?, copy?, reveal?, text?, title?, subtitle?, initialsPath?, tone? }`.
429
+ * - `fields` — the entries, in order. Each is `{ path, label?, as?, from?, format?, map?, itemPath?, parts?, separator?, route?, variant?, variantMap?, colorMap?, icon?, copy?, reveal?, inline?, text?, title?, subtitle?, initialsPath?, tone? }`.
428
430
  * `as` is `row` (default when `label` or `icon` is set), `title`, `line`, `text` (a line in the normal ink), `lead` (an icon tile or initials beside `title` and `subtitle`), `pill` or `meter`.
429
431
  * `countFrom` (`{ register, schema, filter? }`, `@objectId` / `@object.<field>` in the filter) shows how many records that list holds ("Lopende zaken 43"); the row is left out while the count cannot be read.
430
432
  * A `meter` shows the amount at `path` against the limit at `limitPath` (or a constant `limit`) as "12 of 50" with a bar; past the limit the bar turns red and the text says "over the limit"; without a positive limit it shows the amount and no bar.
433
+ * A row with `inline: true` spans both columns as one line, the label, the value and its buttons side by side (a masked BSN with Show).
431
434
  * A row's `format` may also be `email`, `phone` or `url` (a link) or `masked` (the last four characters; `reveal: true` adds Show, which unmasks the record's value; `reveal: { url, path? }` makes Show ask the server for the value at `url` (tokens resolved), read at `path` (else the entry's path), so a server that answers the mask can check and record the reveal). `copy: true` adds a copy button; `icon` replaces the label.
432
435
  * A part may be `{ text }`, a constant; a `line` or `text` entry without `path` or `parts` shows its `text`.
433
436
  * `resolve: { register, schema, labelField? }` on an entry or a part turns a value that is a reference (an id, or a list of ids) into the label of the referenced object (`labelField`, else `title`, then `name`): the case type or status of a task shows its name, not its uuid. The ids are fetched once per register and schema through the object store and shared by every facts widget on the page; the entry stays empty until its label is known, and when no label can be read. A resolved value works as a pill too: `map` is applied to the resolved label, and `variantMap` / `colorMap` look the tone up by the value of `resolve.toneField` on the referenced object when that is set (a status named by its `name`, coloured by its `state`), then by the resolved label, then by the stored value.
434
437
  * `route` (`{ name, params?, query? }`, `@source.<path>` in a param) on a `title` or a `row` links its value to that route; a param that does not fill leaves it plain.
435
438
  * `format: 'user'` shows a Nextcloud user id as the user's display name, read once per id; the entry stays empty until the name is known.
436
- * `template` ("{available} {unit}") fills its `{path}` placeholders from the record instead of reading `path`; it is empty when every placeholder is. `suffix` (" kg") is added to a value that is not empty.
439
+ * `template` ("{available} {unit}") fills its `{path}` placeholders from the record instead of reading `path`; it is empty when every placeholder is. `suffix` (" kg") is added to a value that is not empty, its words translated.
437
440
  * `labelPath` ("@context.caseType.handlerLabel") names a value on the record or the page's `@context` records that labels the row when it holds text; `label` is the fallback.
438
441
  * `emptyValue` ("No") is shown, translated, when the entry has no value, so the entry stays drawn whatever `hideEmpty` says (an override that was never set reads "No").
439
442
  * - `action` — an action drawn as a button under the facts; `headerAction` — one drawn in the card header (reported through the `header` event).
@@ -441,14 +444,14 @@ export default {
441
444
  * - `rowsPath` — a path to a list of `{ label, value }` the record holds, drawn as rows after `fields` (a preview the server builds).
442
445
  * - `headerPill` — a status pill in the card header (reported through the `header` event): `{ path?, text?, map?, variant?, variantMap?, colorMap? }`, read from the record like a `pill` entry (DqBezwaar "Advies ontvangen"); nothing shows while the value is empty.
443
446
  * - `notice` — `{ text, variant? }`, a note band inside the card under the facts (`info` by default; `success`, `warning` or `error`); the text is translated.
444
- * `format` is `text` (default), `date` (board short date), `date-long` (always with the year), `longdate` (the month written out, the year only outside this year: "3 oktober"), `list` (an array joined by ", ", each item read at `itemPath`), `bytes` (a size, "1.5 MB"), `number` (in the reader's language, `decimals` fraction digits when set) or `currency` (a money amount in the reader's language, "€ 1.234,50": the code in `currency`, else the record's `currencyField`, else the app's reporting currency, else EUR; `decimals` defaults to 2).
447
+ * `format` is `text` (default), `date` (board short date), `date-long` (always with the year), `longdate` (the month written out, the year only outside this year: "3 oktober"), `list` (an array joined by ", ", each item read at `itemPath` and written in `itemFormat`), `group` (a Nextcloud group by its display name), `bytes` (a size, "1.5 MB"), `number` (in the reader's language, `decimals` fraction digits when set), `months` (a number of months in words, whole years as years: 240 is "20 jaar") or `currency` (a money amount in the reader's language, "€ 1.234,50": the code in `currency`, else the record's `currencyField`, else the app's reporting currency, else EUR; `decimals` defaults to 2).
445
448
  * `map` turns a raw value into a label (translated). `parts` (on a `line`) lists paths, or `{ path, label?, format? }`, joined by `separator` (default " · "); empty parts are dropped.
446
449
  * - `source` — `{ register, schema, id }` or `{ url }`; omitted = the current object. `{ register, schema, filter, sort? }` without `id` reads the first object that matches `filter` in `sort` order (`{ field, dir }`): the latest count of a shift. An entry with `from: "object"` reads the current object even then (a client card that also shows the ticket's channel).
447
450
  * - `endpointSource` — `{ url, method?, params?, responsePath? }`, the block a stat or chart takes: the facts read an app endpoint, shared and cached like those.
448
451
  * - `emptyWhen` — a path; when the record's value there is truthy the card shows `emptyText` (an endpoint that answers `empty: true`).
449
452
  * - `link` — `{ label, route? | href? }` at the foot. `{ label, icon?, sidebarTab }` instead opens the page's object sidebar on that tab (no link without a sidebar).
450
453
  * - `hideEmpty` — drop an entry whose value is empty (default true).
451
- * - `columns` — `2` lays the rows out in two columns with the label above the value (board DqContact); a row with `parts` joins them like a line (an address on one row).
454
+ * - `columns` — `2` lays the rows out in two columns with the label above the value (board DqContact); a row with `parts` joins them like a line (an address on one row). `1` stacks each label above its value in one column, for a narrow card with long labels (board DqHandhaving "Afwijking en definitief").
452
455
  * - `emptyText` — shown when no entry has a value.
453
456
  *
454
457
  * @type {{fields?: Array<object>, source?: object, link?: object, hideEmpty?: boolean, emptyText?: string}}
@@ -510,6 +513,15 @@ export default {
510
513
  return Number(this.content?.columns) === 2
511
514
  },
512
515
 
516
+ /**
517
+ * Whether the rows stack in one column, label above value.
518
+ *
519
+ * @return {boolean}
520
+ */
521
+ stacked() {
522
+ return Number(this.content?.columns) === 1
523
+ },
524
+
513
525
  /**
514
526
  * The token context: the detail object (prop, else the injected context)
515
527
  * and the page workspace.
@@ -725,7 +737,7 @@ export default {
725
737
  }
726
738
  if (kind === 'row') {
727
739
  const last = out[out.length - 1]
728
- const row = { key, label: this.labelOf(record, field), value, ...this.rowExtras(record, field, value) }
740
+ const row = { key, label: this.labelOf(record, field), value, inline: field.inline === true, ...this.rowExtras(record, field, value) }
729
741
  if (field.route && !row.href) {
730
742
  row.to = this.resolveRoute(field.route, record)
731
743
  }
@@ -1130,7 +1142,12 @@ export default {
1130
1142
  if (value === '') {
1131
1143
  return typeof field.emptyValue === 'string' && field.emptyValue !== '' ? this.tr(field.emptyValue) : ''
1132
1144
  }
1133
- return typeof field.suffix === 'string' ? `${value}${field.suffix}` : value
1145
+ if (typeof field.suffix !== 'string') {
1146
+ return value
1147
+ }
1148
+ // The words of a suffix are translated; its leading space stays as written.
1149
+ const lead = field.suffix.match(/^\s*/)[0]
1150
+ return `${value}${lead}${this.tr(field.suffix.slice(lead.length))}`
1134
1151
  },
1135
1152
 
1136
1153
  /**
@@ -1798,7 +1815,7 @@ export default {
1798
1815
  if (format === 'list' || Array.isArray(raw)) {
1799
1816
  const items = (Array.isArray(raw) ? raw : [raw])
1800
1817
  .map((item) => (spec.itemPath && item && typeof item === 'object' ? readPath(item, spec.itemPath) : item))
1801
- .map((item) => this.format(item, { ...spec, format: 'text' }))
1818
+ .map((item) => this.format(item, { ...spec, format: spec.itemFormat || 'text' }))
1802
1819
  .filter((item) => item !== '')
1803
1820
  return items.join(', ')
1804
1821
  }
@@ -1816,6 +1833,16 @@ export default {
1816
1833
  ? formatBoardDate(date, new Date(date.getFullYear() + 1, 0, 1))
1817
1834
  : formatBoardDate(date)
1818
1835
  }
1836
+ // A number of months in words: whole years as years ("20 jaar"), else months.
1837
+ if (format === 'months') {
1838
+ const months = Number(raw)
1839
+ if (typeof raw === 'object' || !Number.isInteger(months) || months < 0) {
1840
+ return typeof raw === 'object' ? '' : String(raw)
1841
+ }
1842
+ return months > 0 && months % 12 === 0
1843
+ ? n('nextcloud-vue', '%n year', '%n years', months / 12)
1844
+ : n('nextcloud-vue', '%n month', '%n months', months)
1845
+ }
1819
1846
  if (format === 'bytes') {
1820
1847
  const bytes = Number(raw)
1821
1848
  return Number.isFinite(bytes) && bytes >= 0 ? formatFileSize(bytes) : ''
@@ -1823,6 +1850,18 @@ export default {
1823
1850
  if (format === 'user') {
1824
1851
  return userDisplayName(raw)
1825
1852
  }
1853
+ // A Nextcloud group by its display name; the id until the name is known.
1854
+ if (format === 'group') {
1855
+ if (typeof raw === 'object') {
1856
+ return ''
1857
+ }
1858
+ const name = groupDisplayName(String(raw))
1859
+ if (name === null) {
1860
+ loadGroupDisplayName(String(raw))
1861
+ return String(raw)
1862
+ }
1863
+ return name
1864
+ }
1826
1865
  if (format === 'number') {
1827
1866
  const number = Number(raw)
1828
1867
  if (typeof raw === 'object' || !Number.isFinite(number)) {
@@ -1938,6 +1977,21 @@ export default {
1938
1977
  display: contents;
1939
1978
  }
1940
1979
 
1980
+ .cn-facts-widget__row--inline {
1981
+ display: flex;
1982
+ grid-column: 1 / -1;
1983
+ flex-wrap: wrap;
1984
+ align-items: center;
1985
+ gap: 10px;
1986
+ }
1987
+
1988
+ .cn-facts-widget__row--inline .cn-facts-widget__value {
1989
+ display: flex;
1990
+ flex-wrap: wrap;
1991
+ align-items: center;
1992
+ gap: 10px;
1993
+ }
1994
+
1941
1995
  /* Nextcloud core styles `dt` and `dd` (text-align end, 12px padding, a
1942
1996
  130px-wide `dt`), which pushed the label to the right of its column, added
1943
1997
  12px around both and widened the label column to 130px. The class is
@@ -2203,6 +2257,17 @@ export default {
2203
2257
  font-size: 13px;
2204
2258
  }
2205
2259
 
2260
+ /* One column, label above value: 2px under its label, 10px to the next
2261
+ label, so a long label never squeezes its value (DqHandhaving). */
2262
+ .cn-facts-widget__rows--stacked {
2263
+ grid-template-columns: minmax(0, 1fr);
2264
+ gap: 2px 0;
2265
+ }
2266
+
2267
+ .cn-facts-widget__rows--stacked .cn-facts-widget__value.cn-facts-widget__value {
2268
+ margin-bottom: 8px;
2269
+ }
2270
+
2206
2271
  @media (max-width: 640px) {
2207
2272
  .cn-facts-widget__rows--columns {
2208
2273
  grid-template-columns: minmax(0, 1fr);
@@ -607,7 +607,7 @@ export default {
607
607
  /**
608
608
  * The card title row's actions: the add button with `add.placement: "title"`.
609
609
  *
610
- * @return {Array<{label: string, icon: string, run: () => void}>} The actions.
610
+ * @return {Array<{label: string, icon: string, add: boolean, run: () => void}>} The actions.
611
611
  */
612
612
  titleActions() {
613
613
  if (!(this.canAdd && this.addTrigger && this.addPlacement === 'title')) {
@@ -616,7 +616,7 @@ export default {
616
616
  const run = () => {
617
617
  this.adding = !this.adding
618
618
  }
619
- return [{ label: this.addLabel, icon: 'Plus', run }]
619
+ return [{ label: this.addLabel, icon: 'Plus', add: true, run }]
620
620
  },
621
621
 
622
622
  addLabel() {
@@ -8940,7 +8940,7 @@ export default {
8940
8940
  // through the app's label lookup.
8941
8941
  const countText = this.presentedCountText
8942
8942
  // The note after the breadcrumb already says how many there are.
8943
- if (!countText && !this.countSubtitle && this.headerMetaCarriesCount) {
8943
+ if (!countText && !this.countSubtitle && (this.headerMetaCarriesCount || this.showCount === false)) {
8944
8944
  return this.description
8945
8945
  }
8946
8946
  if (!countText && !this.countSubtitle) {
@@ -9877,9 +9877,12 @@ export default {
9877
9877
  }
9878
9878
  const sorted = [...groups].sort((a, b) => b.count - a.count)
9879
9879
  const total = sorted.reduce((sum, g) => sum + g.count, 0)
9880
+ // Each tab asks to be counted as the list counts it (within the
9881
+ // active filters, through the list endpoint); the grouped number,
9882
+ // over the page filter only, stands until that count arrives.
9880
9883
  this.generatedQuickFilters = [
9881
- { label: from.allLabel || t('nextcloud-vue', 'All'), filter: {}, default: true, count: total },
9882
- ...sorted.map((g) => ({ label: (labels && labels[g.key]) || g.key, filter: { [from.field]: g.key }, count: g.count })),
9884
+ { label: from.allLabel || t('nextcloud-vue', 'All'), filter: {}, default: true, count: total, showCount: true },
9885
+ ...sorted.map((g) => ({ label: (labels && labels[g.key]) || g.key, filter: { [from.field]: g.key }, count: g.count, showCount: true })),
9883
9886
  ]
9884
9887
  },
9885
9888
 
@@ -19,9 +19,11 @@
19
19
  <h3 v-if="heading" class="cn-mail-tab__title">
20
20
  {{ heading }}
21
21
  </h3>
22
- <p v-if="description" class="cn-mail-tab__subtitle">
22
+ <!-- Divs, not <p>: a theme that sizes every paragraph with !important
23
+ would override the card's 14px description. -->
24
+ <div v-if="description" class="cn-mail-tab__subtitle">
23
25
  {{ description }}
24
- </p>
26
+ </div>
25
27
  </div>
26
28
  <div v-if="hasHeaderActions" class="cn-mail-tab__header-actions">
27
29
  <NcButton
@@ -77,9 +79,9 @@
77
79
  {{ t('nextcloud-vue', 'Empty draft') }}
78
80
  </NcButton>
79
81
  </div>
80
- <p v-if="hint" class="cn-mail-tab__hint">
82
+ <div v-if="hint" class="cn-mail-tab__hint">
81
83
  {{ hint }}
82
- </p>
84
+ </div>
83
85
 
84
86
  <NcNoteCard
85
87
  v-if="error"
@@ -128,6 +128,7 @@
128
128
  </p>
129
129
  <NcButton
130
130
  v-if="canReply"
131
+ class="cn-notes-card__reply-btn"
131
132
  variant="tertiary"
132
133
  size="small"
133
134
  :aria-label="replyAria(row.note)"
@@ -915,7 +916,10 @@ export default {
915
916
  padding-right: 32px;
916
917
  }
917
918
 
918
- .cn-notes-card__delete-btn {
919
+ /* The note's own class leads so the position outranks NcButton's
920
+ `position: relative`, which loads later at the same weight and put the
921
+ button in the flow under the note. */
922
+ .cn-notes-card__note > .cn-notes-card__delete-btn {
919
923
  position: absolute;
920
924
  top: 8px;
921
925
  right: -4px;
@@ -923,7 +927,8 @@ export default {
923
927
  transition: opacity 0.15s ease;
924
928
  }
925
929
 
926
- .cn-notes-card__note:hover .cn-notes-card__delete-btn {
930
+ .cn-notes-card__note:hover > .cn-notes-card__delete-btn,
931
+ .cn-notes-card__note:focus-within > .cn-notes-card__delete-btn {
927
932
  opacity: 1;
928
933
  }
929
934