@conduction/nextcloud-vue 2.63.0 → 2.64.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 (100) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +154 -7
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +119 -42
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +87 -0
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +26 -24
  8. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  9. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +61 -16
  10. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
  11. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
  12. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js +20 -0
  13. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js.map +1 -1
  14. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js +3 -2
  15. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js.map +1 -1
  16. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -2
  17. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  18. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +29 -0
  20. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +150 -133
  22. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +37 -1
  24. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  25. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +12 -4
  26. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  27. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +66 -14
  28. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  29. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +23 -14
  30. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  31. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +81 -4
  32. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  33. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +22 -5
  34. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  35. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +30 -0
  36. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  37. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  38. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  39. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +5 -1
  40. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  41. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -2
  42. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  43. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +63 -2
  44. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  45. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +96 -37
  46. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  47. package/dist/esm/components/CnStagesWidget/stagesModel.js +5 -2
  48. package/dist/esm/components/CnStagesWidget/stagesModel.js.map +1 -1
  49. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +52 -2
  50. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  51. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +6 -6
  52. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  53. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +14 -2
  54. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  55. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +2 -1
  56. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  57. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +73 -1
  58. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  59. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +14 -2
  60. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  61. package/dist/esm/l10n/en.json.js +2 -1
  62. package/dist/esm/l10n/en.json.js.map +1 -1
  63. package/dist/esm/l10n/nl.json.js +2 -1
  64. package/dist/esm/l10n/nl.json.js.map +1 -1
  65. package/dist/esm/nextcloud-vue.css +331 -81
  66. package/dist/esm/utils/schemaRefSlug.js +19 -3
  67. package/dist/esm/utils/schemaRefSlug.js.map +1 -1
  68. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  69. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  70. package/dist/nextcloud-vue.cjs.js +6933 -5958
  71. package/dist/nextcloud-vue.cjs.js.map +2 -2
  72. package/dist/nextcloud-vue.css +331 -81
  73. package/l10n/en.json +2 -0
  74. package/l10n/nl.json +2 -0
  75. package/package.json +1 -1
  76. package/src/components/CnAppNav/CnAppNav.vue +297 -11
  77. package/src/components/CnAppRoot/CnAppRoot.md +4 -0
  78. package/src/components/CnAppRoot/CnAppRoot.vue +94 -1
  79. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +61 -16
  80. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +22 -1
  81. package/src/components/CnCellRenderer/CnCellRenderer.vue +33 -2
  82. package/src/components/CnDashboardPage/CnDashboardPage.vue +43 -2
  83. package/src/components/CnDataTable/CnDataTable.vue +45 -1
  84. package/src/components/CnDetailPage/CnDetailPage.md +4 -0
  85. package/src/components/CnDetailPage/CnDetailPage.vue +60 -1
  86. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +116 -6
  87. package/src/components/CnIndexPage/CnIndexPage.vue +32 -2
  88. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +7 -1
  89. package/src/components/CnStagesWidget/CnStagesWidget.vue +180 -2
  90. package/src/components/CnStagesWidget/stagesModel.js +5 -2
  91. package/src/components/CnStatWidget/CnStatWidget.vue +57 -3
  92. package/src/components/CnTabsWidget/CnTabsWidget.vue +15 -2
  93. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +4 -0
  94. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +107 -1
  95. package/src/css/detail-page.css +10 -4
  96. package/src/css/kpi-card.css +24 -1
  97. package/src/css/table.css +18 -6
  98. package/src/schemas/app-manifest-v2.schema.json +113 -3
  99. package/src/utils/schemaRefSlug.js +19 -3
  100. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -92,8 +92,23 @@
92
92
  <!-- Decorative beside a name: the name already says whose
93
93
  app this is, and an alt text would say it twice. A logo
94
94
  on its own carries the name as its alt text. -->
95
+ <!-- An emblem (`nav.brand.emblem`) stands in for the logo:
96
+ the municipality's mark beside the app name, distinct
97
+ from the wordmark in the top bar. `true` draws the
98
+ theme's emblem from --nldesign-emblem-url. -->
95
99
  <img
96
- v-if="resolvedBrand.logo"
100
+ v-if="typeof resolvedBrand.emblem === 'string' && resolvedBrand.emblem !== ''"
101
+ class="cn-app-nav__brand-emblem"
102
+ data-testid="cn-nav-brand-emblem"
103
+ :src="resolvedBrand.emblem"
104
+ :alt="resolvedBrand.name ? '' : resolvedBrand.alt">
105
+ <span
106
+ v-else-if="resolvedBrand.emblem === true"
107
+ class="cn-app-nav__brand-emblem cn-app-nav__brand-emblem--theme"
108
+ data-testid="cn-nav-brand-emblem"
109
+ aria-hidden="true" />
110
+ <img
111
+ v-else-if="resolvedBrand.logo"
97
112
  class="cn-app-nav__brand-logo"
98
113
  :src="resolvedBrand.logo"
99
114
  :alt="resolvedBrand.name ? '' : resolvedBrand.alt">
@@ -118,17 +133,33 @@
118
133
  OR nav root). Omitted entirely when neither the slot nor any
119
134
  resolvable primaryAction is present. -->
120
135
  <slot name="primary-action">
136
+ <!-- A primary action that carries an `action` runs a page action
137
+ (open-form opens the create dialog, navigate pushes a route)
138
+ through CnActionButtons, so "New case" can live in the
139
+ navigation without a create page. Drawn as one solid,
140
+ full-width primary button. -->
141
+ <div
142
+ v-if="activePrimaryAction && primaryDispatchEntry"
143
+ class="app-navigation-new cn-app-nav__primary-action cn-app-nav__primary-action--solid"
144
+ data-testid="cn-nav-primary-action">
145
+ <CnActionButtons
146
+ :actions="[primaryDispatchEntry]"
147
+ data-testid="cn-nav-primary-action-dispatch"
148
+ @created="onPrimaryActionCreated" />
149
+ </div>
121
150
  <!-- A primary action with an href or route is a real link.
122
151
  NcAppNavigationNew cannot render one, so this mirrors its
123
- wrapper and button. -->
152
+ wrapper and button. `solid: true` takes this same solid,
153
+ full-width button for an action with neither. -->
124
154
  <div
125
- v-if="activePrimaryAction && primaryActionLink"
155
+ v-else-if="activePrimaryAction && (primaryActionLink || activePrimaryAction.solid === true)"
126
156
  class="app-navigation-new cn-app-nav__primary-action"
157
+ :class="{ 'cn-app-nav__primary-action--solid': activePrimaryAction.solid === true }"
127
158
  data-testid="cn-nav-primary-action">
128
159
  <NcButton
129
160
  variant="primary"
130
161
  wide
131
- v-bind="primaryActionLink"
162
+ v-bind="primaryActionLink || {}"
132
163
  @click="onPrimaryActionClick">
133
164
  <template #icon>
134
165
  <component :is="primaryActionIconComponent" :size="20" />
@@ -215,7 +246,32 @@
215
246
  </NcAppNavigationItem>
216
247
  </template>
217
248
  </template>
218
- <template v-if="footerItems.length > 0 || showSettingsFoldout" #footer>
249
+ <template v-if="footerItems.length > 0 || showSettingsFoldout || resolvedCard || resolvedHelp" #footer>
250
+ <!--
251
+ @slot card
252
+ @description Replace the card above the footer entries
253
+ (`nav.card`). Default content: a title, a line of text and one
254
+ link. Renders nothing when neither the slot nor `nav.card` is set.
255
+ @binding {object|null} card The resolved card, or null.
256
+ -->
257
+ <slot name="card" :card="resolvedCard">
258
+ <div
259
+ v-if="resolvedCard"
260
+ class="cn-app-nav__card"
261
+ data-testid="cn-nav-card">
262
+ <strong class="cn-app-nav__card-title">{{ resolvedCard.title }}</strong>
263
+ <span v-if="resolvedCard.text" class="cn-app-nav__card-text">{{ resolvedCard.text }}</span>
264
+ <component
265
+ :is="resolvedCard.linkTag"
266
+ v-if="resolvedCard.linkTag"
267
+ class="cn-app-nav__card-link"
268
+ data-testid="cn-nav-card-link"
269
+ v-bind="resolvedCard.linkAttrs"
270
+ @click="onCardLinkClick">
271
+ {{ resolvedCard.linkLabel }}
272
+ </component>
273
+ </div>
274
+ </slot>
219
275
  <!-- Footer-section entries (Documentation, Features & Roadmap,
220
276
  About) live in NcAppNavigation's #footer slot — OUTSIDE the
221
277
  scrollable list — so they stay visible above the settings
@@ -226,7 +282,20 @@
226
282
  hover highlight are scoped to itself, so they survive being
227
283
  slotted. The main list's inset comes from NcAppNavigation's
228
284
  scope, which slot content does not carry. -->
229
- <NcAppNavigationList v-if="footerItems.length > 0" class="cn-app-nav__footer-list">
285
+ <NcAppNavigationList v-if="footerItems.length > 0 || resolvedHelp" class="cn-app-nav__footer-list">
286
+ <!-- The help entry (`nav.help`): a link to the app's own help
287
+ with a help icon, above the footer entries. -->
288
+ <NcAppNavigationItem
289
+ v-if="resolvedHelp"
290
+ :name="resolvedHelp.label"
291
+ :to="resolvedHelp.to"
292
+ :href="resolvedHelp.href"
293
+ :target="resolvedHelp.href ? '_blank' : undefined"
294
+ data-testid="cn-nav-help">
295
+ <template #icon>
296
+ <HelpCircleOutline :size="20" />
297
+ </template>
298
+ </NcAppNavigationItem>
230
299
  <NcAppNavigationItem
231
300
  v-for="item in footerItems"
232
301
  :key="item.id"
@@ -360,6 +429,7 @@ import CnMenuItemIcon from '../CnMenuWidget/CnMenuItemIcon.vue'
360
429
  import { isAppInstalled } from '../../utils/appInstalled.js'
361
430
  import { isSvgPath } from '../../utils/iconUtils.js'
362
431
  import { passesContextPredicates } from '../../utils/visibleIfContext.js'
432
+ import { CnActionButtons } from '../CnActionButtons/index.js'
363
433
  // The legacy `icon-*` → MDI bridge lives beside CnIcon now, so the menu EDITOR
364
434
  // resolves a seeded `icon-comment` the same way this nav does. It used to be a
365
435
  // map local to this file, which is why the nav drew a proper glyph and the
@@ -444,6 +514,7 @@ export default {
444
514
  NcAppNavigationSettings,
445
515
  NcButton,
446
516
  NcCounterBubble,
517
+ CnActionButtons,
447
518
  CnMenuItemIcon,
448
519
  Cog,
449
520
  HelpCircleOutline,
@@ -490,6 +561,16 @@ export default {
490
561
  * @type {{ [register: string]: { [schema: string]: number } }}
491
562
  */
492
563
  cnMenuCounts: { default: () => ({}) },
564
+ /**
565
+ * Provided by CnAppRoot — reactive `{ [menuItemId]: number }` totals
566
+ * for every menu entry whose `count` is an object
567
+ * (`{ register, schema, filter? }`): the total of that filtered
568
+ * list, fetched under the entry's own key so an index page's
569
+ * whole-schema total is left alone. Empty outside a CnAppRoot.
570
+ *
571
+ * @type {{ [itemId: string]: number }}
572
+ */
573
+ cnMenuItemCounts: { default: () => ({}) },
493
574
  },
494
575
 
495
576
  props: {
@@ -613,7 +694,7 @@ export default {
613
694
  },
614
695
  },
615
696
 
616
- emits: ['primary-action', 'primary-action-click'],
697
+ emits: ['primary-action', 'primary-action-click', 'primary-action-created', 'card-action'],
617
698
 
618
699
  /**
619
700
  * The navigation's own state: which groups are open, and which entry
@@ -674,11 +755,70 @@ export default {
674
755
  const text = (value) => (typeof value === 'string' && value !== '' ? this.effectiveTranslate(value) : '')
675
756
  const brand = {
676
757
  logo: typeof declared.logo === 'string' ? declared.logo : '',
758
+ // A URL, or `true` for the theme's emblem (--nldesign-emblem-url).
759
+ emblem: declared.emblem === true ? true : (typeof declared.emblem === 'string' ? declared.emblem : ''),
677
760
  name: text(declared.name),
678
761
  caption: text(declared.caption),
679
762
  alt: text(declared.alt),
680
763
  }
681
- return (brand.logo || brand.name || brand.caption) ? brand : null
764
+ return (brand.logo || brand.emblem || brand.name || brand.caption) ? brand : null
765
+ },
766
+
767
+ /**
768
+ * The card above the footer entries (`nav.card`): `{ title, text?,
769
+ * link?: { label, route?, params?, href?, action? } }`. Title, text
770
+ * and link label go through the translate function. The link is a
771
+ * router link for a `route` (with a router), an anchor for an
772
+ * `href`, and a button emitting `card-action` with the `action` id
773
+ * otherwise. Null without a title.
774
+ *
775
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-navigation-carries-a-card-and-a-help-entry
776
+ * @return {object|null}
777
+ */
778
+ resolvedCard() {
779
+ const declared = this.effectiveManifest?.nav?.card
780
+ if (!declared || typeof declared !== 'object' || typeof declared.title !== 'string' || declared.title === '') {
781
+ return null
782
+ }
783
+ const text = (value) => (typeof value === 'string' && value !== '' ? this.effectiveTranslate(value) : '')
784
+ const card = { title: text(declared.title), text: text(declared.text), linkTag: '', linkAttrs: {}, linkLabel: '', action: '' }
785
+ const link = declared.link
786
+ if (link && typeof link === 'object' && typeof link.label === 'string' && link.label !== '') {
787
+ card.linkLabel = text(link.label)
788
+ if (link.route && this.$router) {
789
+ card.linkTag = 'router-link'
790
+ card.linkAttrs = { to: { name: link.route, ...(link.params && typeof link.params === 'object' ? { params: link.params } : {}) } }
791
+ } else if (typeof link.href === 'string' && link.href !== '') {
792
+ card.linkTag = 'a'
793
+ card.linkAttrs = { href: link.href }
794
+ } else if (typeof link.action === 'string' && link.action !== '') {
795
+ card.linkTag = 'button'
796
+ card.linkAttrs = { type: 'button' }
797
+ card.action = link.action
798
+ }
799
+ }
800
+ return card
801
+ },
802
+
803
+ /**
804
+ * The help entry (`nav.help`): `{ label, route?, href? }` rendered as
805
+ * a footer entry with a help icon. Null without a label or a target.
806
+ *
807
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-navigation-carries-a-card-and-a-help-entry
808
+ * @return {{label: string, to?: object, href?: string}|null}
809
+ */
810
+ resolvedHelp() {
811
+ const declared = this.effectiveManifest?.nav?.help
812
+ if (!declared || typeof declared !== 'object' || typeof declared.label !== 'string' || declared.label === '') {
813
+ return null
814
+ }
815
+ if (declared.route && this.$router) {
816
+ return { label: this.effectiveTranslate(declared.label), to: { name: declared.route }, href: undefined }
817
+ }
818
+ if (typeof declared.href === 'string' && declared.href !== '') {
819
+ return { label: this.effectiveTranslate(declared.label), to: undefined, href: declared.href }
820
+ }
821
+ return null
682
822
  },
683
823
 
684
824
  /**
@@ -699,20 +839,30 @@ export default {
699
839
  * 2. `manifest.nav.primaryAction` as app-wide default
700
840
  * 3. `null` — no primary-action button renders
701
841
  *
702
- * Page-scoped declarations always win over the nav-root default.
842
+ * Page-scoped declarations always win over the nav-root default. An
843
+ * action declaring `permission` or `visibleIf` is gated like a menu
844
+ * entry.
703
845
  *
846
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-navigation-primary-action-runs-a-page-action
704
847
  * @return {object|null}
705
848
  */
706
849
  activePrimaryAction() {
707
850
  const pages = this.effectiveManifest?.pages ?? []
708
851
  const routeName = this.$route?.name
852
+ // The same `permission` / `visibleIf` gate a menu entry has: an
853
+ // action the reader may not take, or that a condition hides, is
854
+ // not drawn. A gated-out page action falls back to the nav one.
855
+ const gated = (action) => (action && this.passesPermission(action) && this.passesVisibleIf(action) ? action : null)
709
856
  if (routeName) {
710
857
  const page = pages.find((p) => p.id === routeName)
711
858
  if (page && page.primaryAction) {
712
- return page.primaryAction
859
+ const pageAction = gated(page.primaryAction)
860
+ if (pageAction) {
861
+ return pageAction
862
+ }
713
863
  }
714
864
  }
715
- return this.effectiveManifest?.nav?.primaryAction ?? null
865
+ return gated(this.effectiveManifest?.nav?.primaryAction ?? null)
716
866
  },
717
867
 
718
868
  /**
@@ -726,6 +876,31 @@ export default {
726
876
  return this.mdiIconComponent(this.activePrimaryAction) ?? Plus
727
877
  },
728
878
 
879
+ /**
880
+ * The CnActionButtons entry for a primary action that carries an
881
+ * `action` (`$defs/action`: open-form, navigate, api-call, ...):
882
+ * the action with the primary action's `id`, `label` and `icon`
883
+ * folded in and `variant: "primary"`. Null for a plain primary
884
+ * action, which keeps its link or button.
885
+ *
886
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-navigation-primary-action-runs-a-page-action
887
+ * @return {object|null}
888
+ */
889
+ primaryDispatchEntry() {
890
+ const action = this.activePrimaryAction
891
+ const inner = action && action.action
892
+ if (!inner || typeof inner !== 'object' || typeof inner.type !== 'string') {
893
+ return null
894
+ }
895
+ return {
896
+ icon: action.icon || 'Plus',
897
+ ...inner,
898
+ id: inner.id || action.id || 'primary-action',
899
+ label: action.label,
900
+ variant: 'primary',
901
+ }
902
+ },
903
+
729
904
  /**
730
905
  * Link props for the primary action's button: an `href` opens in a
731
906
  * new tab, a `route` is a router link. Null when the action is
@@ -1590,6 +1765,9 @@ export default {
1590
1765
  * `NcAppNavigationItem`).
1591
1766
  *
1592
1767
  * - Literal positive integer in `item.count` → return as-is.
1768
+ * - `item.count` is an object (`{ register, schema, filter? }`) →
1769
+ * `cnMenuItemCounts[item.id] ?? null`, the filtered total CnAppRoot
1770
+ * fetched for this entry.
1593
1771
  * - `item.count === "auto"` → look up the entry's resolved page;
1594
1772
  * when that page is `type: "index"` with a `register`/`schema`
1595
1773
  * in its `config`, return
@@ -1613,6 +1791,11 @@ export default {
1613
1791
  if (typeof raw === 'number') {
1614
1792
  return raw > 0 ? raw : null
1615
1793
  }
1794
+ if (raw && typeof raw === 'object') {
1795
+ // A filtered count: CnAppRoot fetched it per entry id.
1796
+ const value = this.cnMenuItemCounts?.[item.id]
1797
+ return (typeof value === 'number' && value > 0) ? value : null
1798
+ }
1616
1799
  if (raw !== 'auto') {
1617
1800
  return null
1618
1801
  }
@@ -1923,6 +2106,39 @@ export default {
1923
2106
  *
1924
2107
  * @return {void}
1925
2108
  */
2109
+ /**
2110
+ * Re-emit the object an `open-form` primary action created, so the
2111
+ * host can refresh a list or open the record.
2112
+ *
2113
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-navigation-primary-action-runs-a-page-action
2114
+ * @param {object} created The created object.
2115
+ * @return {void}
2116
+ */
2117
+ onPrimaryActionCreated(created) {
2118
+ /**
2119
+ * @event primary-action-created Emitted after an `open-form` primary action saves. Payload: the created object.
2120
+ */
2121
+ this.$emit('primary-action-created', created)
2122
+ },
2123
+
2124
+ /**
2125
+ * A card link that is a button (an `action` id) emits `card-action`
2126
+ * with that id for the host to run; links and anchors navigate on
2127
+ * their own.
2128
+ *
2129
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-navigation-carries-a-card-and-a-help-entry
2130
+ * @return {void}
2131
+ */
2132
+ onCardLinkClick() {
2133
+ const card = this.resolvedCard
2134
+ if (card && card.action) {
2135
+ /**
2136
+ * @event card-action Emitted when the nav card's link is an action button. Payload: the action id.
2137
+ */
2138
+ this.$emit('card-action', card.action)
2139
+ }
2140
+ },
2141
+
1926
2142
  onPrimaryActionClick() {
1927
2143
  const action = this.activePrimaryAction
1928
2144
  if (!action) {
@@ -1952,6 +2168,76 @@ export default {
1952
2168
  </script>
1953
2169
 
1954
2170
  <style>
2171
+ /* The emblem beside the app name (`nav.brand.emblem`). A theme sets
2172
+ --nldesign-emblem-url for `emblem: true`, and --cn-nav-emblem-size for both. */
2173
+ .cn-app-nav__brand-emblem {
2174
+ flex: none;
2175
+ height: var(--cn-nav-emblem-size, 34px);
2176
+ width: auto;
2177
+ object-fit: contain;
2178
+ }
2179
+
2180
+ .cn-app-nav__brand-emblem--theme {
2181
+ display: inline-block;
2182
+ width: var(--cn-nav-emblem-size, 34px);
2183
+ background: var(--nldesign-emblem-url) center / contain no-repeat;
2184
+ }
2185
+
2186
+ /* The solid primary action (`solid: true`, or an action that dispatches):
2187
+ one full-width primary button, as the board draws "New case". */
2188
+ .cn-app-nav__primary-action--solid .cn-action-buttons,
2189
+ .cn-app-nav__primary-action--solid .cn-action-buttons .button-vue,
2190
+ .cn-app-nav__primary-action--solid .button-vue {
2191
+ width: 100%;
2192
+ justify-content: center;
2193
+ }
2194
+
2195
+ /* The card above the footer entries (`nav.card`). Theme hooks:
2196
+ --cn-nav-card-background and --cn-nav-card-radius. */
2197
+ .cn-app-nav__card {
2198
+ display: flex;
2199
+ flex-direction: column;
2200
+ gap: calc(2 * var(--default-grid-baseline));
2201
+ margin: calc(2 * var(--default-grid-baseline));
2202
+ padding: calc(4 * var(--default-grid-baseline));
2203
+ border-radius: var(--cn-nav-card-radius, var(--border-radius-large));
2204
+ background: var(--cn-nav-card-background, var(--color-background-hover));
2205
+ }
2206
+
2207
+ .cn-app-nav__card-title {
2208
+ color: var(--color-main-text);
2209
+ font-weight: 700;
2210
+ }
2211
+
2212
+ .cn-app-nav__card-text {
2213
+ color: var(--color-text-maxcontrast);
2214
+ line-height: 1.4;
2215
+ }
2216
+
2217
+ .cn-app-nav__card-link {
2218
+ align-self: flex-start;
2219
+ margin: 0;
2220
+ padding: 0;
2221
+ border: 0;
2222
+ background: none;
2223
+ color: var(--color-primary-element);
2224
+ font: inherit;
2225
+ font-weight: 600;
2226
+ text-decoration: none;
2227
+ cursor: pointer;
2228
+ }
2229
+
2230
+ .cn-app-nav__card-link:hover,
2231
+ .cn-app-nav__card-link:focus-visible {
2232
+ text-decoration: underline;
2233
+ }
2234
+
2235
+ .cn-app-nav__card-link:focus-visible {
2236
+ outline: 2px solid var(--color-primary-element);
2237
+ outline-offset: 2px;
2238
+ border-radius: var(--border-radius-small, 4px);
2239
+ }
2240
+
1955
2241
  /* The brand block at the top of the navigation. */
1956
2242
  .cn-app-nav__brand {
1957
2243
  align-items: center;
@@ -203,3 +203,7 @@ Static-manifest apps use `useAppManifest('myapp', bundledManifest)`, which fetch
203
203
  | `softDependencyNotices` | Boolean | `false` | **Deprecated.** Restores the in-shell orange notices, one per unresolved optional dependency. Off by default: they stacked one per optional leaf and pushed the app's own content below the fold, for an audience who cannot act on them. Mount `CnLeafDependencySettings` in admin settings instead. Slated for removal. |
204
204
 
205
205
  `unresolvedSoftDependencies` is unchanged and still exposed, so an app rendering its own surface from it keeps working.
206
+
207
+ ### Without a navigation (`hideMenu`)
208
+
209
+ `hideMenu` (default `false`) renders neither the default CnAppNav nor the `#menu` slot, so the content starts at the left edge.
@@ -199,7 +199,13 @@
199
199
  <!-- @binding {boolean} isOwner Whether the user owns this app (ADR-079 §3). -->
200
200
  <!-- @binding {boolean} isAdmin Whether the user administers the instance; gates the Admin-settings link. -->
201
201
  <!-- @binding {string} appId The app id used to build the Admin-settings target. -->
202
- <slot name="menu"
202
+ <!-- `hideMenu` renders no navigation at all, so the content starts
203
+ at the left edge. An EMPTY #menu override cannot do that: an
204
+ empty slot falls back to the default CnAppNav (ensureValidVNode),
205
+ which is why launchpad passed a hidden empty span. -->
206
+ <slot
207
+ v-if="!hideMenu"
208
+ name="menu"
203
209
  :manifest="menuManifest"
204
210
  :permissions="permissions"
205
211
  :isOwner="isOwner"
@@ -700,6 +706,7 @@ import { BUILT_IN_FORMATTERS } from '../../utils/builtInFormatters.js'
700
706
  import { DEFAULT_FORGE, resolveForge } from '../../utils/forge.js'
701
707
  import { BUILT_IN_KB_PROVIDERS } from '../../utils/kbSearchProviders.js'
702
708
  import { installModalStack, uninstallModalStack } from '../../utils/modalStack.js'
709
+ import { resolveFilterTokens } from '../../utils/resolveFilterTokens.js'
703
710
  import { passesContextPredicates } from '../../utils/visibleIfContext.js'
704
711
 
705
712
  /**
@@ -1049,6 +1056,13 @@ export default {
1049
1056
  * the counts.
1050
1057
  */
1051
1058
  cnMenuCounts: this.cnMenuCounts,
1059
+ /**
1060
+ * Reactive `{ [menuItemId]: number }` totals for menu entries
1061
+ * whose `count` is an object (`{ register, schema, filter? }`),
1062
+ * populated by `_hydrateMenuItemCounts()`. Read by
1063
+ * `CnAppNav.resolveCount()`.
1064
+ */
1065
+ cnMenuItemCounts: this.cnMenuItemCounts,
1052
1066
  }
1053
1067
  },
1054
1068
 
@@ -1192,6 +1206,19 @@ export default {
1192
1206
  required: true,
1193
1207
  },
1194
1208
 
1209
+ /**
1210
+ * Render no app navigation at all: neither the default CnAppNav nor
1211
+ * the `#menu` slot, so the content starts at the left edge (a start
1212
+ * page without a menu). Off by default; the navigation renders as it
1213
+ * always has.
1214
+ *
1215
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-an-app-root-can-hide-its-menu
1216
+ */
1217
+ hideMenu: {
1218
+ type: Boolean,
1219
+ default: false,
1220
+ },
1221
+
1195
1222
  /**
1196
1223
  * Human-readable name shown in the Nextcloud top bar. When set it
1197
1224
  * overrides the technical `appId` so a virtual app shows its own name
@@ -1707,6 +1734,13 @@ export default {
1707
1734
  * picked up by CnAppNav's reactive render.
1708
1735
  */
1709
1736
  cnMenuCounts: reactive({}),
1737
+ /**
1738
+ * Reactive `{ [menuItemId]: number }` map of filtered totals, one
1739
+ * per menu entry whose `count` is an object. Provided to
1740
+ * descendants as `cnMenuItemCounts`. Populated by
1741
+ * `_hydrateMenuItemCounts()` at mount.
1742
+ */
1743
+ cnMenuItemCounts: reactive({}),
1710
1744
  /**
1711
1745
  * Open state of the host NcAppSettingsDialog. Toggled
1712
1746
  * to `true` by the provided `cnOpenUserSettings()`
@@ -2925,6 +2959,7 @@ export default {
2925
2959
  this._validateRegistry()
2926
2960
  this._warnCustomComponentsDeprecation()
2927
2961
  this._hydrateMenuCounts()
2962
+ this._hydrateMenuItemCounts()
2928
2963
  return
2929
2964
  }
2930
2965
 
@@ -2953,6 +2988,7 @@ export default {
2953
2988
  this._validateRegistry()
2954
2989
  this._warnCustomComponentsDeprecation()
2955
2990
  this._hydrateMenuCounts()
2991
+ this._hydrateMenuItemCounts()
2956
2992
  },
2957
2993
 
2958
2994
  beforeUnmount() {
@@ -3609,6 +3645,63 @@ export default {
3609
3645
  })
3610
3646
  },
3611
3647
 
3648
+ /**
3649
+ * Fetch the total of every menu entry (top level and children) whose
3650
+ * `count` is an object `{ register, schema, filter? }`: one
3651
+ * `GET /api/objects/{register}/{schema}?_limit=1` per entry with the
3652
+ * filter's tokens (`@me`, `@today`, ...) resolved at fetch time, the
3653
+ * response `total` written to `cnMenuItemCounts[item.id]`. The
3654
+ * request goes straight through axios rather than the object store,
3655
+ * so the index page's whole-schema total in the store is left alone.
3656
+ * Failures leave the badge unrendered.
3657
+ *
3658
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-menu-entry-counts-a-filtered-list
3659
+ * @return {void}
3660
+ * @private
3661
+ */
3662
+ _hydrateMenuItemCounts() {
3663
+ const collect = (items) => {
3664
+ const out = []
3665
+ for (const item of items ?? []) {
3666
+ const count = item?.count
3667
+ if (count && typeof count === 'object' && typeof item.id === 'string' && count.register && count.schema) {
3668
+ out.push(item)
3669
+ }
3670
+ if (Array.isArray(item?.children)) {
3671
+ out.push(...collect(item.children))
3672
+ }
3673
+ }
3674
+ return out
3675
+ }
3676
+ for (const item of collect(this.manifest?.menu)) {
3677
+ this._fetchMenuItemCount(item)
3678
+ }
3679
+ },
3680
+
3681
+ /**
3682
+ * One filtered-count request for a menu entry; see
3683
+ * `_hydrateMenuItemCounts`.
3684
+ *
3685
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-menu-entry-counts-a-filtered-list
3686
+ * @param {{ id: string, count: { register: string, schema: string, filter?: object } }} item The menu entry.
3687
+ * @return {Promise<void>}
3688
+ * @private
3689
+ */
3690
+ async _fetchMenuItemCount(item) {
3691
+ try {
3692
+ const { register, schema, filter } = item.count
3693
+ const resolved = resolveFilterTokens(filter && typeof filter === 'object' ? filter : {}, {})
3694
+ const url = generateUrl(`/apps/openregister/api/objects/${encodeURIComponent(register)}/${encodeURIComponent(schema)}`)
3695
+ const { data } = await axios.get(url, { params: { _limit: 1, ...resolved } })
3696
+ const total = data?.total
3697
+ if (typeof total === 'number' && total >= 0) {
3698
+ this.cnMenuItemCounts[item.id] = total
3699
+ }
3700
+ } catch {
3701
+ // Non-fatal: the badge stays unrendered.
3702
+ }
3703
+ },
3704
+
3612
3705
  /**
3613
3706
  * Hydrate all menu counts with a single `POST /api/objects/counts`
3614
3707
  * (OpenRegister batched-counts endpoint). Distributes each returned