@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.
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +154 -7
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +119 -42
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +87 -0
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +26 -24
- package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
- package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +61 -16
- package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
- package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
- package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js +20 -0
- package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js +3 -2
- package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -2
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +29 -0
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +150 -133
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +37 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +12 -4
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +66 -14
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +23 -14
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +81 -4
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +22 -5
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +30 -0
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +5 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -2
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +63 -2
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +96 -37
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStagesWidget/stagesModel.js +5 -2
- package/dist/esm/components/CnStagesWidget/stagesModel.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +52 -2
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +6 -6
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +14 -2
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +2 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +73 -1
- package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
- package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +14 -2
- package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
- package/dist/esm/l10n/en.json.js +2 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +2 -1
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/nextcloud-vue.css +331 -81
- package/dist/esm/utils/schemaRefSlug.js +19 -3
- package/dist/esm/utils/schemaRefSlug.js.map +1 -1
- package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +6933 -5958
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +331 -81
- package/l10n/en.json +2 -0
- package/l10n/nl.json +2 -0
- package/package.json +1 -1
- package/src/components/CnAppNav/CnAppNav.vue +297 -11
- package/src/components/CnAppRoot/CnAppRoot.md +4 -0
- package/src/components/CnAppRoot/CnAppRoot.vue +94 -1
- package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +61 -16
- package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +22 -1
- package/src/components/CnCellRenderer/CnCellRenderer.vue +33 -2
- package/src/components/CnDashboardPage/CnDashboardPage.vue +43 -2
- package/src/components/CnDataTable/CnDataTable.vue +45 -1
- package/src/components/CnDetailPage/CnDetailPage.md +4 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +60 -1
- package/src/components/CnHeaderWidget/CnHeaderWidget.vue +116 -6
- package/src/components/CnIndexPage/CnIndexPage.vue +32 -2
- package/src/components/CnObjectListWidget/CnObjectListWidget.vue +7 -1
- package/src/components/CnStagesWidget/CnStagesWidget.vue +180 -2
- package/src/components/CnStagesWidget/stagesModel.js +5 -2
- package/src/components/CnStatWidget/CnStatWidget.vue +57 -3
- package/src/components/CnTabsWidget/CnTabsWidget.vue +15 -2
- package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +4 -0
- package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +107 -1
- package/src/css/detail-page.css +10 -4
- package/src/css/kpi-card.css +24 -1
- package/src/css/table.css +18 -6
- package/src/schemas/app-manifest-v2.schema.json +113 -3
- package/src/utils/schemaRefSlug.js +19 -3
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|