@conduction/nextcloud-vue 2.61.0 → 2.63.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 (186) hide show
  1. package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue.js +10 -0
  2. package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue.js.map +1 -0
  3. package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue2.js +189 -0
  4. package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue2.js.map +1 -0
  5. package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue3.js +72 -0
  6. package/dist/esm/components/CnAdminActionCard/CnAdminActionCard.vue3.js.map +1 -0
  7. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +394 -27
  8. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  9. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  10. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +99 -9
  11. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  12. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +39 -18
  13. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  14. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js +79 -2
  15. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js.map +1 -1
  16. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js +38 -25
  17. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js.map +1 -1
  18. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue2.js +21 -1
  19. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue2.js.map +1 -1
  20. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue3.js +4 -1
  21. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue3.js.map +1 -1
  22. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue2.js.map +1 -1
  23. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js +53 -37
  24. package/dist/esm/components/CnChoiceCards/CnChoiceCards.vue3.js.map +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +30 -6
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  27. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +2 -2
  28. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  29. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue.js +10 -0
  30. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue.js.map +1 -0
  31. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js +293 -0
  32. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue2.js.map +1 -0
  33. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js +189 -0
  34. package/dist/esm/components/CnDataTable/CnColumnFilterPopover.vue3.js.map +1 -0
  35. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +256 -23
  36. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  37. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +119 -51
  38. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  39. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  40. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +2 -1
  41. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  42. package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue2.js +3 -1
  43. package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue2.js.map +1 -1
  44. package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue3.js +1 -1
  45. package/dist/esm/components/CnFieldHelper/CnFieldHelper.vue3.js.map +1 -1
  46. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +234 -9
  47. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  48. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +96 -57
  49. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  50. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +51 -2
  51. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  52. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +5 -1
  53. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  54. package/dist/esm/components/CnIndexPage/selfModeActions.js +3 -3
  55. package/dist/esm/components/CnIndexPage/selfModeActions.js.map +1 -1
  56. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js.map +1 -1
  57. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js.map +1 -1
  58. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +36 -1
  59. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  60. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -1
  61. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  62. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +53 -0
  63. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  64. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +22 -5
  65. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  66. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js.map +1 -1
  67. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js.map +1 -1
  68. package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue2.js +121 -12
  69. package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue2.js.map +1 -1
  70. package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue3.js +3 -1
  71. package/dist/esm/components/CnResourceSelect/CnResourceSelect.vue3.js.map +1 -1
  72. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js +326 -1
  73. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  74. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +112 -26
  75. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  76. package/dist/esm/components/CnStorePage/CnStorePage.vue2.js.map +1 -1
  77. package/dist/esm/components/CnStorePage/CnStorePage.vue3.js.map +1 -1
  78. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue.js +10 -0
  79. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue.js.map +1 -0
  80. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js +337 -0
  81. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue2.js.map +1 -0
  82. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js +102 -0
  83. package/dist/esm/components/CnTimelineWidget/CnTimelineWidget.vue3.js.map +1 -0
  84. package/dist/esm/components/CnTimelineWidget/dashboardRegistration.js +34 -0
  85. package/dist/esm/components/CnTimelineWidget/dashboardRegistration.js.map +1 -0
  86. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js +131 -8
  87. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js.map +1 -1
  88. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js +40 -38
  89. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js.map +1 -1
  90. package/dist/esm/components/CnWidgetGrid/builtInWidgets.js +8 -6
  91. package/dist/esm/components/CnWidgetGrid/builtInWidgets.js.map +1 -1
  92. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  93. package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
  94. package/dist/esm/components/CnWikiPage/CnWikiPage.vue2.js.map +1 -1
  95. package/dist/esm/components/CnWikiPage/CnWikiPage.vue3.js.map +1 -1
  96. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js +19 -0
  97. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue2.js.map +1 -1
  98. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js +1 -1
  99. package/dist/esm/components/CnWizardDialog/CnWizardDialog.vue3.js.map +1 -1
  100. package/dist/esm/composables/useDependencyCheck.js +65 -0
  101. package/dist/esm/composables/useDependencyCheck.js.map +1 -0
  102. package/dist/esm/composables/useWalkthrough.js +218 -6
  103. package/dist/esm/composables/useWalkthrough.js.map +1 -1
  104. package/dist/esm/index.js +2 -0
  105. package/dist/esm/index.js.map +1 -1
  106. package/dist/esm/l10n/en.json.js +48 -0
  107. package/dist/esm/l10n/en.json.js.map +1 -1
  108. package/dist/esm/l10n/nl.json.js +48 -0
  109. package/dist/esm/l10n/nl.json.js.map +1 -1
  110. package/dist/esm/nextcloud-vue.css +557 -9
  111. package/dist/esm/store/useObjectStore.js +8 -0
  112. package/dist/esm/store/useObjectStore.js.map +1 -1
  113. package/dist/esm/utils/columnFilters.js +311 -0
  114. package/dist/esm/utils/columnFilters.js.map +1 -0
  115. package/dist/esm/utils/groupAutocomplete.js +123 -0
  116. package/dist/esm/utils/groupAutocomplete.js.map +1 -0
  117. package/dist/esm/utils/libraryWidgetKeys.js +2 -0
  118. package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
  119. package/dist/esm/utils/pickerOptions.js +198 -0
  120. package/dist/esm/utils/pickerOptions.js.map +1 -0
  121. package/dist/esm/utils/schema.js +83 -7
  122. package/dist/esm/utils/schema.js.map +1 -1
  123. package/dist/esm/utils/timelineEvents.js +174 -0
  124. package/dist/esm/utils/timelineEvents.js.map +1 -0
  125. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  126. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  127. package/dist/esm/utils/walkthroughSignals.js +88 -0
  128. package/dist/esm/utils/walkthroughSignals.js.map +1 -0
  129. package/dist/nextcloud-vue.cjs.js +9779 -5236
  130. package/dist/nextcloud-vue.cjs.js.map +2 -2
  131. package/dist/nextcloud-vue.css +557 -9
  132. package/l10n/en.json +48 -0
  133. package/l10n/nl.json +48 -0
  134. package/package.json +3 -2
  135. package/scripts/manifest-schema-hash.js +41 -0
  136. package/scripts/record-manifest-schema-hash.js +52 -0
  137. package/src/components/CnAdminActionCard/CnAdminActionCard.vue +234 -0
  138. package/src/components/CnAdminActionCard/index.js +4 -0
  139. package/src/components/CnAppNav/CnAppNav.vue +394 -27
  140. package/src/components/CnAppRoot/CnAppRoot.vue +129 -15
  141. package/src/components/CnBannerWidget/CnBannerWidget.vue +113 -2
  142. package/src/components/CnBrandStripe/CnBrandStripe.md +16 -0
  143. package/src/components/CnBrandStripe/CnBrandStripe.vue +58 -5
  144. package/src/components/CnChoiceCards/CnChoiceCards.vue +56 -31
  145. package/src/components/CnDashboardPage/CnDashboardPage.vue +32 -8
  146. package/src/components/CnDataTable/CnColumnFilterPopover.vue +524 -0
  147. package/src/components/CnDataTable/CnDataTable.md +4 -1
  148. package/src/components/CnDataTable/CnDataTable.vue +302 -21
  149. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +2 -2
  150. package/src/components/CnFieldHelper/CnFieldHelper.vue +4 -2
  151. package/src/components/CnFormDialog/CnFormDialog.vue +290 -31
  152. package/src/components/CnIndexPage/CnIndexPage.vue +55 -2
  153. package/src/components/CnIndexPage/selfModeActions.js +3 -3
  154. package/src/components/CnLinkCardsPage/CnLinkCardsPage.vue +35 -2
  155. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +39 -1
  156. package/src/components/CnObjectSidebar/CnFilesTab.vue +65 -0
  157. package/src/components/CnReportsPage/CnReportsPage.vue +7 -0
  158. package/src/components/CnResourceSelect/CnResourceSelect.vue +123 -12
  159. package/src/components/CnSetupWizard/CnSetupWizard.vue +392 -3
  160. package/src/components/CnStorePage/CnStorePage.vue +7 -0
  161. package/src/components/CnTimelineWidget/CnTimelineWidget.vue +477 -0
  162. package/src/components/CnTimelineWidget/dashboardRegistration.js +32 -0
  163. package/src/components/CnTimelineWidget/index.js +4 -0
  164. package/src/components/CnWalkthrough/CnWalkthrough.vue +143 -10
  165. package/src/components/CnWidgetGrid/builtInWidgets.js +2 -0
  166. package/src/components/CnWidgetGrid/registerDashboardWidgets.js +1 -0
  167. package/src/components/CnWikiPage/CnWikiPage.vue +7 -0
  168. package/src/components/CnWizardDialog/CnWizardDialog.vue +20 -1
  169. package/src/components/index.js +2 -0
  170. package/src/composables/useDependencyCheck.js +61 -0
  171. package/src/composables/useWalkthrough.js +217 -5
  172. package/src/css/dashboard.css +49 -0
  173. package/src/css/detail-page.css +19 -0
  174. package/src/css/table.css +82 -2
  175. package/src/index.js +2 -0
  176. package/src/public/index.js +5 -0
  177. package/src/schemas/app-manifest-v2.schema.json +23 -1
  178. package/src/store/useObjectStore.js +8 -0
  179. package/src/utils/columnFilters.js +324 -0
  180. package/src/utils/groupAutocomplete.js +119 -0
  181. package/src/utils/libraryWidgetKeys.js +2 -0
  182. package/src/utils/pickerOptions.js +194 -0
  183. package/src/utils/schema.js +83 -7
  184. package/src/utils/timelineEvents.js +171 -0
  185. package/src/utils/validateManifestV2.compiled.js +2 -2
  186. package/src/utils/walkthroughSignals.js +94 -0
@@ -282,9 +282,9 @@
282
282
  <NcEmptyContent
283
283
  :name="installAppLabel(missingRequiredApp(item))"
284
284
  :description="t('nextcloud-vue', 'This widget shows data from another app that isn\'t installed yet.')"
285
- class="cn-dashboard-page__requires-app">
285
+ class="cn-dashboard-page__requires-app cn-requires-app">
286
286
  <template #icon>
287
- <Download :size="32" />
287
+ <Download :size="24" />
288
288
  </template>
289
289
  <template #action>
290
290
  <NcButton
@@ -2752,8 +2752,12 @@ export default {
2752
2752
  * which for an attention card is its `title` when it has no `text`.
2753
2753
  *
2754
2754
  * @param {object} def Widget definition.
2755
+ * `layout` is returned so the page can tell an attention card from a
2756
+ * plain banner: only the former keeps its cell when its check fails.
2757
+ *
2755
2758
  * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-an-attention-card-with-a-title-keeps-its-cell
2756
- * @return {{ text: string, visibleWhen: (object|null) }}
2759
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-an-attention-card-says-when-it-could-not-check
2760
+ * @return {{ text: string, visibleWhen: (object|null), layout: string }}
2757
2761
  */
2758
2762
  widgetDisplayConfig(def) {
2759
2763
  const content = (def && typeof def.content === 'object' && def.content) || {}
@@ -2767,6 +2771,7 @@ export default {
2767
2771
  return {
2768
2772
  text: content.text || props.text || title || '',
2769
2773
  visibleWhen: (def && def.visibleWhen) || content.visibleWhen || props.visibleWhen || null,
2774
+ layout,
2770
2775
  }
2771
2776
  },
2772
2777
 
@@ -2776,15 +2781,22 @@ export default {
2776
2781
  * text at all — and must therefore surrender its grid cell in live
2777
2782
  * mode instead of leaving an empty card behind.
2778
2783
  *
2784
+ * One exception: an attention card whose `visibleWhen` request FAILED
2785
+ * keeps its cell, so the card can say it could not check. A collapsed
2786
+ * cell reads as "nothing needs attention", which a failed count does
2787
+ * not know. A count that was read and does not meet the condition
2788
+ * still collapses.
2789
+ *
2779
2790
  * @param {object} item Layout item.
2780
2791
  * @return {boolean} true when the cell must collapse.
2792
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-an-attention-card-says-when-it-could-not-check
2781
2793
  */
2782
2794
  isCollapsedWidget(item) {
2783
2795
  const def = this.getWidgetDef(item.widgetId)
2784
2796
  if (!def || !def.type) {
2785
2797
  return false
2786
2798
  }
2787
- const { text, visibleWhen } = this.widgetDisplayConfig(def)
2799
+ const { text, visibleWhen, layout } = this.widgetDisplayConfig(def)
2788
2800
  if (this.isBannerDef(def) && text === '') {
2789
2801
  return true
2790
2802
  }
@@ -2792,6 +2804,9 @@ export default {
2792
2804
  return false
2793
2805
  }
2794
2806
  const outcome = this.widgetConditionOutcome[item.widgetId]
2807
+ if (outcome && outcome.failed === true && layout === 'attention' && this.isBannerDef(def)) {
2808
+ return false
2809
+ }
2795
2810
  return !outcome || outcome.met !== true
2796
2811
  },
2797
2812
 
@@ -2802,13 +2817,17 @@ export default {
2802
2817
  * Runs on created() and again whenever `widgets` changes (the
2803
2818
  * in-app editor mutates defs in place). Fail-safe like the banner
2804
2819
  * itself: any fetch/shape error counts as "not met", so a broken
2805
- * predicate collapses the cell rather than breaking the page.
2820
+ * predicate collapses the cell rather than breaking the page. The
2821
+ * outcome of a failed read carries `failed: true` and the `reason`,
2822
+ * which `isCollapsedWidget` and CnBannerWidget use to show an
2823
+ * attention card's "Could not check" line instead of nothing.
2806
2824
  *
2807
2825
  * Flips `widgetConditionsSettled` when the initial round is done —
2808
2826
  * synchronously when there is nothing to evaluate, so a dashboard
2809
2827
  * without conditional widgets never waits.
2810
2828
  *
2811
2829
  * @return {Promise<void>}
2830
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-an-attention-card-says-when-it-could-not-check
2812
2831
  */
2813
2832
  async evaluateWidgetConditions() {
2814
2833
  const conditional = (this.widgets || []).filter((def) => def && def.id && def.type
@@ -2830,12 +2849,17 @@ export default {
2830
2849
  }
2831
2850
  await Promise.all(conditional.map(async (def) => {
2832
2851
  const cond = this.widgetDisplayConfig(def).visibleWhen
2833
- let outcome = { met: false, value: null }
2852
+ let outcome
2834
2853
  try {
2835
2854
  const value = await readVisibleWhenValue(cond)
2836
2855
  outcome = { met: compareVisibleWhen(value, cond.op || 'eq', cond.value), value }
2837
- } catch {
2838
- // fail-safe: hidden
2856
+ } catch (error) {
2857
+ // Fail-safe: not met. But the failure is kept, and said
2858
+ // out loud: a failed count and "nothing to report" look
2859
+ // the same on screen.
2860
+ outcome = { met: false, value: null, failed: true, reason: typeof error?.message === 'string' ? error.message : String(error ?? '') }
2861
+ // eslint-disable-next-line no-console
2862
+ console.warn(`[CnDashboardPage] visibleWhen of widget "${def.id}" could not be evaluated:`, error?.message || error)
2839
2863
  }
2840
2864
  // A newer run owns the map now — a stale verdict (possibly for
2841
2865
  // a def that no longer exists) must not overwrite its writes.
@@ -0,0 +1,524 @@
1
+ <!-- SPDX-License-Identifier: EUPL-1.2 -->
2
+ <!-- SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl> -->
3
+ <template>
4
+ <Teleport to="body" :disabled="!teleport">
5
+ <div
6
+ ref="panel"
7
+ class="cn-column-filter"
8
+ role="dialog"
9
+ :aria-labelledby="headingId"
10
+ :style="panelStyle"
11
+ data-testid="cn-column-filter"
12
+ @keydown.esc.stop.prevent="close"
13
+ @keydown.enter="onEnter">
14
+ <p :id="headingId" class="cn-column-filter__heading">
15
+ {{ headingText }}
16
+ </p>
17
+
18
+ <!-- enum and reference: a checkbox list of real inputs -->
19
+ <template v-if="def.kind === 'enum' || def.kind === 'reference'">
20
+ <label v-if="def.kind === 'reference'" class="cn-column-filter__field">
21
+ <span class="cn-column-filter__label">{{ searchLabel }}</span>
22
+ <input
23
+ v-model="query"
24
+ class="cn-column-filter__input"
25
+ type="search"
26
+ data-testid="cn-column-filter-search"
27
+ @input="onSearchInput">
28
+ </label>
29
+ <p v-if="def.kind === 'reference' && referenceLoading" class="cn-column-filter__hint" role="status">
30
+ {{ loadingLabel }}
31
+ </p>
32
+ <p v-else-if="def.kind === 'reference' && referenceError" class="cn-column-filter__hint" role="status">
33
+ {{ referenceError }}
34
+ </p>
35
+ <fieldset class="cn-column-filter__options">
36
+ <legend class="cn-column-filter__sr-only">
37
+ {{ headingText }}
38
+ </legend>
39
+ <label
40
+ v-for="option in listOptions"
41
+ :key="option.value"
42
+ class="cn-column-filter__option">
43
+ <input
44
+ type="checkbox"
45
+ :value="option.value"
46
+ :checked="draft.values.includes(option.value)"
47
+ data-testid="cn-column-filter-option"
48
+ @change="toggleValue(option.value)">
49
+ <span>{{ option.label }}</span>
50
+ </label>
51
+ <p v-if="listOptions.length === 0 && !referenceLoading" class="cn-column-filter__hint">
52
+ {{ noOptionsLabel }}
53
+ </p>
54
+ </fieldset>
55
+ </template>
56
+
57
+ <!-- boolean: yes, no, any -->
58
+ <fieldset v-else-if="def.kind === 'boolean'" class="cn-column-filter__options">
59
+ <legend class="cn-column-filter__sr-only">
60
+ {{ headingText }}
61
+ </legend>
62
+ <label v-for="option in booleanOptions" :key="option.value" class="cn-column-filter__option">
63
+ <input
64
+ type="radio"
65
+ :name="headingId + '-bool'"
66
+ :value="option.value"
67
+ :checked="draft.value === option.value"
68
+ data-testid="cn-column-filter-option"
69
+ @change="draft.value = option.value">
70
+ <span>{{ option.label }}</span>
71
+ </label>
72
+ </fieldset>
73
+
74
+ <!-- number and date: from and to -->
75
+ <div v-else-if="def.kind === 'number' || def.kind === 'date'" class="cn-column-filter__range">
76
+ <label class="cn-column-filter__field">
77
+ <span class="cn-column-filter__label">{{ fromLabel }}</span>
78
+ <input
79
+ v-model="draft.from"
80
+ class="cn-column-filter__input"
81
+ :type="def.kind === 'date' ? 'date' : 'number'"
82
+ data-testid="cn-column-filter-from">
83
+ </label>
84
+ <label class="cn-column-filter__field">
85
+ <span class="cn-column-filter__label">{{ toLabel }}</span>
86
+ <input
87
+ v-model="draft.to"
88
+ class="cn-column-filter__input"
89
+ :type="def.kind === 'date' ? 'date' : 'number'"
90
+ data-testid="cn-column-filter-to">
91
+ </label>
92
+ </div>
93
+
94
+ <!-- string: equals -->
95
+ <label v-else class="cn-column-filter__field">
96
+ <span class="cn-column-filter__label">{{ equalsLabel }}</span>
97
+ <input
98
+ v-model="draft.value"
99
+ class="cn-column-filter__input"
100
+ type="text"
101
+ data-testid="cn-column-filter-text">
102
+ </label>
103
+
104
+ <div class="cn-column-filter__actions">
105
+ <button
106
+ type="button"
107
+ class="cn-column-filter__button"
108
+ data-testid="cn-column-filter-clear"
109
+ @click="clear">
110
+ {{ clearLabel }}
111
+ </button>
112
+ <button
113
+ type="button"
114
+ class="cn-column-filter__button cn-column-filter__button--primary"
115
+ data-testid="cn-column-filter-apply"
116
+ @click="apply">
117
+ {{ applyLabel }}
118
+ </button>
119
+ </div>
120
+ </div>
121
+ </Teleport>
122
+ </template>
123
+
124
+ <script>
125
+ import { translate as t } from '@nextcloud/l10n'
126
+
127
+ let popoverSeq = 0
128
+
129
+ /**
130
+ * CnColumnFilterPopover — the small panel a table header's filter button opens.
131
+ *
132
+ * Renders the control that fits the column: a checkbox list for an enum, yes,
133
+ * no or any for a boolean, from and to for a number or a date, a searchable
134
+ * list of referenced objects for a reference, and an equals box for text.
135
+ * Real inputs throughout, so every control works from the keyboard. Escape
136
+ * closes it and the host returns focus to the filter button.
137
+ *
138
+ * Internal to CnDataTable. It emits a state object; CnDataTable turns that
139
+ * into query parameters with `columnFilterParams`.
140
+ *
141
+ * @spec openspec/changes/table-header-sort-and-filter/specs/cn-data-table/spec.md#requirement-every-backed-column-filters-from-its-header
142
+ */
143
+ export default {
144
+ name: 'CnColumnFilterPopover',
145
+
146
+ props: {
147
+ /** The filter definition from `columnFilterDef`. */
148
+ def: {
149
+ type: Object,
150
+ required: true,
151
+ },
152
+
153
+ /** The column's current state from `columnFilterState`. */
154
+ state: {
155
+ type: Object,
156
+ default: () => ({}),
157
+ },
158
+
159
+ /** The translated column label. */
160
+ label: {
161
+ type: String,
162
+ default: '',
163
+ },
164
+
165
+ /** The filter button's bounding rect, to place the panel under it. */
166
+ anchorRect: {
167
+ type: Object,
168
+ default: null,
169
+ },
170
+
171
+ /** Register slug a reference column's objects live in, when the column names none. */
172
+ register: {
173
+ type: String,
174
+ default: '',
175
+ },
176
+
177
+ /** Render into `<body>`, so a scrolling table cannot clip the panel. */
178
+ teleport: {
179
+ type: Boolean,
180
+ default: true,
181
+ },
182
+ },
183
+
184
+ emits: ['apply', 'close'],
185
+
186
+ data() {
187
+ popoverSeq += 1
188
+ const s = this.state || {}
189
+ return {
190
+ headingId: 'cn-column-filter-' + popoverSeq,
191
+ draft: {
192
+ values: Array.isArray(s.values) ? [...s.values] : [],
193
+ value: s.value !== undefined && s.value !== null ? String(s.value) : '',
194
+ from: s.from || '',
195
+ to: s.to || '',
196
+ },
197
+
198
+ query: '',
199
+ referenceOptions: [],
200
+ referenceLabels: {},
201
+ referenceLoading: false,
202
+ referenceError: '',
203
+ searchTimer: null,
204
+ }
205
+ },
206
+
207
+ computed: {
208
+ headingText() {
209
+ return t('nextcloud-vue', 'Filter {column}', { column: this.label })
210
+ },
211
+
212
+ /**
213
+ * The options a list filter shows. For a reference, the search results
214
+ * plus any picked object that is not among them, so a selection never
215
+ * disappears from view.
216
+ *
217
+ * @return {Array<{value: string, label: string}>}
218
+ */
219
+ listOptions() {
220
+ if (this.def.kind !== 'reference') {
221
+ return this.def.options || []
222
+ }
223
+ const shown = new Set(this.referenceOptions.map((o) => o.value))
224
+ const picked = this.draft.values
225
+ .filter((v) => !shown.has(v))
226
+ .map((v) => ({ value: v, label: this.referenceLabels[v] || v }))
227
+ return [...picked, ...this.referenceOptions]
228
+ },
229
+
230
+ booleanOptions() {
231
+ return [
232
+ { value: '', label: t('nextcloud-vue', 'Any') },
233
+ { value: 'true', label: t('nextcloud-vue', 'Yes') },
234
+ { value: 'false', label: t('nextcloud-vue', 'No') },
235
+ ]
236
+ },
237
+
238
+ panelStyle() {
239
+ if (!this.teleport || !this.anchorRect) {
240
+ return {}
241
+ }
242
+ const width = 280
243
+ const viewport = typeof window !== 'undefined' ? window.innerWidth : 1024
244
+ const left = Math.max(8, Math.min(this.anchorRect.left, viewport - width - 8))
245
+ return { position: 'fixed', top: `${this.anchorRect.bottom + 4}px`, left: `${left}px`, width: `${width}px` }
246
+ },
247
+
248
+ searchLabel() {
249
+ return t('nextcloud-vue', 'Search')
250
+ },
251
+
252
+ loadingLabel() {
253
+ return t('nextcloud-vue', 'Loading…')
254
+ },
255
+
256
+ noOptionsLabel() {
257
+ return t('nextcloud-vue', 'Nothing to choose from here.')
258
+ },
259
+
260
+ fromLabel() {
261
+ return t('nextcloud-vue', 'From')
262
+ },
263
+
264
+ toLabel() {
265
+ return t('nextcloud-vue', 'To')
266
+ },
267
+
268
+ equalsLabel() {
269
+ return t('nextcloud-vue', 'Equals')
270
+ },
271
+
272
+ clearLabel() {
273
+ return t('nextcloud-vue', 'Clear')
274
+ },
275
+
276
+ applyLabel() {
277
+ return t('nextcloud-vue', 'Apply')
278
+ },
279
+ },
280
+
281
+ mounted() {
282
+ document.addEventListener('mousedown', this.onOutsideMouseDown, true)
283
+ if (this.def.kind === 'reference') {
284
+ this.loadReferenceOptions('')
285
+ }
286
+ this.$nextTick(() => {
287
+ const first = this.$refs.panel && this.$refs.panel.querySelector('input, button')
288
+ if (first && typeof first.focus === 'function') {
289
+ first.focus()
290
+ }
291
+ })
292
+ },
293
+
294
+ beforeUnmount() {
295
+ document.removeEventListener('mousedown', this.onOutsideMouseDown, true)
296
+ clearTimeout(this.searchTimer)
297
+ },
298
+
299
+ methods: {
300
+ toggleValue(value) {
301
+ this.draft.values = this.draft.values.includes(value)
302
+ ? this.draft.values.filter((v) => v !== value)
303
+ : [...this.draft.values, value]
304
+ },
305
+
306
+ /**
307
+ * The state this panel applies, in the shape `columnFilterParams` reads.
308
+ *
309
+ * @return {object}
310
+ */
311
+ draftState() {
312
+ if (this.def.kind === 'enum' || this.def.kind === 'reference') {
313
+ return { values: [...this.draft.values] }
314
+ }
315
+ if (this.def.kind === 'number' || this.def.kind === 'date') {
316
+ return { from: this.draft.from, to: this.draft.to }
317
+ }
318
+ return { value: this.draft.value }
319
+ },
320
+
321
+ apply() {
322
+ /**
323
+ * @event apply The person applied the filter.
324
+ * @type {object} The column filter state.
325
+ */
326
+ this.$emit('apply', this.draftState())
327
+ },
328
+
329
+ clear() {
330
+ this.$emit('apply', this.def.kind === 'enum' || this.def.kind === 'reference'
331
+ ? { values: [] }
332
+ : (this.def.kind === 'number' || this.def.kind === 'date') ? { from: '', to: '' } : { value: '' })
333
+ },
334
+
335
+ close() {
336
+ /**
337
+ * @event close The panel should close without applying.
338
+ */
339
+ this.$emit('close')
340
+ },
341
+
342
+ onEnter(event) {
343
+ // Enter in a text, number or date box applies, like a form submit.
344
+ // On a checkbox or a button it keeps its own meaning.
345
+ const target = event && event.target
346
+ if (target && target.tagName === 'INPUT' && ['text', 'number', 'date', 'search'].includes(target.type)) {
347
+ event.preventDefault()
348
+ this.apply()
349
+ }
350
+ },
351
+
352
+ onOutsideMouseDown(event) {
353
+ const panel = this.$refs.panel
354
+ if (panel && event && event.target && !panel.contains(event.target)) {
355
+ this.close()
356
+ }
357
+ },
358
+
359
+ onSearchInput() {
360
+ clearTimeout(this.searchTimer)
361
+ this.searchTimer = setTimeout(() => this.loadReferenceOptions(this.query), 250)
362
+ },
363
+
364
+ /**
365
+ * Search the referenced objects. The value is the object's uuid,
366
+ * because that is what the server filters a reference on.
367
+ *
368
+ * @param {string} search The search term.
369
+ * @return {Promise<void>}
370
+ */
371
+ async loadReferenceOptions(search) {
372
+ const ref = this.def.reference || {}
373
+ const register = ref.register || this.register
374
+ if (!register || !ref.schema) {
375
+ this.referenceError = t('nextcloud-vue', 'This column cannot list its objects.')
376
+ return
377
+ }
378
+ this.referenceLoading = true
379
+ this.referenceError = ''
380
+ try {
381
+ const [{ default: axios }, { generateUrl }] = await Promise.all([
382
+ import('@nextcloud/axios'),
383
+ import('@nextcloud/router'),
384
+ ])
385
+ const url = generateUrl('/apps/openregister/api/objects/{register}/{schema}', {
386
+ register,
387
+ schema: String(ref.schema),
388
+ })
389
+ const params = { _limit: 50 }
390
+ if (search) {
391
+ params._search = search
392
+ }
393
+ const response = await axios.get(url, { params })
394
+ const rows = (response && response.data && (response.data.results || response.data)) || []
395
+ const list = Array.isArray(rows) ? rows : []
396
+ const options = list
397
+ .map((row) => {
398
+ const self = (row && row['@self']) || {}
399
+ const value = String(self.id || self.uuid || row.id || row.uuid || '')
400
+ const label = String((ref.labelField && row[ref.labelField]) || row.name || row.title || self.name || value)
401
+ return { value, label }
402
+ })
403
+ .filter((o) => o.value !== '')
404
+ this.referenceOptions = options
405
+ this.referenceLabels = { ...this.referenceLabels, ...Object.fromEntries(options.map((o) => [o.value, o.label])) }
406
+ } catch {
407
+ this.referenceOptions = []
408
+ this.referenceError = t('nextcloud-vue', 'The list could not load.')
409
+ } finally {
410
+ this.referenceLoading = false
411
+ }
412
+ },
413
+ },
414
+ }
415
+ </script>
416
+
417
+ <style scoped>
418
+ .cn-column-filter {
419
+ z-index: 10000;
420
+ display: flex;
421
+ flex-direction: column;
422
+ gap: 8px;
423
+ box-sizing: border-box;
424
+ max-height: 60vh;
425
+ overflow-y: auto;
426
+ padding: 12px;
427
+ background: var(--color-main-background);
428
+ color: var(--color-main-text);
429
+ border: 1px solid var(--color-border-dark);
430
+ border-radius: var(--border-radius-large, 8px);
431
+ box-shadow: 0 2px 8px var(--color-box-shadow);
432
+ font-weight: normal;
433
+ text-align: start;
434
+ }
435
+
436
+ .cn-column-filter__heading {
437
+ margin: 0;
438
+ font-weight: bold;
439
+ }
440
+
441
+ .cn-column-filter__options {
442
+ display: flex;
443
+ flex-direction: column;
444
+ gap: 4px;
445
+ margin: 0;
446
+ padding: 0;
447
+ border: none;
448
+ }
449
+
450
+ .cn-column-filter__option {
451
+ display: flex;
452
+ align-items: center;
453
+ gap: 8px;
454
+ min-height: 32px;
455
+ cursor: pointer;
456
+ }
457
+
458
+ .cn-column-filter__range {
459
+ display: flex;
460
+ gap: 8px;
461
+ }
462
+
463
+ .cn-column-filter__field {
464
+ display: flex;
465
+ flex: 1 1 0;
466
+ flex-direction: column;
467
+ gap: 4px;
468
+ min-width: 0;
469
+ }
470
+
471
+ .cn-column-filter__label {
472
+ font-size: var(--font-size-small, 13px);
473
+ color: var(--color-text-maxcontrast);
474
+ }
475
+
476
+ .cn-column-filter__input {
477
+ width: 100%;
478
+ box-sizing: border-box;
479
+ margin: 0;
480
+ }
481
+
482
+ .cn-column-filter__hint {
483
+ margin: 0;
484
+ color: var(--color-text-maxcontrast);
485
+ }
486
+
487
+ .cn-column-filter__actions {
488
+ display: flex;
489
+ justify-content: flex-end;
490
+ gap: 8px;
491
+ }
492
+
493
+ .cn-column-filter__button {
494
+ min-height: 34px;
495
+ padding: 0 12px;
496
+ border: 1px solid var(--color-border-dark);
497
+ border-radius: var(--border-radius-element, 8px);
498
+ background: var(--color-main-background);
499
+ color: var(--color-main-text);
500
+ cursor: pointer;
501
+ }
502
+
503
+ .cn-column-filter__button--primary {
504
+ border-color: var(--color-primary-element);
505
+ background: var(--color-primary-element);
506
+ color: var(--color-primary-element-text);
507
+ }
508
+
509
+ .cn-column-filter__button:focus-visible,
510
+ .cn-column-filter__input:focus-visible,
511
+ .cn-column-filter__option input:focus-visible {
512
+ outline: 2px solid var(--color-primary-element);
513
+ outline-offset: 2px;
514
+ }
515
+
516
+ .cn-column-filter__sr-only {
517
+ position: absolute;
518
+ width: 1px;
519
+ height: 1px;
520
+ overflow: hidden;
521
+ clip-path: inset(50%);
522
+ white-space: nowrap;
523
+ }
524
+ </style>
@@ -186,7 +186,7 @@ export default {
186
186
  | `includeColumns` | Array | `null` | Column keys to include (whitelist) in schema mode |
187
187
  | `sortKey` | String | `null` | Currently active sort column key (controlled) |
188
188
  | `sortOrder` | String | `'asc'` | Current sort direction: `'asc'` or `'desc'` |
189
- | `sortKeys` | Array | `[]` | Ordered multi-column ("shift+click") sort key list, `[{ key, order }, …]` (0–3 entries); takes precedence over `sortKey`/`sortOrder` when non-empty |
189
+ | `sortKeys` | Array | `[]` | Ordered multi-column ("shift+click") sort key list, `[{ key, order }, …]` (0–3 entries); takes precedence over `sortKey`/`sortOrder` when non-empty. Priority badges show only when two or more rendered, sortable columns are sorted; a key whose column is not rendered or not sortable (such as a `_uuid` tie-break) still sorts, but is not counted or numbered |
190
190
  | `selectedIds` | Array | `[]` | Array of selected row IDs (controlled) |
191
191
  | `rowClass` | Function | `null` | `(row) => string` — CSS class(es) applied to each `<tr>` |
192
192
  | `rowClickToView` | Boolean | `false` | Emit `row-click` on a row-body click even while `selectable` (selection then via the checkbox column only) |
@@ -201,6 +201,9 @@ export default {
201
201
  | `hideHeader` | Boolean | `false` | Hide the column-header row (`<thead>`) — for compact dashboard list widgets that want a plain bordered-row list without column labels |
202
202
  | `fixedLayout` | Boolean | `false` | Switch to `table-layout: fixed` so each column's `width` is authoritative rather than a hint the browser may override from cell content. Long unbreakable values (a PHP FQCN, a UUID) then wrap inside their cell instead of widening the column or painting past it, and no single unsized column soaks up all remaining width. Size every column when using it — percentages summing to 100 are easiest to reason about |
203
203
  | `fillHeight` | Boolean | `false` | Fill the parent's height (a flex-column card / widget content area) so an optional `#footer` is pushed to the bottom instead of floating under a short list; the footer stays pinned via its sticky rule when the list overflows. No-op outside a height-constrained parent — opt-in |
204
+ | `filterable` | Boolean | `false` | Show a filter button in every header whose column can filter: enum, boolean, text, number, date and reference columns backed by a schema property. A column opts out with `filterable: false`. Applying a filter emits `column-filter`; the host owns the state and passes it back as `activeFilters`. `CnIndexPage` turns it on by default. |
205
+ | `activeFilters` | Object | `{}` | The active filter map, `{ paramKey: values[] }`: the same map the facet sidebar writes, so a header filter and a sidebar filter on one field show the same state. |
206
+ | `filterRegister` | String | `''` | Register slug a reference column's filter searches in when the column names none. |
204
207
 
205
208
  ## Slots
206
209