@conduction/nextcloud-vue 2.76.0 → 2.77.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 (119) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +18 -3
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +49 -3
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  5. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +42 -1
  6. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  7. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +228 -203
  8. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  9. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue.js +9 -0
  10. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue.js.map +1 -0
  11. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue2.js +50 -0
  12. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue2.js.map +1 -0
  13. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue3.js +13 -0
  14. package/dist/esm/components/CnCellRenderer/CnGroupNameCell.vue3.js.map +1 -0
  15. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +41 -4
  16. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  17. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +90 -51
  18. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +16 -28
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +12 -24
  22. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  23. package/dist/esm/components/CnFavouriteToggle/CnFavouriteToggle.vue2.js +7 -2
  24. package/dist/esm/components/CnFavouriteToggle/CnFavouriteToggle.vue2.js.map +1 -1
  25. package/dist/esm/components/CnFavouriteToggle/CnFavouriteToggle.vue3.js.map +1 -1
  26. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +114 -1
  27. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
  28. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +115 -62
  29. package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
  30. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue2.js +89 -7
  31. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue2.js.map +1 -1
  32. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue3.js +62 -30
  33. package/dist/esm/components/CnFollowToggle/CnFollowToggle.vue3.js.map +1 -1
  34. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +98 -13
  35. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  36. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +13 -9
  37. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  38. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js +199 -7
  39. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue2.js.map +1 -1
  40. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js +155 -119
  41. package/dist/esm/components/CnObjectDataWidget/CnObjectDataWidget.vue3.js.map +1 -1
  42. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +6 -0
  43. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  44. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +4 -1
  45. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  46. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js +13 -0
  47. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue2.js.map +1 -1
  48. package/dist/esm/components/CnWidgetObjectTable/CnWidgetObjectTable.vue3.js.map +1 -1
  49. package/dist/esm/composables/useListView.js +4 -0
  50. package/dist/esm/composables/useListView.js.map +1 -1
  51. package/dist/esm/index.js +2 -1
  52. package/dist/esm/index.js.map +1 -1
  53. package/dist/esm/l10n/en.json.js +25 -1
  54. package/dist/esm/l10n/en.json.js.map +1 -1
  55. package/dist/esm/l10n/nl.json.js +25 -1
  56. package/dist/esm/l10n/nl.json.js.map +1 -1
  57. package/dist/esm/nextcloud-vue.css +288 -74
  58. package/dist/esm/store/plugins/interactions.js +20 -10
  59. package/dist/esm/store/plugins/interactions.js.map +1 -1
  60. package/dist/esm/store/useObjectStore.js +17 -0
  61. package/dist/esm/store/useObjectStore.js.map +1 -1
  62. package/dist/esm/utils/builtInFormatters.js +15 -7
  63. package/dist/esm/utils/builtInFormatters.js.map +1 -1
  64. package/dist/esm/utils/groupAutocomplete.js +63 -1
  65. package/dist/esm/utils/groupAutocomplete.js.map +1 -1
  66. package/dist/esm/utils/lensAvailability.js +90 -0
  67. package/dist/esm/utils/lensAvailability.js.map +1 -0
  68. package/dist/esm/utils/personalLenses.js +13 -6
  69. package/dist/esm/utils/personalLenses.js.map +1 -1
  70. package/dist/esm/utils/recordInteractions.js +31 -13
  71. package/dist/esm/utils/recordInteractions.js.map +1 -1
  72. package/dist/esm/utils/resolveFilterTokens.js +46 -3
  73. package/dist/esm/utils/resolveFilterTokens.js.map +1 -1
  74. package/dist/esm/utils/schema.js +1 -1
  75. package/dist/esm/utils/schema.js.map +1 -1
  76. package/dist/esm/utils/sentinelTokens.js +4 -4
  77. package/dist/esm/utils/sentinelTokens.js.map +1 -1
  78. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  79. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  80. package/dist/esm/utils/widgetVisibility.js +46 -1
  81. package/dist/esm/utils/widgetVisibility.js.map +1 -1
  82. package/dist/nextcloud-vue.cjs.js +3873 -2674
  83. package/dist/nextcloud-vue.cjs.js.map +2 -2
  84. package/dist/nextcloud-vue.css +288 -74
  85. package/l10n/en.json +25 -1
  86. package/l10n/nl.json +25 -1
  87. package/package.json +1 -1
  88. package/src/components/CnAppNav/CnAppNav.vue +44 -5
  89. package/src/components/CnCellRenderer/CnCellRenderer.vue +56 -1
  90. package/src/components/CnCellRenderer/CnGroupNameCell.vue +61 -0
  91. package/src/components/CnDataTable/CnDataTable.md +9 -0
  92. package/src/components/CnDataTable/CnDataTable.vue +80 -20
  93. package/src/components/CnDetailPage/CnDetailPage.vue +10 -27
  94. package/src/components/CnFavouriteToggle/CnFavouriteToggle.vue +7 -2
  95. package/src/components/CnFilesBrowser/CnFilesBrowser.vue +204 -5
  96. package/src/components/CnFollowToggle/CnFollowToggle.vue +118 -13
  97. package/src/components/CnIndexPage/CnIndexPage.md +36 -0
  98. package/src/components/CnIndexPage/CnIndexPage.vue +106 -19
  99. package/src/components/CnObjectDataWidget/CnObjectDataWidget.vue +226 -2
  100. package/src/components/CnObjectSidebar/CnFilesTab.vue +9 -0
  101. package/src/components/CnWidgetObjectTable/CnWidgetObjectTable.vue +13 -0
  102. package/src/composables/useListView.js +4 -0
  103. package/src/css/look-board-detail.css +40 -2
  104. package/src/css/table.css +82 -17
  105. package/src/index.js +2 -1
  106. package/src/schemas/app-manifest-v2.schema.json +11 -7
  107. package/src/store/plugins/interactions.js +20 -10
  108. package/src/store/useObjectStore.js +17 -0
  109. package/src/utils/builtInFormatters.js +15 -7
  110. package/src/utils/groupAutocomplete.js +75 -0
  111. package/src/utils/index.js +1 -1
  112. package/src/utils/lensAvailability.js +86 -0
  113. package/src/utils/personalLenses.js +13 -6
  114. package/src/utils/recordInteractions.js +31 -13
  115. package/src/utils/resolveFilterTokens.js +45 -2
  116. package/src/utils/schema.js +1 -1
  117. package/src/utils/sentinelTokens.js +4 -4
  118. package/src/utils/validateManifestV2.compiled.js +2 -2
  119. package/src/utils/widgetVisibility.js +45 -0
@@ -7,10 +7,29 @@
7
7
  class="cn-files-browser"
8
8
  :class="{ 'cn-files-browser--dragging': dragging }"
9
9
  data-testid="cn-files-browser"
10
- @dragenter.prevent="dragging = true"
11
- @dragover.prevent="dragging = true"
12
- @dragleave.prevent="dragging = false"
10
+ @dragenter.prevent="onDragEnter"
11
+ @dragover.prevent="onDragOver"
12
+ @dragleave.prevent="onDragLeave"
13
13
  @drop.prevent="onDrop">
14
+ <!-- The drop state, drawn on the browser itself when the host names it
15
+ (`dropOverlay`): a dashed frame and one centred line over the list,
16
+ so it is plain where the files go. The overlay is decoration for
17
+ sighted users; the live region below says the same thing to a
18
+ screen reader, and the upload button stays the way in for anyone
19
+ who cannot drag. -->
20
+ <div v-if="dropOverlay && dragging"
21
+ class="cn-files-browser__drop-overlay"
22
+ aria-hidden="true"
23
+ data-testid="cn-files-browser-drop-overlay">
24
+ <span class="cn-files-browser__drop-icon"><Upload :size="28" /></span>
25
+ <strong class="cn-files-browser__drop-label">{{ dropLabel }}</strong>
26
+ </div>
27
+ <p v-if="dropOverlay"
28
+ class="hidden-visually"
29
+ aria-live="polite"
30
+ data-testid="cn-files-browser-drop-status">
31
+ {{ dropStatus }}
32
+ </p>
14
33
  <!-- The bar: where you are, and what you can add. The crumbs are the
15
34
  Files app's own breadcrumb component; the New menu lists what the
16
35
  Files app and its plugins registered for this folder (a new folder,
@@ -56,7 +75,7 @@
56
75
  :menuName="newLabel"
57
76
  :forceMenu="true"
58
77
  :forceName="true"
59
- variant="primary"
78
+ :variant="uploadButton ? 'secondary' : 'primary'"
60
79
  :disabled="folder === null"
61
80
  data-testid="cn-files-browser-new">
62
81
  <template #icon>
@@ -94,6 +113,19 @@
94
113
  {{ action.label }}
95
114
  </NcActionButton>
96
115
  </NcActions>
116
+ <!-- A plain upload as its own button, when the host names it
117
+ (`uploadButton`). It takes the primary place and the New
118
+ menu steps back beside it. -->
119
+ <NcButton v-if="uploadButton"
120
+ variant="primary"
121
+ :disabled="folder === null"
122
+ data-testid="cn-files-browser-upload-button"
123
+ @click="pickFiles">
124
+ <template #icon>
125
+ <Upload :size="20" />
126
+ </template>
127
+ {{ uploadButtonLabel }}
128
+ </NcButton>
97
129
  </div>
98
130
 
99
131
  <!-- `:ref` (dynamic), not `ref`: a static ref on a vnode with only static
@@ -344,6 +376,13 @@
344
376
  </tbody>
345
377
  </table>
346
378
 
379
+ <p v-if="dropHint && !loading && error === ''"
380
+ class="cn-files-browser__drop-hint"
381
+ data-testid="cn-files-browser-drop-hint">
382
+ <Upload :size="16" />
383
+ {{ dropHintLabel }}
384
+ </p>
385
+
347
386
  <NcDialog
348
387
  v-if="renaming !== null"
349
388
  :name="renameLabel"
@@ -443,7 +482,7 @@ import { getClient, getDefaultPropfind, getRemoteURL, getRootPath, resultToNode
443
482
  * copy read `window._nc_fileactions`, which the server's plugins never write,
444
483
  * and saw nothing while the page held thirty actions.
445
484
  */
446
- import { translate as t } from '@nextcloud/l10n'
485
+ import { translatePlural as n, translate as t } from '@nextcloud/l10n'
447
486
  import { generateUrl } from '@nextcloud/router'
448
487
  import { NcActionButton, NcActionCheckbox, NcActionLink, NcActions, NcActionSeparator, NcBreadcrumb, NcBreadcrumbs, NcButton, NcDateTime, NcDialog, NcIconSvgWrapper, NcLoadingIcon, NcProgressBar, NcTextField } from '@nextcloud/vue'
449
488
  import AlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
@@ -624,6 +663,51 @@ export default {
624
663
  default: () => t('nextcloud-vue', 'Show in Files'),
625
664
  },
626
665
 
666
+ /**
667
+ * Show a primary upload button beside the New menu, which then steps
668
+ * back to a secondary button. Off (the default) keeps the upload
669
+ * inside the New menu only.
670
+ */
671
+ uploadButton: {
672
+ type: Boolean,
673
+ default: false,
674
+ },
675
+
676
+ /** Label of the upload button `uploadButton` shows. */
677
+ uploadButtonLabel: {
678
+ type: String,
679
+ default: () => t('nextcloud-vue', 'Add files'),
680
+ },
681
+
682
+ /**
683
+ * Draw the drop state over the browser while files are dragged onto
684
+ * it (a dashed frame and `dropLabel`, centred) and announce it to
685
+ * screen readers. Off (the default) keeps the plain dashed outline and
686
+ * announces nothing.
687
+ */
688
+ dropOverlay: {
689
+ type: Boolean,
690
+ default: false,
691
+ },
692
+
693
+ /** The line `dropOverlay` draws and announces. */
694
+ dropLabel: {
695
+ type: String,
696
+ default: () => t('nextcloud-vue', 'Drop to add'),
697
+ },
698
+
699
+ /** Show a line under the list saying files can be dropped on it. */
700
+ dropHint: {
701
+ type: Boolean,
702
+ default: false,
703
+ },
704
+
705
+ /** The line `dropHint` shows. */
706
+ dropHintLabel: {
707
+ type: String,
708
+ default: () => t('nextcloud-vue', 'Or drag files onto this list.'),
709
+ },
710
+
627
711
  /** Title of the empty state. */
628
712
  emptyLabel: {
629
713
  type: String,
@@ -726,6 +810,10 @@ export default {
726
810
  */
727
811
  visibleColumnKeys: null,
728
812
  dragging: false,
813
+ /** Nested dragenter/dragleave pairs, so a child does not end the drag. */
814
+ dragDepth: 0,
815
+ /** What the live region says: the drop label, then what was added. */
816
+ dropStatus: '',
729
817
  uploads: [],
730
818
  previewFailed: {},
731
819
  newFolderOpen: false,
@@ -1265,12 +1353,76 @@ export default {
1265
1353
  */
1266
1354
  async onDrop(event) {
1267
1355
  this.dragging = false
1356
+ this.dragDepth = 0
1357
+ this.dropStatus = ''
1268
1358
  const files = event.dataTransfer?.files
1269
1359
  if (files?.length) {
1270
1360
  await this.uploadFiles(files)
1361
+ if (this.dropOverlay) {
1362
+ this.dropStatus = n('nextcloud-vue', '%n file added', '%n files added', files.length)
1363
+ }
1271
1364
  }
1272
1365
  },
1273
1366
 
1367
+ /**
1368
+ * Something is dragged into the browser: show the drop state when it
1369
+ * carries files, and say so once.
1370
+ *
1371
+ * @param {DragEvent} event The drag.
1372
+ * @return {void}
1373
+ */
1374
+ onDragEnter(event) {
1375
+ if (!this.carriesFiles(event)) {
1376
+ return
1377
+ }
1378
+ this.dragDepth++
1379
+ if (!this.dragging) {
1380
+ this.dragging = true
1381
+ this.dropStatus = this.dropOverlay ? this.dropLabel : ''
1382
+ }
1383
+ },
1384
+
1385
+ /**
1386
+ * Keep the drop state while files move over the browser.
1387
+ *
1388
+ * @param {DragEvent} event The drag.
1389
+ * @return {void}
1390
+ */
1391
+ onDragOver(event) {
1392
+ if (this.carriesFiles(event)) {
1393
+ this.dragging = true
1394
+ }
1395
+ },
1396
+
1397
+ /**
1398
+ * Something left the browser, or one of its children. The drop state
1399
+ * ends only when the drag has left the browser itself.
1400
+ *
1401
+ * @return {void}
1402
+ */
1403
+ onDragLeave() {
1404
+ this.dragDepth = Math.max(0, this.dragDepth - 1)
1405
+ if (this.dragDepth === 0) {
1406
+ this.dragging = false
1407
+ this.dropStatus = ''
1408
+ }
1409
+ },
1410
+
1411
+ /**
1412
+ * Whether a drag carries files (not text or a link from the page).
1413
+ * A drag whose types cannot be read counts as files, as before.
1414
+ *
1415
+ * @param {DragEvent} event The drag.
1416
+ * @return {boolean}
1417
+ */
1418
+ carriesFiles(event) {
1419
+ const types = event?.dataTransfer?.types
1420
+ if (!types) {
1421
+ return true
1422
+ }
1423
+ return Array.from(types).includes('Files')
1424
+ },
1425
+
1274
1426
  /**
1275
1427
  * PUT each file into the open folder over DAV, with a progress row
1276
1428
  * each, then re-read the folder once.
@@ -1660,6 +1812,7 @@ export default {
1660
1812
  flex-direction: column;
1661
1813
  gap: 8px;
1662
1814
  min-width: 0;
1815
+ position: relative;
1663
1816
  border-radius: var(--border-radius-large, 8px);
1664
1817
  }
1665
1818
 
@@ -1668,6 +1821,52 @@ export default {
1668
1821
  outline-offset: 4px;
1669
1822
  }
1670
1823
 
1824
+ .cn-files-browser__drop-overlay {
1825
+ position: absolute;
1826
+ inset: 0;
1827
+ z-index: 2;
1828
+ display: flex;
1829
+ flex-direction: column;
1830
+ align-items: center;
1831
+ justify-content: center;
1832
+ gap: 10px;
1833
+ padding: 24px;
1834
+ box-sizing: border-box;
1835
+ border: 2px dashed var(--color-primary-element);
1836
+ border-radius: var(--border-radius-large, 8px);
1837
+ background: var(--color-primary-element-light);
1838
+ color: var(--color-primary-element-light-text, var(--color-main-text));
1839
+ text-align: center;
1840
+ pointer-events: none;
1841
+ }
1842
+
1843
+ .cn-files-browser__drop-icon {
1844
+ display: flex;
1845
+ align-items: center;
1846
+ justify-content: center;
1847
+ width: 56px;
1848
+ height: 56px;
1849
+ border-radius: 50%;
1850
+ background: var(--color-main-background);
1851
+ color: var(--color-primary-element);
1852
+ }
1853
+
1854
+ .cn-files-browser__drop-label {
1855
+ font-size: 20px;
1856
+ font-weight: 700;
1857
+ }
1858
+
1859
+ .cn-files-browser__drop-hint {
1860
+ display: flex;
1861
+ align-items: center;
1862
+ gap: 8px;
1863
+ margin: 0;
1864
+ padding-top: 12px;
1865
+ border-top: 1px solid var(--color-border);
1866
+ font-size: 13px;
1867
+ color: var(--color-text-maxcontrast);
1868
+ }
1869
+
1671
1870
  .cn-files-browser__bar {
1672
1871
  display: flex;
1673
1872
  align-items: center;
@@ -3,22 +3,41 @@
3
3
  - SPDX-License-Identifier: EUPL-1.2
4
4
  -->
5
5
  <template>
6
- <span class="cn-follow-toggle" data-testid="cn-follow-toggle">
6
+ <span class="cn-follow-toggle"
7
+ :class="{ 'cn-follow-toggle--compact': compact }"
8
+ data-testid="cn-follow-toggle">
7
9
  <NcButton
8
- :variant="state ? 'secondary' : 'tertiary'"
10
+ :variant="state && !compact ? 'secondary' : 'tertiary'"
9
11
  :aria-pressed="state ? 'true' : 'false'"
12
+ :aria-label="compact ? (state ? followingLabel : followLabel) : undefined"
10
13
  :title="tooltip"
11
14
  :disabled="busy"
12
15
  data-testid="cn-follow-toggle-button"
13
16
  @click.stop="toggle">
14
17
  <template #icon>
15
- <Bell v-if="state" :size="20" />
16
- <BellOutline v-else :size="20" />
18
+ <Eye v-if="state" :size="20" class="cn-follow-toggle__on" />
19
+ <EyeOutline v-else :size="20" />
20
+ </template>
21
+ <template v-if="!compact">
22
+ {{ state ? followingLabel : followLabel }}
23
+ </template>
24
+ </NcButton>
25
+ <NcButton
26
+ v-if="showNotifySwitch"
27
+ variant="tertiary"
28
+ :aria-pressed="notifyState ? 'true' : 'false'"
29
+ :aria-label="notifyLabel"
30
+ :title="notifyLabel"
31
+ :disabled="busy"
32
+ data-testid="cn-follow-toggle-notify"
33
+ @click.stop="toggleNotify">
34
+ <template #icon>
35
+ <Bell v-if="notifyState" :size="20" />
36
+ <BellOffOutline v-else :size="20" />
17
37
  </template>
18
- {{ state ? followingLabel : followLabel }}
19
38
  </NcButton>
20
39
  <NcPopover
21
- v-if="count !== null"
40
+ v-if="count !== null && !compact"
22
41
  v-model:shown="open"
23
42
  placement="bottom-start">
24
43
  <template #trigger>
@@ -82,15 +101,26 @@
82
101
  import { translate as t } from '@nextcloud/l10n'
83
102
  import { NcAvatar, NcButton, NcLoadingIcon, NcPopover, NcSelect } from '@nextcloud/vue'
84
103
  import Bell from 'vue-material-design-icons/Bell.vue'
85
- import BellOutline from 'vue-material-design-icons/BellOutline.vue'
104
+ import BellOffOutline from 'vue-material-design-icons/BellOffOutline.vue'
86
105
  import Close from 'vue-material-design-icons/Close.vue'
106
+ import Eye from 'vue-material-design-icons/Eye.vue'
107
+ import EyeOutline from 'vue-material-design-icons/EyeOutline.vue'
87
108
  import { patchStoredSelf } from '../../utils/patchStoredSelf.js'
88
109
  import { listWatchers, setWatcher, setWatching } from '../../utils/recordInteractions.js'
89
110
  import { searchNextcloudUsers } from '../../utils/userAutocomplete.js'
90
111
 
91
112
  /**
92
- * CnFollowToggle — a Follow toggle bound to a record's `@self.watching` marker,
93
- * with the follower count and a followers popover for users who may see them.
113
+ * CnFollowToggle — the one Follow control of a record: a Follow toggle bound to
114
+ * `@self.watching`, a notifications switch bound to `@self.watchNotify`, the
115
+ * follower count and a followers popover for users who may see them.
116
+ *
117
+ * Following and favourites are one feature (OpenRegister's
118
+ * `merge-follow-and-favourites`): a favourite is a follow with notifications
119
+ * off, so there is no separate star. While the user follows, a bell beside the
120
+ * toggle turns the follow's notifications on or off (`PUT .../watch` with
121
+ * `{"notify": bool}`). With `notifies` false (the register sends no change
122
+ * notifications) the bell is not offered. `compact` renders the toggle as an
123
+ * icon only, without the bell, count or popover, for a column in a list.
94
124
  *
95
125
  * A click flips the toggle at once and sends `PUT` or `DELETE .../watch`; on
96
126
  * failure it flips back and shows the server's message, and a 404 says the
@@ -105,13 +135,14 @@ import { searchNextcloudUsers } from '../../utils/userAutocomplete.js'
105
135
  * Example:
106
136
  * ```vue
107
137
  * <CnFollowToggle register="pipelinq" schema="ticket" :object-id="id"
108
- * :watching="self.watching" :watcher-count="self.watcherCount" :can-manage="self.can && self.can.manage" />
138
+ * :watching="self.watching" :notify="self.watchNotify"
139
+ * :watcher-count="self.watcherCount" :can-manage="self.can && self.can.manage" />
109
140
  * ```
110
141
  */
111
142
  export default {
112
143
  name: 'CnFollowToggle',
113
144
 
114
- components: { Bell, BellOutline, Close, NcAvatar, NcButton, NcLoadingIcon, NcPopover, NcSelect },
145
+ components: { Bell, BellOffOutline, Close, Eye, EyeOutline, NcAvatar, NcButton, NcLoadingIcon, NcPopover, NcSelect },
115
146
 
116
147
  props: {
117
148
  /** Register slug of the record. */
@@ -138,6 +169,21 @@ export default {
138
169
  default: false,
139
170
  },
140
171
 
172
+ /**
173
+ * Whether the user's follow notifies them (`@self.watchNotify`). Null
174
+ * (an older server, or not following) reads as on.
175
+ */
176
+ notify: {
177
+ type: Boolean,
178
+ default: null,
179
+ },
180
+
181
+ /** Icon only, without the bell, count or popover: for a column in a list. */
182
+ compact: {
183
+ type: Boolean,
184
+ default: false,
185
+ },
186
+
141
187
  /** Number of followers (`@self.watcherCount`). Null hides the count and the popover. */
142
188
  watcherCount: {
143
189
  type: Number,
@@ -168,6 +214,7 @@ export default {
168
214
  data() {
169
215
  return {
170
216
  state: this.watching,
217
+ notifyState: this.notify !== false,
171
218
  count: this.watcherCount,
172
219
  busy: false,
173
220
  open: false,
@@ -196,6 +243,21 @@ export default {
196
243
  return t('nextcloud-vue', 'Add a colleague')
197
244
  },
198
245
 
246
+ notifyLabel() {
247
+ return this.notifyState
248
+ ? t('nextcloud-vue', 'Turn notifications off')
249
+ : t('nextcloud-vue', 'Turn notifications on')
250
+ },
251
+
252
+ /**
253
+ * Whether the bell renders: following, not compact, and the register notifies.
254
+ *
255
+ * @return {boolean}
256
+ */
257
+ showNotifySwitch() {
258
+ return this.state && !this.compact && this.notifies
259
+ },
260
+
199
261
  followersLabel() {
200
262
  return t('nextcloud-vue', '{count} followers', { count: this.count })
201
263
  },
@@ -216,6 +278,10 @@ export default {
216
278
  this.state = value
217
279
  },
218
280
 
281
+ notify(value) {
282
+ this.notifyState = value !== false
283
+ },
284
+
219
285
  watcherCount(value) {
220
286
  this.count = value
221
287
  },
@@ -272,12 +338,47 @@ export default {
272
338
  }
273
339
  return
274
340
  }
341
+ // A new follow notifies (the server's default); an unfollow has no switch.
342
+ this.notifyState = this.state
275
343
  patchStoredSelf(this.register, this.schema, this.objectId, {
276
344
  watching: this.state,
345
+ favourite: this.state,
346
+ watchNotify: this.state ? true : undefined,
277
347
  ...(this.count !== null ? { watcherCount: this.count } : {}),
278
348
  })
279
- /** @event change Emitted after a successful call. Payload: `{ watching, count }`. */
280
- this.$emit('change', { watching: this.state, count: this.count })
349
+ /** @event change Emitted after a successful call. Payload: `{ watching, notify, count }`. */
350
+ this.$emit('change', { watching: this.state, notify: this.state ? this.notifyState : null, count: this.count })
351
+ },
352
+
353
+ /**
354
+ * Turn the follow's notifications on or off, optimistically.
355
+ *
356
+ * @return {Promise<void>}
357
+ * @spec openspec/changes/one-follow-control/specs/record-follow/spec.md#requirement-one-follow-control-with-a-notifications-switch
358
+ */
359
+ async toggleNotify() {
360
+ if (this.busy || !this.state) {
361
+ return
362
+ }
363
+ const previous = this.notifyState
364
+ this.notifyState = !previous
365
+ this.busy = true
366
+ const result = await setWatching(this.register, this.schema, this.objectId, true, { notify: this.notifyState })
367
+ this.busy = false
368
+ if (!result.ok) {
369
+ this.notifyState = previous
370
+ const message = result.status === 404
371
+ ? t('nextcloud-vue', 'You can no longer see this record.')
372
+ : (result.message || t('nextcloud-vue', 'Could not change notifications.'))
373
+ import('@nextcloud/dialogs').then(({ showError }) => showError(message)).catch(() => {})
374
+ this.$emit('error', message)
375
+ if (result.status === 404) {
376
+ this.$emit('not-found')
377
+ }
378
+ return
379
+ }
380
+ patchStoredSelf(this.register, this.schema, this.objectId, { watchNotify: this.notifyState })
381
+ this.$emit('change', { watching: true, notify: this.notifyState, count: this.count })
281
382
  },
282
383
 
283
384
  async loadWatchers() {
@@ -340,6 +441,10 @@ export default {
340
441
  gap: 4px;
341
442
  }
342
443
 
444
+ .cn-follow-toggle__on {
445
+ color: var(--color-primary-element);
446
+ }
447
+
343
448
  .cn-follow-toggle__popover {
344
449
  min-width: 260px;
345
450
  padding: 12px;
@@ -358,6 +358,8 @@ export default {
358
358
  | `includeColumns` | Array | `null` | Column keys to show (whitelist); `null` means all |
359
359
  | `columnOverrides` | Object | `{}` | Per-column config overrides in schema mode |
360
360
  | `emptyText` | String | `'No items found'` | Text shown in the empty state |
361
+ | `lenses` | Object \| null | `null` | The personal-lens report of the list on screen (`@self.lenses` of the response) for a host that fetches itself. In self-fetch mode the page reads it from the store. See [When a lens cannot answer](#when-a-lens-cannot-answer). |
362
+ | `lensReasonTexts` | Object \| null | `null` | App wording for an unavailable lens, keyed `<lens>.<reason>` or `<reason>`. |
361
363
  | `loadingText` | String | `'Loading…'` | Accessible label for the loading spinner (NcLoadingIcon aria-label) |
362
364
  | `rowIcon` | String \| Function | `null` | Optional leading icon for every table row — a static MDI name or `(row) => iconName`. Forwarded to CnDataTable; fed from the manifest as `pages[].config.rowIcon`. Unset = no icon column. |
363
365
  | `rowClass` | Function | `null` | Callback returning CSS class(es) for a row |
@@ -994,6 +996,40 @@ A person who has claimed no teams gets an empty list, and the tab says so
994
996
  through `narrowsToNothing`. A lens labelled "my teams" that quietly showed
995
997
  every team would be a label stating one rule while the fetch ran another.
996
998
 
999
+ ### When a lens cannot answer
1000
+
1001
+ OpenRegister reports, for every personal lens a list asked for, whether it
1002
+ could answer (openregister#4514, needed for any effect):
1003
+
1004
+ ```json
1005
+ { "results": [], "@self": { "lenses": { "recent": { "available": false, "reason": "audit-trail-disabled" } } } }
1006
+ ```
1007
+
1008
+ When a lens reports `available: false`, the empty state says why instead of
1009
+ showing `emptyText`:
1010
+
1011
+ | Reason | Text |
1012
+ |---|---|
1013
+ | `audit-trail-disabled` | This server does not keep track of what you open. |
1014
+ | `anonymous` | Log in to see what you opened recently. |
1015
+ | `read-history-unavailable` | Your recent items are not available right now. |
1016
+
1017
+ The texts are object-neutral and translated (Dutch: "Deze server houdt niet
1018
+ bij welke items je opent.", "Log in om te zien wat je onlangs opende.", "Je
1019
+ recente items zijn nu niet beschikbaar."). Say it in the app's own words with
1020
+ `lensReasonTexts`:
1021
+
1022
+ ```html
1023
+ <CnIndexPage :lens-reason-texts="{ 'recent.audit-trail-disabled': t('dossiq', 'This server does not keep track of which cases you open.') }" />
1024
+ ```
1025
+
1026
+ A response without `@self.lenses`, a lens that is available, or a reason with
1027
+ no text keeps `emptyText`, and an `#empty` slot still wins. The lens name is a
1028
+ key, so another lens (`watching`) that starts reporting needs only text. The
1029
+ store keeps the report as `lenses[type]` and `useListView` exposes it as
1030
+ `lenses`; `readLensReports` and `lensUnavailableText` are in
1031
+ `src/utils/lensAvailability.js`.
1032
+
997
1033
  ### Row actions and indicators
998
1034
 
999
1035
  See the two sections below. Row actions are the intersection of what the page