@conduction/nextcloud-vue 2.68.0 → 2.70.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/CnActionsBar/CnActionsBar.vue2.js +25 -0
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +155 -152
- package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +77 -0
- package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +8 -8
- package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue.js +1 -0
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue.js.map +1 -1
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js +28 -0
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +4 -2
- package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
- package/dist/esm/components/CnCalendarWidget/CnCalendarWidget.vue2.js +25 -1
- package/dist/esm/components/CnCalendarWidget/CnCalendarWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnCalendarWidget/CnCalendarWidget.vue3.js +32 -17
- package/dist/esm/components/CnCalendarWidget/CnCalendarWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnCapabilityTable/CnCapabilityTable.vue2.js +14 -1
- package/dist/esm/components/CnCapabilityTable/CnCapabilityTable.vue2.js.map +1 -1
- package/dist/esm/components/CnCapabilityTable/CnCapabilityTable.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js +43 -2
- package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js +3 -3
- package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +45 -35
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +19 -1
- package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +35 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
- package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +94 -6
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +64 -19
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +28 -0
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +3 -2
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
- package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js +12 -0
- package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js.map +1 -1
- package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js +9 -0
- package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js.map +1 -1
- package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js.map +1 -1
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +54 -7
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +26 -8
- package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +143 -0
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +68 -25
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnNextStepCard/CnNextStepCard.vue2.js.map +1 -1
- package/dist/esm/components/CnNextStepCard/CnNextStepCard.vue3.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +4 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js +17 -3
- package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js +25 -24
- package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +29 -2
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +7 -0
- package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue2.js +24 -1
- package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue2.js.map +1 -1
- package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue3.js +1 -1
- package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue3.js.map +1 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +81 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +25 -3
- package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue2.js +15 -2
- package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue3.js +31 -32
- package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue3.js.map +1 -1
- package/dist/esm/l10n/en.json.js +3 -0
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +3 -0
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/mixins/pageViews.js +278 -11
- package/dist/esm/mixins/pageViews.js.map +1 -1
- package/dist/esm/nextcloud-vue.css +145 -0
- package/dist/esm/store/plugins/dashboardLayouts.js +35 -1
- package/dist/esm/store/plugins/dashboardLayouts.js.map +1 -1
- package/dist/esm/utils/capabilityComparison.js +29 -1
- package/dist/esm/utils/capabilityComparison.js.map +1 -1
- package/dist/esm/utils/formatMetric.js +137 -16
- package/dist/esm/utils/formatMetric.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 +2840 -1477
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +145 -0
- package/l10n/en.json +3 -0
- package/l10n/nl.json +3 -0
- package/package.json +1 -1
- package/src/components/CnActionsBar/CnActionsBar.md +2 -0
- package/src/components/CnActionsBar/CnActionsBar.vue +28 -2
- package/src/components/CnAppNav/CnAppNav.vue +85 -8
- package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +45 -0
- package/src/components/CnCalendarWidget/CnCalendarWidget.vue +32 -4
- package/src/components/CnCapabilityTable/CnCapabilityTable.vue +14 -0
- package/src/components/CnDashboardGrid/CnDashboardGrid.md +2 -0
- package/src/components/CnDashboardGrid/CnDashboardGrid.vue +54 -2
- package/src/components/CnDashboardPage/CnDashboardPage.md +2 -0
- package/src/components/CnDashboardPage/CnDashboardPage.vue +59 -35
- package/src/components/CnDataTable/CnDataTable.vue +35 -1
- package/src/components/CnDetailPage/CnDetailPage.md +8 -0
- package/src/components/CnDetailPage/CnDetailPage.vue +124 -8
- package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +30 -1
- package/src/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue +12 -0
- package/src/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +9 -0
- package/src/components/CnHeaderWidget/CnHeaderWidget.vue +95 -8
- package/src/components/CnIndexPage/CnIndexPage.vue +171 -6
- package/src/components/CnNextStepCard/CnNextStepCard.vue +11 -0
- package/src/components/CnObjectListWidget/CnObjectListWidget.vue +4 -1
- package/src/components/CnStackedBarWidget/CnStackedBarWidget.vue +34 -7
- package/src/components/CnStagesWidget/CnStagesWidget.vue +19 -0
- package/src/components/CnStatWidget/CnStatWidget.vue +38 -2
- package/src/components/CnStatsBlock/CnStatsBlock.md +2 -0
- package/src/components/CnStatsBlock/CnStatsBlock.vue +25 -2
- package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +95 -3
- package/src/components/CnWeekStripWidget/CnWeekStripWidget.vue +24 -3
- package/src/css/detail-page.css +10 -0
- package/src/css/index-page.css +12 -0
- package/src/css/kpi-card.css +15 -0
- package/src/mixins/pageViews.js +277 -10
- package/src/schemas/app-manifest-v2.schema.json +73 -6
- package/src/store/plugins/dashboardLayouts.js +34 -0
- package/src/utils/capabilityComparison.js +28 -0
- package/src/utils/formatMetric.js +143 -15
- package/src/utils/validateManifestV2.compiled.js +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CnStatsBlock.vue3.js","sources":["../../../../src/components/CnStatsBlock/CnStatsBlock.vue?vue&type=template&id=722d9aef&lang.js"],"sourcesContent":["<!--\n - CnStatsBlock renders the CANONICAL KPI card (src/css/kpi-card.css) — the\n - same markup and the same stylesheet CnStatWidget renders, so OpenCatalogi's\n - KPIs and dossiq's are one design rather than two. This component's own\n - `cn-stats-block*` classes stay on the same elements for backwards\n - compatibility with app CSS that targets them; the LOOK comes from the\n - shared sheet.\n -\n - The comment lives OUT here rather than inside <template>: a comment node\n - beside the root element makes the component multi-root in Vue 3, and a\n - multi-root component has no root to put class attributes on — `rootClasses`\n - silently stopped reaching the DOM.\n-->\n<template>\n\t<component\n\t\t:is=\"componentTag\"\n\t\tclass=\"cn-kpi-card cn-stats-block\"\n\t\t:class=\"rootClasses\"\n\t\tv-bind=\"componentAttrs\"\n\t\t@click=\"onClick\">\n\t\t<!-- Icon -->\n\t\t<div v-if=\"hasIcon\" class=\"cn-kpi-card__icon cn-stats-block__icon\" :class=\"iconClasses\">\n\t\t\t<slot name=\"icon\">\n\t\t\t\t<component :is=\"icon\" v-if=\"icon\" :size=\"iconSize\" />\n\t\t\t</slot>\n\t\t</div>\n\n\t\t<!-- Content -->\n\t\t<div class=\"cn-kpi-card__body cn-stats-block__content\">\n\t\t\t<div class=\"cn-stats-block__header\">\n\t\t\t\t<h4 class=\"cn-kpi-card__title\" :title=\"title || undefined\">\n\t\t\t\t\t{{ title || t('nextcloud-vue', 'Objects') }}\n\t\t\t\t</h4>\n\t\t\t</div>\n\n\t\t\t<div v-if=\"hasError\" class=\"cn-stats-block__count cn-stats-block__count--error\">\n\t\t\t\t<span class=\"cn-stats-block__count-value\">—</span>\n\t\t\t\t<span class=\"cn-stats-block__count-label\">{{ errorLabel }}</span>\n\t\t\t</div>\n\t\t\t<div v-else-if=\"hasValueSlot || count > 0 || (showZeroCount && count === 0)\" class=\"cn-kpi-card__value-row cn-stats-block__count\">\n\t\t\t\t<span class=\"cn-kpi-card__value cn-stats-block__count-value\">\n\t\t\t\t\t<!-- @slot Override the prominently-displayed value — render a pre-formatted string (currency, percent, a \"—\" placeholder, …). `count` stays the raw number; this is presentation only. Defaults to the localized count. -->\n\t\t\t\t\t<!-- @binding {number} count The raw numeric count. -->\n\t\t\t\t\t<!-- @binding {string} formatted The default localized count string. -->\n\t\t\t\t\t<slot name=\"value\" :count=\"count\" :formatted=\"formattedCount\">{{ formattedCount }}</slot>\n\t\t\t\t</span>\n\t\t\t\t<span class=\"cn-kpi-card__label cn-stats-block__count-label\">{{ countLabel }}</span>\n\t\t\t</div>\n\t\t\t<div v-else-if=\"loading\" class=\"cn-stats-block__loading\">\n\t\t\t\t<NcLoadingIcon :size=\"16\" />\n\t\t\t\t{{ loadingLabel }}\n\t\t\t</div>\n\t\t\t<div v-else class=\"cn-stats-block__empty\">\n\t\t\t\t{{ emptyLabel }}\n\t\t\t</div>\n\n\t\t\t<!-- Breakdown details -->\n\t\t\t<div\n\t\t\t\tv-if=\"hasError === false && breakdown && (hasValueSlot || count > 0 || showZeroCount)\"\n\t\t\t\tclass=\"cn-stats-block__breakdown\">\n\t\t\t\t<div\n\t\t\t\t\tv-for=\"(value, key) in breakdown\"\n\t\t\t\t\t:key=\"key\"\n\t\t\t\t\tclass=\"cn-stats-block__breakdown-item\">\n\t\t\t\t\t<span class=\"cn-stats-block__breakdown-label\">{{ formatBreakdownLabel(key) }}</span>\n\t\t\t\t\t<span\n\t\t\t\t\t\tclass=\"cn-stats-block__breakdown-value\"\n\t\t\t\t\t\t:class=\"'cn-stats-block__breakdown-value--' + key\">\n\t\t\t\t\t\t{{ value }}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</component>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport { NcLoadingIcon } from '@nextcloud/vue'\n\n// The canonical KPI look, shared with CnStatWidget. Imported here so the\n// card is styled even when the consuming app has not pulled in the\n// library's global css/index.css.\nimport '../../css/kpi-card.css'\n\n/**\n * CnStatsBlock — Statistics display card with icon, count, and optional breakdown.\n *\n * Renders the canonical KPI card: a large circular icon beside a left-aligned\n * number, drawing no box of its own because the wrapper around it already\n * draws one. Supports colour variants, icons and clickable state. Use in a\n * CnKpiGrid for responsive dashboard layouts.\n *\n * Basic (canonical horizontal card)\n * ```vue\n * <CnStatsBlock title=\"Cases\" :count=\"42\" count-label=\"open cases\" />\n * ```\n *\n * With icon and variant\n * ```vue\n * <CnStatsBlock\n * title=\"Open Cases\"\n * :count=\"42\"\n * :icon=\"BriefcaseOutline\"\n * variant=\"primary\"\n * clickable\n * @click=\"goToCases\" />\n * ```\n *\n * Stacked and boxed — for a block mounted with no wrapper around it\n * ```vue\n * <CnStatsBlock title=\"Cases\" :count=\"42\" vertical filled />\n * ```\n *\n * With route-based navigation (renders as <router-link>)\n * ```vue\n * <CnStatsBlock\n * title=\"Open Cases\"\n * :count=\"42\"\n * :icon=\"BriefcaseOutline\"\n * variant=\"primary\"\n * :route=\"{ name: 'Cases', query: { status: 'open' } }\" />\n * ```\n *\n * With breakdown\n * ```vue\n * <CnStatsBlock\n * title=\"Cases\"\n * :count=\"42\"\n * :breakdown=\"{ total: 100, invalid: 3, deleted: 5, published: 92 }\" />\n * ```\n *\n * Custom icon slot\n * ```vue\n * <CnStatsBlock title=\"Files\" :count=\"128\">\n * <template #icon>\n * <FileDocumentOutline :size=\"24\" />\n * </template>\n * </CnStatsBlock>\n * ```\n */\nexport default {\n\tname: 'CnStatsBlock',\n\n\tcomponents: {\n\t\tNcLoadingIcon,\n\t},\n\n\tprops: {\n\t\t/** Block title */\n\t\ttitle: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** The main count number to display prominently */\n\t\tcount: {\n\t\t\ttype: Number,\n\t\t\tdefault: 0,\n\t\t},\n\n\t\t/** Label displayed next to the count */\n\t\tcountLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: () => t('nextcloud-vue', 'objects'),\n\t\t},\n\n\t\t/** Detailed breakdown object (key-value pairs) */\n\t\tbreakdown: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Whether data is currently loading */\n\t\tloading: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/** Text shown while loading */\n\t\tloadingLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: () => t('nextcloud-vue', 'Loading…'),\n\t\t},\n\n\t\t/** Text shown when count is 0 */\n\t\temptyLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: () => t('nextcloud-vue', 'No items found'),\n\t\t},\n\n\t\t/**\n\t\t * The tile could not load its number. Anything truthy counts — pass the\n\t\t * caught error itself, or just `true`.\n\t\t *\n\t\t * The tile then shows a dash and `errorLabel` INSTEAD of a count, and\n\t\t * tints itself `error`. This takes precedence over `count`, `loading`\n\t\t * and `emptyLabel` deliberately: a stale or defaulted number rendered\n\t\t * during a failure is the exact thing this prop exists to stop. A\n\t\t * dashboard showing 0 because the backend is down is worse than one\n\t\t * showing nothing, because 0 is a number a reader will believe.\n\t\t *\n\t\t * @type {boolean|string|Error|null}\n\t\t */\n\t\terror: {\n\t\t\t// TYPE ORDER IS SEMANTIC HERE. Vue casts an empty value to `true`\n\t\t\t// when Boolean appears BEFORE String in the union, so\n\t\t\t// `[Boolean, String]` would read `error=\"\"` as an error. A caller\n\t\t\t// clearing its message back to '' is reporting RECOVERY, and that\n\t\t\t// would have pinned the tile in its error state after the fetch\n\t\t\t// succeeded again. String first keeps '' falsy.\n\t\t\t// eslint-disable-next-line vue/prefer-prop-type-boolean-first -- Boolean first would make `error=\"\"` truthy, which is the recovery signal.\n\t\t\ttype: [String, Boolean, Object],\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Text shown in place of the count when `error` is set. */\n\t\terrorLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: () => t('nextcloud-vue', 'Unavailable'),\n\t\t},\n\n\t\t/** Icon component (e.g., imported MDI icon) */\n\t\ticon: {\n\t\t\ttype: [Object, Function],\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Icon size in pixels */\n\t\ticonSize: {\n\t\t\ttype: Number,\n\t\t\tdefault: 24,\n\t\t},\n\n\t\t/** Color variant: 'default', 'primary', 'success', 'warning', 'error' */\n\t\tvariant: {\n\t\t\ttype: String,\n\t\t\tdefault: 'default',\n\t\t\tvalidator: (v) => ['default', 'primary', 'success', 'warning', 'error'].includes(v),\n\t\t},\n\n\t\t/**\n\t\t * Stack the icon above a centred number instead of placing it beside\n\t\t * one. The canonical KPI card is horizontal, so this is the opt-out;\n\t\t * `horizontal` below is kept only for callers that already pass it.\n\t\t */\n\t\tvertical: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * Lay the icon left of the content. No longer needed — this is the\n\t\t * canonical card's own layout — and kept so existing callers that pass\n\t\t * `horizontal` keep working. Pass `vertical` to stack instead.\n\t\t *\n\t\t * @deprecated since 2.25.0, the horizontal layout is the default.\n\t\t */\n\t\thorizontal: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * Draw the card's own grey box. Off by default: a stats block is\n\t\t * normally rendered inside a wrapper that already draws a card, and a\n\t\t * second box reads as a card inside a card. Turn it on for a block\n\t\t * mounted with no wrapper around it.\n\t\t */\n\t\tfilled: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/** Whether the card is clickable */\n\t\tclickable: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/** Whether to display 0 as a count value instead of the empty label */\n\t\tshowZeroCount: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * Vue Router location object for declarative navigation.\n\t\t * When set, the card renders as a <router-link> and clickable styles are implied.\n\t\t * { name: 'Cases', query: { status: 'open' } }\n\t\t * { path: '/catalogi' }\n\t\t */\n\t\troute: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\t},\n\n\temits: ['click'],\n\n\tcomputed: {\n\t\thasIcon() {\n\t\t\treturn this.icon !== null || this.$slots.icon || this.$slots.icon\n\t\t},\n\n\t\t/**\n\t\t * Whether a consumer provided the `value` slot. When set, the value\n\t\t * area always renders (the slot decides what to show, even at count 0).\n\t\t */\n\t\thasValueSlot() {\n\t\t\treturn !!this.$slots.value || !!this.$slots.value\n\t\t},\n\n\t\t/**\n\t\t * Whether the tile is in its error state.\n\t\t *\n\t\t * Anything truthy counts, so a caller can pass the caught error itself\n\t\t * rather than converting it to a boolean first. An empty string is NOT\n\t\t * an error — that is a caller who cleared the message, not one\n\t\t * reporting a failure.\n\t\t *\n\t\t * @return {boolean} True when the tile should show `errorLabel`.\n\t\t */\n\t\thasError() {\n\t\t\treturn Boolean(this.error)\n\t\t},\n\n\t\tformattedCount() {\n\t\t\treturn this.count.toLocaleString()\n\t\t},\n\n\t\t/**\n\t\t * Whether the card is interactive (clickable or has a route).\n\t\t * Used for applying hover/focus styles.\n\t\t */\n\t\tisInteractive() {\n\t\t\treturn !!this.route || this.clickable\n\t\t},\n\n\t\t/**\n\t\t * Determines which HTML element or component to render.\n\t\t * - route set → 'router-link' (SPA navigation)\n\t\t * - clickable → 'a' (app handles click via event)\n\t\t * - neither → 'div' (static display)\n\t\t */\n\t\tcomponentTag() {\n\t\t\tif (this.route) {\n\t\t\t\treturn 'router-link'\n\t\t\t}\n\t\t\tif (this.clickable) {\n\t\t\t\treturn 'a'\n\t\t\t}\n\t\t\treturn 'div'\n\t\t},\n\n\t\t/**\n\t\t * Dynamic attributes for the root element based on rendering mode.\n\t\t */\n\t\tcomponentAttrs() {\n\t\t\tif (this.route) {\n\t\t\t\treturn { to: this.route, tabindex: '0' }\n\t\t\t}\n\t\t\tif (this.clickable) {\n\t\t\t\treturn { href: '#', role: 'button', tabindex: '0' }\n\t\t\t}\n\t\t\treturn {}\n\t\t},\n\n\t\trootClasses() {\n\t\t\treturn {\n\t\t\t\t// Canonical + legacy, in pairs: the canonical class carries the\n\t\t\t\t// look, the legacy one keeps existing app CSS matching.\n\t\t\t\t// The canonical card is already horizontal, so the legacy pair\n\t\t\t\t// is emitted for app CSS but carries no look of its own.\n\t\t\t\t'cn-kpi-card--horizontal': this.horizontal || !this.vertical,\n\t\t\t\t'cn-stats-block--horizontal': this.horizontal || !this.vertical,\n\t\t\t\t'cn-kpi-card--vertical': this.vertical,\n\t\t\t\t'cn-stats-block--vertical': this.vertical,\n\t\t\t\t'cn-kpi-card--filled': this.filled,\n\t\t\t\t'cn-kpi-card--clickable': this.isInteractive,\n\t\t\t\t'cn-stats-block--clickable': this.isInteractive,\n\t\t\t\t'cn-kpi-card--error': this.hasError,\n\t\t\t\t[`cn-kpi-card--${this.variant}`]: this.variant !== 'default' && this.hasError === false,\n\t\t\t\t// An errored tile tints itself. Requiring the consumer to also\n\t\t\t\t// pass `variant=\"error\"` would mean every caller remembering\n\t\t\t\t// two props to express one state, and forgetting the second is\n\t\t\t\t// invisible — a tile that reads \"Unavailable\" in the default\n\t\t\t\t// colour looks like ordinary content.\n\t\t\t\t'cn-stats-block--error': this.hasError,\n\t\t\t\t[`cn-stats-block--${this.variant}`]: this.variant !== 'default' && this.hasError === false,\n\t\t\t}\n\t\t},\n\n\t\ticonClasses() {\n\t\t\treturn {\n\t\t\t\t[`cn-stats-block__icon--${this.variant}`]: this.variant !== 'default',\n\t\t\t}\n\t\t},\n\n\t},\n\n\tmethods: {\n\t\tformatBreakdownLabel(key) {\n\t\t\treturn key.charAt(0).toUpperCase() + key.slice(1) + ':'\n\t\t},\n\n\t\tonClick(event) {\n\t\t\t// When route is set, router-link handles navigation — do not emit click\n\t\t\tif (this.route) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (this.clickable) {\n\t\t\t\tevent.preventDefault()\n\t\t\t\tthis.$emit('click', event)\n\t\t\t}\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n/*\n * The card's own look — box, body stack, icon circle, title, value, variants,\n * hover — is the CANONICAL KPI card in src/css/kpi-card.css, shared with\n * CnStatWidget. The rules that lived here duplicated it in different numbers\n * (a 1.2rem count row, a 40px icon, hardcoded rgba() tints that ignored a\n * re-themed palette), and that duplication IS how the two KPI looks drifted.\n * What is left below is this component's own extras: the breakdown block, the\n * loading line and the empty line.\n *\n * Those extras are secondary KPI text, so they read the shared\n * `--cn-kpi-label-*` scale rather than carrying sizes of their own — an empty\n * state that is a different size from the label it replaces is the same drift\n * in miniature.\n */\n\n.cn-stats-block__header {\n\tmax-width: 100%;\n\tmin-width: 0;\n}\n\n.cn-stats-block__loading {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: flex-start;\n\tgap: 6px;\n\tfont-size: var(--cn-kpi-label-size, 13px);\n\tcolor: var(--cn-kpi-label-color, var(--color-text-maxcontrast));\n}\n\n.cn-stats-block__empty {\n\ttext-align: start;\n\tfont-size: var(--cn-kpi-label-size, 13px);\n\tcolor: var(--cn-kpi-label-color, var(--color-text-maxcontrast));\n\tfont-style: italic;\n}\n\n.cn-stats-block__breakdown {\n\tmargin-top: 4px;\n\tpadding: 8px;\n\tfont-size: var(--cn-kpi-label-size, 13px);\n\tbackground: var(--color-background-hover);\n\tborder-radius: var(--border-radius);\n\tborder: 1px solid var(--color-border);\n\twidth: 100%;\n\ttext-align: start;\n}\n\n.cn-stats-block__breakdown-item {\n\tdisplay: flex;\n\tjustify-content: space-between;\n\talign-items: center;\n\tgap: 8px;\n\tmargin-bottom: 4px;\n}\n\n.cn-stats-block__breakdown-item:last-child {\n\tmargin-bottom: 0;\n}\n\n.cn-stats-block__breakdown-label {\n\tfont-weight: 500;\n\tcolor: var(--color-main-text);\n}\n\n/* The value takes the card's accent, so a breakdown reads as part of the tile\n it sits in rather than as a table that happened to land there. It used to\n carry a `--color-background-hover` chip — on a `--color-background-hover`\n breakdown box, which is to say an invisible chip drawn at the cost of two\n extra declarations and a differently-shaped hit area. The per-key modifiers\n below still win: `invalid`, `deleted` and `published` mean something\n specific and outrank the tile's own colour. */\n.cn-stats-block__breakdown-value {\n\tfont-weight: var(--cn-kpi-title-weight, 600);\n\tcolor: var(--cn-kpi-accent, var(--color-primary-element));\n}\n\n.cn-stats-block__breakdown-value--invalid { color: var(--color-element-warning); }\n\n.cn-stats-block__breakdown-value--deleted { color: var(--color-element-error); }\n\n.cn-stats-block__breakdown-value--published { color: var(--color-element-success); }\n</style>\n"],"names":["_renderSlot","_openBlock","_createBlock","_resolveDynamicComponent","_createElementVNode","_toDisplayString","_createElementBlock","_createVNode","_createTextVNode"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"CnStatsBlock.vue3.js","sources":["../../../../src/components/CnStatsBlock/CnStatsBlock.vue?vue&type=template&id=722d9aef&lang.js"],"sourcesContent":["<!--\n - CnStatsBlock renders the CANONICAL KPI card (src/css/kpi-card.css) — the\n - same markup and the same stylesheet CnStatWidget renders, so OpenCatalogi's\n - KPIs and dossiq's are one design rather than two. This component's own\n - `cn-stats-block*` classes stay on the same elements for backwards\n - compatibility with app CSS that targets them; the LOOK comes from the\n - shared sheet.\n -\n - The comment lives OUT here rather than inside <template>: a comment node\n - beside the root element makes the component multi-root in Vue 3, and a\n - multi-root component has no root to put class attributes on — `rootClasses`\n - silently stopped reaching the DOM.\n-->\n<template>\n\t<component\n\t\t:is=\"componentTag\"\n\t\tclass=\"cn-kpi-card cn-stats-block\"\n\t\t:class=\"rootClasses\"\n\t\tv-bind=\"componentAttrs\"\n\t\t@click=\"onClick\">\n\t\t<!-- Icon -->\n\t\t<div v-if=\"hasIcon && !isStacked\" class=\"cn-kpi-card__icon cn-stats-block__icon\" :class=\"iconClasses\">\n\t\t\t<slot name=\"icon\">\n\t\t\t\t<component :is=\"icon\" v-if=\"icon\" :size=\"iconSize\" />\n\t\t\t</slot>\n\t\t</div>\n\n\t\t<!-- Content -->\n\t\t<div class=\"cn-kpi-card__body cn-stats-block__content\">\n\t\t\t<div class=\"cn-stats-block__header\">\n\t\t\t\t<h4 class=\"cn-kpi-card__title\" :title=\"title || undefined\">\n\t\t\t\t\t{{ title || t('nextcloud-vue', 'Objects') }}\n\t\t\t\t</h4>\n\t\t\t</div>\n\n\t\t\t<div v-if=\"hasError\" class=\"cn-stats-block__count cn-stats-block__count--error\">\n\t\t\t\t<span class=\"cn-stats-block__count-value\">—</span>\n\t\t\t\t<span class=\"cn-stats-block__count-label\">{{ errorLabel }}</span>\n\t\t\t</div>\n\t\t\t<div v-else-if=\"hasValueSlot || count > 0 || (showZeroCount && count === 0)\" class=\"cn-kpi-card__value-row cn-stats-block__count\">\n\t\t\t\t<span class=\"cn-kpi-card__value cn-stats-block__count-value\">\n\t\t\t\t\t<!-- @slot Override the prominently-displayed value — render a pre-formatted string (currency, percent, a \"—\" placeholder, …). `count` stays the raw number; this is presentation only. Defaults to the localized count. -->\n\t\t\t\t\t<!-- @binding {number} count The raw numeric count. -->\n\t\t\t\t\t<!-- @binding {string} formatted The default localized count string. -->\n\t\t\t\t\t<slot name=\"value\" :count=\"count\" :formatted=\"formattedCount\">{{ formattedCount }}</slot>\n\t\t\t\t</span>\n\t\t\t\t<span class=\"cn-kpi-card__label cn-stats-block__count-label\">{{ countLabel }}</span>\n\t\t\t</div>\n\t\t\t<div v-else-if=\"loading\" class=\"cn-stats-block__loading\">\n\t\t\t\t<NcLoadingIcon :size=\"16\" />\n\t\t\t\t{{ loadingLabel }}\n\t\t\t</div>\n\t\t\t<div v-else class=\"cn-stats-block__empty\">\n\t\t\t\t{{ emptyLabel }}\n\t\t\t</div>\n\n\t\t\t<!-- Breakdown details -->\n\t\t\t<div\n\t\t\t\tv-if=\"hasError === false && breakdown && (hasValueSlot || count > 0 || showZeroCount)\"\n\t\t\t\tclass=\"cn-stats-block__breakdown\">\n\t\t\t\t<div\n\t\t\t\t\tv-for=\"(value, key) in breakdown\"\n\t\t\t\t\t:key=\"key\"\n\t\t\t\t\tclass=\"cn-stats-block__breakdown-item\">\n\t\t\t\t\t<span class=\"cn-stats-block__breakdown-label\">{{ formatBreakdownLabel(key) }}</span>\n\t\t\t\t\t<span\n\t\t\t\t\t\tclass=\"cn-stats-block__breakdown-value\"\n\t\t\t\t\t\t:class=\"'cn-stats-block__breakdown-value--' + key\">\n\t\t\t\t\t\t{{ value }}\n\t\t\t\t\t</span>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</component>\n</template>\n\n<script>\nimport { translate as t } from '@nextcloud/l10n'\nimport { NcLoadingIcon } from '@nextcloud/vue'\n\n// The canonical KPI look, shared with CnStatWidget. Imported here so the\n// card is styled even when the consuming app has not pulled in the\n// library's global css/index.css.\nimport '../../css/kpi-card.css'\n\n/**\n * CnStatsBlock — Statistics display card with icon, count, and optional breakdown.\n *\n * Renders the canonical KPI card: a large circular icon beside a left-aligned\n * number, drawing no box of its own because the wrapper around it already\n * draws one. Supports colour variants, icons and clickable state. Use in a\n * CnKpiGrid for responsive dashboard layouts.\n *\n * Basic (canonical horizontal card)\n * ```vue\n * <CnStatsBlock title=\"Cases\" :count=\"42\" count-label=\"open cases\" />\n * ```\n *\n * With icon and variant\n * ```vue\n * <CnStatsBlock\n * title=\"Open Cases\"\n * :count=\"42\"\n * :icon=\"BriefcaseOutline\"\n * variant=\"primary\"\n * clickable\n * @click=\"goToCases\" />\n * ```\n *\n * Stacked and boxed — for a block mounted with no wrapper around it\n * ```vue\n * <CnStatsBlock title=\"Cases\" :count=\"42\" vertical filled />\n * ```\n *\n * With route-based navigation (renders as <router-link>)\n * ```vue\n * <CnStatsBlock\n * title=\"Open Cases\"\n * :count=\"42\"\n * :icon=\"BriefcaseOutline\"\n * variant=\"primary\"\n * :route=\"{ name: 'Cases', query: { status: 'open' } }\" />\n * ```\n *\n * With breakdown\n * ```vue\n * <CnStatsBlock\n * title=\"Cases\"\n * :count=\"42\"\n * :breakdown=\"{ total: 100, invalid: 3, deleted: 5, published: 92 }\" />\n * ```\n *\n * Custom icon slot\n * ```vue\n * <CnStatsBlock title=\"Files\" :count=\"128\">\n * <template #icon>\n * <FileDocumentOutline :size=\"24\" />\n * </template>\n * </CnStatsBlock>\n * ```\n */\nexport default {\n\tname: 'CnStatsBlock',\n\n\tcomponents: {\n\t\tNcLoadingIcon,\n\t},\n\n\tprops: {\n\t\t/** Block title */\n\t\ttitle: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** The main count number to display prominently */\n\t\tcount: {\n\t\t\ttype: Number,\n\t\t\tdefault: 0,\n\t\t},\n\n\t\t/** Label displayed next to the count */\n\t\tcountLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: () => t('nextcloud-vue', 'objects'),\n\t\t},\n\n\t\t/** Detailed breakdown object (key-value pairs) */\n\t\tbreakdown: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Whether data is currently loading */\n\t\tloading: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/** Text shown while loading */\n\t\tloadingLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: () => t('nextcloud-vue', 'Loading…'),\n\t\t},\n\n\t\t/** Text shown when count is 0 */\n\t\temptyLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: () => t('nextcloud-vue', 'No items found'),\n\t\t},\n\n\t\t/**\n\t\t * The tile could not load its number. Anything truthy counts — pass the\n\t\t * caught error itself, or just `true`.\n\t\t *\n\t\t * The tile then shows a dash and `errorLabel` INSTEAD of a count, and\n\t\t * tints itself `error`. This takes precedence over `count`, `loading`\n\t\t * and `emptyLabel` deliberately: a stale or defaulted number rendered\n\t\t * during a failure is the exact thing this prop exists to stop. A\n\t\t * dashboard showing 0 because the backend is down is worse than one\n\t\t * showing nothing, because 0 is a number a reader will believe.\n\t\t *\n\t\t * @type {boolean|string|Error|null}\n\t\t */\n\t\terror: {\n\t\t\t// TYPE ORDER IS SEMANTIC HERE. Vue casts an empty value to `true`\n\t\t\t// when Boolean appears BEFORE String in the union, so\n\t\t\t// `[Boolean, String]` would read `error=\"\"` as an error. A caller\n\t\t\t// clearing its message back to '' is reporting RECOVERY, and that\n\t\t\t// would have pinned the tile in its error state after the fetch\n\t\t\t// succeeded again. String first keeps '' falsy.\n\t\t\t// eslint-disable-next-line vue/prefer-prop-type-boolean-first -- Boolean first would make `error=\"\"` truthy, which is the recovery signal.\n\t\t\ttype: [String, Boolean, Object],\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Text shown in place of the count when `error` is set. */\n\t\terrorLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: () => t('nextcloud-vue', 'Unavailable'),\n\t\t},\n\n\t\t/** Icon component (e.g., imported MDI icon) */\n\t\ticon: {\n\t\t\ttype: [Object, Function],\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Icon size in pixels */\n\t\ticonSize: {\n\t\t\ttype: Number,\n\t\t\tdefault: 24,\n\t\t},\n\n\t\t/** Color variant: 'default', 'primary', 'success', 'warning', 'error' */\n\t\tvariant: {\n\t\t\ttype: String,\n\t\t\tdefault: 'default',\n\t\t\tvalidator: (v) => ['default', 'primary', 'success', 'warning', 'error'].includes(v),\n\t\t},\n\n\t\t/**\n\t\t * Stack the icon above a centred number instead of placing it beside\n\t\t * one. The canonical KPI card is horizontal, so this is the opt-out;\n\t\t * `horizontal` below is kept only for callers that already pass it.\n\t\t */\n\t\tvertical: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * `stacked` is the board's tile, as CnStatWidget `content.layout:\n\t\t * \"stacked\"`: no icon circle, a 14px muted title, the number at\n\t\t * 34px/700 in the text colour. Empty (the default) keeps the KPI card.\n\t\t *\n\t\t * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-a-stats-block-can-take-the-stacked-board-look\n\t\t */\n\t\tlayout: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/**\n\t\t * Lay the icon left of the content. No longer needed — this is the\n\t\t * canonical card's own layout — and kept so existing callers that pass\n\t\t * `horizontal` keep working. Pass `vertical` to stack instead.\n\t\t *\n\t\t * @deprecated since 2.25.0, the horizontal layout is the default.\n\t\t */\n\t\thorizontal: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * Draw the card's own grey box. Off by default: a stats block is\n\t\t * normally rendered inside a wrapper that already draws a card, and a\n\t\t * second box reads as a card inside a card. Turn it on for a block\n\t\t * mounted with no wrapper around it.\n\t\t */\n\t\tfilled: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/** Whether the card is clickable */\n\t\tclickable: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/** Whether to display 0 as a count value instead of the empty label */\n\t\tshowZeroCount: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * Vue Router location object for declarative navigation.\n\t\t * When set, the card renders as a <router-link> and clickable styles are implied.\n\t\t * { name: 'Cases', query: { status: 'open' } }\n\t\t * { path: '/catalogi' }\n\t\t */\n\t\troute: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\t},\n\n\temits: ['click'],\n\n\tcomputed: {\n\t\thasIcon() {\n\t\t\treturn this.icon !== null || this.$slots.icon || this.$slots.icon\n\t\t},\n\n\t\t/**\n\t\t * Whether a consumer provided the `value` slot. When set, the value\n\t\t * area always renders (the slot decides what to show, even at count 0).\n\t\t */\n\t\thasValueSlot() {\n\t\t\treturn !!this.$slots.value || !!this.$slots.value\n\t\t},\n\n\t\t/**\n\t\t * Whether the tile is in its error state.\n\t\t *\n\t\t * Anything truthy counts, so a caller can pass the caught error itself\n\t\t * rather than converting it to a boolean first. An empty string is NOT\n\t\t * an error — that is a caller who cleared the message, not one\n\t\t * reporting a failure.\n\t\t *\n\t\t * @return {boolean} True when the tile should show `errorLabel`.\n\t\t */\n\t\thasError() {\n\t\t\treturn Boolean(this.error)\n\t\t},\n\n\t\tformattedCount() {\n\t\t\treturn this.count.toLocaleString()\n\t\t},\n\n\t\t/**\n\t\t * Whether the card is interactive (clickable or has a route).\n\t\t * Used for applying hover/focus styles.\n\t\t */\n\t\tisInteractive() {\n\t\t\treturn !!this.route || this.clickable\n\t\t},\n\n\t\t/**\n\t\t * Determines which HTML element or component to render.\n\t\t * - route set → 'router-link' (SPA navigation)\n\t\t * - clickable → 'a' (app handles click via event)\n\t\t * - neither → 'div' (static display)\n\t\t */\n\t\tcomponentTag() {\n\t\t\tif (this.route) {\n\t\t\t\treturn 'router-link'\n\t\t\t}\n\t\t\tif (this.clickable) {\n\t\t\t\treturn 'a'\n\t\t\t}\n\t\t\treturn 'div'\n\t\t},\n\n\t\t/**\n\t\t * Dynamic attributes for the root element based on rendering mode.\n\t\t */\n\t\tcomponentAttrs() {\n\t\t\tif (this.route) {\n\t\t\t\treturn { to: this.route, tabindex: '0' }\n\t\t\t}\n\t\t\tif (this.clickable) {\n\t\t\t\treturn { href: '#', role: 'button', tabindex: '0' }\n\t\t\t}\n\t\t\treturn {}\n\t\t},\n\n\t\t/**\n\t\t * Whether the block takes the stacked board look (`layout`).\n\t\t *\n\t\t * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-a-stats-block-can-take-the-stacked-board-look\n\t\t * @return {boolean}\n\t\t */\n\t\tisStacked() {\n\t\t\treturn this.layout === 'stacked'\n\t\t},\n\n\t\trootClasses() {\n\t\t\treturn {\n\t\t\t\t// Canonical + legacy, in pairs: the canonical class carries the\n\t\t\t\t// look, the legacy one keeps existing app CSS matching.\n\t\t\t\t// The canonical card is already horizontal, so the legacy pair\n\t\t\t\t// is emitted for app CSS but carries no look of its own.\n\t\t\t\t'cn-kpi-card--horizontal': !this.isStacked && (this.horizontal || !this.vertical),\n\t\t\t\t'cn-kpi-card--stacked': this.isStacked,\n\t\t\t\t'cn-stats-block--horizontal': this.horizontal || !this.vertical,\n\t\t\t\t'cn-kpi-card--vertical': this.vertical,\n\t\t\t\t'cn-stats-block--vertical': this.vertical,\n\t\t\t\t'cn-kpi-card--filled': this.filled,\n\t\t\t\t'cn-kpi-card--clickable': this.isInteractive,\n\t\t\t\t'cn-stats-block--clickable': this.isInteractive,\n\t\t\t\t'cn-kpi-card--error': this.hasError,\n\t\t\t\t[`cn-kpi-card--${this.variant}`]: this.variant !== 'default' && this.hasError === false,\n\t\t\t\t// An errored tile tints itself. Requiring the consumer to also\n\t\t\t\t// pass `variant=\"error\"` would mean every caller remembering\n\t\t\t\t// two props to express one state, and forgetting the second is\n\t\t\t\t// invisible — a tile that reads \"Unavailable\" in the default\n\t\t\t\t// colour looks like ordinary content.\n\t\t\t\t'cn-stats-block--error': this.hasError,\n\t\t\t\t[`cn-stats-block--${this.variant}`]: this.variant !== 'default' && this.hasError === false,\n\t\t\t}\n\t\t},\n\n\t\ticonClasses() {\n\t\t\treturn {\n\t\t\t\t[`cn-stats-block__icon--${this.variant}`]: this.variant !== 'default',\n\t\t\t}\n\t\t},\n\n\t},\n\n\tmethods: {\n\t\tformatBreakdownLabel(key) {\n\t\t\treturn key.charAt(0).toUpperCase() + key.slice(1) + ':'\n\t\t},\n\n\t\tonClick(event) {\n\t\t\t// When route is set, router-link handles navigation — do not emit click\n\t\t\tif (this.route) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (this.clickable) {\n\t\t\t\tevent.preventDefault()\n\t\t\t\tthis.$emit('click', event)\n\t\t\t}\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n/*\n * The card's own look — box, body stack, icon circle, title, value, variants,\n * hover — is the CANONICAL KPI card in src/css/kpi-card.css, shared with\n * CnStatWidget. The rules that lived here duplicated it in different numbers\n * (a 1.2rem count row, a 40px icon, hardcoded rgba() tints that ignored a\n * re-themed palette), and that duplication IS how the two KPI looks drifted.\n * What is left below is this component's own extras: the breakdown block, the\n * loading line and the empty line.\n *\n * Those extras are secondary KPI text, so they read the shared\n * `--cn-kpi-label-*` scale rather than carrying sizes of their own — an empty\n * state that is a different size from the label it replaces is the same drift\n * in miniature.\n */\n\n.cn-stats-block__header {\n\tmax-width: 100%;\n\tmin-width: 0;\n}\n\n.cn-stats-block__loading {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: flex-start;\n\tgap: 6px;\n\tfont-size: var(--cn-kpi-label-size, 13px);\n\tcolor: var(--cn-kpi-label-color, var(--color-text-maxcontrast));\n}\n\n.cn-stats-block__empty {\n\ttext-align: start;\n\tfont-size: var(--cn-kpi-label-size, 13px);\n\tcolor: var(--cn-kpi-label-color, var(--color-text-maxcontrast));\n\tfont-style: italic;\n}\n\n.cn-stats-block__breakdown {\n\tmargin-top: 4px;\n\tpadding: 8px;\n\tfont-size: var(--cn-kpi-label-size, 13px);\n\tbackground: var(--color-background-hover);\n\tborder-radius: var(--border-radius);\n\tborder: 1px solid var(--color-border);\n\twidth: 100%;\n\ttext-align: start;\n}\n\n.cn-stats-block__breakdown-item {\n\tdisplay: flex;\n\tjustify-content: space-between;\n\talign-items: center;\n\tgap: 8px;\n\tmargin-bottom: 4px;\n}\n\n.cn-stats-block__breakdown-item:last-child {\n\tmargin-bottom: 0;\n}\n\n.cn-stats-block__breakdown-label {\n\tfont-weight: 500;\n\tcolor: var(--color-main-text);\n}\n\n/* The value takes the card's accent, so a breakdown reads as part of the tile\n it sits in rather than as a table that happened to land there. It used to\n carry a `--color-background-hover` chip — on a `--color-background-hover`\n breakdown box, which is to say an invisible chip drawn at the cost of two\n extra declarations and a differently-shaped hit area. The per-key modifiers\n below still win: `invalid`, `deleted` and `published` mean something\n specific and outrank the tile's own colour. */\n.cn-stats-block__breakdown-value {\n\tfont-weight: var(--cn-kpi-title-weight, 600);\n\tcolor: var(--cn-kpi-accent, var(--color-primary-element));\n}\n\n.cn-stats-block__breakdown-value--invalid { color: var(--color-element-warning); }\n\n.cn-stats-block__breakdown-value--deleted { color: var(--color-element-error); }\n\n.cn-stats-block__breakdown-value--published { color: var(--color-element-success); }\n</style>\n"],"names":["_renderSlot","_openBlock","_createBlock","_resolveDynamicComponent","_createElementVNode","_toDisplayString","_createElementBlock","_createVNode","_createTextVNode"],"mappings":"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"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { subscribe, unsubscribe } from '@nextcloud/event-bus';
|
|
2
2
|
import __sfc_main from '../CnStatsBlock/CnStatsBlock.vue.js';
|
|
3
3
|
import { useDataSource } from '../../composables/useDataSource.js';
|
|
4
|
+
import { unwrapAppConfig, normalizeMetricFormat, formatMetricValue } from '../../utils/formatMetric.js';
|
|
4
5
|
import { dropOptionalUnresolved, resolveFilterTokens, hasUnresolvedTokens } from '../../utils/resolveFilterTokens.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -23,6 +24,15 @@ const PAGE_REFRESH_BUS_CHANNEL = 'cn:page:refresh';
|
|
|
23
24
|
* `field`, token-resolved `filter`) plus per-entry presentation
|
|
24
25
|
* (`title`, `variant`, `countLabel`), an optional `route` deep link, and
|
|
25
26
|
* `hideWhenZero` (the entry is omitted when its resolved count is 0).
|
|
27
|
+
*
|
|
28
|
+
* An entry formats its value with `format` (a style name such as
|
|
29
|
+
* `"currency"`, or `{ style, currency, currencyField, decimals, prefix,
|
|
30
|
+
* suffix }`), as a `type:"stat"` widget's `content.format` does. A money
|
|
31
|
+
* value takes the currency from `currencyField` (a field of the detail
|
|
32
|
+
* page's object, e.g. a contract's own `currency`), then `currency` (a code
|
|
33
|
+
* or `@config.currency`), then the app's reporting currency, then EUR, and
|
|
34
|
+
* is formatted in the user's locale. `currency` / `currencyField` may sit on
|
|
35
|
+
* the entry itself and imply `format: "currency"`.
|
|
26
36
|
* Exactly one of `dataSource` / `entries` must be provided — when
|
|
27
37
|
* `entries` is absent, the single-`dataSource` path renders exactly as
|
|
28
38
|
* before.
|
|
@@ -78,6 +88,11 @@ var script = {
|
|
|
78
88
|
* `filter`. Null on pages that don't provide it.
|
|
79
89
|
*/
|
|
80
90
|
cnWorkspaceContext: { default: null },
|
|
91
|
+
/**
|
|
92
|
+
* Page-level app config provided by the page — the reporting
|
|
93
|
+
* currency (`currency`) and `@config.<key>` tokens in `format`.
|
|
94
|
+
*/
|
|
95
|
+
cnAppConfig: { default: () => ({}) },
|
|
81
96
|
},
|
|
82
97
|
|
|
83
98
|
props: {
|
|
@@ -117,6 +132,20 @@ var script = {
|
|
|
117
132
|
default: () => [],
|
|
118
133
|
},
|
|
119
134
|
|
|
135
|
+
/**
|
|
136
|
+
* How the single-source value is shown: a style name (`'currency'`,
|
|
137
|
+
* `'number'`, `'percent'`, `'decimal'`, `'duration-hours'`) or
|
|
138
|
+
* `{ style, currency, currencyField, decimals, prefix, suffix }`.
|
|
139
|
+
* Empty (the default) shows the plain localized count. Entries carry
|
|
140
|
+
* their own `format`.
|
|
141
|
+
*
|
|
142
|
+
* @type {string|object|null}
|
|
143
|
+
*/
|
|
144
|
+
format: {
|
|
145
|
+
type: [String, Object],
|
|
146
|
+
default: null,
|
|
147
|
+
},
|
|
148
|
+
|
|
120
149
|
/** Block title (manifest `widgetDef.title`). */
|
|
121
150
|
title: {
|
|
122
151
|
type: String,
|
|
@@ -159,6 +188,17 @@ var script = {
|
|
|
159
188
|
default: false,
|
|
160
189
|
},
|
|
161
190
|
|
|
191
|
+
/**
|
|
192
|
+
* `stacked` gives every block the board look (see CnStatsBlock
|
|
193
|
+
* `layout`). Empty (the default) keeps the KPI card.
|
|
194
|
+
*
|
|
195
|
+
* @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-a-stats-block-can-take-the-stacked-board-look
|
|
196
|
+
*/
|
|
197
|
+
layout: {
|
|
198
|
+
type: String,
|
|
199
|
+
default: '',
|
|
200
|
+
},
|
|
201
|
+
|
|
162
202
|
/**
|
|
163
203
|
* Draw each tile's own grey box. Off by default — the widget is
|
|
164
204
|
* rendered inside a CnWidgetWrapper that already draws a card.
|
|
@@ -267,6 +307,41 @@ var script = {
|
|
|
267
307
|
return (typeof c === 'object' && 'value' in c) ? c.value : c
|
|
268
308
|
},
|
|
269
309
|
|
|
310
|
+
/**
|
|
311
|
+
* The page-level app config map (reporting currency, `@config` keys).
|
|
312
|
+
*
|
|
313
|
+
* @return {object}
|
|
314
|
+
*/
|
|
315
|
+
configCtx() {
|
|
316
|
+
return unwrapAppConfig(this.cnAppConfig)
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The detail page's object, or null on a dashboard. Its fields feed
|
|
321
|
+
* an entry's `currencyField`.
|
|
322
|
+
*
|
|
323
|
+
* @return {object|null}
|
|
324
|
+
*/
|
|
325
|
+
pageObject() {
|
|
326
|
+
const object = this.objectCtx && this.objectCtx.object;
|
|
327
|
+
return object && typeof object === 'object' ? object : null
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The single-source value formatted per `format`, or null to keep
|
|
332
|
+
* the plain count.
|
|
333
|
+
*
|
|
334
|
+
* @return {string|null}
|
|
335
|
+
* @spec openspec/changes/stat-currency-from-object/specs/dashboard-page/spec.md#requirement-a-stats-block-entry-formats-its-value
|
|
336
|
+
*/
|
|
337
|
+
formattedSingle() {
|
|
338
|
+
const fmt = normalizeMetricFormat({ format: this.format });
|
|
339
|
+
if (!fmt || this.loading) {
|
|
340
|
+
return null
|
|
341
|
+
}
|
|
342
|
+
return formatMetricValue(this.resolvedCount, fmt, this.configCtx, this.pageObject)
|
|
343
|
+
},
|
|
344
|
+
|
|
270
345
|
/**
|
|
271
346
|
* Token-resolution context for entry filters, merged from the
|
|
272
347
|
* detail-page object context and the page-level workspace bag.
|
|
@@ -284,14 +359,19 @@ var script = {
|
|
|
284
359
|
* its fetched count and loading flag, with `hideWhenZero` entries
|
|
285
360
|
* whose resolved count is 0 omitted.
|
|
286
361
|
*
|
|
287
|
-
* @return {Array<{entry: object, key: string, count: number, loading: boolean}>}
|
|
362
|
+
* @return {Array<{entry: object, key: string, count: number, loading: boolean, formatted: (string|null)}>}
|
|
363
|
+
* @spec openspec/changes/stat-currency-from-object/specs/dashboard-page/spec.md#requirement-a-stats-block-entry-formats-its-value
|
|
288
364
|
*/
|
|
289
365
|
entryViews() {
|
|
290
366
|
return (this.entries || [])
|
|
291
367
|
.map((entry, i) => {
|
|
292
368
|
const raw = this.entryCounts[i];
|
|
293
369
|
const resolved = typeof raw === 'number';
|
|
370
|
+
const fmt = normalizeMetricFormat(entry);
|
|
294
371
|
return {
|
|
372
|
+
// The value as the entry's `format` says, money in the
|
|
373
|
+
// currency the entry names; null keeps the plain count.
|
|
374
|
+
formatted: fmt && resolved ? formatMetricValue(raw, fmt, this.configCtx, this.pageObject) : null,
|
|
295
375
|
entry: entry || {},
|
|
296
376
|
key: `${(entry && entry.register) || ''}/${(entry && entry.schema) || ''}/${i}`,
|
|
297
377
|
count: resolved ? raw : 0,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CnStatsBlockWidget.vue2.js","sources":["../../../../src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue"],"sourcesContent":["<!--\n CnStatsBlockWidget — Manifest-driven stats block.\n\n Thin wrapper around CnStatsBlock that pulls its count from a\n `dataSource` block on a dashboard widget definition. The\n dispatcher in CnDashboardPage mounts this when\n `widgetDef.type === 'stats-block'`.\n\n ADR-049 enrichment: an optional `entries[]` renders N related KPIs in one\n card, each entry self-fetching its own token-resolved count over the same\n REST /value aggregation. Exactly one of `dataSource` / `entries` must be\n provided; the single-`dataSource` path is unchanged.\n\n Static usage (no data fetching) is intentionally NOT supported —\n use CnStatsBlock directly when you already have a count number.\n-->\n<template>\n\t<div\n\t\tclass=\"cn-stats-block-widget\"\n\t\t:class=\"[iconClass, { 'cn-stats-block-widget--multi': hasEntries }]\">\n\t\t<!-- Multi-entry mode: one KPI per entry, all inside this widget card.\n\t\t An entry with hideWhenZero whose resolved count is 0 is omitted. -->\n\t\t<template v-if=\"hasEntries\">\n\t\t\t<CnStatsBlock\n\t\t\t\tv-for=\"view in entryViews\"\n\t\t\t\t:key=\"view.key\"\n\t\t\t\t:title=\"view.entry.title || ''\"\n\t\t\t\t:count=\"view.count\"\n\t\t\t\t:countLabel=\"view.entry.countLabel || ''\"\n\t\t\t\t:loading=\"view.loading\"\n\t\t\t\t:variant=\"view.entry.variant || 'default'\"\n\t\t\t\t:showZeroCount=\"showZeroCount\"\n\t\t\t\t:horizontal=\"horizontal\"\n\t\t\t\t:vertical=\"vertical\"\n\t\t\t\t:filled=\"filled\"\n\t\t\t\t:clickable=\"!!view.route\"\n\t\t\t\t:route=\"view.route || null\" />\n\t\t</template>\n\n\t\t<!-- Single-source mode (pre-existing interface, unchanged). -->\n\t\t<CnStatsBlock\n\t\t\tv-else\n\t\t\t:title=\"title\"\n\t\t\t:count=\"resolvedCount\"\n\t\t\t:countLabel=\"countLabel\"\n\t\t\t:loading=\"loading\"\n\t\t\t:variant=\"variant\"\n\t\t\t:showZeroCount=\"showZeroCount\"\n\t\t\t:horizontal=\"horizontal\"\n\t\t\t:vertical=\"vertical\"\n\t\t\t:filled=\"filled\"\n\t\t\t:clickable=\"!!route\"\n\t\t\t:route=\"route\" />\n\t</div>\n</template>\n\n<script>\nimport { subscribe, unsubscribe } from '@nextcloud/event-bus'\nimport CnStatsBlock from '../CnStatsBlock/CnStatsBlock.vue'\nimport { useDataSource } from '../../composables/useDataSource.js'\nimport { dropOptionalUnresolved, hasUnresolvedTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'\n\n// The canonical KPI scale (`--cn-kpi-*`) lives in one stylesheet. Imported\n// here as well as from css/index.css so the tokens resolve even when the\n// consuming app pulls in components individually.\nimport '../../css/kpi-card.css'\n\n/**\n * Event-bus channel the PAGE-level Refresh action broadcasts on\n * (CnDashboardPage / CnDetailPage pass it to their CnActionsMenu).\n */\nconst PAGE_REFRESH_BUS_CHANNEL = 'cn:page:refresh'\n\n/**\n * CnStatsBlockWidget — Stats-block dashboard widget.\n *\n * Reads a `dataSource` block (manifest shorthand or raw GraphQL)\n * and forwards the resolved count to CnStatsBlock. Loading state\n * propagates through; resolution errors fall back to count = 0\n * with an empty-state label so the dashboard doesn't blank.\n *\n * Multi-entry mode (ADR-049 / list-widget-enrichment): an `entries[]`\n * array renders N KPIs in one card. Each entry carries the same source\n * contract as a `type:\"stat\"` widget (`register`, `schema`, `metric`,\n * `field`, token-resolved `filter`) plus per-entry presentation\n * (`title`, `variant`, `countLabel`), an optional `route` deep link, and\n * `hideWhenZero` (the entry is omitted when its resolved count is 0).\n * Exactly one of `dataSource` / `entries` must be provided — when\n * `entries` is absent, the single-`dataSource` path renders exactly as\n * before.\n *\n * Manifest example (decidesk dashboard):\n * ```json\n * {\n * \"id\": \"minutes-in-review\",\n * \"type\": \"stats-block\",\n * \"title\": \"Notulen ter goedkeuring\",\n * \"iconClass\": \"icon-file\",\n * \"props\": { \"countLabel\": \"notulen\", \"variant\": \"warning\" },\n * \"dataSource\": {\n * \"register\": \"decidesk\",\n * \"schema\": \"minutes\",\n * \"filter\": { \"lifecycle\": \"review\" },\n * \"aggregate\": \"count\"\n * }\n * }\n * ```\n *\n * Multi-entry example (a grouped retention card):\n * ```json\n * {\n * \"widgetKey\": \"stats-block\",\n * \"props\": {\n * \"entries\": [\n * { \"title\": \"Expiring soon\", \"register\": \"docudesk\", \"schema\": \"document\",\n * \"filter\": { \"retention\": { \"lt\": \"@today+30d\" } }, \"variant\": \"warning\",\n * \"route\": { \"name\": \"documents\" }, \"hideWhenZero\": true },\n * { \"title\": \"Archived\", \"register\": \"docudesk\", \"schema\": \"document\",\n * \"filter\": { \"status\": \"archived\" } }\n * ]\n * }\n * }\n * ```\n */\nexport default {\n\tname: 'CnStatsBlockWidget',\n\n\tcomponents: { CnStatsBlock },\n\n\tinject: {\n\t\t/**\n\t\t * Detail-page object context provided by CnDetailPage — enables\n\t\t * `@objectId` / `@object.<field>` tokens in an entry's `filter`.\n\t\t * Null on dashboards.\n\t\t */\n\t\tcnObjectContext: { default: null },\n\t\t/**\n\t\t * Page-level workspace context (reactive `ref({})`) provided by\n\t\t * CnDashboardPage — enables `@workspace.<key>` tokens in an entry's\n\t\t * `filter`. Null on pages that don't provide it.\n\t\t */\n\t\tcnWorkspaceContext: { default: null },\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * Manifest dataSource block. See `useDataSource`. Exactly one of\n\t\t * `dataSource` / `entries` must be provided — a stats-block widget\n\t\t * without any source has no count to render (flagged with a console\n\t\t * error at mount).\n\t\t *\n\t\t * @type {{\n\t\t * register?: string,\n\t\t * schema?: string,\n\t\t * filter?: object,\n\t\t * aggregate?: 'count',\n\t\t * graphql?: { query: string, variables?: object, selectors: object }\n\t\t * }}\n\t\t */\n\t\tdataSource: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Multi-entry declarative sources (ADR-049). Each entry renders one\n\t\t * KPI within this widget's card and self-fetches its own count over\n\t\t * the REST /value aggregation: `{ title, register, schema, metric,\n\t\t * field, filter, route, variant, countLabel, hideWhenZero }`. Filter\n\t\t * values use the shared @-token grammar (`@today`, `@me`,\n\t\t * `@workspace.*`, `?`-optional clauses). An entry with `hideWhenZero`\n\t\t * is omitted when its resolved count is 0. Mutually exclusive with\n\t\t * `dataSource`; defaults to empty (single-source mode).\n\t\t *\n\t\t * @type {Array<object>}\n\t\t */\n\t\tentries: {\n\t\t\ttype: Array,\n\t\t\tdefault: () => [],\n\t\t},\n\n\t\t/** Block title (manifest `widgetDef.title`). */\n\t\ttitle: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Label displayed next to the count. */\n\t\tcountLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Color variant: default | primary | success | warning | error. */\n\t\tvariant: {\n\t\t\ttype: String,\n\t\t\tdefault: 'default',\n\t\t},\n\n\t\t/** Whether to show 0 as a real count instead of the empty label. */\n\t\tshowZeroCount: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/**\n\t\t * Lay the icon left of the content. The canonical KPI card already\n\t\t * does this, so the prop is redundant and kept only for existing\n\t\t * callers.\n\t\t *\n\t\t * @deprecated since 2.25.0, the horizontal layout is the default.\n\t\t */\n\t\thorizontal: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/** Stack the icon above a centred number instead of beside it. */\n\t\tvertical: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * Draw each tile's own grey box. Off by default — the widget is\n\t\t * rendered inside a CnWidgetWrapper that already draws a card.\n\t\t */\n\t\tfilled: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * Vue-router location to navigate to on click. When set, the\n\t\t * inner CnStatsBlock renders as a `<router-link>`.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\troute: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Optional CSS class applied to the widget's outermost wrapping\n\t\t * `<div>`. Designed for Nextcloud core icon classes (`icon-link`,\n\t\t * `icon-mail`, `icon-history`, …) that ship with NC and render a\n\t\t * 16×16 glyph via background-image. When empty (the default), the\n\t\t * wrapper carries only the base `cn-stats-block-widget` class and\n\t\t * is visually invisible.\n\t\t *\n\t\t * The wrapping element is a `<div>` with no layout styles of its\n\t\t * own, so the rendered KPI tile keeps the same dimensions and\n\t\t * spacing it had before this prop was added.\n\t\t *\n\t\t * MDI icon names (e.g. `'Database'`) are NOT supported here — that\n\t\t * would require an async dynamic-import. Use `CnStatsBlock`\n\t\t * directly when you need an MDI Vue component icon.\n\t\t */\n\t\ticonClass: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\t},\n\n\tsetup(props) {\n\t\t// GraphQL count path is hidden from useDataSource when a plain\n\t\t// register+schema source is given — that case is served over REST\n\t\t// (fetchRest) against OpenRegister's /value aggregation, which is more\n\t\t// robust than the GraphQL count shorthand. Raw `graphql` still flows here.\n\t\tconst dsForGraphql = () => {\n\t\t\tconst ds = props.dataSource\n\t\t\tif (ds && ds.register && ds.schema && !ds.graphql) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn ds\n\t\t}\n\t\tconst { data, loading, error, refetch } = useDataSource(dsForGraphql)\n\t\treturn { dsData: data, loading, error, dsRefetch: refetch }\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\trestCount: null,\n\t\t\t/** @type {Array<number|null>} Per-entry resolved counts (multi-entry mode; null = pending/failed). */\n\t\t\tentryCounts: [],\n\t\t\t/** @type {Array<boolean>} Per-entry fetch-in-flight flags (multi-entry mode). */\n\t\t\tentryFetching: [],\n\t\t\t/** Monotonic id used to discard a stale entries batch when `entries` changes mid-flight. */\n\t\t\tentriesRequestId: 0,\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * Whether the widget runs in multi-entry mode (`entries[]` supplied).\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\thasEntries() {\n\t\t\treturn Array.isArray(this.entries) && this.entries.length > 0\n\t\t},\n\n\t\t/**\n\t\t * The unwrapped detail-page object context for token resolution, or\n\t\t * null on surfaces (dashboards) that don't provide one.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tobjectCtx() {\n\t\t\tconst c = this.cnObjectContext\n\t\t\tif (!c) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn (typeof c === 'object' && 'value' in c) ? c.value : c\n\t\t},\n\n\t\t/**\n\t\t * The unwrapped workspace context bag (or null). Vue 2.7 inject may\n\t\t * hand back a raw ref; unwrap `.value` for token resolution.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tworkspaceCtx() {\n\t\t\tconst c = this.cnWorkspaceContext\n\t\t\tif (!c) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn (typeof c === 'object' && 'value' in c) ? c.value : c\n\t\t},\n\n\t\t/**\n\t\t * Token-resolution context for entry filters, merged from the\n\t\t * detail-page object context and the page-level workspace bag.\n\t\t *\n\t\t * @return {object}\n\t\t */\n\t\ttokenCtx() {\n\t\t\tconst base = this.objectCtx ? { ...this.objectCtx } : {}\n\t\t\tbase.workspace = this.workspaceCtx || {}\n\t\t\treturn base\n\t\t},\n\n\t\t/**\n\t\t * Renderable view models for multi-entry mode: each entry paired with\n\t\t * its fetched count and loading flag, with `hideWhenZero` entries\n\t\t * whose resolved count is 0 omitted.\n\t\t *\n\t\t * @return {Array<{entry: object, key: string, count: number, loading: boolean}>}\n\t\t */\n\t\tentryViews() {\n\t\t\treturn (this.entries || [])\n\t\t\t\t.map((entry, i) => {\n\t\t\t\t\tconst raw = this.entryCounts[i]\n\t\t\t\t\tconst resolved = typeof raw === 'number'\n\t\t\t\t\treturn {\n\t\t\t\t\t\tentry: entry || {},\n\t\t\t\t\t\tkey: `${(entry && entry.register) || ''}/${(entry && entry.schema) || ''}/${i}`,\n\t\t\t\t\t\tcount: resolved ? raw : 0,\n\t\t\t\t\t\tloading: !!this.entryFetching[i],\n\t\t\t\t\t\tresolved,\n\t\t\t\t\t\t// Token-resolve the deep link the same way entry.filter\n\t\t\t\t\t\t// is resolved — `route.query: { case: \"@objectId\" }` must\n\t\t\t\t\t\t// land on the target pre-scoped, never as a literal token\n\t\t\t\t\t\t// (ADR-062; parity with object-list's viewAllQuery).\n\t\t\t\t\t\troute: this.resolveEntryRoute(entry && entry.route),\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.filter((view) => !(view.entry.hideWhenZero && view.resolved && view.count === 0))\n\t\t},\n\n\t\t/**\n\t\t * Stable signature of a REST-fetchable source (else null).\n\t\t *\n\t\t * @spec openspec/specs/dashboard-page/spec.md\n\t\t */\n\t\trestKey() {\n\t\t\tconst ds = this.dataSource || {}\n\t\t\tif (!ds.register || !ds.schema || ds.graphql) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn JSON.stringify({\n\t\t\t\tregister: ds.register,\n\t\t\t\tschema: ds.schema,\n\t\t\t\tmetric: ds.metric || (ds.aggregate === 'count' ? 'count' : 'count'),\n\t\t\t\tfield: ds.field || '',\n\t\t\t\tfilter: ds.filter || {},\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Stable signature of the multi-entry sources INCLUDING their\n\t\t * token-resolved filters, so the watcher refetches when page-level\n\t\t * state a `@workspace.*` token reads changes.\n\t\t *\n\t\t * @return {string|null}\n\t\t */\n\t\tentriesKey() {\n\t\t\tif (!this.hasEntries) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn JSON.stringify(this.entries.map((entry) => ({\n\t\t\t\tregister: (entry && entry.register) || '',\n\t\t\t\tschema: (entry && entry.schema) || '',\n\t\t\t\tmetric: (entry && entry.metric) || 'count',\n\t\t\t\tfield: (entry && entry.field) || '',\n\t\t\t\tfilter: this.resolvedEntryFilter(entry),\n\t\t\t})))\n\t\t},\n\n\t\tresolvedCount() {\n\t\t\tif (typeof this.restCount === 'number') {\n\t\t\t\treturn this.restCount\n\t\t\t}\n\t\t\tconst value = this.dsData?.count\n\t\t\tif (typeof value === 'number') {\n\t\t\t\treturn value\n\t\t\t}\n\t\t\tif (typeof value === 'string') {\n\t\t\t\tconst parsed = Number(value)\n\t\t\t\treturn Number.isFinite(parsed) ? parsed : 0\n\t\t\t}\n\t\t\treturn 0\n\t\t},\n\t},\n\n\twatch: {\n\t\trestKey() {\n\t\t\tthis.fetchRest()\n\t\t},\n\n\t\tentriesKey() {\n\t\t\tthis.fetchEntries()\n\t\t},\n\t},\n\n\tcreated() {\n\t\t// \"Exactly one of dataSource / entries\" — a misconfigured widget\n\t\t// fails loudly (console error + no counts) instead of rendering an\n\t\t// ambiguous card. Vue 2 prop validators cannot see sibling props, so\n\t\t// the cross-prop rule lives here (and, for manifests, in\n\t\t// validateManifestV2's post-schema check).\n\t\tconst hasDataSource = !!this.dataSource\n\t\tif (hasDataSource === this.hasEntries) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.error('[CnStatsBlockWidget] Exactly one of `dataSource` / `entries` must be provided.')\n\t\t}\n\t},\n\n\tmounted() {\n\t\tthis.fetchRest()\n\t\tthis.fetchEntries()\n\t\t// Page-level Refresh. This tile is now rendered inside CnWidgetWrapper\n\t\t// like every other widget (it went flat in 2026-08-30's KPI-card\n\t\t// change, so the wrapper is what draws its card), but it is registered\n\t\t// as a CARD widget and cards carry no per-widget Actions menu — so the\n\t\t// page action is still its only refresh affordance. It reached nothing\n\t\t// before this subscription: the counts come from `fetchRest` /\n\t\t// `fetchEntries` and the useDataSource GraphQL path, none of which\n\t\t// subscribe on their own. No widgetId to match: a page refresh\n\t\t// refreshes everything on the page.\n\t\tthis._onPageRefresh = (payload) => {\n\t\t\tconst done = this.refresh()\n\t\t\tpayload?.waitUntil?.(done)\n\t\t}\n\t\tsubscribe(PAGE_REFRESH_BUS_CHANNEL, this._onPageRefresh)\n\t},\n\n\tbeforeUnmount() {\n\t\tif (this._onPageRefresh) {\n\t\t\tunsubscribe(PAGE_REFRESH_BUS_CHANNEL, this._onPageRefresh)\n\t\t\tthis._onPageRefresh = null\n\t\t}\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Re-query the tile's counts — the REST `/value` aggregation, the\n\t\t * per-entry counts of multi-entry mode, and the `dataSource` GraphQL\n\t\t * path. Exposed as a ref-callable method for parity with the other data\n\t\t * widgets, and invoked by the page-level Refresh subscription.\n\t\t *\n\t\t * @return {Promise<unknown[]>} Settles when every re-query has.\n\t\t */\n\t\trefresh() {\n\t\t\treturn Promise.all([\n\t\t\t\ttypeof this.dsRefetch === 'function' ? this.dsRefetch() : null,\n\t\t\t\tthis.fetchRest(),\n\t\t\t\tthis.fetchEntries(),\n\t\t\t])\n\t\t},\n\n\t\t/**\n\t\t * An entry's `route` deep link with `@`-tokens in `query` / `params`\n\t\t * resolved against `tokenCtx` (same treatment as `entry.filter`), so\n\t\t * a KPI card can deep-link to an index pre-scoped to the current\n\t\t * object. Unresolved optional query clauses are dropped; a route\n\t\t * whose query still carries a REQUIRED unresolved token is returned\n\t\t * without that query entry resolution left as-is (router will show\n\t\t * the unfiltered index rather than a broken literal-token filter).\n\t\t *\n\t\t * @param {object|string|null} route The entry's route location.\n\t\t * @return {object|string|null} The token-resolved route.\n\t\t */\n\t\tresolveEntryRoute(route) {\n\t\t\tif (!route || typeof route !== 'object') {\n\t\t\t\treturn route || null\n\t\t\t}\n\t\t\tconst out = { ...route }\n\t\t\tif (out.query && typeof out.query === 'object') {\n\t\t\t\tout.query = dropOptionalUnresolved(resolveFilterTokens(out.query, this.tokenCtx))\n\t\t\t}\n\t\t\tif (out.params && typeof out.params === 'object') {\n\t\t\t\tout.params = resolveFilterTokens(out.params, this.tokenCtx)\n\t\t\t}\n\t\t\treturn out\n\t\t},\n\n\t\t/**\n\t\t * An entry's filter with every @-token resolved against `tokenCtx`\n\t\t * and UNRESOLVED OPTIONAL clauses (`@workspace.<key>?`) dropped.\n\t\t *\n\t\t * @param {object} entry The entry definition.\n\t\t * @return {object} The resolved filter map.\n\t\t */\n\t\tresolvedEntryFilter(entry) {\n\t\t\treturn dropOptionalUnresolved(resolveFilterTokens((entry && entry.filter) || {}, this.tokenCtx))\n\t\t},\n\n\t\t/**\n\t\t * Fetch one count/value over REST from OpenRegister's /value\n\t\t * aggregation. Shared by the single-`dataSource` path and the\n\t\t * per-entry multi-entry path. Lazily imports axios/router (the\n\t\t * library never hard-depends on them at module load).\n\t\t *\n\t\t * @param {{register: string, schema: string, metric?: string, aggregate?: string, field?: string}} src The source block.\n\t\t * @param {object} filter The ALREADY-RESOLVED filter map.\n\t\t * @return {Promise<number|null>} The value, or null on failure.\n\t\t */\n\t\tasync fetchValue(src, filter) {\n\t\t\ttry {\n\t\t\t\tconst [{ default: axios }, { generateUrl }] = await Promise.all([\n\t\t\t\t\timport('@nextcloud/axios'),\n\t\t\t\t\timport('@nextcloud/router'),\n\t\t\t\t])\n\t\t\t\tconst url = generateUrl(\n\t\t\t\t\t'/apps/openregister/api/objects/aggregations/{register}/{schema}/value',\n\t\t\t\t\t{ register: src.register, schema: src.schema },\n\t\t\t\t)\n\t\t\t\tconst params = { metric: src.metric || (src.aggregate === 'count' ? 'count' : 'count') }\n\t\t\t\tif (src.field) {\n\t\t\t\t\tparams.field = src.field\n\t\t\t\t}\n\t\t\t\tfor (const [k, v] of Object.entries(filter || {})) {\n\t\t\t\t\tif (v && typeof v === 'object') {\n\t\t\t\t\t\tfor (const [op, ov] of Object.entries(v)) {\n\t\t\t\t\t\t\tparams[`filter[${k}][${op}]`] = ov\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (v !== '' && v !== null && v !== undefined) {\n\t\t\t\t\t\tparams[`filter[${k}]`] = v\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst res = await axios.get(url, { params })\n\t\t\t\treturn Number(res?.data?.value ?? 0) || 0\n\t\t\t} catch {\n\t\t\t\treturn null\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Fetch the count/value over REST when the single `dataSource` is a\n\t\t * plain register+schema block (operator + dynamic tokens supported).\n\t\t * No-op for raw GraphQL or multi-entry mode. Delegates the request to\n\t\t * `fetchValue` (the per-entry refactor) — behaviour is unchanged.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync fetchRest() {\n\t\t\tconst ds = this.dataSource || {}\n\t\t\tif (!ds.register || !ds.schema || ds.graphql) {\n\t\t\t\tthis.restCount = null\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.restCount = await this.fetchValue(ds, resolveFilterTokens(ds.filter || {}))\n\t\t},\n\n\t\t/**\n\t\t * Fetch every entry's count in parallel (multi-entry mode). An entry\n\t\t * whose filter still carries an unresolved REQUIRED token (e.g.\n\t\t * `@workspace.selectedClient` with no selection) is skipped rather\n\t\t * than counted across the whole register. A monotonic request id\n\t\t * discards a stale batch when `entries` changes mid-flight.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync fetchEntries() {\n\t\t\tif (!this.hasEntries) {\n\t\t\t\tthis.entryCounts = []\n\t\t\t\tthis.entryFetching = []\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst id = ++this.entriesRequestId\n\t\t\tconst entries = this.entries\n\t\t\tthis.entryFetching = entries.map(() => true)\n\t\t\tconst counts = await Promise.all(entries.map(async (entry) => {\n\t\t\t\tif (!entry || !entry.register || !entry.schema) {\n\t\t\t\t\treturn null\n\t\t\t\t}\n\t\t\t\tconst filter = this.resolvedEntryFilter(entry)\n\t\t\t\tif (hasUnresolvedTokens(filter)) {\n\t\t\t\t\treturn null\n\t\t\t\t}\n\t\t\t\treturn this.fetchValue(entry, filter)\n\t\t\t}))\n\t\t\tif (id !== this.entriesRequestId) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.entryCounts = counts\n\t\t\tthis.entryFetching = entries.map(() => false)\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n/*\n * This widget draws no KPI of its own — every tile it shows is a CnStatsBlock,\n * so the canonical card in src/css/kpi-card.css is already what a reader sees.\n * All that is left here is how N of those tiles stack inside one widget, and\n * that spacing comes from the shared `--cn-kpi-stack-gap` so a multi-entry\n * widget and a CnStatsPanel stack of the same tiles are spaced identically.\n */\n/*\n * Fill the host cell rather than sizing to content.\n *\n * 🔴 WITHOUT THIS THE WIDGET COLLAPSES TO THE WIDTH OF ITS ICON, and the\n * number it exists to show is clipped to a sliver. Measured on a live\n * instance: a 282px `.cn-widget-wrapper__content` held a 20px widget, a 20px\n * `.cn-kpi-card` and a 0px `.cn-kpi-card__body`. 20px is the icon.\n *\n * The mechanism is a sizing cycle, which is why the KPI card's own CSS looks\n * correct in isolation and is. `.cn-widget-wrapper__content` is `display:\n * flex`, so this root is a flex ITEM and defaults to `flex: 0 1 auto` —\n * content-sized. Its content is `.cn-kpi-card { width: 100% }`, which resolves\n * against THIS element, which is resolving against its content. The card's\n * body carries `min-width: 0` (deliberately, so a long number can shrink\n * instead of overflowing), so the cycle settles with the body at zero and only\n * the icon's intrinsic width surviving.\n *\n * `flex: 1 1 auto` + `width: 100%` gives the card a definite width to resolve\n * against and breaks the cycle; `min-width: 0` keeps the shrink behaviour the\n * body's rule was written for.\n *\n * On the BASE class, not `--multi`: both the single-source and multi-entry\n * modes render this root, so both collapse. The bug was found on a\n * single-entry stats-block.\n */\n.cn-stats-block-widget {\n\tflex: 1 1 auto;\n\tmin-width: 0;\n\twidth: 100%;\n}\n\n.cn-stats-block-widget--multi {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: var(--cn-kpi-stack-gap, 12px);\n}\n</style>\n"],"names":["CnStatsBlock"],"mappings":";;;;;AAmEA;;;;AAIA,MAAM,wBAAuB,GAAI,kBAAgB;;AAEjD;;AAEC;;;;;AAKA;;;;;AAKA;;;;AAIA;AACA;AACA;AACA;;;AAGA;AACA;;;AAGA;;AAEA;AACA;AACA;;;AAGA;AACA;AACA;AACA;;;AAGA;;AAEA;AACA;;AAEA;AACA;;;;AAIA;;AAED,aAAe;CACd,IAAI,EAAE,oBAAoB;;CAE1B,UAAU,EAAE,gBAAEA,UAAW,EAAG;;AAE5B,CAAA,MAAM,EAAE;AACP;;;AAGC;;AAED,EAAA,eAAe,EAAE,EAAE,OAAO,EAAE,IAAG,EAAG;AAClC;;;AAGC;;AAED,EAAA,kBAAkB,EAAE,EAAE,OAAO,EAAE,MAAM;EACrC;;AAED,CAAA,KAAK,EAAE;AACN;;;;AAIC;AACA;AACA;AACA;AACA;AACA;AACA;;AAEA;;EAED,UAAU,EAAE;GACX,IAAI,EAAE,MAAM;GACZ,OAAO,EAAE,IAAI;GACb;;AAED;;;;;;;;;AASC;AACA;;AAED,EAAA,OAAO,EAAE;GACR,IAAI,EAAE,KAAK;AACX,GAAA,OAAO,EAAE,MAAM,EAAE;GACjB;;AAED;AACA,EAAA,KAAK,EAAE;GACN,IAAI,EAAE,MAAM;GACZ,OAAO,EAAE,EAAE;GACX;;AAED;EACA,UAAU,EAAE;GACX,IAAI,EAAE,MAAM;GACZ,OAAO,EAAE,EAAE;GACX;;;AAGD,EAAA,OAAO,EAAE;GACR,IAAI,EAAE,MAAM;AACZ,GAAA,OAAO,EAAE,SAAS;GAClB;;;AAGD,EAAA,aAAa,EAAE;GACd,IAAI,EAAE,OAAO;GACb,OAAO,EAAE,IAAI;GACb;;AAED;;;AAGC;AACA;;;EAGD,UAAU,EAAE;GACX,IAAI,EAAE,OAAO;GACb,OAAO,EAAE,KAAK;GACd;;;AAGD,EAAA,QAAQ,EAAE;GACT,IAAI,EAAE,OAAO;GACb,OAAO,EAAE,KAAK;GACd;;AAED;;;;AAIA,EAAA,MAAM,EAAE;GACP,IAAI,EAAE,OAAO;GACb,OAAO,EAAE,KAAK;GACd;;AAED;;AAEC;AACA;AACA;;AAED,EAAA,KAAK,EAAE;GACN,IAAI,EAAE,MAAM;GACZ,OAAO,EAAE,IAAI;GACb;;AAED;;;;;;AAMC;AACA;;;AAGA;AACA;;;AAGA;;EAED,SAAS,EAAE;GACV,IAAI,EAAE,MAAM;GACZ,OAAO,EAAE,EAAE;GACX;EACD;;CAED,KAAK,CAAC,KAAK,EAAE;;;AAGZ;AACA;EACA,MAAM,YAAW,GAAI,MAAM;GAC1B,MAAM,EAAG,GAAE,KAAK,CAAC,WAAS;AAC1B,GAAA,IAAI,MAAM,EAAE,CAAC,QAAO,IAAK,EAAE,CAAC,MAAK,IAAK,CAAC,EAAE,CAAC,OAAO,EAAE;AAClD,IAAA,OAAO,IAAG;AACX,IAAA;AACA,GAAA,OAAO,EAAC;AACT,IAAA;EACA,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAQ,EAAA,GAAI,aAAa,CAAC,YAAY,EAAA;EACpE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,OAAQ,EAAA;EAC1D;;AAED,CAAA,IAAI,GAAG;AACN,EAAA,OAAO;AACN,GAAA,SAAS,EAAE,IAAI;AACf;AACA,GAAA,WAAW,EAAE,EAAE;AACf;AACA,GAAA,aAAa,EAAE,EAAE;AACjB;AACA,GAAA,gBAAgB,EAAE,CAAC;AACpB,GAAA;EACA;;AAED,CAAA,QAAQ,EAAE;AACT;;AAEC;AACA;;EAED,UAAU,GAAG;GACZ,OAAO,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAE,IAAG,IAAI,CAAC,OAAO,CAAC,MAAK,GAAI,CAAA;GAC5D;;AAED;;;AAGC;AACA;;EAED,SAAS,GAAG;GACX,MAAM,CAAA,GAAI,IAAI,CAAC,gBAAc;AAC7B,GAAA,IAAI,CAAC,CAAC,EAAE;AACP,IAAA,OAAO,IAAG;AACX,IAAA;GACA,OAAO,CAAC,OAAO,CAAA,KAAM,QAAS,IAAG,OAAQ,IAAG,CAAC,IAAI,CAAC,CAAC,KAAI,GAAI,CAAA;GAC3D;;AAED;;;AAGC;AACA;;AAED,EAAA,YAAY,GAAG;GACd,MAAM,CAAA,GAAI,IAAI,CAAC,mBAAiB;AAChC,GAAA,IAAI,CAAC,CAAC,EAAE;AACP,IAAA,OAAO,IAAG;AACX,IAAA;GACA,OAAO,CAAC,OAAO,CAAA,KAAM,QAAS,IAAG,OAAQ,IAAG,CAAC,IAAI,CAAC,CAAC,KAAI,GAAI,CAAA;GAC3D;;AAED;;;AAGC;AACA;;EAED,QAAQ,GAAG;GACV,MAAM,OAAO,IAAI,CAAC,YAAY,EAAE,GAAG,IAAI,CAAC,SAAU,EAAA,GAAI,GAAC;AACvD,GAAA,IAAI,CAAC,SAAQ,GAAI,IAAI,CAAC,YAAa,IAAG,GAAC;AACvC,GAAA,OAAO,IAAG;GACV;;AAED;;;;AAIC;AACA;;EAED,UAAU,GAAG;GACZ,OAAO,CAAC,IAAI,CAAC,OAAQ,IAAG,EAAE;AACzB,KAAC,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC,KAAK;KAClB,MAAM,GAAI,GAAE,IAAI,CAAC,WAAW,CAAC,CAAC,EAAA;AAC9B,KAAA,MAAM,QAAO,GAAI,OAAO,QAAQ,SAAO;AACvC,KAAA,OAAO;AACN,MAAA,KAAK,EAAE,KAAI,IAAK,EAAE;AAClB,MAAA,GAAG,EAAE,CAAC,EAAE,CAAC,KAAM,IAAG,KAAK,CAAC,QAAQ,KAAK,EAAE,CAAC,CAAC,EAAE,CAAC,KAAI,IAAK,KAAK,CAAC,MAAM,KAAK,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAC/E,MAAA,KAAK,EAAE,QAAS,GAAE,GAAE,GAAI,CAAC;AACzB,MAAA,OAAO,EAAE,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC;AAChC,MAAA,QAAQ;;;;;MAKR,KAAK,EAAE,IAAI,CAAC,iBAAiB,CAAC,SAAS,KAAK,CAAC,KAAK,CAAC;AACpD,MAAA;KACA,CAAA;AACD,KAAC,MAAM,CAAC,CAAC,IAAI,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,YAAa,IAAG,IAAI,CAAC,QAAS,IAAG,IAAI,CAAC,KAAM,KAAI,CAAC,CAAC,CAAA;GAClF;;AAED;;AAEC;AACA;;AAED,EAAA,OAAO,GAAG;GACT,MAAM,KAAK,IAAI,CAAC,UAAS,IAAK,GAAC;AAC/B,GAAA,IAAI,CAAC,EAAE,CAAC,YAAY,CAAC,EAAE,CAAC,MAAK,IAAK,EAAE,CAAC,OAAO,EAAE;AAC7C,IAAA,OAAO,IAAG;AACX,IAAA;AACA,GAAA,OAAO,IAAI,CAAC,SAAS,CAAC;AACrB,IAAA,QAAQ,EAAE,EAAE,CAAC,QAAQ;AACrB,IAAA,MAAM,EAAE,EAAE,CAAC,MAAM;IACjB,MAAM,EAAE,EAAE,CAAC,MAAO,KAAI,EAAE,CAAC,SAAU,KAAI,OAAQ,GAAE,OAAM,GAAI,OAAO,CAAC;AACnE,IAAA,KAAK,EAAE,EAAE,CAAC,KAAI,IAAK,EAAE;AACrB,IAAA,MAAM,EAAE,EAAE,CAAC,UAAU,EAAE;IACvB,CAAA;GACD;;AAED;;;AAGC;AACA;AACA;;EAED,UAAU,GAAG;AACZ,GAAA,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE;AACrB,IAAA,OAAO,IAAG;AACX,IAAA;GACA,OAAO,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,KAAK,MAAM;AAClD,IAAA,QAAQ,EAAE,CAAC,KAAI,IAAK,KAAK,CAAC,QAAQ,KAAK,EAAE;AACzC,IAAA,MAAM,EAAE,CAAC,KAAI,IAAK,KAAK,CAAC,MAAM,KAAK,EAAE;AACrC,IAAA,MAAM,EAAE,CAAC,KAAI,IAAK,KAAK,CAAC,MAAM,KAAK,OAAO;AAC1C,IAAA,KAAK,EAAE,CAAC,KAAM,IAAG,KAAK,CAAC,KAAK,KAAK,EAAE;AACnC,IAAA,MAAM,EAAE,IAAI,CAAC,mBAAmB,CAAC,KAAK,CAAC;AACxC,IAAC,CAAC,CAAC,CAAA;GACH;;AAED,EAAA,aAAa,GAAG;AACf,GAAA,IAAI,OAAO,IAAI,CAAC,SAAQ,KAAM,QAAQ,EAAE;AACvC,IAAA,OAAO,IAAI,CAAC,SAAQ;AACrB,IAAA;GACA,MAAM,KAAI,GAAI,IAAI,CAAC,MAAM,EAAE,MAAI;GAC/B,IAAI,OAAO,UAAU,QAAQ,EAAE;IAC9B,OAAO,KAAI;AACZ,IAAA;GACA,IAAI,OAAO,UAAU,QAAQ,EAAE;IAC9B,MAAM,MAAO,GAAE,MAAM,CAAC,KAAK,EAAA;AAC3B,IAAA,OAAO,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAE,GAAE,SAAS,CAAA;AAC3C,IAAA;AACA,GAAA,OAAO,CAAA;GACP;EACD;;AAED,CAAA,KAAK,EAAE;AACN,EAAA,OAAO,GAAG;AACT,GAAA,IAAI,CAAC,SAAS,GAAC;GACf;;EAED,UAAU,GAAG;AACZ,GAAA,IAAI,CAAC,YAAY,GAAC;GAClB;EACD;;AAED,CAAA,OAAO,GAAG;;;;;AAKT;AACA,EAAA,MAAM,aAAc,GAAE,CAAC,CAAC,IAAI,CAAC,WAAS;AACtC,EAAA,IAAI,aAAc,KAAI,IAAI,CAAC,UAAU,EAAE;AACtC;AACA,GAAA,OAAO,CAAC,KAAK,CAAC,gFAAgF,EAAA;AAC/F,GAAA;EACA;;AAED,CAAA,OAAO,GAAG;AACT,EAAA,IAAI,CAAC,SAAS,GAAC;AACf,EAAA,IAAI,CAAC,YAAY,GAAC;;;;;;;;;AASlB;AACA,EAAA,IAAI,CAAC,cAAa,GAAI,CAAC,OAAO,KAAK;GAClC,MAAM,OAAO,IAAI,CAAC,OAAO,GAAC;GAC1B,OAAO,EAAE,SAAS,GAAG,IAAI,EAAA;AAC1B,IAAA;EACA,SAAS,CAAC,wBAAwB,EAAE,IAAI,CAAC,cAAc,EAAA;EACvD;;AAED,CAAA,aAAa,GAAG;EACf,IAAI,IAAI,CAAC,cAAc,EAAE;GACxB,WAAW,CAAC,wBAAwB,EAAE,IAAI,CAAC,cAAc,EAAA;GACzD,IAAI,CAAC,cAAe,GAAE,KAAG;AAC1B,GAAA;EACA;;AAED,CAAA,OAAO,EAAE;AACR;;;;;AAKC;;;AAGD,EAAA,OAAO,GAAG;AACT,GAAA,OAAO,OAAO,CAAC,GAAG,CAAC;IAClB,OAAO,IAAI,CAAC,SAAQ,KAAM,UAAW,GAAE,IAAI,CAAC,SAAS,EAAC,GAAI,IAAI;AAC9D,IAAA,IAAI,CAAC,SAAS,EAAE;AAChB,IAAA,IAAI,CAAC,YAAY,EAAE;IACnB,CAAA;GACD;;AAED;;;;;;;;AAQC;;;;EAID,iBAAiB,CAAC,KAAK,EAAE;AACxB,GAAA,IAAI,CAAC,KAAM,IAAG,OAAO,KAAM,KAAI,QAAQ,EAAE;AACxC,IAAA,OAAO,KAAM,IAAG,IAAG;AACpB,IAAA;AACA,GAAA,MAAM,GAAI,GAAE,EAAE,GAAG,KAAM,GAAA;AACvB,GAAA,IAAI,GAAG,CAAC,SAAS,OAAO,GAAG,CAAC,UAAU,QAAQ,EAAE;AAC/C,IAAA,GAAG,CAAC,KAAM,GAAE,sBAAsB,CAAC,mBAAmB,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,QAAQ,CAAC,EAAA;AACjF,IAAA;AACA,GAAA,IAAI,GAAG,CAAC,UAAU,OAAO,GAAG,CAAC,MAAO,KAAI,QAAQ,EAAE;IACjD,GAAG,CAAC,MAAO,GAAE,mBAAmB,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,QAAQ,EAAA;AAC3D,IAAA;AACA,GAAA,OAAO,GAAE;GACT;;AAED;;;AAGC;AACA;AACA;;EAED,mBAAmB,CAAC,KAAK,EAAE;AAC1B,GAAA,OAAO,sBAAsB,CAAC,mBAAmB,CAAC,CAAC,KAAI,IAAK,KAAK,CAAC,MAAM,KAAK,EAAE,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAA;GAC/F;;AAED;;;;AAIC;AACA;;;;;EAKD,MAAM,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE;AAC7B,GAAA,IAAI;IACH,MAAM,CAAC,EAAE,OAAO,EAAE,KAAI,EAAG,EAAE,EAAE,WAAU,EAAG,CAAE,GAAE,MAAM,OAAO,CAAC,GAAG,CAAC;KAC/D,OAAO,kBAAkB,CAAC;KAC1B,OAAO,mBAAmB,CAAC;KAC3B,EAAA;AACD,IAAA,MAAM,MAAM,WAAW;AACtB,KAAA,uEAAuE;AACvE,KAAA,EAAE,QAAQ,EAAE,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,CAAC,QAAQ;AAC/C,MAAA;AACA,IAAA,MAAM,MAAO,GAAE,EAAE,MAAM,EAAE,GAAG,CAAC,MAAO,KAAI,GAAG,CAAC,SAAU,KAAI,OAAQ,GAAE,OAAM,GAAI,OAAO,CAAE,GAAA;IACvF,IAAI,GAAG,CAAC,KAAK,EAAE;AACd,KAAA,MAAM,CAAC,KAAM,GAAE,GAAG,CAAC,MAAI;AACxB,KAAA;AACA,IAAA,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAE,IAAG,MAAM,CAAC,OAAO,CAAC,MAAO,IAAG,EAAE,CAAC,EAAE;KAClD,IAAI,CAAE,IAAG,OAAO,MAAM,QAAQ,EAAE;AAC/B,MAAA,KAAK,MAAM,CAAC,EAAE,EAAE,EAAE,KAAK,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE;AACzC,OAAA,MAAM,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,CAAA,GAAI,GAAC;AAClC,OAAA;AACD,MAAA,MAAO,IAAI,CAAA,KAAM,EAAC,IAAK,CAAA,KAAM,IAAK,IAAG,CAAA,KAAM,SAAS,EAAE;MACrD,MAAM,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAC,CAAA,GAAI,EAAA;AAC1B,MAAA;AACD,KAAA;AACA,IAAA,MAAM,GAAE,GAAI,MAAM,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,QAAQ,EAAA;AAC3C,IAAA,OAAO,MAAM,CAAC,GAAG,EAAE,IAAI,EAAE,SAAS,CAAC,CAAA,IAAK,CAAA;AACzC,IAAA,CAAE,MAAM;AACP,IAAA,OAAO,IAAG;AACX,IAAA;GACA;;AAED;;;;;AAKC;;;AAGD,EAAA,MAAM,SAAS,GAAG;GACjB,MAAM,KAAK,IAAI,CAAC,UAAS,IAAK,GAAC;AAC/B,GAAA,IAAI,CAAC,EAAE,CAAC,YAAY,CAAC,EAAE,CAAC,MAAK,IAAK,EAAE,CAAC,OAAO,EAAE;AAC7C,IAAA,IAAI,CAAC,SAAQ,GAAI,KAAG;IACpB,MAAK;AACN,IAAA;AACA,GAAA,IAAI,CAAC,SAAQ,GAAI,MAAM,IAAI,CAAC,UAAU,CAAC,EAAE,EAAE,mBAAmB,CAAC,EAAE,CAAC,MAAO,IAAG,EAAE,CAAC,EAAA;GAC/E;;AAED;;;;;;AAMC;;;AAGD,EAAA,MAAM,YAAY,GAAG;AACpB,GAAA,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE;AACrB,IAAA,IAAI,CAAC,WAAU,GAAI,GAAC;AACpB,IAAA,IAAI,CAAC,aAAY,GAAI,GAAC;IACtB,MAAK;AACN,IAAA;AACA,GAAA,MAAM,EAAC,GAAI,EAAE,IAAI,CAAC,iBAAe;GACjC,MAAM,OAAM,GAAI,IAAI,CAAC,QAAM;AAC3B,GAAA,IAAI,CAAC,aAAc,GAAE,OAAO,CAAC,GAAG,CAAC,MAAM,IAAI,EAAA;GAC3C,MAAM,MAAK,GAAI,MAAM,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,KAAK,KAAK;AAC7D,IAAA,IAAI,CAAC,KAAM,IAAG,CAAC,KAAK,CAAC,QAAO,IAAK,CAAC,KAAK,CAAC,MAAM,EAAE;AAC/C,KAAA,OAAO,IAAG;AACX,KAAA;AACA,IAAA,MAAM,MAAO,GAAE,IAAI,CAAC,mBAAmB,CAAC,KAAK,EAAA;AAC7C,IAAA,IAAI,mBAAmB,CAAC,MAAM,CAAC,EAAE;AAChC,KAAA,OAAO,IAAG;AACX,KAAA;AACA,IAAA,OAAO,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,MAAM,CAAA;AACrC,IAAC,CAAC,EAAA;AACF,GAAA,IAAI,EAAC,KAAM,IAAI,CAAC,gBAAgB,EAAE;IACjC,MAAK;AACN,IAAA;GACA,IAAI,CAAC,WAAY,GAAE,OAAK;AACxB,GAAA,IAAI,CAAC,gBAAgB,OAAO,CAAC,GAAG,CAAC,MAAM,KAAK,EAAA;GAC5C;EACD;AACF;;;;"}
|
|
1
|
+
{"version":3,"file":"CnStatsBlockWidget.vue2.js","sources":["../../../../src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue"],"sourcesContent":["<!--\n CnStatsBlockWidget — Manifest-driven stats block.\n\n Thin wrapper around CnStatsBlock that pulls its count from a\n `dataSource` block on a dashboard widget definition. The\n dispatcher in CnDashboardPage mounts this when\n `widgetDef.type === 'stats-block'`.\n\n ADR-049 enrichment: an optional `entries[]` renders N related KPIs in one\n card, each entry self-fetching its own token-resolved count over the same\n REST /value aggregation. Exactly one of `dataSource` / `entries` must be\n provided; the single-`dataSource` path is unchanged.\n\n Static usage (no data fetching) is intentionally NOT supported —\n use CnStatsBlock directly when you already have a count number.\n-->\n<template>\n\t<div\n\t\tclass=\"cn-stats-block-widget\"\n\t\t:class=\"[iconClass, { 'cn-stats-block-widget--multi': hasEntries }]\">\n\t\t<!-- Multi-entry mode: one KPI per entry, all inside this widget card.\n\t\t An entry with hideWhenZero whose resolved count is 0 is omitted. -->\n\t\t<template v-if=\"hasEntries\">\n\t\t\t<CnStatsBlock\n\t\t\t\tv-for=\"view in entryViews\"\n\t\t\t\t:key=\"view.key\"\n\t\t\t\t:title=\"view.entry.title || ''\"\n\t\t\t\t:count=\"view.count\"\n\t\t\t\t:countLabel=\"view.entry.countLabel || ''\"\n\t\t\t\t:loading=\"view.loading\"\n\t\t\t\t:variant=\"view.entry.variant || 'default'\"\n\t\t\t\t:showZeroCount=\"showZeroCount\"\n\t\t\t\t:horizontal=\"horizontal\"\n\t\t\t\t:vertical=\"vertical\"\n\t\t\t\t:layout=\"layout\"\n\t\t\t\t:filled=\"filled\"\n\t\t\t\t:clickable=\"!!view.route\"\n\t\t\t\t:route=\"view.route || null\">\n\t\t\t\t<!-- An entry with a `format` (or `currency` / `currencyField`)\n\t\t\t\t shows its value formatted, money in its own currency. -->\n\t\t\t\t<template v-if=\"view.formatted !== null\" #value>\n\t\t\t\t\t{{ view.formatted }}\n\t\t\t\t</template>\n\t\t\t</CnStatsBlock>\n\t\t</template>\n\n\t\t<!-- Single-source mode (pre-existing interface, unchanged). -->\n\t\t<CnStatsBlock\n\t\t\tv-else\n\t\t\t:title=\"title\"\n\t\t\t:count=\"resolvedCount\"\n\t\t\t:countLabel=\"countLabel\"\n\t\t\t:loading=\"loading\"\n\t\t\t:variant=\"variant\"\n\t\t\t:showZeroCount=\"showZeroCount\"\n\t\t\t:horizontal=\"horizontal\"\n\t\t\t:vertical=\"vertical\"\n\t\t\t:layout=\"layout\"\n\t\t\t:filled=\"filled\"\n\t\t\t:clickable=\"!!route\"\n\t\t\t:route=\"route\">\n\t\t\t<template v-if=\"formattedSingle !== null\" #value>\n\t\t\t\t{{ formattedSingle }}\n\t\t\t</template>\n\t\t</CnStatsBlock>\n\t</div>\n</template>\n\n<script>\nimport { subscribe, unsubscribe } from '@nextcloud/event-bus'\nimport CnStatsBlock from '../CnStatsBlock/CnStatsBlock.vue'\nimport { useDataSource } from '../../composables/useDataSource.js'\nimport { formatMetricValue, normalizeMetricFormat, unwrapAppConfig } from '../../utils/formatMetric.js'\nimport { dropOptionalUnresolved, hasUnresolvedTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'\n\n// The canonical KPI scale (`--cn-kpi-*`) lives in one stylesheet. Imported\n// here as well as from css/index.css so the tokens resolve even when the\n// consuming app pulls in components individually.\nimport '../../css/kpi-card.css'\n\n/**\n * Event-bus channel the PAGE-level Refresh action broadcasts on\n * (CnDashboardPage / CnDetailPage pass it to their CnActionsMenu).\n */\nconst PAGE_REFRESH_BUS_CHANNEL = 'cn:page:refresh'\n\n/**\n * CnStatsBlockWidget — Stats-block dashboard widget.\n *\n * Reads a `dataSource` block (manifest shorthand or raw GraphQL)\n * and forwards the resolved count to CnStatsBlock. Loading state\n * propagates through; resolution errors fall back to count = 0\n * with an empty-state label so the dashboard doesn't blank.\n *\n * Multi-entry mode (ADR-049 / list-widget-enrichment): an `entries[]`\n * array renders N KPIs in one card. Each entry carries the same source\n * contract as a `type:\"stat\"` widget (`register`, `schema`, `metric`,\n * `field`, token-resolved `filter`) plus per-entry presentation\n * (`title`, `variant`, `countLabel`), an optional `route` deep link, and\n * `hideWhenZero` (the entry is omitted when its resolved count is 0).\n *\n * An entry formats its value with `format` (a style name such as\n * `\"currency\"`, or `{ style, currency, currencyField, decimals, prefix,\n * suffix }`), as a `type:\"stat\"` widget's `content.format` does. A money\n * value takes the currency from `currencyField` (a field of the detail\n * page's object, e.g. a contract's own `currency`), then `currency` (a code\n * or `@config.currency`), then the app's reporting currency, then EUR, and\n * is formatted in the user's locale. `currency` / `currencyField` may sit on\n * the entry itself and imply `format: \"currency\"`.\n * Exactly one of `dataSource` / `entries` must be provided — when\n * `entries` is absent, the single-`dataSource` path renders exactly as\n * before.\n *\n * Manifest example (decidesk dashboard):\n * ```json\n * {\n * \"id\": \"minutes-in-review\",\n * \"type\": \"stats-block\",\n * \"title\": \"Notulen ter goedkeuring\",\n * \"iconClass\": \"icon-file\",\n * \"props\": { \"countLabel\": \"notulen\", \"variant\": \"warning\" },\n * \"dataSource\": {\n * \"register\": \"decidesk\",\n * \"schema\": \"minutes\",\n * \"filter\": { \"lifecycle\": \"review\" },\n * \"aggregate\": \"count\"\n * }\n * }\n * ```\n *\n * Multi-entry example (a grouped retention card):\n * ```json\n * {\n * \"widgetKey\": \"stats-block\",\n * \"props\": {\n * \"entries\": [\n * { \"title\": \"Expiring soon\", \"register\": \"docudesk\", \"schema\": \"document\",\n * \"filter\": { \"retention\": { \"lt\": \"@today+30d\" } }, \"variant\": \"warning\",\n * \"route\": { \"name\": \"documents\" }, \"hideWhenZero\": true },\n * { \"title\": \"Archived\", \"register\": \"docudesk\", \"schema\": \"document\",\n * \"filter\": { \"status\": \"archived\" } }\n * ]\n * }\n * }\n * ```\n */\nexport default {\n\tname: 'CnStatsBlockWidget',\n\n\tcomponents: { CnStatsBlock },\n\n\tinject: {\n\t\t/**\n\t\t * Detail-page object context provided by CnDetailPage — enables\n\t\t * `@objectId` / `@object.<field>` tokens in an entry's `filter`.\n\t\t * Null on dashboards.\n\t\t */\n\t\tcnObjectContext: { default: null },\n\t\t/**\n\t\t * Page-level workspace context (reactive `ref({})`) provided by\n\t\t * CnDashboardPage — enables `@workspace.<key>` tokens in an entry's\n\t\t * `filter`. Null on pages that don't provide it.\n\t\t */\n\t\tcnWorkspaceContext: { default: null },\n\t\t/**\n\t\t * Page-level app config provided by the page — the reporting\n\t\t * currency (`currency`) and `@config.<key>` tokens in `format`.\n\t\t */\n\t\tcnAppConfig: { default: () => ({}) },\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * Manifest dataSource block. See `useDataSource`. Exactly one of\n\t\t * `dataSource` / `entries` must be provided — a stats-block widget\n\t\t * without any source has no count to render (flagged with a console\n\t\t * error at mount).\n\t\t *\n\t\t * @type {{\n\t\t * register?: string,\n\t\t * schema?: string,\n\t\t * filter?: object,\n\t\t * aggregate?: 'count',\n\t\t * graphql?: { query: string, variables?: object, selectors: object }\n\t\t * }}\n\t\t */\n\t\tdataSource: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Multi-entry declarative sources (ADR-049). Each entry renders one\n\t\t * KPI within this widget's card and self-fetches its own count over\n\t\t * the REST /value aggregation: `{ title, register, schema, metric,\n\t\t * field, filter, route, variant, countLabel, hideWhenZero }`. Filter\n\t\t * values use the shared @-token grammar (`@today`, `@me`,\n\t\t * `@workspace.*`, `?`-optional clauses). An entry with `hideWhenZero`\n\t\t * is omitted when its resolved count is 0. Mutually exclusive with\n\t\t * `dataSource`; defaults to empty (single-source mode).\n\t\t *\n\t\t * @type {Array<object>}\n\t\t */\n\t\tentries: {\n\t\t\ttype: Array,\n\t\t\tdefault: () => [],\n\t\t},\n\n\t\t/**\n\t\t * How the single-source value is shown: a style name (`'currency'`,\n\t\t * `'number'`, `'percent'`, `'decimal'`, `'duration-hours'`) or\n\t\t * `{ style, currency, currencyField, decimals, prefix, suffix }`.\n\t\t * Empty (the default) shows the plain localized count. Entries carry\n\t\t * their own `format`.\n\t\t *\n\t\t * @type {string|object|null}\n\t\t */\n\t\tformat: {\n\t\t\ttype: [String, Object],\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Block title (manifest `widgetDef.title`). */\n\t\ttitle: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Label displayed next to the count. */\n\t\tcountLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Color variant: default | primary | success | warning | error. */\n\t\tvariant: {\n\t\t\ttype: String,\n\t\t\tdefault: 'default',\n\t\t},\n\n\t\t/** Whether to show 0 as a real count instead of the empty label. */\n\t\tshowZeroCount: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/**\n\t\t * Lay the icon left of the content. The canonical KPI card already\n\t\t * does this, so the prop is redundant and kept only for existing\n\t\t * callers.\n\t\t *\n\t\t * @deprecated since 2.25.0, the horizontal layout is the default.\n\t\t */\n\t\thorizontal: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/** Stack the icon above a centred number instead of beside it. */\n\t\tvertical: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * `stacked` gives every block the board look (see CnStatsBlock\n\t\t * `layout`). Empty (the default) keeps the KPI card.\n\t\t *\n\t\t * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-a-stats-block-can-take-the-stacked-board-look\n\t\t */\n\t\tlayout: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/**\n\t\t * Draw each tile's own grey box. Off by default — the widget is\n\t\t * rendered inside a CnWidgetWrapper that already draws a card.\n\t\t */\n\t\tfilled: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * Vue-router location to navigate to on click. When set, the\n\t\t * inner CnStatsBlock renders as a `<router-link>`.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\troute: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Optional CSS class applied to the widget's outermost wrapping\n\t\t * `<div>`. Designed for Nextcloud core icon classes (`icon-link`,\n\t\t * `icon-mail`, `icon-history`, …) that ship with NC and render a\n\t\t * 16×16 glyph via background-image. When empty (the default), the\n\t\t * wrapper carries only the base `cn-stats-block-widget` class and\n\t\t * is visually invisible.\n\t\t *\n\t\t * The wrapping element is a `<div>` with no layout styles of its\n\t\t * own, so the rendered KPI tile keeps the same dimensions and\n\t\t * spacing it had before this prop was added.\n\t\t *\n\t\t * MDI icon names (e.g. `'Database'`) are NOT supported here — that\n\t\t * would require an async dynamic-import. Use `CnStatsBlock`\n\t\t * directly when you need an MDI Vue component icon.\n\t\t */\n\t\ticonClass: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\t},\n\n\tsetup(props) {\n\t\t// GraphQL count path is hidden from useDataSource when a plain\n\t\t// register+schema source is given — that case is served over REST\n\t\t// (fetchRest) against OpenRegister's /value aggregation, which is more\n\t\t// robust than the GraphQL count shorthand. Raw `graphql` still flows here.\n\t\tconst dsForGraphql = () => {\n\t\t\tconst ds = props.dataSource\n\t\t\tif (ds && ds.register && ds.schema && !ds.graphql) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn ds\n\t\t}\n\t\tconst { data, loading, error, refetch } = useDataSource(dsForGraphql)\n\t\treturn { dsData: data, loading, error, dsRefetch: refetch }\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\trestCount: null,\n\t\t\t/** @type {Array<number|null>} Per-entry resolved counts (multi-entry mode; null = pending/failed). */\n\t\t\tentryCounts: [],\n\t\t\t/** @type {Array<boolean>} Per-entry fetch-in-flight flags (multi-entry mode). */\n\t\t\tentryFetching: [],\n\t\t\t/** Monotonic id used to discard a stale entries batch when `entries` changes mid-flight. */\n\t\t\tentriesRequestId: 0,\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * Whether the widget runs in multi-entry mode (`entries[]` supplied).\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\thasEntries() {\n\t\t\treturn Array.isArray(this.entries) && this.entries.length > 0\n\t\t},\n\n\t\t/**\n\t\t * The unwrapped detail-page object context for token resolution, or\n\t\t * null on surfaces (dashboards) that don't provide one.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tobjectCtx() {\n\t\t\tconst c = this.cnObjectContext\n\t\t\tif (!c) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn (typeof c === 'object' && 'value' in c) ? c.value : c\n\t\t},\n\n\t\t/**\n\t\t * The unwrapped workspace context bag (or null). Vue 2.7 inject may\n\t\t * hand back a raw ref; unwrap `.value` for token resolution.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tworkspaceCtx() {\n\t\t\tconst c = this.cnWorkspaceContext\n\t\t\tif (!c) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn (typeof c === 'object' && 'value' in c) ? c.value : c\n\t\t},\n\n\t\t/**\n\t\t * The page-level app config map (reporting currency, `@config` keys).\n\t\t *\n\t\t * @return {object}\n\t\t */\n\t\tconfigCtx() {\n\t\t\treturn unwrapAppConfig(this.cnAppConfig)\n\t\t},\n\n\t\t/**\n\t\t * The detail page's object, or null on a dashboard. Its fields feed\n\t\t * an entry's `currencyField`.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tpageObject() {\n\t\t\tconst object = this.objectCtx && this.objectCtx.object\n\t\t\treturn object && typeof object === 'object' ? object : null\n\t\t},\n\n\t\t/**\n\t\t * The single-source value formatted per `format`, or null to keep\n\t\t * the plain count.\n\t\t *\n\t\t * @return {string|null}\n\t\t * @spec openspec/changes/stat-currency-from-object/specs/dashboard-page/spec.md#requirement-a-stats-block-entry-formats-its-value\n\t\t */\n\t\tformattedSingle() {\n\t\t\tconst fmt = normalizeMetricFormat({ format: this.format })\n\t\t\tif (!fmt || this.loading) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn formatMetricValue(this.resolvedCount, fmt, this.configCtx, this.pageObject)\n\t\t},\n\n\t\t/**\n\t\t * Token-resolution context for entry filters, merged from the\n\t\t * detail-page object context and the page-level workspace bag.\n\t\t *\n\t\t * @return {object}\n\t\t */\n\t\ttokenCtx() {\n\t\t\tconst base = this.objectCtx ? { ...this.objectCtx } : {}\n\t\t\tbase.workspace = this.workspaceCtx || {}\n\t\t\treturn base\n\t\t},\n\n\t\t/**\n\t\t * Renderable view models for multi-entry mode: each entry paired with\n\t\t * its fetched count and loading flag, with `hideWhenZero` entries\n\t\t * whose resolved count is 0 omitted.\n\t\t *\n\t\t * @return {Array<{entry: object, key: string, count: number, loading: boolean, formatted: (string|null)}>}\n\t\t * @spec openspec/changes/stat-currency-from-object/specs/dashboard-page/spec.md#requirement-a-stats-block-entry-formats-its-value\n\t\t */\n\t\tentryViews() {\n\t\t\treturn (this.entries || [])\n\t\t\t\t.map((entry, i) => {\n\t\t\t\t\tconst raw = this.entryCounts[i]\n\t\t\t\t\tconst resolved = typeof raw === 'number'\n\t\t\t\t\tconst fmt = normalizeMetricFormat(entry)\n\t\t\t\t\treturn {\n\t\t\t\t\t\t// The value as the entry's `format` says, money in the\n\t\t\t\t\t\t// currency the entry names; null keeps the plain count.\n\t\t\t\t\t\tformatted: fmt && resolved ? formatMetricValue(raw, fmt, this.configCtx, this.pageObject) : null,\n\t\t\t\t\t\tentry: entry || {},\n\t\t\t\t\t\tkey: `${(entry && entry.register) || ''}/${(entry && entry.schema) || ''}/${i}`,\n\t\t\t\t\t\tcount: resolved ? raw : 0,\n\t\t\t\t\t\tloading: !!this.entryFetching[i],\n\t\t\t\t\t\tresolved,\n\t\t\t\t\t\t// Token-resolve the deep link the same way entry.filter\n\t\t\t\t\t\t// is resolved — `route.query: { case: \"@objectId\" }` must\n\t\t\t\t\t\t// land on the target pre-scoped, never as a literal token\n\t\t\t\t\t\t// (ADR-062; parity with object-list's viewAllQuery).\n\t\t\t\t\t\troute: this.resolveEntryRoute(entry && entry.route),\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.filter((view) => !(view.entry.hideWhenZero && view.resolved && view.count === 0))\n\t\t},\n\n\t\t/**\n\t\t * Stable signature of a REST-fetchable source (else null).\n\t\t *\n\t\t * @spec openspec/specs/dashboard-page/spec.md\n\t\t */\n\t\trestKey() {\n\t\t\tconst ds = this.dataSource || {}\n\t\t\tif (!ds.register || !ds.schema || ds.graphql) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn JSON.stringify({\n\t\t\t\tregister: ds.register,\n\t\t\t\tschema: ds.schema,\n\t\t\t\tmetric: ds.metric || (ds.aggregate === 'count' ? 'count' : 'count'),\n\t\t\t\tfield: ds.field || '',\n\t\t\t\tfilter: ds.filter || {},\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Stable signature of the multi-entry sources INCLUDING their\n\t\t * token-resolved filters, so the watcher refetches when page-level\n\t\t * state a `@workspace.*` token reads changes.\n\t\t *\n\t\t * @return {string|null}\n\t\t */\n\t\tentriesKey() {\n\t\t\tif (!this.hasEntries) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn JSON.stringify(this.entries.map((entry) => ({\n\t\t\t\tregister: (entry && entry.register) || '',\n\t\t\t\tschema: (entry && entry.schema) || '',\n\t\t\t\tmetric: (entry && entry.metric) || 'count',\n\t\t\t\tfield: (entry && entry.field) || '',\n\t\t\t\tfilter: this.resolvedEntryFilter(entry),\n\t\t\t})))\n\t\t},\n\n\t\tresolvedCount() {\n\t\t\tif (typeof this.restCount === 'number') {\n\t\t\t\treturn this.restCount\n\t\t\t}\n\t\t\tconst value = this.dsData?.count\n\t\t\tif (typeof value === 'number') {\n\t\t\t\treturn value\n\t\t\t}\n\t\t\tif (typeof value === 'string') {\n\t\t\t\tconst parsed = Number(value)\n\t\t\t\treturn Number.isFinite(parsed) ? parsed : 0\n\t\t\t}\n\t\t\treturn 0\n\t\t},\n\t},\n\n\twatch: {\n\t\trestKey() {\n\t\t\tthis.fetchRest()\n\t\t},\n\n\t\tentriesKey() {\n\t\t\tthis.fetchEntries()\n\t\t},\n\t},\n\n\tcreated() {\n\t\t// \"Exactly one of dataSource / entries\" — a misconfigured widget\n\t\t// fails loudly (console error + no counts) instead of rendering an\n\t\t// ambiguous card. Vue 2 prop validators cannot see sibling props, so\n\t\t// the cross-prop rule lives here (and, for manifests, in\n\t\t// validateManifestV2's post-schema check).\n\t\tconst hasDataSource = !!this.dataSource\n\t\tif (hasDataSource === this.hasEntries) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.error('[CnStatsBlockWidget] Exactly one of `dataSource` / `entries` must be provided.')\n\t\t}\n\t},\n\n\tmounted() {\n\t\tthis.fetchRest()\n\t\tthis.fetchEntries()\n\t\t// Page-level Refresh. This tile is now rendered inside CnWidgetWrapper\n\t\t// like every other widget (it went flat in 2026-08-30's KPI-card\n\t\t// change, so the wrapper is what draws its card), but it is registered\n\t\t// as a CARD widget and cards carry no per-widget Actions menu — so the\n\t\t// page action is still its only refresh affordance. It reached nothing\n\t\t// before this subscription: the counts come from `fetchRest` /\n\t\t// `fetchEntries` and the useDataSource GraphQL path, none of which\n\t\t// subscribe on their own. No widgetId to match: a page refresh\n\t\t// refreshes everything on the page.\n\t\tthis._onPageRefresh = (payload) => {\n\t\t\tconst done = this.refresh()\n\t\t\tpayload?.waitUntil?.(done)\n\t\t}\n\t\tsubscribe(PAGE_REFRESH_BUS_CHANNEL, this._onPageRefresh)\n\t},\n\n\tbeforeUnmount() {\n\t\tif (this._onPageRefresh) {\n\t\t\tunsubscribe(PAGE_REFRESH_BUS_CHANNEL, this._onPageRefresh)\n\t\t\tthis._onPageRefresh = null\n\t\t}\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Re-query the tile's counts — the REST `/value` aggregation, the\n\t\t * per-entry counts of multi-entry mode, and the `dataSource` GraphQL\n\t\t * path. Exposed as a ref-callable method for parity with the other data\n\t\t * widgets, and invoked by the page-level Refresh subscription.\n\t\t *\n\t\t * @return {Promise<unknown[]>} Settles when every re-query has.\n\t\t */\n\t\trefresh() {\n\t\t\treturn Promise.all([\n\t\t\t\ttypeof this.dsRefetch === 'function' ? this.dsRefetch() : null,\n\t\t\t\tthis.fetchRest(),\n\t\t\t\tthis.fetchEntries(),\n\t\t\t])\n\t\t},\n\n\t\t/**\n\t\t * An entry's `route` deep link with `@`-tokens in `query` / `params`\n\t\t * resolved against `tokenCtx` (same treatment as `entry.filter`), so\n\t\t * a KPI card can deep-link to an index pre-scoped to the current\n\t\t * object. Unresolved optional query clauses are dropped; a route\n\t\t * whose query still carries a REQUIRED unresolved token is returned\n\t\t * without that query entry resolution left as-is (router will show\n\t\t * the unfiltered index rather than a broken literal-token filter).\n\t\t *\n\t\t * @param {object|string|null} route The entry's route location.\n\t\t * @return {object|string|null} The token-resolved route.\n\t\t */\n\t\tresolveEntryRoute(route) {\n\t\t\tif (!route || typeof route !== 'object') {\n\t\t\t\treturn route || null\n\t\t\t}\n\t\t\tconst out = { ...route }\n\t\t\tif (out.query && typeof out.query === 'object') {\n\t\t\t\tout.query = dropOptionalUnresolved(resolveFilterTokens(out.query, this.tokenCtx))\n\t\t\t}\n\t\t\tif (out.params && typeof out.params === 'object') {\n\t\t\t\tout.params = resolveFilterTokens(out.params, this.tokenCtx)\n\t\t\t}\n\t\t\treturn out\n\t\t},\n\n\t\t/**\n\t\t * An entry's filter with every @-token resolved against `tokenCtx`\n\t\t * and UNRESOLVED OPTIONAL clauses (`@workspace.<key>?`) dropped.\n\t\t *\n\t\t * @param {object} entry The entry definition.\n\t\t * @return {object} The resolved filter map.\n\t\t */\n\t\tresolvedEntryFilter(entry) {\n\t\t\treturn dropOptionalUnresolved(resolveFilterTokens((entry && entry.filter) || {}, this.tokenCtx))\n\t\t},\n\n\t\t/**\n\t\t * Fetch one count/value over REST from OpenRegister's /value\n\t\t * aggregation. Shared by the single-`dataSource` path and the\n\t\t * per-entry multi-entry path. Lazily imports axios/router (the\n\t\t * library never hard-depends on them at module load).\n\t\t *\n\t\t * @param {{register: string, schema: string, metric?: string, aggregate?: string, field?: string}} src The source block.\n\t\t * @param {object} filter The ALREADY-RESOLVED filter map.\n\t\t * @return {Promise<number|null>} The value, or null on failure.\n\t\t */\n\t\tasync fetchValue(src, filter) {\n\t\t\ttry {\n\t\t\t\tconst [{ default: axios }, { generateUrl }] = await Promise.all([\n\t\t\t\t\timport('@nextcloud/axios'),\n\t\t\t\t\timport('@nextcloud/router'),\n\t\t\t\t])\n\t\t\t\tconst url = generateUrl(\n\t\t\t\t\t'/apps/openregister/api/objects/aggregations/{register}/{schema}/value',\n\t\t\t\t\t{ register: src.register, schema: src.schema },\n\t\t\t\t)\n\t\t\t\tconst params = { metric: src.metric || (src.aggregate === 'count' ? 'count' : 'count') }\n\t\t\t\tif (src.field) {\n\t\t\t\t\tparams.field = src.field\n\t\t\t\t}\n\t\t\t\tfor (const [k, v] of Object.entries(filter || {})) {\n\t\t\t\t\tif (v && typeof v === 'object') {\n\t\t\t\t\t\tfor (const [op, ov] of Object.entries(v)) {\n\t\t\t\t\t\t\tparams[`filter[${k}][${op}]`] = ov\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (v !== '' && v !== null && v !== undefined) {\n\t\t\t\t\t\tparams[`filter[${k}]`] = v\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst res = await axios.get(url, { params })\n\t\t\t\treturn Number(res?.data?.value ?? 0) || 0\n\t\t\t} catch {\n\t\t\t\treturn null\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Fetch the count/value over REST when the single `dataSource` is a\n\t\t * plain register+schema block (operator + dynamic tokens supported).\n\t\t * No-op for raw GraphQL or multi-entry mode. Delegates the request to\n\t\t * `fetchValue` (the per-entry refactor) — behaviour is unchanged.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync fetchRest() {\n\t\t\tconst ds = this.dataSource || {}\n\t\t\tif (!ds.register || !ds.schema || ds.graphql) {\n\t\t\t\tthis.restCount = null\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.restCount = await this.fetchValue(ds, resolveFilterTokens(ds.filter || {}))\n\t\t},\n\n\t\t/**\n\t\t * Fetch every entry's count in parallel (multi-entry mode). An entry\n\t\t * whose filter still carries an unresolved REQUIRED token (e.g.\n\t\t * `@workspace.selectedClient` with no selection) is skipped rather\n\t\t * than counted across the whole register. A monotonic request id\n\t\t * discards a stale batch when `entries` changes mid-flight.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync fetchEntries() {\n\t\t\tif (!this.hasEntries) {\n\t\t\t\tthis.entryCounts = []\n\t\t\t\tthis.entryFetching = []\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst id = ++this.entriesRequestId\n\t\t\tconst entries = this.entries\n\t\t\tthis.entryFetching = entries.map(() => true)\n\t\t\tconst counts = await Promise.all(entries.map(async (entry) => {\n\t\t\t\tif (!entry || !entry.register || !entry.schema) {\n\t\t\t\t\treturn null\n\t\t\t\t}\n\t\t\t\tconst filter = this.resolvedEntryFilter(entry)\n\t\t\t\tif (hasUnresolvedTokens(filter)) {\n\t\t\t\t\treturn null\n\t\t\t\t}\n\t\t\t\treturn this.fetchValue(entry, filter)\n\t\t\t}))\n\t\t\tif (id !== this.entriesRequestId) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.entryCounts = counts\n\t\t\tthis.entryFetching = entries.map(() => false)\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n/*\n * This widget draws no KPI of its own — every tile it shows is a CnStatsBlock,\n * so the canonical card in src/css/kpi-card.css is already what a reader sees.\n * All that is left here is how N of those tiles stack inside one widget, and\n * that spacing comes from the shared `--cn-kpi-stack-gap` so a multi-entry\n * widget and a CnStatsPanel stack of the same tiles are spaced identically.\n */\n/*\n * Fill the host cell rather than sizing to content.\n *\n * 🔴 WITHOUT THIS THE WIDGET COLLAPSES TO THE WIDTH OF ITS ICON, and the\n * number it exists to show is clipped to a sliver. Measured on a live\n * instance: a 282px `.cn-widget-wrapper__content` held a 20px widget, a 20px\n * `.cn-kpi-card` and a 0px `.cn-kpi-card__body`. 20px is the icon.\n *\n * The mechanism is a sizing cycle, which is why the KPI card's own CSS looks\n * correct in isolation and is. `.cn-widget-wrapper__content` is `display:\n * flex`, so this root is a flex ITEM and defaults to `flex: 0 1 auto` —\n * content-sized. Its content is `.cn-kpi-card { width: 100% }`, which resolves\n * against THIS element, which is resolving against its content. The card's\n * body carries `min-width: 0` (deliberately, so a long number can shrink\n * instead of overflowing), so the cycle settles with the body at zero and only\n * the icon's intrinsic width surviving.\n *\n * `flex: 1 1 auto` + `width: 100%` gives the card a definite width to resolve\n * against and breaks the cycle; `min-width: 0` keeps the shrink behaviour the\n * body's rule was written for.\n *\n * On the BASE class, not `--multi`: both the single-source and multi-entry\n * modes render this root, so both collapse. The bug was found on a\n * single-entry stats-block.\n */\n.cn-stats-block-widget {\n\tflex: 1 1 auto;\n\tmin-width: 0;\n\twidth: 100%;\n}\n\n.cn-stats-block-widget--multi {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: var(--cn-kpi-stack-gap, 12px);\n}\n</style>\n"],"names":["CnStatsBlock"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveComponent, openBlock, createElementBlock, normalizeClass, Fragment, renderList, createBlock } from 'vue';
|
|
1
|
+
import { resolveComponent, openBlock, createElementBlock, normalizeClass, Fragment, renderList, createBlock, createSlots, withCtx, createTextVNode, toDisplayString } from 'vue';
|
|
2
2
|
|
|
3
3
|
function render(_ctx, _cache, $props, $setup, $data, $options) {
|
|
4
4
|
const _component_CnStatsBlock = resolveComponent("CnStatsBlock");
|
|
@@ -18,10 +18,21 @@ function render(_ctx, _cache, $props, $setup, $data, $options) {
|
|
|
18
18
|
showZeroCount: $props.showZeroCount,
|
|
19
19
|
horizontal: $props.horizontal,
|
|
20
20
|
vertical: $props.vertical,
|
|
21
|
+
layout: $props.layout,
|
|
21
22
|
filled: $props.filled,
|
|
22
23
|
clickable: !!view.route,
|
|
23
24
|
route: view.route || null
|
|
24
|
-
},
|
|
25
|
+
}, createSlots({ _: 2 /* DYNAMIC */ }, [
|
|
26
|
+
(view.formatted !== null)
|
|
27
|
+
? {
|
|
28
|
+
name: "value",
|
|
29
|
+
fn: withCtx(() => [
|
|
30
|
+
createTextVNode(toDisplayString(view.formatted), 1 /* TEXT */)
|
|
31
|
+
]),
|
|
32
|
+
key: "0"
|
|
33
|
+
}
|
|
34
|
+
: undefined
|
|
35
|
+
]), 1032 /* PROPS, DYNAMIC_SLOTS */, ["title", "count", "countLabel", "loading", "variant", "showZeroCount", "horizontal", "vertical", "layout", "filled", "clickable", "route"]))
|
|
25
36
|
}), 128 /* KEYED_FRAGMENT */))
|
|
26
37
|
: (openBlock(), createBlock(_component_CnStatsBlock, {
|
|
27
38
|
key: 1,
|
|
@@ -33,10 +44,21 @@ function render(_ctx, _cache, $props, $setup, $data, $options) {
|
|
|
33
44
|
showZeroCount: $props.showZeroCount,
|
|
34
45
|
horizontal: $props.horizontal,
|
|
35
46
|
vertical: $props.vertical,
|
|
47
|
+
layout: $props.layout,
|
|
36
48
|
filled: $props.filled,
|
|
37
49
|
clickable: !!$props.route,
|
|
38
50
|
route: $props.route
|
|
39
|
-
},
|
|
51
|
+
}, createSlots({ _: 2 /* DYNAMIC */ }, [
|
|
52
|
+
($options.formattedSingle !== null)
|
|
53
|
+
? {
|
|
54
|
+
name: "value",
|
|
55
|
+
fn: withCtx(() => [
|
|
56
|
+
createTextVNode(toDisplayString($options.formattedSingle), 1 /* TEXT */)
|
|
57
|
+
]),
|
|
58
|
+
key: "0"
|
|
59
|
+
}
|
|
60
|
+
: undefined
|
|
61
|
+
]), 1032 /* PROPS, DYNAMIC_SLOTS */, ["title", "count", "countLabel", "loading", "variant", "showZeroCount", "horizontal", "vertical", "layout", "filled", "clickable", "route"]))
|
|
40
62
|
], 2 /* CLASS */))
|
|
41
63
|
}
|
|
42
64
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CnStatsBlockWidget.vue3.js","sources":["../../../../src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue?vue&type=template&id=4f8583ef&lang.js"],"sourcesContent":["<!--\n CnStatsBlockWidget — Manifest-driven stats block.\n\n Thin wrapper around CnStatsBlock that pulls its count from a\n `dataSource` block on a dashboard widget definition. The\n dispatcher in CnDashboardPage mounts this when\n `widgetDef.type === 'stats-block'`.\n\n ADR-049 enrichment: an optional `entries[]` renders N related KPIs in one\n card, each entry self-fetching its own token-resolved count over the same\n REST /value aggregation. Exactly one of `dataSource` / `entries` must be\n provided; the single-`dataSource` path is unchanged.\n\n Static usage (no data fetching) is intentionally NOT supported —\n use CnStatsBlock directly when you already have a count number.\n-->\n<template>\n\t<div\n\t\tclass=\"cn-stats-block-widget\"\n\t\t:class=\"[iconClass, { 'cn-stats-block-widget--multi': hasEntries }]\">\n\t\t<!-- Multi-entry mode: one KPI per entry, all inside this widget card.\n\t\t An entry with hideWhenZero whose resolved count is 0 is omitted. -->\n\t\t<template v-if=\"hasEntries\">\n\t\t\t<CnStatsBlock\n\t\t\t\tv-for=\"view in entryViews\"\n\t\t\t\t:key=\"view.key\"\n\t\t\t\t:title=\"view.entry.title || ''\"\n\t\t\t\t:count=\"view.count\"\n\t\t\t\t:countLabel=\"view.entry.countLabel || ''\"\n\t\t\t\t:loading=\"view.loading\"\n\t\t\t\t:variant=\"view.entry.variant || 'default'\"\n\t\t\t\t:showZeroCount=\"showZeroCount\"\n\t\t\t\t:horizontal=\"horizontal\"\n\t\t\t\t:vertical=\"vertical\"\n\t\t\t\t:filled=\"filled\"\n\t\t\t\t:clickable=\"!!view.route\"\n\t\t\t\t:route=\"view.route || null\" />\n\t\t</template>\n\n\t\t<!-- Single-source mode (pre-existing interface, unchanged). -->\n\t\t<CnStatsBlock\n\t\t\tv-else\n\t\t\t:title=\"title\"\n\t\t\t:count=\"resolvedCount\"\n\t\t\t:countLabel=\"countLabel\"\n\t\t\t:loading=\"loading\"\n\t\t\t:variant=\"variant\"\n\t\t\t:showZeroCount=\"showZeroCount\"\n\t\t\t:horizontal=\"horizontal\"\n\t\t\t:vertical=\"vertical\"\n\t\t\t:filled=\"filled\"\n\t\t\t:clickable=\"!!route\"\n\t\t\t:route=\"route\" />\n\t</div>\n</template>\n\n<script>\nimport { subscribe, unsubscribe } from '@nextcloud/event-bus'\nimport CnStatsBlock from '../CnStatsBlock/CnStatsBlock.vue'\nimport { useDataSource } from '../../composables/useDataSource.js'\nimport { dropOptionalUnresolved, hasUnresolvedTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'\n\n// The canonical KPI scale (`--cn-kpi-*`) lives in one stylesheet. Imported\n// here as well as from css/index.css so the tokens resolve even when the\n// consuming app pulls in components individually.\nimport '../../css/kpi-card.css'\n\n/**\n * Event-bus channel the PAGE-level Refresh action broadcasts on\n * (CnDashboardPage / CnDetailPage pass it to their CnActionsMenu).\n */\nconst PAGE_REFRESH_BUS_CHANNEL = 'cn:page:refresh'\n\n/**\n * CnStatsBlockWidget — Stats-block dashboard widget.\n *\n * Reads a `dataSource` block (manifest shorthand or raw GraphQL)\n * and forwards the resolved count to CnStatsBlock. Loading state\n * propagates through; resolution errors fall back to count = 0\n * with an empty-state label so the dashboard doesn't blank.\n *\n * Multi-entry mode (ADR-049 / list-widget-enrichment): an `entries[]`\n * array renders N KPIs in one card. Each entry carries the same source\n * contract as a `type:\"stat\"` widget (`register`, `schema`, `metric`,\n * `field`, token-resolved `filter`) plus per-entry presentation\n * (`title`, `variant`, `countLabel`), an optional `route` deep link, and\n * `hideWhenZero` (the entry is omitted when its resolved count is 0).\n * Exactly one of `dataSource` / `entries` must be provided — when\n * `entries` is absent, the single-`dataSource` path renders exactly as\n * before.\n *\n * Manifest example (decidesk dashboard):\n * ```json\n * {\n * \"id\": \"minutes-in-review\",\n * \"type\": \"stats-block\",\n * \"title\": \"Notulen ter goedkeuring\",\n * \"iconClass\": \"icon-file\",\n * \"props\": { \"countLabel\": \"notulen\", \"variant\": \"warning\" },\n * \"dataSource\": {\n * \"register\": \"decidesk\",\n * \"schema\": \"minutes\",\n * \"filter\": { \"lifecycle\": \"review\" },\n * \"aggregate\": \"count\"\n * }\n * }\n * ```\n *\n * Multi-entry example (a grouped retention card):\n * ```json\n * {\n * \"widgetKey\": \"stats-block\",\n * \"props\": {\n * \"entries\": [\n * { \"title\": \"Expiring soon\", \"register\": \"docudesk\", \"schema\": \"document\",\n * \"filter\": { \"retention\": { \"lt\": \"@today+30d\" } }, \"variant\": \"warning\",\n * \"route\": { \"name\": \"documents\" }, \"hideWhenZero\": true },\n * { \"title\": \"Archived\", \"register\": \"docudesk\", \"schema\": \"document\",\n * \"filter\": { \"status\": \"archived\" } }\n * ]\n * }\n * }\n * ```\n */\nexport default {\n\tname: 'CnStatsBlockWidget',\n\n\tcomponents: { CnStatsBlock },\n\n\tinject: {\n\t\t/**\n\t\t * Detail-page object context provided by CnDetailPage — enables\n\t\t * `@objectId` / `@object.<field>` tokens in an entry's `filter`.\n\t\t * Null on dashboards.\n\t\t */\n\t\tcnObjectContext: { default: null },\n\t\t/**\n\t\t * Page-level workspace context (reactive `ref({})`) provided by\n\t\t * CnDashboardPage — enables `@workspace.<key>` tokens in an entry's\n\t\t * `filter`. Null on pages that don't provide it.\n\t\t */\n\t\tcnWorkspaceContext: { default: null },\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * Manifest dataSource block. See `useDataSource`. Exactly one of\n\t\t * `dataSource` / `entries` must be provided — a stats-block widget\n\t\t * without any source has no count to render (flagged with a console\n\t\t * error at mount).\n\t\t *\n\t\t * @type {{\n\t\t * register?: string,\n\t\t * schema?: string,\n\t\t * filter?: object,\n\t\t * aggregate?: 'count',\n\t\t * graphql?: { query: string, variables?: object, selectors: object }\n\t\t * }}\n\t\t */\n\t\tdataSource: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Multi-entry declarative sources (ADR-049). Each entry renders one\n\t\t * KPI within this widget's card and self-fetches its own count over\n\t\t * the REST /value aggregation: `{ title, register, schema, metric,\n\t\t * field, filter, route, variant, countLabel, hideWhenZero }`. Filter\n\t\t * values use the shared @-token grammar (`@today`, `@me`,\n\t\t * `@workspace.*`, `?`-optional clauses). An entry with `hideWhenZero`\n\t\t * is omitted when its resolved count is 0. Mutually exclusive with\n\t\t * `dataSource`; defaults to empty (single-source mode).\n\t\t *\n\t\t * @type {Array<object>}\n\t\t */\n\t\tentries: {\n\t\t\ttype: Array,\n\t\t\tdefault: () => [],\n\t\t},\n\n\t\t/** Block title (manifest `widgetDef.title`). */\n\t\ttitle: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Label displayed next to the count. */\n\t\tcountLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Color variant: default | primary | success | warning | error. */\n\t\tvariant: {\n\t\t\ttype: String,\n\t\t\tdefault: 'default',\n\t\t},\n\n\t\t/** Whether to show 0 as a real count instead of the empty label. */\n\t\tshowZeroCount: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/**\n\t\t * Lay the icon left of the content. The canonical KPI card already\n\t\t * does this, so the prop is redundant and kept only for existing\n\t\t * callers.\n\t\t *\n\t\t * @deprecated since 2.25.0, the horizontal layout is the default.\n\t\t */\n\t\thorizontal: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/** Stack the icon above a centred number instead of beside it. */\n\t\tvertical: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * Draw each tile's own grey box. Off by default — the widget is\n\t\t * rendered inside a CnWidgetWrapper that already draws a card.\n\t\t */\n\t\tfilled: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * Vue-router location to navigate to on click. When set, the\n\t\t * inner CnStatsBlock renders as a `<router-link>`.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\troute: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Optional CSS class applied to the widget's outermost wrapping\n\t\t * `<div>`. Designed for Nextcloud core icon classes (`icon-link`,\n\t\t * `icon-mail`, `icon-history`, …) that ship with NC and render a\n\t\t * 16×16 glyph via background-image. When empty (the default), the\n\t\t * wrapper carries only the base `cn-stats-block-widget` class and\n\t\t * is visually invisible.\n\t\t *\n\t\t * The wrapping element is a `<div>` with no layout styles of its\n\t\t * own, so the rendered KPI tile keeps the same dimensions and\n\t\t * spacing it had before this prop was added.\n\t\t *\n\t\t * MDI icon names (e.g. `'Database'`) are NOT supported here — that\n\t\t * would require an async dynamic-import. Use `CnStatsBlock`\n\t\t * directly when you need an MDI Vue component icon.\n\t\t */\n\t\ticonClass: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\t},\n\n\tsetup(props) {\n\t\t// GraphQL count path is hidden from useDataSource when a plain\n\t\t// register+schema source is given — that case is served over REST\n\t\t// (fetchRest) against OpenRegister's /value aggregation, which is more\n\t\t// robust than the GraphQL count shorthand. Raw `graphql` still flows here.\n\t\tconst dsForGraphql = () => {\n\t\t\tconst ds = props.dataSource\n\t\t\tif (ds && ds.register && ds.schema && !ds.graphql) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn ds\n\t\t}\n\t\tconst { data, loading, error, refetch } = useDataSource(dsForGraphql)\n\t\treturn { dsData: data, loading, error, dsRefetch: refetch }\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\trestCount: null,\n\t\t\t/** @type {Array<number|null>} Per-entry resolved counts (multi-entry mode; null = pending/failed). */\n\t\t\tentryCounts: [],\n\t\t\t/** @type {Array<boolean>} Per-entry fetch-in-flight flags (multi-entry mode). */\n\t\t\tentryFetching: [],\n\t\t\t/** Monotonic id used to discard a stale entries batch when `entries` changes mid-flight. */\n\t\t\tentriesRequestId: 0,\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * Whether the widget runs in multi-entry mode (`entries[]` supplied).\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\thasEntries() {\n\t\t\treturn Array.isArray(this.entries) && this.entries.length > 0\n\t\t},\n\n\t\t/**\n\t\t * The unwrapped detail-page object context for token resolution, or\n\t\t * null on surfaces (dashboards) that don't provide one.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tobjectCtx() {\n\t\t\tconst c = this.cnObjectContext\n\t\t\tif (!c) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn (typeof c === 'object' && 'value' in c) ? c.value : c\n\t\t},\n\n\t\t/**\n\t\t * The unwrapped workspace context bag (or null). Vue 2.7 inject may\n\t\t * hand back a raw ref; unwrap `.value` for token resolution.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tworkspaceCtx() {\n\t\t\tconst c = this.cnWorkspaceContext\n\t\t\tif (!c) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn (typeof c === 'object' && 'value' in c) ? c.value : c\n\t\t},\n\n\t\t/**\n\t\t * Token-resolution context for entry filters, merged from the\n\t\t * detail-page object context and the page-level workspace bag.\n\t\t *\n\t\t * @return {object}\n\t\t */\n\t\ttokenCtx() {\n\t\t\tconst base = this.objectCtx ? { ...this.objectCtx } : {}\n\t\t\tbase.workspace = this.workspaceCtx || {}\n\t\t\treturn base\n\t\t},\n\n\t\t/**\n\t\t * Renderable view models for multi-entry mode: each entry paired with\n\t\t * its fetched count and loading flag, with `hideWhenZero` entries\n\t\t * whose resolved count is 0 omitted.\n\t\t *\n\t\t * @return {Array<{entry: object, key: string, count: number, loading: boolean}>}\n\t\t */\n\t\tentryViews() {\n\t\t\treturn (this.entries || [])\n\t\t\t\t.map((entry, i) => {\n\t\t\t\t\tconst raw = this.entryCounts[i]\n\t\t\t\t\tconst resolved = typeof raw === 'number'\n\t\t\t\t\treturn {\n\t\t\t\t\t\tentry: entry || {},\n\t\t\t\t\t\tkey: `${(entry && entry.register) || ''}/${(entry && entry.schema) || ''}/${i}`,\n\t\t\t\t\t\tcount: resolved ? raw : 0,\n\t\t\t\t\t\tloading: !!this.entryFetching[i],\n\t\t\t\t\t\tresolved,\n\t\t\t\t\t\t// Token-resolve the deep link the same way entry.filter\n\t\t\t\t\t\t// is resolved — `route.query: { case: \"@objectId\" }` must\n\t\t\t\t\t\t// land on the target pre-scoped, never as a literal token\n\t\t\t\t\t\t// (ADR-062; parity with object-list's viewAllQuery).\n\t\t\t\t\t\troute: this.resolveEntryRoute(entry && entry.route),\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.filter((view) => !(view.entry.hideWhenZero && view.resolved && view.count === 0))\n\t\t},\n\n\t\t/**\n\t\t * Stable signature of a REST-fetchable source (else null).\n\t\t *\n\t\t * @spec openspec/specs/dashboard-page/spec.md\n\t\t */\n\t\trestKey() {\n\t\t\tconst ds = this.dataSource || {}\n\t\t\tif (!ds.register || !ds.schema || ds.graphql) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn JSON.stringify({\n\t\t\t\tregister: ds.register,\n\t\t\t\tschema: ds.schema,\n\t\t\t\tmetric: ds.metric || (ds.aggregate === 'count' ? 'count' : 'count'),\n\t\t\t\tfield: ds.field || '',\n\t\t\t\tfilter: ds.filter || {},\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Stable signature of the multi-entry sources INCLUDING their\n\t\t * token-resolved filters, so the watcher refetches when page-level\n\t\t * state a `@workspace.*` token reads changes.\n\t\t *\n\t\t * @return {string|null}\n\t\t */\n\t\tentriesKey() {\n\t\t\tif (!this.hasEntries) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn JSON.stringify(this.entries.map((entry) => ({\n\t\t\t\tregister: (entry && entry.register) || '',\n\t\t\t\tschema: (entry && entry.schema) || '',\n\t\t\t\tmetric: (entry && entry.metric) || 'count',\n\t\t\t\tfield: (entry && entry.field) || '',\n\t\t\t\tfilter: this.resolvedEntryFilter(entry),\n\t\t\t})))\n\t\t},\n\n\t\tresolvedCount() {\n\t\t\tif (typeof this.restCount === 'number') {\n\t\t\t\treturn this.restCount\n\t\t\t}\n\t\t\tconst value = this.dsData?.count\n\t\t\tif (typeof value === 'number') {\n\t\t\t\treturn value\n\t\t\t}\n\t\t\tif (typeof value === 'string') {\n\t\t\t\tconst parsed = Number(value)\n\t\t\t\treturn Number.isFinite(parsed) ? parsed : 0\n\t\t\t}\n\t\t\treturn 0\n\t\t},\n\t},\n\n\twatch: {\n\t\trestKey() {\n\t\t\tthis.fetchRest()\n\t\t},\n\n\t\tentriesKey() {\n\t\t\tthis.fetchEntries()\n\t\t},\n\t},\n\n\tcreated() {\n\t\t// \"Exactly one of dataSource / entries\" — a misconfigured widget\n\t\t// fails loudly (console error + no counts) instead of rendering an\n\t\t// ambiguous card. Vue 2 prop validators cannot see sibling props, so\n\t\t// the cross-prop rule lives here (and, for manifests, in\n\t\t// validateManifestV2's post-schema check).\n\t\tconst hasDataSource = !!this.dataSource\n\t\tif (hasDataSource === this.hasEntries) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.error('[CnStatsBlockWidget] Exactly one of `dataSource` / `entries` must be provided.')\n\t\t}\n\t},\n\n\tmounted() {\n\t\tthis.fetchRest()\n\t\tthis.fetchEntries()\n\t\t// Page-level Refresh. This tile is now rendered inside CnWidgetWrapper\n\t\t// like every other widget (it went flat in 2026-08-30's KPI-card\n\t\t// change, so the wrapper is what draws its card), but it is registered\n\t\t// as a CARD widget and cards carry no per-widget Actions menu — so the\n\t\t// page action is still its only refresh affordance. It reached nothing\n\t\t// before this subscription: the counts come from `fetchRest` /\n\t\t// `fetchEntries` and the useDataSource GraphQL path, none of which\n\t\t// subscribe on their own. No widgetId to match: a page refresh\n\t\t// refreshes everything on the page.\n\t\tthis._onPageRefresh = (payload) => {\n\t\t\tconst done = this.refresh()\n\t\t\tpayload?.waitUntil?.(done)\n\t\t}\n\t\tsubscribe(PAGE_REFRESH_BUS_CHANNEL, this._onPageRefresh)\n\t},\n\n\tbeforeUnmount() {\n\t\tif (this._onPageRefresh) {\n\t\t\tunsubscribe(PAGE_REFRESH_BUS_CHANNEL, this._onPageRefresh)\n\t\t\tthis._onPageRefresh = null\n\t\t}\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Re-query the tile's counts — the REST `/value` aggregation, the\n\t\t * per-entry counts of multi-entry mode, and the `dataSource` GraphQL\n\t\t * path. Exposed as a ref-callable method for parity with the other data\n\t\t * widgets, and invoked by the page-level Refresh subscription.\n\t\t *\n\t\t * @return {Promise<unknown[]>} Settles when every re-query has.\n\t\t */\n\t\trefresh() {\n\t\t\treturn Promise.all([\n\t\t\t\ttypeof this.dsRefetch === 'function' ? this.dsRefetch() : null,\n\t\t\t\tthis.fetchRest(),\n\t\t\t\tthis.fetchEntries(),\n\t\t\t])\n\t\t},\n\n\t\t/**\n\t\t * An entry's `route` deep link with `@`-tokens in `query` / `params`\n\t\t * resolved against `tokenCtx` (same treatment as `entry.filter`), so\n\t\t * a KPI card can deep-link to an index pre-scoped to the current\n\t\t * object. Unresolved optional query clauses are dropped; a route\n\t\t * whose query still carries a REQUIRED unresolved token is returned\n\t\t * without that query entry resolution left as-is (router will show\n\t\t * the unfiltered index rather than a broken literal-token filter).\n\t\t *\n\t\t * @param {object|string|null} route The entry's route location.\n\t\t * @return {object|string|null} The token-resolved route.\n\t\t */\n\t\tresolveEntryRoute(route) {\n\t\t\tif (!route || typeof route !== 'object') {\n\t\t\t\treturn route || null\n\t\t\t}\n\t\t\tconst out = { ...route }\n\t\t\tif (out.query && typeof out.query === 'object') {\n\t\t\t\tout.query = dropOptionalUnresolved(resolveFilterTokens(out.query, this.tokenCtx))\n\t\t\t}\n\t\t\tif (out.params && typeof out.params === 'object') {\n\t\t\t\tout.params = resolveFilterTokens(out.params, this.tokenCtx)\n\t\t\t}\n\t\t\treturn out\n\t\t},\n\n\t\t/**\n\t\t * An entry's filter with every @-token resolved against `tokenCtx`\n\t\t * and UNRESOLVED OPTIONAL clauses (`@workspace.<key>?`) dropped.\n\t\t *\n\t\t * @param {object} entry The entry definition.\n\t\t * @return {object} The resolved filter map.\n\t\t */\n\t\tresolvedEntryFilter(entry) {\n\t\t\treturn dropOptionalUnresolved(resolveFilterTokens((entry && entry.filter) || {}, this.tokenCtx))\n\t\t},\n\n\t\t/**\n\t\t * Fetch one count/value over REST from OpenRegister's /value\n\t\t * aggregation. Shared by the single-`dataSource` path and the\n\t\t * per-entry multi-entry path. Lazily imports axios/router (the\n\t\t * library never hard-depends on them at module load).\n\t\t *\n\t\t * @param {{register: string, schema: string, metric?: string, aggregate?: string, field?: string}} src The source block.\n\t\t * @param {object} filter The ALREADY-RESOLVED filter map.\n\t\t * @return {Promise<number|null>} The value, or null on failure.\n\t\t */\n\t\tasync fetchValue(src, filter) {\n\t\t\ttry {\n\t\t\t\tconst [{ default: axios }, { generateUrl }] = await Promise.all([\n\t\t\t\t\timport('@nextcloud/axios'),\n\t\t\t\t\timport('@nextcloud/router'),\n\t\t\t\t])\n\t\t\t\tconst url = generateUrl(\n\t\t\t\t\t'/apps/openregister/api/objects/aggregations/{register}/{schema}/value',\n\t\t\t\t\t{ register: src.register, schema: src.schema },\n\t\t\t\t)\n\t\t\t\tconst params = { metric: src.metric || (src.aggregate === 'count' ? 'count' : 'count') }\n\t\t\t\tif (src.field) {\n\t\t\t\t\tparams.field = src.field\n\t\t\t\t}\n\t\t\t\tfor (const [k, v] of Object.entries(filter || {})) {\n\t\t\t\t\tif (v && typeof v === 'object') {\n\t\t\t\t\t\tfor (const [op, ov] of Object.entries(v)) {\n\t\t\t\t\t\t\tparams[`filter[${k}][${op}]`] = ov\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (v !== '' && v !== null && v !== undefined) {\n\t\t\t\t\t\tparams[`filter[${k}]`] = v\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst res = await axios.get(url, { params })\n\t\t\t\treturn Number(res?.data?.value ?? 0) || 0\n\t\t\t} catch {\n\t\t\t\treturn null\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Fetch the count/value over REST when the single `dataSource` is a\n\t\t * plain register+schema block (operator + dynamic tokens supported).\n\t\t * No-op for raw GraphQL or multi-entry mode. Delegates the request to\n\t\t * `fetchValue` (the per-entry refactor) — behaviour is unchanged.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync fetchRest() {\n\t\t\tconst ds = this.dataSource || {}\n\t\t\tif (!ds.register || !ds.schema || ds.graphql) {\n\t\t\t\tthis.restCount = null\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.restCount = await this.fetchValue(ds, resolveFilterTokens(ds.filter || {}))\n\t\t},\n\n\t\t/**\n\t\t * Fetch every entry's count in parallel (multi-entry mode). An entry\n\t\t * whose filter still carries an unresolved REQUIRED token (e.g.\n\t\t * `@workspace.selectedClient` with no selection) is skipped rather\n\t\t * than counted across the whole register. A monotonic request id\n\t\t * discards a stale batch when `entries` changes mid-flight.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync fetchEntries() {\n\t\t\tif (!this.hasEntries) {\n\t\t\t\tthis.entryCounts = []\n\t\t\t\tthis.entryFetching = []\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst id = ++this.entriesRequestId\n\t\t\tconst entries = this.entries\n\t\t\tthis.entryFetching = entries.map(() => true)\n\t\t\tconst counts = await Promise.all(entries.map(async (entry) => {\n\t\t\t\tif (!entry || !entry.register || !entry.schema) {\n\t\t\t\t\treturn null\n\t\t\t\t}\n\t\t\t\tconst filter = this.resolvedEntryFilter(entry)\n\t\t\t\tif (hasUnresolvedTokens(filter)) {\n\t\t\t\t\treturn null\n\t\t\t\t}\n\t\t\t\treturn this.fetchValue(entry, filter)\n\t\t\t}))\n\t\t\tif (id !== this.entriesRequestId) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.entryCounts = counts\n\t\t\tthis.entryFetching = entries.map(() => false)\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n/*\n * This widget draws no KPI of its own — every tile it shows is a CnStatsBlock,\n * so the canonical card in src/css/kpi-card.css is already what a reader sees.\n * All that is left here is how N of those tiles stack inside one widget, and\n * that spacing comes from the shared `--cn-kpi-stack-gap` so a multi-entry\n * widget and a CnStatsPanel stack of the same tiles are spaced identically.\n */\n/*\n * Fill the host cell rather than sizing to content.\n *\n * 🔴 WITHOUT THIS THE WIDGET COLLAPSES TO THE WIDTH OF ITS ICON, and the\n * number it exists to show is clipped to a sliver. Measured on a live\n * instance: a 282px `.cn-widget-wrapper__content` held a 20px widget, a 20px\n * `.cn-kpi-card` and a 0px `.cn-kpi-card__body`. 20px is the icon.\n *\n * The mechanism is a sizing cycle, which is why the KPI card's own CSS looks\n * correct in isolation and is. `.cn-widget-wrapper__content` is `display:\n * flex`, so this root is a flex ITEM and defaults to `flex: 0 1 auto` —\n * content-sized. Its content is `.cn-kpi-card { width: 100% }`, which resolves\n * against THIS element, which is resolving against its content. The card's\n * body carries `min-width: 0` (deliberately, so a long number can shrink\n * instead of overflowing), so the cycle settles with the body at zero and only\n * the icon's intrinsic width surviving.\n *\n * `flex: 1 1 auto` + `width: 100%` gives the card a definite width to resolve\n * against and breaks the cycle; `min-width: 0` keeps the shrink behaviour the\n * body's rule was written for.\n *\n * On the BASE class, not `--multi`: both the single-source and multi-entry\n * modes render this root, so both collapse. The bug was found on a\n * single-entry stats-block.\n */\n.cn-stats-block-widget {\n\tflex: 1 1 auto;\n\tmin-width: 0;\n\twidth: 100%;\n}\n\n.cn-stats-block-widget--multi {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: var(--cn-kpi-stack-gap, 12px);\n}\n</style>\n"],"names":["_createElementBlock","_normalizeClass","_Fragment","_renderList","_createBlock"],"mappings":";;;;;uBAiCIA;AACA,IAAA,KAAG,EAACC,cAAA,CAAA,CAAA,uBAAW,EACR,CAAA,MAAA,CAAA,SAAS,oCAAQ,QAAA,CAAA,UAAA,EAAA,CAAA,CAAA,CAAA;;;0BAIzBD,kBAagB,CAAAE,QAAA,EAAA,EAAA,GAAA,EAAA,CAAA,EAAA,EAAAC,UAAA,CAZL,QAAA,CAAA,UAAA,EAAA,CAAH,IAAG,KAAA;+BADXC,WAagB,CAAA,uBAAA,EAAA;YAXd,GAAG,EAAA,IAAA,CAAA,GAAA;AACH,YAAA,KAAK,EAAE,IAAI,CAAA,KAAA,CAAA,KAAA,IAAA,EAAA;YACX,KAAK,EAAE,IAAI,CAAC,KAAK;AACjB,YAAA,UAAU,EAAE,IAAI,CAAC,KAAI,CAAA,UAAA,IAAA,EAAA;YACrB,OAAO,EAAE,IAAI,CAAC,OAAC;AACf,YAAA,OAAO,EAAE,IAAI,CAAC,KAAC,CAAA,OAAA,IAAA,SAAA;AACf,YAAA,aAAa,EAAE,MAAY,CAAA,aAAA;AAC3B,YAAA,UAAU,EAAE,MAAS,CAAA,UAAA;AACrB,YAAA,QAAQ,EAAE,MAAO,CAAA,QAAA;AACjB,YAAA,MAAM,EAAE,MAAK,CAAA,MAAA;YACb,SAAS,EAAA,CAAA,CAAI,IAAI,CAAA,KAAA;YACjB,KAAK,EAAE,IAAI,MAAG,IAAA,IAAA;;;;;;;;;;;;;;;;;;;;;;"}
|
|
1
|
+
{"version":3,"file":"CnStatsBlockWidget.vue3.js","sources":["../../../../src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue?vue&type=template&id=4f8583ef&lang.js"],"sourcesContent":["<!--\n CnStatsBlockWidget — Manifest-driven stats block.\n\n Thin wrapper around CnStatsBlock that pulls its count from a\n `dataSource` block on a dashboard widget definition. The\n dispatcher in CnDashboardPage mounts this when\n `widgetDef.type === 'stats-block'`.\n\n ADR-049 enrichment: an optional `entries[]` renders N related KPIs in one\n card, each entry self-fetching its own token-resolved count over the same\n REST /value aggregation. Exactly one of `dataSource` / `entries` must be\n provided; the single-`dataSource` path is unchanged.\n\n Static usage (no data fetching) is intentionally NOT supported —\n use CnStatsBlock directly when you already have a count number.\n-->\n<template>\n\t<div\n\t\tclass=\"cn-stats-block-widget\"\n\t\t:class=\"[iconClass, { 'cn-stats-block-widget--multi': hasEntries }]\">\n\t\t<!-- Multi-entry mode: one KPI per entry, all inside this widget card.\n\t\t An entry with hideWhenZero whose resolved count is 0 is omitted. -->\n\t\t<template v-if=\"hasEntries\">\n\t\t\t<CnStatsBlock\n\t\t\t\tv-for=\"view in entryViews\"\n\t\t\t\t:key=\"view.key\"\n\t\t\t\t:title=\"view.entry.title || ''\"\n\t\t\t\t:count=\"view.count\"\n\t\t\t\t:countLabel=\"view.entry.countLabel || ''\"\n\t\t\t\t:loading=\"view.loading\"\n\t\t\t\t:variant=\"view.entry.variant || 'default'\"\n\t\t\t\t:showZeroCount=\"showZeroCount\"\n\t\t\t\t:horizontal=\"horizontal\"\n\t\t\t\t:vertical=\"vertical\"\n\t\t\t\t:layout=\"layout\"\n\t\t\t\t:filled=\"filled\"\n\t\t\t\t:clickable=\"!!view.route\"\n\t\t\t\t:route=\"view.route || null\">\n\t\t\t\t<!-- An entry with a `format` (or `currency` / `currencyField`)\n\t\t\t\t shows its value formatted, money in its own currency. -->\n\t\t\t\t<template v-if=\"view.formatted !== null\" #value>\n\t\t\t\t\t{{ view.formatted }}\n\t\t\t\t</template>\n\t\t\t</CnStatsBlock>\n\t\t</template>\n\n\t\t<!-- Single-source mode (pre-existing interface, unchanged). -->\n\t\t<CnStatsBlock\n\t\t\tv-else\n\t\t\t:title=\"title\"\n\t\t\t:count=\"resolvedCount\"\n\t\t\t:countLabel=\"countLabel\"\n\t\t\t:loading=\"loading\"\n\t\t\t:variant=\"variant\"\n\t\t\t:showZeroCount=\"showZeroCount\"\n\t\t\t:horizontal=\"horizontal\"\n\t\t\t:vertical=\"vertical\"\n\t\t\t:layout=\"layout\"\n\t\t\t:filled=\"filled\"\n\t\t\t:clickable=\"!!route\"\n\t\t\t:route=\"route\">\n\t\t\t<template v-if=\"formattedSingle !== null\" #value>\n\t\t\t\t{{ formattedSingle }}\n\t\t\t</template>\n\t\t</CnStatsBlock>\n\t</div>\n</template>\n\n<script>\nimport { subscribe, unsubscribe } from '@nextcloud/event-bus'\nimport CnStatsBlock from '../CnStatsBlock/CnStatsBlock.vue'\nimport { useDataSource } from '../../composables/useDataSource.js'\nimport { formatMetricValue, normalizeMetricFormat, unwrapAppConfig } from '../../utils/formatMetric.js'\nimport { dropOptionalUnresolved, hasUnresolvedTokens, resolveFilterTokens } from '../../utils/resolveFilterTokens.js'\n\n// The canonical KPI scale (`--cn-kpi-*`) lives in one stylesheet. Imported\n// here as well as from css/index.css so the tokens resolve even when the\n// consuming app pulls in components individually.\nimport '../../css/kpi-card.css'\n\n/**\n * Event-bus channel the PAGE-level Refresh action broadcasts on\n * (CnDashboardPage / CnDetailPage pass it to their CnActionsMenu).\n */\nconst PAGE_REFRESH_BUS_CHANNEL = 'cn:page:refresh'\n\n/**\n * CnStatsBlockWidget — Stats-block dashboard widget.\n *\n * Reads a `dataSource` block (manifest shorthand or raw GraphQL)\n * and forwards the resolved count to CnStatsBlock. Loading state\n * propagates through; resolution errors fall back to count = 0\n * with an empty-state label so the dashboard doesn't blank.\n *\n * Multi-entry mode (ADR-049 / list-widget-enrichment): an `entries[]`\n * array renders N KPIs in one card. Each entry carries the same source\n * contract as a `type:\"stat\"` widget (`register`, `schema`, `metric`,\n * `field`, token-resolved `filter`) plus per-entry presentation\n * (`title`, `variant`, `countLabel`), an optional `route` deep link, and\n * `hideWhenZero` (the entry is omitted when its resolved count is 0).\n *\n * An entry formats its value with `format` (a style name such as\n * `\"currency\"`, or `{ style, currency, currencyField, decimals, prefix,\n * suffix }`), as a `type:\"stat\"` widget's `content.format` does. A money\n * value takes the currency from `currencyField` (a field of the detail\n * page's object, e.g. a contract's own `currency`), then `currency` (a code\n * or `@config.currency`), then the app's reporting currency, then EUR, and\n * is formatted in the user's locale. `currency` / `currencyField` may sit on\n * the entry itself and imply `format: \"currency\"`.\n * Exactly one of `dataSource` / `entries` must be provided — when\n * `entries` is absent, the single-`dataSource` path renders exactly as\n * before.\n *\n * Manifest example (decidesk dashboard):\n * ```json\n * {\n * \"id\": \"minutes-in-review\",\n * \"type\": \"stats-block\",\n * \"title\": \"Notulen ter goedkeuring\",\n * \"iconClass\": \"icon-file\",\n * \"props\": { \"countLabel\": \"notulen\", \"variant\": \"warning\" },\n * \"dataSource\": {\n * \"register\": \"decidesk\",\n * \"schema\": \"minutes\",\n * \"filter\": { \"lifecycle\": \"review\" },\n * \"aggregate\": \"count\"\n * }\n * }\n * ```\n *\n * Multi-entry example (a grouped retention card):\n * ```json\n * {\n * \"widgetKey\": \"stats-block\",\n * \"props\": {\n * \"entries\": [\n * { \"title\": \"Expiring soon\", \"register\": \"docudesk\", \"schema\": \"document\",\n * \"filter\": { \"retention\": { \"lt\": \"@today+30d\" } }, \"variant\": \"warning\",\n * \"route\": { \"name\": \"documents\" }, \"hideWhenZero\": true },\n * { \"title\": \"Archived\", \"register\": \"docudesk\", \"schema\": \"document\",\n * \"filter\": { \"status\": \"archived\" } }\n * ]\n * }\n * }\n * ```\n */\nexport default {\n\tname: 'CnStatsBlockWidget',\n\n\tcomponents: { CnStatsBlock },\n\n\tinject: {\n\t\t/**\n\t\t * Detail-page object context provided by CnDetailPage — enables\n\t\t * `@objectId` / `@object.<field>` tokens in an entry's `filter`.\n\t\t * Null on dashboards.\n\t\t */\n\t\tcnObjectContext: { default: null },\n\t\t/**\n\t\t * Page-level workspace context (reactive `ref({})`) provided by\n\t\t * CnDashboardPage — enables `@workspace.<key>` tokens in an entry's\n\t\t * `filter`. Null on pages that don't provide it.\n\t\t */\n\t\tcnWorkspaceContext: { default: null },\n\t\t/**\n\t\t * Page-level app config provided by the page — the reporting\n\t\t * currency (`currency`) and `@config.<key>` tokens in `format`.\n\t\t */\n\t\tcnAppConfig: { default: () => ({}) },\n\t},\n\n\tprops: {\n\t\t/**\n\t\t * Manifest dataSource block. See `useDataSource`. Exactly one of\n\t\t * `dataSource` / `entries` must be provided — a stats-block widget\n\t\t * without any source has no count to render (flagged with a console\n\t\t * error at mount).\n\t\t *\n\t\t * @type {{\n\t\t * register?: string,\n\t\t * schema?: string,\n\t\t * filter?: object,\n\t\t * aggregate?: 'count',\n\t\t * graphql?: { query: string, variables?: object, selectors: object }\n\t\t * }}\n\t\t */\n\t\tdataSource: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Multi-entry declarative sources (ADR-049). Each entry renders one\n\t\t * KPI within this widget's card and self-fetches its own count over\n\t\t * the REST /value aggregation: `{ title, register, schema, metric,\n\t\t * field, filter, route, variant, countLabel, hideWhenZero }`. Filter\n\t\t * values use the shared @-token grammar (`@today`, `@me`,\n\t\t * `@workspace.*`, `?`-optional clauses). An entry with `hideWhenZero`\n\t\t * is omitted when its resolved count is 0. Mutually exclusive with\n\t\t * `dataSource`; defaults to empty (single-source mode).\n\t\t *\n\t\t * @type {Array<object>}\n\t\t */\n\t\tentries: {\n\t\t\ttype: Array,\n\t\t\tdefault: () => [],\n\t\t},\n\n\t\t/**\n\t\t * How the single-source value is shown: a style name (`'currency'`,\n\t\t * `'number'`, `'percent'`, `'decimal'`, `'duration-hours'`) or\n\t\t * `{ style, currency, currencyField, decimals, prefix, suffix }`.\n\t\t * Empty (the default) shows the plain localized count. Entries carry\n\t\t * their own `format`.\n\t\t *\n\t\t * @type {string|object|null}\n\t\t */\n\t\tformat: {\n\t\t\ttype: [String, Object],\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/** Block title (manifest `widgetDef.title`). */\n\t\ttitle: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Label displayed next to the count. */\n\t\tcountLabel: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/** Color variant: default | primary | success | warning | error. */\n\t\tvariant: {\n\t\t\ttype: String,\n\t\t\tdefault: 'default',\n\t\t},\n\n\t\t/** Whether to show 0 as a real count instead of the empty label. */\n\t\tshowZeroCount: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: true,\n\t\t},\n\n\t\t/**\n\t\t * Lay the icon left of the content. The canonical KPI card already\n\t\t * does this, so the prop is redundant and kept only for existing\n\t\t * callers.\n\t\t *\n\t\t * @deprecated since 2.25.0, the horizontal layout is the default.\n\t\t */\n\t\thorizontal: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/** Stack the icon above a centred number instead of beside it. */\n\t\tvertical: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * `stacked` gives every block the board look (see CnStatsBlock\n\t\t * `layout`). Empty (the default) keeps the KPI card.\n\t\t *\n\t\t * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-a-stats-block-can-take-the-stacked-board-look\n\t\t */\n\t\tlayout: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/**\n\t\t * Draw each tile's own grey box. Off by default — the widget is\n\t\t * rendered inside a CnWidgetWrapper that already draws a card.\n\t\t */\n\t\tfilled: {\n\t\t\ttype: Boolean,\n\t\t\tdefault: false,\n\t\t},\n\n\t\t/**\n\t\t * Vue-router location to navigate to on click. When set, the\n\t\t * inner CnStatsBlock renders as a `<router-link>`.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\troute: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Optional CSS class applied to the widget's outermost wrapping\n\t\t * `<div>`. Designed for Nextcloud core icon classes (`icon-link`,\n\t\t * `icon-mail`, `icon-history`, …) that ship with NC and render a\n\t\t * 16×16 glyph via background-image. When empty (the default), the\n\t\t * wrapper carries only the base `cn-stats-block-widget` class and\n\t\t * is visually invisible.\n\t\t *\n\t\t * The wrapping element is a `<div>` with no layout styles of its\n\t\t * own, so the rendered KPI tile keeps the same dimensions and\n\t\t * spacing it had before this prop was added.\n\t\t *\n\t\t * MDI icon names (e.g. `'Database'`) are NOT supported here — that\n\t\t * would require an async dynamic-import. Use `CnStatsBlock`\n\t\t * directly when you need an MDI Vue component icon.\n\t\t */\n\t\ticonClass: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\t},\n\n\tsetup(props) {\n\t\t// GraphQL count path is hidden from useDataSource when a plain\n\t\t// register+schema source is given — that case is served over REST\n\t\t// (fetchRest) against OpenRegister's /value aggregation, which is more\n\t\t// robust than the GraphQL count shorthand. Raw `graphql` still flows here.\n\t\tconst dsForGraphql = () => {\n\t\t\tconst ds = props.dataSource\n\t\t\tif (ds && ds.register && ds.schema && !ds.graphql) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn ds\n\t\t}\n\t\tconst { data, loading, error, refetch } = useDataSource(dsForGraphql)\n\t\treturn { dsData: data, loading, error, dsRefetch: refetch }\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\trestCount: null,\n\t\t\t/** @type {Array<number|null>} Per-entry resolved counts (multi-entry mode; null = pending/failed). */\n\t\t\tentryCounts: [],\n\t\t\t/** @type {Array<boolean>} Per-entry fetch-in-flight flags (multi-entry mode). */\n\t\t\tentryFetching: [],\n\t\t\t/** Monotonic id used to discard a stale entries batch when `entries` changes mid-flight. */\n\t\t\tentriesRequestId: 0,\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * Whether the widget runs in multi-entry mode (`entries[]` supplied).\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\thasEntries() {\n\t\t\treturn Array.isArray(this.entries) && this.entries.length > 0\n\t\t},\n\n\t\t/**\n\t\t * The unwrapped detail-page object context for token resolution, or\n\t\t * null on surfaces (dashboards) that don't provide one.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tobjectCtx() {\n\t\t\tconst c = this.cnObjectContext\n\t\t\tif (!c) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn (typeof c === 'object' && 'value' in c) ? c.value : c\n\t\t},\n\n\t\t/**\n\t\t * The unwrapped workspace context bag (or null). Vue 2.7 inject may\n\t\t * hand back a raw ref; unwrap `.value` for token resolution.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tworkspaceCtx() {\n\t\t\tconst c = this.cnWorkspaceContext\n\t\t\tif (!c) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn (typeof c === 'object' && 'value' in c) ? c.value : c\n\t\t},\n\n\t\t/**\n\t\t * The page-level app config map (reporting currency, `@config` keys).\n\t\t *\n\t\t * @return {object}\n\t\t */\n\t\tconfigCtx() {\n\t\t\treturn unwrapAppConfig(this.cnAppConfig)\n\t\t},\n\n\t\t/**\n\t\t * The detail page's object, or null on a dashboard. Its fields feed\n\t\t * an entry's `currencyField`.\n\t\t *\n\t\t * @return {object|null}\n\t\t */\n\t\tpageObject() {\n\t\t\tconst object = this.objectCtx && this.objectCtx.object\n\t\t\treturn object && typeof object === 'object' ? object : null\n\t\t},\n\n\t\t/**\n\t\t * The single-source value formatted per `format`, or null to keep\n\t\t * the plain count.\n\t\t *\n\t\t * @return {string|null}\n\t\t * @spec openspec/changes/stat-currency-from-object/specs/dashboard-page/spec.md#requirement-a-stats-block-entry-formats-its-value\n\t\t */\n\t\tformattedSingle() {\n\t\t\tconst fmt = normalizeMetricFormat({ format: this.format })\n\t\t\tif (!fmt || this.loading) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn formatMetricValue(this.resolvedCount, fmt, this.configCtx, this.pageObject)\n\t\t},\n\n\t\t/**\n\t\t * Token-resolution context for entry filters, merged from the\n\t\t * detail-page object context and the page-level workspace bag.\n\t\t *\n\t\t * @return {object}\n\t\t */\n\t\ttokenCtx() {\n\t\t\tconst base = this.objectCtx ? { ...this.objectCtx } : {}\n\t\t\tbase.workspace = this.workspaceCtx || {}\n\t\t\treturn base\n\t\t},\n\n\t\t/**\n\t\t * Renderable view models for multi-entry mode: each entry paired with\n\t\t * its fetched count and loading flag, with `hideWhenZero` entries\n\t\t * whose resolved count is 0 omitted.\n\t\t *\n\t\t * @return {Array<{entry: object, key: string, count: number, loading: boolean, formatted: (string|null)}>}\n\t\t * @spec openspec/changes/stat-currency-from-object/specs/dashboard-page/spec.md#requirement-a-stats-block-entry-formats-its-value\n\t\t */\n\t\tentryViews() {\n\t\t\treturn (this.entries || [])\n\t\t\t\t.map((entry, i) => {\n\t\t\t\t\tconst raw = this.entryCounts[i]\n\t\t\t\t\tconst resolved = typeof raw === 'number'\n\t\t\t\t\tconst fmt = normalizeMetricFormat(entry)\n\t\t\t\t\treturn {\n\t\t\t\t\t\t// The value as the entry's `format` says, money in the\n\t\t\t\t\t\t// currency the entry names; null keeps the plain count.\n\t\t\t\t\t\tformatted: fmt && resolved ? formatMetricValue(raw, fmt, this.configCtx, this.pageObject) : null,\n\t\t\t\t\t\tentry: entry || {},\n\t\t\t\t\t\tkey: `${(entry && entry.register) || ''}/${(entry && entry.schema) || ''}/${i}`,\n\t\t\t\t\t\tcount: resolved ? raw : 0,\n\t\t\t\t\t\tloading: !!this.entryFetching[i],\n\t\t\t\t\t\tresolved,\n\t\t\t\t\t\t// Token-resolve the deep link the same way entry.filter\n\t\t\t\t\t\t// is resolved — `route.query: { case: \"@objectId\" }` must\n\t\t\t\t\t\t// land on the target pre-scoped, never as a literal token\n\t\t\t\t\t\t// (ADR-062; parity with object-list's viewAllQuery).\n\t\t\t\t\t\troute: this.resolveEntryRoute(entry && entry.route),\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.filter((view) => !(view.entry.hideWhenZero && view.resolved && view.count === 0))\n\t\t},\n\n\t\t/**\n\t\t * Stable signature of a REST-fetchable source (else null).\n\t\t *\n\t\t * @spec openspec/specs/dashboard-page/spec.md\n\t\t */\n\t\trestKey() {\n\t\t\tconst ds = this.dataSource || {}\n\t\t\tif (!ds.register || !ds.schema || ds.graphql) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn JSON.stringify({\n\t\t\t\tregister: ds.register,\n\t\t\t\tschema: ds.schema,\n\t\t\t\tmetric: ds.metric || (ds.aggregate === 'count' ? 'count' : 'count'),\n\t\t\t\tfield: ds.field || '',\n\t\t\t\tfilter: ds.filter || {},\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * Stable signature of the multi-entry sources INCLUDING their\n\t\t * token-resolved filters, so the watcher refetches when page-level\n\t\t * state a `@workspace.*` token reads changes.\n\t\t *\n\t\t * @return {string|null}\n\t\t */\n\t\tentriesKey() {\n\t\t\tif (!this.hasEntries) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn JSON.stringify(this.entries.map((entry) => ({\n\t\t\t\tregister: (entry && entry.register) || '',\n\t\t\t\tschema: (entry && entry.schema) || '',\n\t\t\t\tmetric: (entry && entry.metric) || 'count',\n\t\t\t\tfield: (entry && entry.field) || '',\n\t\t\t\tfilter: this.resolvedEntryFilter(entry),\n\t\t\t})))\n\t\t},\n\n\t\tresolvedCount() {\n\t\t\tif (typeof this.restCount === 'number') {\n\t\t\t\treturn this.restCount\n\t\t\t}\n\t\t\tconst value = this.dsData?.count\n\t\t\tif (typeof value === 'number') {\n\t\t\t\treturn value\n\t\t\t}\n\t\t\tif (typeof value === 'string') {\n\t\t\t\tconst parsed = Number(value)\n\t\t\t\treturn Number.isFinite(parsed) ? parsed : 0\n\t\t\t}\n\t\t\treturn 0\n\t\t},\n\t},\n\n\twatch: {\n\t\trestKey() {\n\t\t\tthis.fetchRest()\n\t\t},\n\n\t\tentriesKey() {\n\t\t\tthis.fetchEntries()\n\t\t},\n\t},\n\n\tcreated() {\n\t\t// \"Exactly one of dataSource / entries\" — a misconfigured widget\n\t\t// fails loudly (console error + no counts) instead of rendering an\n\t\t// ambiguous card. Vue 2 prop validators cannot see sibling props, so\n\t\t// the cross-prop rule lives here (and, for manifests, in\n\t\t// validateManifestV2's post-schema check).\n\t\tconst hasDataSource = !!this.dataSource\n\t\tif (hasDataSource === this.hasEntries) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.error('[CnStatsBlockWidget] Exactly one of `dataSource` / `entries` must be provided.')\n\t\t}\n\t},\n\n\tmounted() {\n\t\tthis.fetchRest()\n\t\tthis.fetchEntries()\n\t\t// Page-level Refresh. This tile is now rendered inside CnWidgetWrapper\n\t\t// like every other widget (it went flat in 2026-08-30's KPI-card\n\t\t// change, so the wrapper is what draws its card), but it is registered\n\t\t// as a CARD widget and cards carry no per-widget Actions menu — so the\n\t\t// page action is still its only refresh affordance. It reached nothing\n\t\t// before this subscription: the counts come from `fetchRest` /\n\t\t// `fetchEntries` and the useDataSource GraphQL path, none of which\n\t\t// subscribe on their own. No widgetId to match: a page refresh\n\t\t// refreshes everything on the page.\n\t\tthis._onPageRefresh = (payload) => {\n\t\t\tconst done = this.refresh()\n\t\t\tpayload?.waitUntil?.(done)\n\t\t}\n\t\tsubscribe(PAGE_REFRESH_BUS_CHANNEL, this._onPageRefresh)\n\t},\n\n\tbeforeUnmount() {\n\t\tif (this._onPageRefresh) {\n\t\t\tunsubscribe(PAGE_REFRESH_BUS_CHANNEL, this._onPageRefresh)\n\t\t\tthis._onPageRefresh = null\n\t\t}\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Re-query the tile's counts — the REST `/value` aggregation, the\n\t\t * per-entry counts of multi-entry mode, and the `dataSource` GraphQL\n\t\t * path. Exposed as a ref-callable method for parity with the other data\n\t\t * widgets, and invoked by the page-level Refresh subscription.\n\t\t *\n\t\t * @return {Promise<unknown[]>} Settles when every re-query has.\n\t\t */\n\t\trefresh() {\n\t\t\treturn Promise.all([\n\t\t\t\ttypeof this.dsRefetch === 'function' ? this.dsRefetch() : null,\n\t\t\t\tthis.fetchRest(),\n\t\t\t\tthis.fetchEntries(),\n\t\t\t])\n\t\t},\n\n\t\t/**\n\t\t * An entry's `route` deep link with `@`-tokens in `query` / `params`\n\t\t * resolved against `tokenCtx` (same treatment as `entry.filter`), so\n\t\t * a KPI card can deep-link to an index pre-scoped to the current\n\t\t * object. Unresolved optional query clauses are dropped; a route\n\t\t * whose query still carries a REQUIRED unresolved token is returned\n\t\t * without that query entry resolution left as-is (router will show\n\t\t * the unfiltered index rather than a broken literal-token filter).\n\t\t *\n\t\t * @param {object|string|null} route The entry's route location.\n\t\t * @return {object|string|null} The token-resolved route.\n\t\t */\n\t\tresolveEntryRoute(route) {\n\t\t\tif (!route || typeof route !== 'object') {\n\t\t\t\treturn route || null\n\t\t\t}\n\t\t\tconst out = { ...route }\n\t\t\tif (out.query && typeof out.query === 'object') {\n\t\t\t\tout.query = dropOptionalUnresolved(resolveFilterTokens(out.query, this.tokenCtx))\n\t\t\t}\n\t\t\tif (out.params && typeof out.params === 'object') {\n\t\t\t\tout.params = resolveFilterTokens(out.params, this.tokenCtx)\n\t\t\t}\n\t\t\treturn out\n\t\t},\n\n\t\t/**\n\t\t * An entry's filter with every @-token resolved against `tokenCtx`\n\t\t * and UNRESOLVED OPTIONAL clauses (`@workspace.<key>?`) dropped.\n\t\t *\n\t\t * @param {object} entry The entry definition.\n\t\t * @return {object} The resolved filter map.\n\t\t */\n\t\tresolvedEntryFilter(entry) {\n\t\t\treturn dropOptionalUnresolved(resolveFilterTokens((entry && entry.filter) || {}, this.tokenCtx))\n\t\t},\n\n\t\t/**\n\t\t * Fetch one count/value over REST from OpenRegister's /value\n\t\t * aggregation. Shared by the single-`dataSource` path and the\n\t\t * per-entry multi-entry path. Lazily imports axios/router (the\n\t\t * library never hard-depends on them at module load).\n\t\t *\n\t\t * @param {{register: string, schema: string, metric?: string, aggregate?: string, field?: string}} src The source block.\n\t\t * @param {object} filter The ALREADY-RESOLVED filter map.\n\t\t * @return {Promise<number|null>} The value, or null on failure.\n\t\t */\n\t\tasync fetchValue(src, filter) {\n\t\t\ttry {\n\t\t\t\tconst [{ default: axios }, { generateUrl }] = await Promise.all([\n\t\t\t\t\timport('@nextcloud/axios'),\n\t\t\t\t\timport('@nextcloud/router'),\n\t\t\t\t])\n\t\t\t\tconst url = generateUrl(\n\t\t\t\t\t'/apps/openregister/api/objects/aggregations/{register}/{schema}/value',\n\t\t\t\t\t{ register: src.register, schema: src.schema },\n\t\t\t\t)\n\t\t\t\tconst params = { metric: src.metric || (src.aggregate === 'count' ? 'count' : 'count') }\n\t\t\t\tif (src.field) {\n\t\t\t\t\tparams.field = src.field\n\t\t\t\t}\n\t\t\t\tfor (const [k, v] of Object.entries(filter || {})) {\n\t\t\t\t\tif (v && typeof v === 'object') {\n\t\t\t\t\t\tfor (const [op, ov] of Object.entries(v)) {\n\t\t\t\t\t\t\tparams[`filter[${k}][${op}]`] = ov\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (v !== '' && v !== null && v !== undefined) {\n\t\t\t\t\t\tparams[`filter[${k}]`] = v\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tconst res = await axios.get(url, { params })\n\t\t\t\treturn Number(res?.data?.value ?? 0) || 0\n\t\t\t} catch {\n\t\t\t\treturn null\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Fetch the count/value over REST when the single `dataSource` is a\n\t\t * plain register+schema block (operator + dynamic tokens supported).\n\t\t * No-op for raw GraphQL or multi-entry mode. Delegates the request to\n\t\t * `fetchValue` (the per-entry refactor) — behaviour is unchanged.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync fetchRest() {\n\t\t\tconst ds = this.dataSource || {}\n\t\t\tif (!ds.register || !ds.schema || ds.graphql) {\n\t\t\t\tthis.restCount = null\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.restCount = await this.fetchValue(ds, resolveFilterTokens(ds.filter || {}))\n\t\t},\n\n\t\t/**\n\t\t * Fetch every entry's count in parallel (multi-entry mode). An entry\n\t\t * whose filter still carries an unresolved REQUIRED token (e.g.\n\t\t * `@workspace.selectedClient` with no selection) is skipped rather\n\t\t * than counted across the whole register. A monotonic request id\n\t\t * discards a stale batch when `entries` changes mid-flight.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync fetchEntries() {\n\t\t\tif (!this.hasEntries) {\n\t\t\t\tthis.entryCounts = []\n\t\t\t\tthis.entryFetching = []\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst id = ++this.entriesRequestId\n\t\t\tconst entries = this.entries\n\t\t\tthis.entryFetching = entries.map(() => true)\n\t\t\tconst counts = await Promise.all(entries.map(async (entry) => {\n\t\t\t\tif (!entry || !entry.register || !entry.schema) {\n\t\t\t\t\treturn null\n\t\t\t\t}\n\t\t\t\tconst filter = this.resolvedEntryFilter(entry)\n\t\t\t\tif (hasUnresolvedTokens(filter)) {\n\t\t\t\t\treturn null\n\t\t\t\t}\n\t\t\t\treturn this.fetchValue(entry, filter)\n\t\t\t}))\n\t\t\tif (id !== this.entriesRequestId) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.entryCounts = counts\n\t\t\tthis.entryFetching = entries.map(() => false)\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n/*\n * This widget draws no KPI of its own — every tile it shows is a CnStatsBlock,\n * so the canonical card in src/css/kpi-card.css is already what a reader sees.\n * All that is left here is how N of those tiles stack inside one widget, and\n * that spacing comes from the shared `--cn-kpi-stack-gap` so a multi-entry\n * widget and a CnStatsPanel stack of the same tiles are spaced identically.\n */\n/*\n * Fill the host cell rather than sizing to content.\n *\n * 🔴 WITHOUT THIS THE WIDGET COLLAPSES TO THE WIDTH OF ITS ICON, and the\n * number it exists to show is clipped to a sliver. Measured on a live\n * instance: a 282px `.cn-widget-wrapper__content` held a 20px widget, a 20px\n * `.cn-kpi-card` and a 0px `.cn-kpi-card__body`. 20px is the icon.\n *\n * The mechanism is a sizing cycle, which is why the KPI card's own CSS looks\n * correct in isolation and is. `.cn-widget-wrapper__content` is `display:\n * flex`, so this root is a flex ITEM and defaults to `flex: 0 1 auto` —\n * content-sized. Its content is `.cn-kpi-card { width: 100% }`, which resolves\n * against THIS element, which is resolving against its content. The card's\n * body carries `min-width: 0` (deliberately, so a long number can shrink\n * instead of overflowing), so the cycle settles with the body at zero and only\n * the icon's intrinsic width surviving.\n *\n * `flex: 1 1 auto` + `width: 100%` gives the card a definite width to resolve\n * against and breaks the cycle; `min-width: 0` keeps the shrink behaviour the\n * body's rule was written for.\n *\n * On the BASE class, not `--multi`: both the single-source and multi-entry\n * modes render this root, so both collapse. The bug was found on a\n * single-entry stats-block.\n */\n.cn-stats-block-widget {\n\tflex: 1 1 auto;\n\tmin-width: 0;\n\twidth: 100%;\n}\n\n.cn-stats-block-widget--multi {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: var(--cn-kpi-stack-gap, 12px);\n}\n</style>\n"],"names":["_createElementBlock","_normalizeClass","_Fragment","_renderList","_createTextVNode","_toDisplayString"],"mappings":";;;;;uBAiCIA;AACA,IAAA,KAAG,EAACC,cAAA,CAAA,CAAA,uBAAW,EACR,CAAA,MAAA,CAAA,SAAQ,oCAAA,QAAA,CAAA,UAAA,EAAA,CAAA,CAAA,CAAA;;KAGD,QAAU,CAAA,UAAA;4CAqBV,CAAAC,QAAA,EAAA,EAAA,GAAA,EAAA,CAAA,EAAA,EAAAC,UAAA,CAnBC,QAAU,CAAA,UAAA,EAAA,CAAlB,IAAI,KAAA;0CAmBG,CAAA,uBAAA,EAAA;YAlBb,GAAG,EAAE,IAAI,CAAC,GAAG;AACb,YAAA,KAAK,EAAE,IAAE,CAAA,KAAA,CAAA,KAAA,IAAA,EAAA;YACT,KAAK,EAAE,IAAI,CAAC,KAAA;AACZ,YAAA,UAAO,EAAA,IAAA,CAAA,KAAA,CAAA,UAAA,IAAA,EAAA;;AAEP,YAAA,OAAO,EAAE,IAAI,CAAC,KAAK,CAAE,OAAM,IAAA,SAAA;AAC3B,YAAA,aAAS,EAAA,MAAA,CAAA,aAAA;AACT,YAAA,UAAG,EAAA,MAAA,CAAA,UAAA;AACH,YAAA,QAAQ,EAAE,MAAC,CAAA,QAAA;AACX,YAAA,MAAM,EAAE,MAAM,CAAA,MAAA;AACd,YAAA,MAAM,EAAE,MAAM,CAAA,MAAA;YACd,SAAS,EAAA,CAAA,CAAI,IAAE,CAAA,KAAA;YACf,KAAK,EAAE,IAAI,CAAC,KAAG,IAAA,IAAA;;AAGA,YAAA,CAAA,IAAE,CAAA,SAAA,KAAA,IAAA;;wBAAA,OAAA;8BACjB,MAAa;AAAV,oBAAAC,eAAA,CAAAC,eAAA,CAAA,IAAI,CAAC,SAAK,CAAA,EAAA,CAAA,YAAA;;;;;;;;;AAQd,UAAA,KAAE,EAAA,MAAA,CAAA,KAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|