diff --git a/.cdsrc.json b/.cdsrc.json index a8a7270f2..6465c1dd4 100644 --- a/.cdsrc.json +++ b/.cdsrc.json @@ -8,7 +8,7 @@ "target": "gen", "tasks": [ { "for": "hana", "src": "db", "dest": "db" }, - { "for": "nodejs", "src": "srv", "dest": "srv", "options": { "model": ["srv", "db", "app", "@cap-js/data-inspector", "@cap-js/ai/srv/AICoreService", "cds-caching/db/cache-store", "cds-caching/db/statistics", "@cap-js/attachments/srv/malware-scanner/malwareScanner-mocked"] } }, + { "for": "nodejs", "src": "srv", "dest": "srv", "options": { "model": ["srv", "db", "db/hana", "app", "@cap-js/data-inspector", "@cap-js/ai/srv/AICoreService", "cds-caching/db/cache-store", "cds-caching/db/statistics", "@cap-js/attachments/srv/malware-scanner/malwareScanner-mocked"] } }, { "for": "hana", "src": "db-qa", "dest": "db-qa", "options": { "model": ["db-qa"] } }, { "for": "nodejs", "src": "srv-qa", "dest": "srv-qa", "options": { "model": ["srv-qa", "db-qa"] } }, { "for": "cds-caching" }, diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index 6f33fe78f..6232075db 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -10,7 +10,7 @@ ID: tutorials-ims # Bump this on each release you deploy — it's the version shown by `cf mtas` # and in the mtar filename (tutorials-ims_.mtar). Deploy is manual: # `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_.mtar -e ../deploy/.mtaext -f`. -version: 1.19.0 +version: 1.20.0 # Top-level parameters (overridable per-env via deploy/.mtaext). parameters: diff --git a/app/admin-annotations.cds b/app/admin-annotations.cds index 88cf38ec7..7c87c2af0 100644 --- a/app/admin-annotations.cds +++ b/app/admin-annotations.cds @@ -1994,20 +1994,26 @@ annotate AdminService.CompletionAnalytics with { }; }; -// --- Devtoberfest Signups (aggregated report — spec 2026-08-13) ----------- +// --- Devtoberfest Signups (aggregated report — spec 2026-08-13; readable-axis +// + total-KPI + breakdowns rework for issue #2047) -------------------------- // // Analytical List Page over the per-signup fact view. Native OData $apply drives // the chart + analytical table + filter bar (group by week / edition / region / -// role, aggregate signup count, grand total). weekIndex is the real, portable, -// groupable time dimension; the read handler enriches each returned row with a -// human-readable weekMonday / weekLabel and — on the pure by-week series — a -// running cumulativeSignups (a window total $apply cannot compute natively). -// Those three are display-only enrichment fields, deliberately NOT dimensions or -// measures, so Fiori never tries to $apply-group or -aggregate the virtual columns. +// role, aggregate signup count, grand total). +// +// Time axis: the chart and table group+sort on the REAL Date column `weekMonday` +// (portable per dialect — db/sqlite/native.cds strftime, db/hana/native.cds +// ADD_DAYS) and DISPLAY the readable `weekStartText` label via #TextOnly text +// arrangement — so the axis reads "Mon 07 Sep 2026" (HANA) / the ISO Monday date +// (local SQLite) as a category, never the raw integer bucket or a thinned Date +// axis. The internal `weekIndex` is hidden (kept only to derive weekMonday) and +// is NOT a user-facing dimension anymore (it used to leak as "449/451" — #2047). +// weekLabel ('YYYY-Www') + cumulativeSignups are read-handler enrichment fields +// (srv/lib/devtoberfest-signup-enrich.js), display-only, never $apply dimensions. annotate AdminService.DevtoberfestSignupAnalytics with @( Aggregation.ApplySupported: { Transformations: ['aggregate', 'groupby', 'filter', 'top', 'skip', 'orderby'], - GroupableProperties: [ weekIndex, eventName, eventType, region, role ], + GroupableProperties: [ weekMonday, weekStartText, eventName, eventType, region, role ], AggregatableProperties: [ { Property: signups } ] }, Analytics.AggregatedProperty #newSignups: { @@ -2016,36 +2022,100 @@ annotate AdminService.DevtoberfestSignupAnalytics with @( AggregatableProperty: signups, ![@Common.Label]: 'New Signups' }, + // Overall total registrations as a prominent, LABELLED KPI header card (#2047). + // SUM(signups) with no filter → grand total across all Devtoberfest signups. + UI.DataPoint #totalSignups: { + Value: signups, + Title: 'Total Registrations' + }, + UI.PresentationVariant #totalSignups: { + Visualizations: ['@UI.DataPoint#totalSignups'] + }, + UI.SelectionVariant #totalSignups: { + SelectOptions: [] + }, + UI.KPI #totalSignups: { + SelectionVariant : ![@UI.SelectionVariant#totalSignups], + DataPoint : ![@UI.DataPoint#totalSignups], + ![@UI.PresentationVariant] : ![@UI.PresentationVariant#totalSignups] + }, + // Default breakdown: signups per calendar week. The chart groups on the real + // Date column weekMonday and shows its readable weekStartText label (#2047). UI.Chart: { ChartType: #Column, - Dimensions: [weekIndex], + Dimensions: [weekMonday], DynamicMeasures: ['@Analytics.AggregatedProperty#newSignups'] }, UI.PresentationVariant: { Visualizations: ['@UI.Chart', '@UI.LineItem'], - SortOrder: [{ Property: weekIndex }] + SortOrder: [{ Property: weekMonday }] + }, + // Alternate breakdowns, one click away via the page's variant management. Every + // GroupableProperty is also reachable through the chart's dimension drill-down. + UI.Chart #byRegion: { + ChartType: #Column, + Dimensions: [region], + DynamicMeasures: ['@Analytics.AggregatedProperty#newSignups'] + }, + UI.PresentationVariant #byRegion: { + Visualizations: ['@UI.Chart#byRegion', '@UI.LineItem'], + SortOrder: [{ Property: newSignups, Descending: true }] + }, + UI.SelectionPresentationVariant #byRegion: { + Text : 'By Region', + SelectionVariant : { SelectOptions: [] }, + PresentationVariant : ![@UI.PresentationVariant#byRegion] + }, + UI.Chart #byEdition: { + ChartType: #Column, + Dimensions: [eventName], + DynamicMeasures: ['@Analytics.AggregatedProperty#newSignups'] + }, + UI.PresentationVariant #byEdition: { + Visualizations: ['@UI.Chart#byEdition', '@UI.LineItem'], + SortOrder: [{ Property: newSignups, Descending: true }] + }, + UI.SelectionPresentationVariant #byEdition: { + Text : 'By Edition', + SelectionVariant : { SelectOptions: [] }, + PresentationVariant : ![@UI.PresentationVariant#byEdition] + }, + UI.Chart #byRole: { + ChartType: #Column, + Dimensions: [role], + DynamicMeasures: ['@Analytics.AggregatedProperty#newSignups'] + }, + UI.PresentationVariant #byRole: { + Visualizations: ['@UI.Chart#byRole', '@UI.LineItem'], + SortOrder: [{ Property: newSignups, Descending: true }] + }, + UI.SelectionPresentationVariant #byRole: { + Text : 'By Role', + SelectionVariant : { SelectOptions: [] }, + PresentationVariant : ![@UI.PresentationVariant#byRole] }, UI.SelectionFields: [ eventName, region, role ], UI.LineItem: [ - { Value: weekLabel, Label: 'Week' }, { Value: weekMonday, Label: 'Week Starting' }, { Value: eventName, Label: 'Edition' }, { Value: region, Label: 'Region' }, { Value: role, Label: 'Role' }, { Value: newSignups, Label: 'New Signups' }, - { Value: cumulativeSignups, Label: 'Cumulative' } + { Value: cumulativeSignups, Label: 'Cumulative (running total)' } ] ) { ID @UI.Hidden; - weekIndex @title: 'Week #' @Analytics.Dimension; + weekMonday @title: 'Week Starting' @Analytics.Dimension + @Common: { Text: weekStartText, TextArrangement: #TextOnly }; + weekStartText @title: 'Week Starting' @Analytics.Dimension @UI.Hidden; + weekIndex @title: 'Week #' @UI.Hidden; eventName @title: 'Edition' @Analytics.Dimension; eventType @title: 'Event Type' @Analytics.Dimension; region @title: 'Region' @Analytics.Dimension; role @title: 'Role' @Analytics.Dimension; signups @title: 'Signups' @Analytics.Measure @Aggregation.default: #SUM; - weekMonday @title: 'Week Starting'; - weekLabel @title: 'Week'; - cumulativeSignups @title: 'Cumulative'; + weekLabel @title: 'Calendar Week'; + cumulativeSignups @title: 'Cumulative (running total)'; }; // Filter-bar value help. eventName resolves against Events (edition picker); diff --git a/app/admin-shell/webapp/controller/Shell.controller.js b/app/admin-shell/webapp/controller/Shell.controller.js index 487183c06..570de98ae 100644 --- a/app/admin-shell/webapp/controller/Shell.controller.js +++ b/app/admin-shell/webapp/controller/Shell.controller.js @@ -135,6 +135,17 @@ sap.ui.define([ this._attachHashChangeDetection(); this._loadUserProfile(); this._loadVersion(); + + // #2041 — Ctrl+K / Cmd+K opens the page-search palette globally. + this._fnGlobalKeydown = this._onGlobalKeydown.bind(this); + document.addEventListener("keydown", this._fnGlobalKeydown); + }, + + onExit: function () { + if (this._fnGlobalKeydown) { + document.removeEventListener("keydown", this._fnGlobalKeydown); + this._fnGlobalKeydown = null; + } }, onNavBack: function () { @@ -149,39 +160,179 @@ sap.ui.define([ }, onNavItemSelect: function (oEvent) { - var sKey = oEvent.getParameter("item").getKey(); + var oItem = oEvent.getParameter("item"); + // Items carrying an href navigate natively via the rendered anchor; do + // NOT also dispatch in JS or external target="_blank" links open twice. + // (The search palette has no anchors, so it routes href items itself.) + if (oItem.getHref()) return; + this._navigateToNavItem({ key: oItem.getKey() }); + }, + + // Shared navigation dispatch used by both the side navigation + // (onNavItemSelect) and the page-search palette (onSearchResultSelect), + // so external links, keyed routes, and the special deep-link hashes stay + // in exactly one place. `oItem`: { key, href, target }. + _navigateToNavItem: function (oItem) { + if (!oItem) return; + + // External links (Analytics, Data Inspector, BAIP, Devtoberfest Planner) + // carry an href but no route — navigate the browser directly. The side + // nav renders these as native anchors; the palette has to do it by hand. + if (oItem.href) { + if (oItem.target === "_blank") { + window.open(oItem.href, "_blank", "noopener"); + } else { + window.location.assign(oItem.href); + } + return; + } + + var sKey = oItem.key; if (!sKey) return; var sRoute = NAV_KEY_TO_ROUTE[sKey]; - if (sRoute) { - this.getOwnerComponent().getRouter().navTo(sRoute); - if (sKey === "pipelinelog") { - HashChanger.getInstance().setHash("pipelinelog&/op/PipelineLog"); - } - if (sKey === "joblog") { - HashChanger.getInstance().setHash("joblog&/op/JobExecutionLog"); - } - if (sKey === "homepageRedirects") { - HashChanger.getInstance().setHash("homepageRedirects&/hp/Redirects"); - } - if (sKey === "homepageConfig") { - // Fixed singleton UUID; matches auto-init handler at - // srv/admin-service.js:601 (HOMEPAGE_CONFIG_SINGLETON_ID). - // HomepageConfig used to be @odata.singleton but that combo is - // incompatible with @odata.draft.enabled (draftActivate requires - // the ID key), so it was demoted to a keyed collection with a - // single well-known row. See srv/admin-service.cds header comment. - HashChanger.getInstance().setHash("homepageConfig&/hp/HomepageConfig(00000000-0000-0000-0000-00000000c8ae)"); - } - if (sKey === "petoberfestContests") { - // Deep-link into the petoberfest componentUsage's inner "Petoberfests" - // List Report route (contest maintenance). The bare "petoberfest" route - // lands on the PetSubmissions moderation queue; this second outer route - // shares the same componentUsage target (prefix "pb") and drives the - // inner hash to the contest LR — mirrors the pipelinelog/joblog pattern - // above that reuses the operations target. (#1449) - HashChanger.getInstance().setHash("petoberfestContests&/pb/Petoberfests"); + if (!sRoute) return; + + this.getOwnerComponent().getRouter().navTo(sRoute); + if (sKey === "pipelinelog") { + HashChanger.getInstance().setHash("pipelinelog&/op/PipelineLog"); + } + if (sKey === "joblog") { + HashChanger.getInstance().setHash("joblog&/op/JobExecutionLog"); + } + if (sKey === "homepageRedirects") { + HashChanger.getInstance().setHash("homepageRedirects&/hp/Redirects"); + } + if (sKey === "homepageConfig") { + // Fixed singleton UUID; matches auto-init handler at + // srv/admin-service.js:601 (HOMEPAGE_CONFIG_SINGLETON_ID). + // HomepageConfig used to be @odata.singleton but that combo is + // incompatible with @odata.draft.enabled (draftActivate requires + // the ID key), so it was demoted to a keyed collection with a + // single well-known row. See srv/admin-service.cds header comment. + HashChanger.getInstance().setHash("homepageConfig&/hp/HomepageConfig(00000000-0000-0000-0000-00000000c8ae)"); + } + if (sKey === "petoberfestContests") { + // Deep-link into the petoberfest componentUsage's inner "Petoberfests" + // List Report route (contest maintenance). The bare "petoberfest" route + // lands on the PetSubmissions moderation queue; this second outer route + // shares the same componentUsage target (prefix "pb") and drives the + // inner hash to the contest LR — mirrors the pipelinelog/joblog pattern + // above that reuses the operations target. (#1449) + HashChanger.getInstance().setHash("petoberfestContests&/pb/Petoberfests"); + } + }, + + // ---- Page search palette (#2041) ------------------------------------- + // A command-palette style popover for jumping to any admin page. Flattens + // the (already role-filtered) nav model into a searchable flat list, so an + // author never sees admin-only pages in results. + + onOpenPageSearch: function (oEvent) { + this._openPageSearch(oEvent.getSource()); + }, + + _openPageSearch: function (oOpenerControl) { + var oPopover = this.byId("pageSearchPopover"); + if (!oPopover) return; + // Rebuild the flat catalog every open — role filtering may have applied + // after the previous open, and it is cheap (~55 rows). + var oSearchModel = this._getSearchModel(); + oSearchModel.setProperty("/all", this._flattenNav()); + oSearchModel.setProperty("/query", ""); + this._applySearchFilter(""); + if (oPopover.isOpen()) return; + oPopover.openBy(oOpenerControl || this.byId("pageSearchBtn")); + }, + + onPageSearchAfterOpen: function () { + var oField = this.byId("pageSearchField"); + if (oField) oField.focus(); + }, + + onPageSearchLiveChange: function (oEvent) { + this._applySearchFilter(oEvent.getParameter("newValue") || ""); + }, + + // Enter in the SearchField activates the first (top) result. + onPageSearchGo: function () { + var aResults = this._getSearchModel().getProperty("/results") || []; + if (aResults.length) { + this._selectSearchResult(aResults[0]); + } + }, + + onSearchResultSelect: function (oEvent) { + var oCtx = oEvent.getSource().getBindingContext("search"); + if (!oCtx) return; + this._selectSearchResult(oCtx.getObject()); + }, + + _selectSearchResult: function (oResult) { + var oPopover = this.byId("pageSearchPopover"); + if (oPopover && oPopover.isOpen()) oPopover.close(); + this._navigateToNavItem(oResult); + }, + + _getSearchModel: function () { + var oModel = this.getView().getModel("search"); + if (!oModel) { + oModel = new JSONModel({ query: "", all: [], results: [] }); + this.getView().setModel(oModel, "search"); + } + return oModel; + }, + + // Flatten the role-filtered nav tree into { key, title, groupTitle, icon, + // href, target }. Container groups contribute their leaves; a top-level + // leaf (e.g. Dashboard) contributes itself. + _flattenNav: function () { + var oNavModel = this.getOwnerComponent().getModel("nav"); + var aGroups = (oNavModel && oNavModel.getData().groups) || []; + var aFlat = []; + aGroups.forEach(function (g) { + if (g.items && g.items.length) { + g.items.forEach(function (leaf) { + aFlat.push({ + key: leaf.key, + title: leaf.title, + groupTitle: g.title, + icon: g.icon, + href: leaf.href, + target: leaf.target + }); + }); + } else { + aFlat.push({ + key: g.key, + title: g.title, + groupTitle: g.title, + icon: g.icon, + href: g.href, + target: g.target + }); } + }); + return aFlat; + }, + + _applySearchFilter: function (sQuery) { + var oModel = this._getSearchModel(); + var aAll = oModel.getProperty("/all") || []; + var q = (sQuery || "").trim().toLowerCase(); + var aResults = !q ? aAll : aAll.filter(function (item) { + return (item.title || "").toLowerCase().indexOf(q) !== -1 + || (item.groupTitle || "").toLowerCase().indexOf(q) !== -1; + }); + oModel.setProperty("/query", sQuery); + oModel.setProperty("/results", aResults); + }, + + _onGlobalKeydown: function (e) { + // Ctrl+K / Cmd+K opens the page search from anywhere in the shell. + if ((e.ctrlKey || e.metaKey) && !e.altKey && (e.key === "k" || e.key === "K")) { + e.preventDefault(); + this._openPageSearch(this.byId("pageSearchBtn")); } }, diff --git a/app/admin-shell/webapp/i18n/i18n.properties b/app/admin-shell/webapp/i18n/i18n.properties index cb1236ff7..8883f46ce 100644 --- a/app/admin-shell/webapp/i18n/i18n.properties +++ b/app/admin-shell/webapp/i18n/i18n.properties @@ -8,3 +8,7 @@ documentTitle.anonymous=No Access noAccess.heading=You don't have access to this console. noAccess.body=This console requires the Admin or Tutorial.Author scope. noAccess.requestAccess=Request access + +pageSearch.tooltip=Search pages (Ctrl+K) +pageSearch.placeholder=Search pages... +pageSearch.noResults=No matching pages diff --git a/app/admin-shell/webapp/view/Shell.view.xml b/app/admin-shell/webapp/view/Shell.view.xml index a6c2654ca..f98f29ab0 100644 --- a/app/admin-shell/webapp/view/Shell.view.xml +++ b/app/admin-shell/webapp/view/Shell.view.xml @@ -39,6 +39,12 @@ + + + + + + diff --git a/hugo-apps/src/devtoberfest-sessions-calendar/WeekAgenda.vue b/hugo-apps/src/devtoberfest-sessions-calendar/WeekAgenda.vue index 89c424bff..a20f5a508 100644 --- a/hugo-apps/src/devtoberfest-sessions-calendar/WeekAgenda.vue +++ b/hugo-apps/src/devtoberfest-sessions-calendar/WeekAgenda.vue @@ -53,8 +53,10 @@ function borderColor(s: Session): string {