From 00c8d39aad73a358ccd1d024f59e36dafa7d4b83 Mon Sep 17 00:00:00 2001 From: Alexander Askin Date: Wed, 12 Aug 2026 06:15:15 +0000 Subject: [PATCH 1/3] feat: render full live reports and panoramas in link previews The reference provider (Smart Picker / link preview) previously showed a static card with icon, title and subtitle. Analytics links pasted into rich text (Text documents, Talk, Tables descriptions) now render the actual content: - Reports render as full chart, data table, KPI or combined view using the stored report options, fetched live from the existing data endpoints with ETag/localStorage caching. - Panoramas render their first page as a grid with all report, text and picture widgets, plus a link to the remaining pages. - The chart stack is lazy-loaded on the first rendered analytics reference, so pages without analytics links load no extra scripts. - Failed loads (deleted report, revoked permission, blocked scripts) fall back to the previous static card. Fixes along the way: - resolveReference() now falls back to ShareService, so reports and panoramas shared with the current user no longer resolve as "Report not found". PanoramaService::read() gained the same fallback, restricted to the fields the panorama view needs. - The admin toggle link_preview_enabled is functional again. Signed-off-by: Alexander Askin --- CHANGELOG.md | 10 + css/reference.css | 219 +++++ js/reference.js | 799 +++++++++++++++++- lib/Listener/ReferenceListener.php | 1 + lib/Reference/ReferenceProvider.php | 26 +- lib/Service/PanoramaService.php | 10 + lib/Service/ShareService.php | 18 + tests/Reference/ReferenceProviderTest.php | 163 ++++ tests/Service/PanoramaServiceReadTest.php | 91 ++ .../ShareServiceSharedPanoramaTest.php | 45 + .../Reference/ReferenceManager.php | 15 + .../ADiscoverableReferenceProvider.php | 28 + .../Collaboration/Reference/IReference.php | 12 + .../ISearchableReferenceProvider.php | 13 + .../OCP/Collaboration/Reference/Reference.php | 63 ++ tests/Stubs/OCP/Constants.php | 18 + tests/Stubs/OCP/IConfig.php | 1 + 17 files changed, 1490 insertions(+), 42 deletions(-) create mode 100644 css/reference.css create mode 100644 tests/Reference/ReferenceProviderTest.php create mode 100644 tests/Service/PanoramaServiceReadTest.php create mode 100644 tests/Service/ShareServiceSharedPanoramaTest.php create mode 100644 tests/Stubs/OC/Collaboration/Reference/ReferenceManager.php create mode 100644 tests/Stubs/OCP/Collaboration/Reference/ADiscoverableReferenceProvider.php create mode 100644 tests/Stubs/OCP/Collaboration/Reference/IReference.php create mode 100644 tests/Stubs/OCP/Collaboration/Reference/ISearchableReferenceProvider.php create mode 100644 tests/Stubs/OCP/Collaboration/Reference/Reference.php create mode 100644 tests/Stubs/OCP/Constants.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b8828a28..05c9461ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 6.8.0 + +### Added +- Link previews (Smart Picker) render the full live report as chart, table or KPI instead of a static card. +- Link previews render the first page of a panorama with all its report, text and picture widgets. + +### Fixed +- Reports and panoramas shared with the current user resolve correctly in link previews instead of showing "Report not found". +- The admin setting `link_preview_enabled` disables analytics link previews again. + ## 6.7.1 - 2026-07-19 ### Fixed - Keep table footer totals aligned with their columns after column reordering. diff --git a/css/reference.css b/css/reference.css new file mode 100644 index 000000000..7d255a349 --- /dev/null +++ b/css/reference.css @@ -0,0 +1,219 @@ +/** + * Analytics + * + * SPDX-FileCopyrightText: 2026 Marcel Scherello + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +.analytics-reference-widget { + display: flex; + flex-direction: column; + width: 100%; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-large, 8px); + background-color: var(--color-main-background); + color: var(--color-main-text); + overflow: hidden; +} + +.analytics-reference-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--color-border); +} + +.analytics-reference-header img { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.analytics-reference-header a, +.analytics-reference-header span { + font-weight: 600; + color: var(--color-main-text); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.analytics-reference-header a:hover { + text-decoration: underline; +} + +.analytics-reference-body { + position: relative; + height: 350px; + padding: 8px; + box-sizing: border-box; +} + +/* combined chart + table view: chart on top, scrollable table below */ +.analytics-reference-body.analytics-reference-ct { + height: auto; +} + +/* table-only view: grow with the content, but stay scrollable within a cap */ +.analytics-reference-body.analytics-reference-scroll { + height: auto; + max-height: 350px; + overflow-y: auto; + overflow-x: auto; +} + +.analytics-reference-chart-area { + position: relative; + height: 334px; +} + +.analytics-reference-ct .analytics-reference-chart-area { + height: 260px; +} + +.analytics-reference-table-area { + max-height: 260px; + overflow-y: auto; + overflow-x: auto; + margin-top: 8px; +} + +.analytics-reference-body table { + width: 100%; +} + +.analytics-reference-message { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--color-text-maxcontrast, var(--color-text-lighter)); +} + +/* panorama grid: first page of the panorama, one cell per widget */ +.analytics-reference-body.analytics-reference-panorama { + height: auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 8px; +} + +.analytics-reference-panorama-cell { + position: relative; + height: 240px; + padding: 4px; + box-sizing: border-box; + border: 1px solid var(--color-border); + border-radius: var(--border-radius, 4px); + overflow: hidden; +} + +.analytics-reference-panorama-cell-title { + height: 24px; + padding-left: 6px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.analytics-reference-panorama-cell-content { + position: relative; + height: calc(100% - 24px); + overflow: hidden; +} + +.analytics-reference-panorama-cell-content.analytics-reference-scroll { + overflow-y: auto; +} + +.analytics-reference-panorama-text { + padding: 6px; + white-space: pre-wrap; + overflow-y: auto; + height: 100%; +} + +.analytics-reference-panorama-picture { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.analytics-reference-panorama-picture img { + max-width: 90%; + max-height: 90%; +} + +.analytics-reference-footer { + padding: 4px 12px; + border-top: 1px solid var(--color-border); + font-size: 0.9em; +} + +.analytics-reference-footer a { + color: var(--color-text-maxcontrast, var(--color-text-lighter)); +} + +/* static fallback card (also used while the rich object carries no id) */ +.analytics-reference-fallback { + display: flex; + color: var(--color-main-text); +} + +.analytics-reference-fallback img { + width: 20%; + padding: 20px; + opacity: .5; + box-sizing: border-box; +} + +.analytics-reference-fallback-content { + padding: 10px; + width: 75%; +} + +.analytics-reference-fallback-title { + font-weight: 600; +} + +.analytics-reference-fallback-subheader { + margin-top: 1em; +} + +/* KPI styles from style.css, which is not loaded in the reference context */ +.analytics-reference-widget .kpiWidget { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + border-radius: 8px; + overflow: hidden; +} + +.analytics-reference-widget .kpiWidgetContent { + text-align: center; +} + +.analytics-reference-widget .kpiWidgetTitel { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.analytics-reference-widget .kpiWidgetValue { + font-size: 3.5rem; + font-weight: 400; +} + +.analytics-reference-panorama-cell .kpiWidgetTitel { + font-size: 1.1rem; +} + +.analytics-reference-panorama-cell .kpiWidgetValue { + font-size: 2.2rem; +} diff --git a/js/reference.js b/js/reference.js index 00f73c53f..3d366c88c 100644 --- a/js/reference.js +++ b/js/reference.js @@ -6,6 +6,10 @@ */ /** global: OC */ +/** global: OCA */ +/** global: Chart */ +/** global: t */ +/** global: _registerWidget */ 'use strict'; @@ -27,62 +31,781 @@ const getSafeReferenceUrl = function (value) { } }; -document.addEventListener('DOMContentLoaded', function () { - OCA.Analytics.Reference.init(); -}) - +if (!window.OCA) { + window.OCA = {}; +} if (!OCA.Analytics) { /** * @namespace */ OCA.Analytics = {}; } + +// minimal namespace state required by visualization.js when it runs outside the main app +OCA.Analytics.chartObject = OCA.Analytics.chartObject || null; +OCA.Analytics.tableObject = OCA.Analytics.tableObject || {}; +OCA.Analytics.unsavedChanges = OCA.Analytics.unsavedChanges || null; +OCA.Analytics.chartTypeMapping = OCA.Analytics.chartTypeMapping || { + 'datetime': 'line', + 'column': 'bar', + 'columnSt': 'bar', // map stacked type also to base type; needed in filter + 'columnSt100': 'bar', // map stacked type also to base type; needed in filter + 'area': 'line', + 'line': 'line', + 'doughnut': 'doughnut', + 'funnel': 'funnel' +}; + /** * @namespace OCA.Analytics.Reference */ OCA.Analytics.Reference = { + PANORAMA_CONTENT_TYPE_REPORT: 0, + PANORAMA_CONTENT_TYPE_TEXT: 1, + PANORAMA_CONTENT_TYPE_PICTURE: 2, + + instanceCounter: 0, + // widget root element => {canvases: [], tableUids: []}, used by the destroy callback + widgetRegistry: new Map(), + coreAssetsPromise: null, + tableAssetsPromise: null, + scriptPromises: {}, + init: function () { + if (typeof _registerWidget !== 'function') { + return; + } _registerWidget('analytics', async (el, {richObjectType, richObject, accessible}) => { - const referenceUrl = getSafeReferenceUrl(richObject.url); - const imageUrl = getSafeReferenceUrl(richObject.image); - const widget = document.createElement(referenceUrl ? 'a' : 'div'); - widget.style.display = 'flex'; + await OCA.Analytics.Reference.renderWidget(el, richObject); + }, (el) => { + OCA.Analytics.Reference.destroyWidget(el); + }, {hasInteractiveView: false}); + }, + + // ************* + // *** rendering + // ************* + + renderWidget: async function (el, richObject) { + if (!richObject + || !richObject.id + || richObject.found === false + || (richObject.item_type !== 'report' && richObject.item_type !== 'panorama') + ) { + // covers items the user cannot access and references cached before this widget existed + OCA.Analytics.Reference.renderStaticCard(el, richObject); + return; + } + + const widget = document.createElement('div'); + widget.classList.add('analytics-reference-widget'); + + const header = document.createElement('div'); + header.classList.add('analytics-reference-header'); + const icon = document.createElement('img'); + const iconUrl = getSafeReferenceUrl(richObject.image); + if (iconUrl) { + icon.setAttribute('src', iconUrl); + icon.setAttribute('alt', ''); + header.appendChild(icon); + } + const referenceUrl = getSafeReferenceUrl(richObject.url); + const headerLink = document.createElement(referenceUrl ? 'a' : 'span'); + if (referenceUrl) { + headerLink.setAttribute('href', referenceUrl); + headerLink.setAttribute('target', '_blank'); + headerLink.setAttribute('rel', 'noopener noreferrer'); + } + headerLink.textContent = richObject.subheader || richObject.name || ''; + header.appendChild(headerLink); + widget.appendChild(header); + + const body = document.createElement('div'); + body.classList.add('analytics-reference-body'); + body.appendChild(OCA.Analytics.Reference.buildLoadingIndicator()); + widget.appendChild(body); + + el.textContent = ''; + el.appendChild(widget); + OCA.Analytics.Reference.widgetRegistry.set(el, {canvases: [], tableUids: []}); + + try { + if (richObject.item_type === 'panorama') { + await OCA.Analytics.Reference.renderPanorama(el, richObject, headerLink, body); + } else { + await OCA.Analytics.Reference.renderReport(el, richObject, headerLink, body); + } + } catch (error) { + // asset loading blocked, report deleted, permission revoked, … + OCA.Analytics.Reference.destroyWidget(el); + OCA.Analytics.Reference.renderStaticCard(el, richObject); + } + }, + + renderReport: async function (el, richObject, headerLink, body) { + await OCA.Analytics.Reference.ensureCoreAssets(); + let data = await OCA.Analytics.Reference.fetchReportData( + OC.generateUrl('apps/analytics/data/' + richObject.id, true), + 'analytics-report-' + richObject.id + ); + + data = OCA.Analytics.Reference.processReceivedData(data); + if (data.options && data.options.name) { + headerLink.textContent = data.options.name; + } + + if (data.status === 'nodata' || !Array.isArray(data.data) || data.data.length === 0) { + body.replaceChildren(OCA.Analytics.Reference.buildMessage(t('analytics', 'No data found'))); + return; + } + + data.data = OCA.Analytics.Visualization.formatDates(data.data); + await OCA.Analytics.Reference.renderVisualization(el, body, data, false); + }, + + renderPanorama: async function (el, richObject, headerLink, body) { + await OCA.Analytics.Reference.ensureCoreAssets(); + const meta = await OCA.Analytics.Reference.fetchJson( + OC.generateUrl('apps/analytics/panorama/' + richObject.id, true) + ); + if (!meta || !meta.id) { + throw new Error('panorama not available'); + } + if (meta.name) { + headerLink.textContent = meta.name; + } + + let pages = meta.pages; + if (typeof pages === 'string') { + pages = JSON.parse(pages); + } + if (!Array.isArray(pages) || pages.length === 0 + || !Array.isArray(pages[0].reports) || pages[0].reports.length === 0) { + body.replaceChildren(OCA.Analytics.Reference.buildMessage(t('analytics', 'No data found'))); + return; + } + + body.replaceChildren(); + body.classList.add('analytics-reference-panorama'); + + const cellPromises = pages[0].reports.map((item) => { + const cell = document.createElement('div'); + cell.classList.add('analytics-reference-panorama-cell'); + body.appendChild(cell); + return OCA.Analytics.Reference.renderPanoramaCell(el, cell, item); + }); + if (pages.length > 1) { + const footer = document.createElement('div'); + footer.classList.add('analytics-reference-footer'); + const referenceUrl = getSafeReferenceUrl(richObject.url); + const link = document.createElement(referenceUrl ? 'a' : 'span'); if (referenceUrl) { - widget.setAttribute('href', referenceUrl); - widget.setAttribute('target', '_blank'); - widget.setAttribute('rel', 'noopener noreferrer'); + link.setAttribute('href', referenceUrl); + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); } + link.textContent = t('analytics', 'Page') + ' 1/' + pages.length; + footer.appendChild(link); + body.parentNode.appendChild(footer); + } + + // a failing single cell must not tear down the whole panorama widget + await Promise.allSettled(cellPromises); + }, + + renderPanoramaCell: async function (el, cell, item) { + if (item === null || item === undefined) { + return; + } + const contentType = parseInt(item['type']); + const contentValue = item['value']; - const content = document.createElement('div'); - content.style.padding = '10px'; - content.style.width = imageUrl ? '75%' : '100%'; - - const title = document.createElement('div'); - title.style.fontWeight = '600'; - title.textContent = richObject.name || ''; - - const subheader = document.createElement('div'); - subheader.style.marginTop = '1em'; - subheader.textContent = richObject.subheader || ''; - - content.appendChild(title); - content.appendChild(subheader); - - if (imageUrl) { - const image = document.createElement('img'); - image.setAttribute('src', imageUrl); - image.setAttribute('alt', ''); - image.style.width = '20%'; - image.style.padding = '20px'; - image.style.opacity = '.5'; - widget.appendChild(image); + if (contentType === OCA.Analytics.Reference.PANORAMA_CONTENT_TYPE_TEXT) { + const text = document.createElement('div'); + text.classList.add('analytics-reference-panorama-text'); + // DOMParser neither executes scripts nor loads resources; plain text is enough here + text.textContent = new DOMParser().parseFromString(String(contentValue ?? ''), 'text/html').body.textContent || ''; + cell.appendChild(text); + return; + } + + if (contentType === OCA.Analytics.Reference.PANORAMA_CONTENT_TYPE_PICTURE) { + const pictureContainer = document.createElement('div'); + pictureContainer.classList.add('analytics-reference-panorama-picture'); + const image = document.createElement('img'); + image.setAttribute('alt', ''); + image.src = OC.generateUrl('/core/preview') + '?fileId=' + encodeURIComponent(contentValue) + '&x=300&y=300&a=true'; + pictureContainer.appendChild(image); + cell.appendChild(pictureContainer); + return; + } + + if (contentType !== OCA.Analytics.Reference.PANORAMA_CONTENT_TYPE_REPORT) { + return; + } + + const title = document.createElement('div'); + title.classList.add('analytics-reference-panorama-cell-title'); + cell.appendChild(title); + const content = document.createElement('div'); + content.classList.add('analytics-reference-panorama-cell-content'); + content.appendChild(OCA.Analytics.Reference.buildLoadingIndicator()); + cell.appendChild(content); + + try { + const reportId = parseInt(contentValue); + let data = await OCA.Analytics.Reference.fetchReportData( + OC.generateUrl('apps/analytics/data/pa/' + reportId, true), + 'analytics-report-' + reportId + ); + data = OCA.Analytics.Reference.processReceivedData(data); + title.textContent = (data.options && data.options.name) || ''; + + if (data.status === 'nodata' || !Array.isArray(data.data) || data.data.length === 0) { + content.replaceChildren(OCA.Analytics.Reference.buildMessage(t('analytics', 'No data found'))); + return; } + data.data = OCA.Analytics.Visualization.formatDates(data.data); + + const legend = item?.options?.legend; + await OCA.Analytics.Reference.renderVisualization(el, content, data, true, legend); + } catch (error) { + content.replaceChildren(OCA.Analytics.Reference.buildMessage(t('analytics', 'The report is not available anymore'))); + } + }, + + /** + * dispatch a processed data payload to chart / KPI / table rendering + * compact = panorama cell; legend only applies to compact charts + */ + renderVisualization: async function (el, container, data, compact, legend) { + const visualization = data.options.visualization; + const registryEntry = OCA.Analytics.Reference.widgetRegistry.get(el); + + if (visualization === 'table' && data.data.length === 1) { + // KPI view, same heuristic as the panorama + const kpi = document.createElement('div'); + container.replaceChildren(kpi); + OCA.Analytics.Visualization.buildKpiDisplay(kpi, data, false, OCA.Analytics.Reference.nextUid()); + return; + } + + if (visualization === 'table') { + await OCA.Analytics.Reference.ensureTableAssets(); + container.replaceChildren(); + container.classList.add('analytics-reference-scroll'); + OCA.Analytics.Reference.buildTable(container, data, registryEntry); + return; + } - widget.appendChild(content); + if (visualization === 'ct') { + await OCA.Analytics.Reference.ensureTableAssets(); + container.replaceChildren(); + container.classList.add('analytics-reference-ct'); + const chartArea = document.createElement('div'); + chartArea.classList.add('analytics-reference-chart-area'); + container.appendChild(chartArea); + OCA.Analytics.Reference.buildChart(chartArea, data, compact, legend, registryEntry); + const tableArea = document.createElement('div'); + tableArea.classList.add('analytics-reference-table-area'); + container.appendChild(tableArea); + OCA.Analytics.Reference.buildTable(tableArea, data, registryEntry); + return; + } + + // 'chart' and anything unknown + container.replaceChildren(); + OCA.Analytics.Reference.buildChart(container, data, compact, legend, registryEntry); + }, + + buildChart: function (container, data, compact, legend, registryEntry) { + const canvas = document.createElement('canvas'); + canvas.id = 'analyticsReferenceChart' + OCA.Analytics.Reference.nextUid(); + container.appendChild(canvas); + if (registryEntry) { + registryEntry.canvases.push(canvas); + } - el.textContent = ''; - el.appendChild(widget); - }, () => {}, { hasInteractiveView: false }); + const chartOptions = compact + ? OCA.Analytics.Reference.getCompactChartOptions(legend) + : OCA.Analytics.Reference.getDefaultChartOptions(); + OCA.Analytics.Visualization.buildChart(canvas.getContext('2d'), data, chartOptions); }, + + buildTable: function (container, data, registryEntry) { + const table = document.createElement('table'); + const uid = OCA.Analytics.Reference.nextUid(); + table.id = 'analyticsReferenceTable' + uid; + container.appendChild(table); + OCA.Analytics.Visualization.buildDataTable(table, data, true, uid); + if (registryEntry) { + registryEntry.tableUids.push(uid); + } + }, + + // unique per widget instance; buildDataTable/buildKpiDisplay reduce the uid to its digits + nextUid: function () { + return String(++OCA.Analytics.Reference.instanceCounter); + }, + + destroyWidget: function (el) { + const entry = OCA.Analytics.Reference.widgetRegistry.get(el); + if (!entry) { + return; + } + entry.canvases.forEach((canvas) => { + try { + const chart = window.Chart ? Chart.getChart(canvas) : null; + if (chart) { + chart.destroy(); + } + } catch (error) { + } + }); + entry.tableUids.forEach((uid) => { + const numericUid = parseInt(String(uid).replace(/[^0-9]+/g, ''), 10); + const tableObject = OCA.Analytics.tableObject && OCA.Analytics.tableObject[numericUid]; + if (tableObject && typeof tableObject.destroy === 'function') { + try { + tableObject.destroy(); + } catch (error) { + } + delete OCA.Analytics.tableObject[numericUid]; + } + }); + OCA.Analytics.Reference.widgetRegistry.delete(el); + }, + + buildLoadingIndicator: function () { + const loading = document.createElement('div'); + loading.classList.add('icon-loading'); + loading.style.height = '100%'; + return loading; + }, + + buildMessage: function (message) { + const div = document.createElement('div'); + div.classList.add('analytics-reference-message'); + div.textContent = message; + return div; + }, + + renderStaticCard: function (el, richObject) { + const referenceUrl = getSafeReferenceUrl(richObject?.url); + const imageUrl = getSafeReferenceUrl(richObject?.image); + const widget = document.createElement(referenceUrl ? 'a' : 'div'); + widget.classList.add('analytics-reference-fallback'); + widget.style.display = 'flex'; + + if (referenceUrl) { + widget.setAttribute('href', referenceUrl); + widget.setAttribute('target', '_blank'); + widget.setAttribute('rel', 'noopener noreferrer'); + } + + const content = document.createElement('div'); + content.classList.add('analytics-reference-fallback-content'); + content.style.padding = '10px'; + content.style.width = imageUrl ? '75%' : '100%'; + + const title = document.createElement('div'); + title.classList.add('analytics-reference-fallback-title'); + title.style.fontWeight = '600'; + title.textContent = richObject?.name || ''; + + const subheader = document.createElement('div'); + subheader.classList.add('analytics-reference-fallback-subheader'); + subheader.style.marginTop = '1em'; + subheader.textContent = richObject?.subheader || ''; + + content.appendChild(title); + content.appendChild(subheader); + + if (imageUrl) { + const image = document.createElement('img'); + image.setAttribute('src', imageUrl); + image.setAttribute('alt', ''); + image.style.width = '20%'; + image.style.padding = '20px'; + image.style.opacity = '.5'; + widget.appendChild(image); + } + + widget.appendChild(content); + + el.textContent = ''; + el.appendChild(widget); + }, + + // ************* + // *** data access + // ************* + + fetchJson: function (url) { + return new Promise(function (resolve, reject) { + const xhr = new XMLHttpRequest(); + xhr.open('GET', url); + xhr.setRequestHeader('requesttoken', OC.requestToken); + xhr.setRequestHeader('OCS-APIREQUEST', 'true'); + xhr.onreadystatechange = function () { + if (xhr.readyState !== XMLHttpRequest.DONE) { + return; + } + if (xhr.status === 200) { + try { + resolve(JSON.parse(xhr.response)); + } catch (e) { + reject(e); + } + } else { + reject(new Error('request failed: ' + xhr.status)); + } + }; + xhr.onerror = function () { + reject(new Error('request failed')); + }; + xhr.send(); + }); + }, + + // ETag / localStorage caching identical to the dashboard widget, but without + // its 20-row truncation - the reference widget shows the full report + fetchReportData: function (url, cacheKey) { + const storage = OCA.Analytics.Reference.getLocalStorage(); + + let cachedData = null; + let cachedVersion = null; + if (storage) { + try { + const cachedEntry = storage.getItem(cacheKey); + if (cachedEntry) { + const parsed = JSON.parse(cachedEntry); + cachedData = parsed.data; + cachedVersion = parsed.version; + } + } catch (e) { + try { + storage.removeItem(cacheKey); + } catch (removeError) { + } + } + } + + return new Promise(function (resolve, reject) { + const xhr = new XMLHttpRequest(); + xhr.open('GET', url); + xhr.setRequestHeader('requesttoken', OC.requestToken); + xhr.setRequestHeader('OCS-APIREQUEST', 'true'); + + if (cachedVersion) { + xhr.setRequestHeader('If-None-Match', cachedVersion); + } + + xhr.onreadystatechange = function () { + if (xhr.readyState !== XMLHttpRequest.DONE) { + return; + } + if (xhr.status === 200) { + let data; + try { + data = JSON.parse(xhr.response); + } catch (e) { + reject(e); + return; + } + + const newVersion = xhr.getResponseHeader('ETag') || null; + const cacheable = xhr.getResponseHeader('X-Analytics-Cacheable') === 'true'; + if (cacheable && newVersion && storage) { + try { + storage.setItem(cacheKey, JSON.stringify({data: data, version: newVersion})); + } catch (e) { + } + } + resolve(data); + } else if (xhr.status === 304 && cachedData) { + resolve(cachedData); + } else { + reject(new Error('request failed: ' + xhr.status)); + } + }; + xhr.onerror = function () { + reject(new Error('request failed')); + }; + xhr.send(); + }); + }, + + getLocalStorage: function () { + if (typeof window === 'undefined') { + return null; + } + try { + return typeof window.localStorage === 'undefined' ? null : window.localStorage; + } catch (e) { + return null; + } + }, + + processReceivedData: function (data) { + data.options.chartoptions = OCA.Analytics.ChartOptions.parseAndNormalize(data.options.chartoptions); + + const parsedDataOptions = OCA.Analytics.ChartOptions.safeParse(data.options.dataoptions, []); + data.options.dataoptions = Array.isArray(parsedDataOptions) ? parsedDataOptions : []; + + const parsedFilterOptions = OCA.Analytics.ChartOptions.safeParse(data.options.filteroptions, {}); + data.options.filteroptions = ( + parsedFilterOptions !== null + && typeof parsedFilterOptions === 'object' + && !Array.isArray(parsedFilterOptions) + ) ? parsedFilterOptions : {}; + + const parsedTableOptions = OCA.Analytics.ChartOptions.safeParse(data.options.tableoptions, {}); + data.options.tableoptions = (parsedTableOptions !== null && typeof parsedTableOptions === 'object') ? parsedTableOptions : {}; + + // if the user uses a special time parser (e.g. DD.MM), the data needs to be sorted differently + data = OCA.Analytics.Visualization.sortDates(data); + data = OCA.Analytics.Visualization.applyTimeAggregation(data); + data = OCA.Analytics.Visualization.applyTopN(data); + + return data; + }, + + // ************* + // *** chart options + // ************* + + // full view: axes, grid and stored report options apply (same as the public report page) + getDefaultChartOptions: function () { + return { + maintainAspectRatio: false, + responsive: true, + scales: { + 'primary': { + type: 'linear', + stacked: false, + position: 'left', + display: true, + grid: { + display: true, + }, + ticks: { + callback: function (value) { + return value.toLocaleString(); + }, + }, + }, + 'secondary': { + type: 'linear', + stacked: false, + position: 'right', + display: false, + grid: { + display: false, + }, + ticks: { + callback: function (value) { + return value.toLocaleString(); + }, + }, + }, + 'x': { + type: 'category', + time: { + parser: 'YYYY-MM-DD HH:mm', + tooltipFormat: 'LL', + }, + distribution: 'linear', + grid: { + display: false + }, + display: true, + }, + }, + animation: { + duration: 0 // general animation time + }, + interaction: { + mode: 'x', + intersect: false, + }, + plugins: { + tooltip: OCA.Analytics.Visualization.getSharedTooltipOptions(), + datalabels: { + display: false, + formatter: (value, ctx) => { + let sum = 0; + let dataArr = ctx.chart.data.datasets[0].data; + dataArr.map(data => { + sum += data; + }); + value = (value * 100 / sum).toFixed(0); + if (value > 5) { + return value + "%"; + } else { + return ''; + } + }, + }, + }, + }; + }, + + // panorama cells: compact like the panorama page (no grid lines, optional legend) + getCompactChartOptions: function (legend) { + const options = { + devicePixelRatio: 2, + maintainAspectRatio: false, + responsive: true, + scales: { + 'primary': { + stacked: false, + position: 'left', + display: true, + grid: { + display: false, + }, + }, + 'secondary': { + stacked: false, + position: 'right', + display: false, + grid: { + display: false, + }, + }, + 'x': { + type: 'category', + distribution: 'linear', + grid: { + display: false + }, + display: true, + }, + }, + animation: { + duration: 0 // general animation time + }, + interaction: { + mode: 'x', + intersect: false, + }, + plugins: { + legend: { + display: true, + }, + tooltip: OCA.Analytics.Visualization.getSharedTooltipOptions(), + datalabels: { + display: false, + formatter: (value, ctx) => { + let sum = 0; + let dataArr = ctx.chart.data.datasets[0].data; + dataArr.map(data => { + sum += data; + }); + value = (value * 100 / sum).toFixed(0); + if (value > 5) { + return value + "%"; + } else { + return ''; + } + }, + } + }, + }; + if (legend !== undefined) { + options.plugins.legend.display = legend; + } + return options; + }, + + // ************* + // *** lazy asset loading + // ************* + + // the chart stack (~600KB) is only loaded once an analytics reference is actually + // rendered, not on every page that might show references (Talk, Text, Tables) + ensureCoreAssets: function () { + if (OCA.Analytics.Reference.coreAssetsPromise) { + return OCA.Analytics.Reference.coreAssetsPromise; + } + const load = OCA.Analytics.Reference.loadScript; + OCA.Analytics.Reference.coreAssetsPromise = Promise.all([ + load('3rdParty/moment.min', () => window.moment), + load('3rdParty/cloner', () => window.cloner), + ]) + .then(() => load('3rdParty/chart.umd', () => window.Chart)) + .then(() => Promise.all([ + load('3rdParty/chartjs-adapter-moment'), + load('3rdParty/chartjs-plugin-datalabels.min', () => window.ChartDataLabels), + load('3rdParty/chartjs-plugin-funnel.min'), + load('3rdParty/chartjs-plugin-annotation.min'), + ])) + .then(() => load('chartOptions', () => OCA.Analytics.ChartOptions && OCA.Analytics.ChartOptions.parseAndNormalize)) + .then(() => load('visualization', () => OCA.Analytics.Visualization && OCA.Analytics.Visualization.buildChart)) + .then(() => { + // visualization.js event handlers call into modules of the main app + // which are not loaded in the reference context + OCA.Analytics.Filter = OCA.Analytics.Filter || {}; + OCA.Analytics.Filter.toggleSaveButtonDisplay = OCA.Analytics.Filter.toggleSaveButtonDisplay || function () {}; + OCA.Analytics.Filter.syncChartLegendSelections = OCA.Analytics.Filter.syncChartLegendSelections || function () {}; + OCA.Analytics.Report = OCA.Analytics.Report || {}; + OCA.Analytics.Report.hideReportMenu = OCA.Analytics.Report.hideReportMenu || function () {}; + }); + return OCA.Analytics.Reference.coreAssetsPromise; + }, + + ensureTableAssets: function () { + if (OCA.Analytics.Reference.tableAssetsPromise) { + return OCA.Analytics.Reference.tableAssetsPromise; + } + const load = OCA.Analytics.Reference.loadScript; + // Talk/Text may already ship a jQuery; never load a second one + OCA.Analytics.Reference.tableAssetsPromise = load('3rdParty/jquery.min', () => window.jQuery) + .then(() => load('3rdParty/datatables.min', () => window.DataTable && window.jQuery && window.jQuery.fn && window.jQuery.fn.dataTable)) + .then(() => OCA.Analytics.Reference.loadStyle('3rdParty/datatables.min')); + return OCA.Analytics.Reference.tableAssetsPromise; + }, + + loadScript: function (name, testFn) { + if (testFn && testFn()) { + return Promise.resolve(); + } + if (OCA.Analytics.Reference.scriptPromises[name]) { + return OCA.Analytics.Reference.scriptPromises[name]; + } + OCA.Analytics.Reference.scriptPromises[name] = new Promise(function (resolve, reject) { + const script = document.createElement('script'); + script.src = OC.filePath('analytics', 'js', name + '.js'); + script.onload = () => resolve(); + script.onerror = () => reject(new Error('could not load ' + name)); + document.head.appendChild(script); + }); + return OCA.Analytics.Reference.scriptPromises[name]; + }, + + loadStyle: function (name) { + const href = OC.filePath('analytics', 'css', name + '.css'); + if (document.querySelector('link[href="' + href + '"]')) { + return Promise.resolve(); + } + return new Promise(function (resolve) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + // a missing stylesheet only degrades the table styling + link.onload = () => resolve(); + link.onerror = () => resolve(); + document.head.appendChild(link); + }); + }, +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + OCA.Analytics.Reference.init(); + }); +} else { + OCA.Analytics.Reference.init(); } diff --git a/lib/Listener/ReferenceListener.php b/lib/Listener/ReferenceListener.php index 5896b5456..7a66df264 100644 --- a/lib/Listener/ReferenceListener.php +++ b/lib/Listener/ReferenceListener.php @@ -22,5 +22,6 @@ public function handle(Event $event): void } Util::addScript('analytics', 'reference'); + Util::addStyle('analytics', 'reference'); } } \ No newline at end of file diff --git a/lib/Reference/ReferenceProvider.php b/lib/Reference/ReferenceProvider.php index 6c8360d77..33d7ab01f 100644 --- a/lib/Reference/ReferenceProvider.php +++ b/lib/Reference/ReferenceProvider.php @@ -10,6 +10,7 @@ use OCA\Analytics\Service\ReportService; use OCA\Analytics\Service\PanoramaService; +use OCA\Analytics\Service\ShareService; use OCP\Collaboration\Reference\ADiscoverableReferenceProvider; use OCP\Collaboration\Reference\ISearchableReferenceProvider; use OCP\Collaboration\Reference\Reference; @@ -32,6 +33,7 @@ class ReferenceProvider extends ADiscoverableReferenceProvider implements ISearc private LoggerInterface $logger; private ReportService $ReportService; private PanoramaService $PanoramaService; + private ShareService $ShareService; public function __construct(IConfig $config, LoggerInterface $logger, @@ -40,6 +42,7 @@ public function __construct(IConfig $config, ReferenceManager $referenceManager, ReportService $ReportService, PanoramaService $PanoramaService, + ShareService $ShareService, ?string $userId) { $this->userId = $userId; @@ -50,6 +53,7 @@ public function __construct(IConfig $config, $this->urlGenerator = $urlGenerator; $this->ReportService = $ReportService; $this->PanoramaService = $PanoramaService; + $this->ShareService = $ShareService; } public function getId(): string @@ -83,7 +87,7 @@ public function matchReference(string $referenceText): bool { $adminLinkPreviewEnabled = $this->config->getAppValue('analytics', 'link_preview_enabled', '1') === '1'; if (!$adminLinkPreviewEnabled) { - //return false; + return false; } return preg_match('~/apps/analytics/(?:r|pa)/~', $referenceText) === 1; } @@ -92,13 +96,24 @@ public function resolveReference(string $referenceText): ?IReference { if ($this->matchReference($referenceText)) { preg_match("/\d+$/", $referenceText, $matches); // get the last integer + $itemId = isset($matches[0]) ? (int)$matches[0] : 0; $isPanorama = str_contains($referenceText, '/pa/'); + $item = []; if ($isPanorama) { - $item = $this->PanoramaService->read((int)$matches[0]); + if ($itemId !== 0) { + // PanoramaService->read() already falls back to shared panoramas + $item = $this->PanoramaService->read($itemId); + } $icon = 'panorama.svg'; $type = $this->l10n->t('Panorama'); } else { - $item = $this->ReportService->read((int)$matches[0]); + if ($itemId !== 0) { + $item = $this->ReportService->read($itemId); + if (empty($item)) { + // fall back to reports shared with the current user + $item = $this->ShareService->getSharedReport($itemId); + } + } $icon = 'report.svg'; $type = $this->l10n->t('Report'); } @@ -121,7 +136,10 @@ public function resolveReference(string $referenceText): ?IReference 'name' => $name, 'subheader' => $subheader, 'url' => $referenceText, - 'image' => $imageUrl + 'image' => $imageUrl, + 'id' => $itemId, + 'item_type' => $isPanorama ? 'panorama' : 'report', + 'found' => !empty($item) ] ); return $reference; diff --git a/lib/Service/PanoramaService.php b/lib/Service/PanoramaService.php index 07b01d998..ac5867a79 100644 --- a/lib/Service/PanoramaService.php +++ b/lib/Service/PanoramaService.php @@ -98,6 +98,7 @@ public function index(): array { /** * get own report details + * falls back to panoramas shared with the current user * * @param int $panoramaId * @return array @@ -105,6 +106,15 @@ public function index(): array { */ public function read(int $panoramaId) { $ownReport = $this->PanoramaMapper->readOwn($panoramaId); + if (empty($ownReport)) { + $sharedPanorama = $this->ShareService->getSharedPanorama($panoramaId); + if (!empty($sharedPanorama)) { + // ToDo: panoramas do not have an edit logic. to be added later + $sharedPanorama['permissions'] = \OCP\Constants::PERMISSION_READ; + $keysToKeep = array('id', 'name', 'dataset', 'favorite', 'parent', 'type', 'pages', 'isShare', 'shareId', 'permissions'); + $ownReport = array_intersect_key($sharedPanorama, array_flip($keysToKeep)); + } + } return $ownReport; } diff --git a/lib/Service/ShareService.php b/lib/Service/ShareService.php index 9eaa06568..e33d49744 100644 --- a/lib/Service/ShareService.php +++ b/lib/Service/ShareService.php @@ -241,6 +241,24 @@ public function getSharedReport($reportId) { } } + /** + * get metadata of a panorama, shared with current user + * used to check if user is allowed to access current panorama + * + * @param $panoramaId + * @return array + * @throws Exception + */ + public function getSharedPanorama($panoramaId) { + $sharedPanoramas = $this->getSharedItems(self::SHARE_ITEM_TYPE_PANORAMA); + if (in_array($panoramaId, array_column($sharedPanoramas, "id"))) { + $key = array_search($panoramaId, array_column($sharedPanoramas, 'id')); + return $sharedPanoramas[$key]; + } else { + return []; + } + } + /** * get metadata of a report, shared with current user as part of a panorama * used to check if user is allowed to execute current report diff --git a/tests/Reference/ReferenceProviderTest.php b/tests/Reference/ReferenceProviderTest.php new file mode 100644 index 000000000..56e51d281 --- /dev/null +++ b/tests/Reference/ReferenceProviderTest.php @@ -0,0 +1,163 @@ +config = $this->createMock(IConfig::class); + $this->config->method('getAppValue')->willReturn('1'); + + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->urlGenerator->method('imagePath') + ->willReturnCallback(function ($app, $file) { + return '/apps/analytics/img/' . $file; + }); + $this->urlGenerator->method('getAbsoluteURL') + ->willReturnCallback(function ($url) { + return 'https://cloud.example.com' . $url; + }); + + $this->reportService = $this->createMock(ReportService::class); + $this->panoramaService = $this->createMock(PanoramaService::class); + $this->shareService = $this->createMock(ShareService::class); + } + + private function buildProvider(): ReferenceProvider { + return new ReferenceProvider( + $this->config, + new NullLogger(), + new FakeL10N(), + $this->urlGenerator, + $this->createMock(ReferenceManager::class), + $this->reportService, + $this->panoramaService, + $this->shareService, + 'testUser' + ); + } + + public function testMatchReference(): void { + $provider = $this->buildProvider(); + + $this->assertTrue($provider->matchReference('https://cloud.example.com/apps/analytics/r/5')); + $this->assertTrue($provider->matchReference('https://cloud.example.com/apps/analytics/pa/7')); + $this->assertFalse($provider->matchReference('https://cloud.example.com/apps/files/')); + $this->assertFalse($provider->matchReference('https://cloud.example.com/apps/analytics/')); + } + + public function testMatchReferenceRespectsAdminDisable(): void { + $this->config = $this->createMock(IConfig::class); + $this->config->method('getAppValue') + ->with('analytics', 'link_preview_enabled', '1') + ->willReturn('0'); + $provider = $this->buildProvider(); + + $this->assertFalse($provider->matchReference('https://cloud.example.com/apps/analytics/r/5')); + } + + public function testResolveOwnReport(): void { + $this->reportService->expects($this->once()) + ->method('read') + ->with(5) + ->willReturn(['id' => 5, 'name' => 'My Report']); + $this->shareService->expects($this->never()) + ->method('getSharedReport'); + + $reference = $this->buildProvider()->resolveReference('https://cloud.example.com/apps/analytics/r/5'); + + $this->assertInstanceOf(IReference::class, $reference); + $richObject = $reference->getRichObject(); + $this->assertSame(5, $richObject['id']); + $this->assertSame('report', $richObject['item_type']); + $this->assertTrue($richObject['found']); + $this->assertSame('My Report', $richObject['subheader']); + } + + public function testResolveSharedReportFallsBackToShareService(): void { + $this->reportService->expects($this->once()) + ->method('read') + ->with(5) + ->willReturn([]); + $this->shareService->expects($this->once()) + ->method('getSharedReport') + ->with(5) + ->willReturn(['id' => 5, 'name' => 'Shared Report']); + + $reference = $this->buildProvider()->resolveReference('https://cloud.example.com/apps/analytics/r/5'); + + $richObject = $reference->getRichObject(); + $this->assertTrue($richObject['found']); + $this->assertSame('Shared Report', $richObject['subheader']); + } + + public function testResolveMissingReport(): void { + $this->reportService->method('read')->willReturn([]); + $this->shareService->method('getSharedReport')->willReturn([]); + + $reference = $this->buildProvider()->resolveReference('https://cloud.example.com/apps/analytics/r/99'); + + $richObject = $reference->getRichObject(); + $this->assertFalse($richObject['found']); + $this->assertSame('Report not found', $richObject['name']); + } + + public function testResolvePanorama(): void { + // PanoramaService->read() contains the shared-panorama fallback itself + $this->panoramaService->expects($this->once()) + ->method('read') + ->with(7) + ->willReturn(['id' => 7, 'name' => 'My Panorama']); + $this->reportService->expects($this->never()) + ->method('read'); + + $reference = $this->buildProvider()->resolveReference('https://cloud.example.com/apps/analytics/pa/7'); + + $richObject = $reference->getRichObject(); + $this->assertSame(7, $richObject['id']); + $this->assertSame('panorama', $richObject['item_type']); + $this->assertTrue($richObject['found']); + $this->assertSame('My Panorama', $richObject['subheader']); + } + + public function testResolveWithoutTrailingIntegerReturnsNotFound(): void { + $this->reportService->expects($this->never())->method('read'); + $this->shareService->expects($this->never())->method('getSharedReport'); + + $reference = $this->buildProvider()->resolveReference('https://cloud.example.com/apps/analytics/r/'); + + $this->assertInstanceOf(IReference::class, $reference); + $richObject = $reference->getRichObject(); + $this->assertSame(0, $richObject['id']); + $this->assertFalse($richObject['found']); + } + + public function testResolveUnmatchedUrlReturnsNull(): void { + $this->assertNull($this->buildProvider()->resolveReference('https://cloud.example.com/apps/files/')); + } +} diff --git a/tests/Service/PanoramaServiceReadTest.php b/tests/Service/PanoramaServiceReadTest.php new file mode 100644 index 000000000..31416a4fa --- /dev/null +++ b/tests/Service/PanoramaServiceReadTest.php @@ -0,0 +1,91 @@ +panoramaMapper = $this->createMock(PanoramaMapper::class); + $this->shareService = $this->createMock(ShareService::class); + } + + private function buildService(): PanoramaService { + return new PanoramaService( + 'testUser', + new FakeL10N(), + new NullLogger(), + $this->createMock(ITagManager::class), + $this->shareService, + $this->panoramaMapper, + $this->createMock(IConfig::class), + $this->createMock(VariableService::class), + $this->createMock(ActivityManager::class) + ); + } + + public function testReadReturnsOwnPanorama(): void { + $own = ['id' => 7, 'name' => 'Own Panorama', 'pages' => '[]']; + $this->panoramaMapper->expects($this->once()) + ->method('readOwn') + ->with(7) + ->willReturn($own); + $this->shareService->expects($this->never()) + ->method('getSharedPanorama'); + + $this->assertSame($own, $this->buildService()->read(7)); + } + + public function testReadFallsBackToSharedPanorama(): void { + $this->panoramaMapper->expects($this->once()) + ->method('readOwn') + ->with(7) + ->willReturn([]); + $this->shareService->expects($this->once()) + ->method('getSharedPanorama') + ->with(7) + ->willReturn([ + 'id' => 7, + 'name' => 'Shared Panorama', + 'pages' => '[]', + 'type' => 99, + 'parent' => 0, + 'user_id' => 'someoneElse', + 'password' => 'secret-hash', + ]); + + $result = $this->buildService()->read(7); + + $this->assertSame(7, $result['id']); + $this->assertSame('Shared Panorama', $result['name']); + $this->assertSame(\OCP\Constants::PERMISSION_READ, $result['permissions']); + // sensitive / internal share fields must not leak + $this->assertArrayNotHasKey('user_id', $result); + $this->assertArrayNotHasKey('password', $result); + } + + public function testReadReturnsEmptyWhenNotAvailable(): void { + $this->panoramaMapper->method('readOwn')->willReturn([]); + $this->shareService->method('getSharedPanorama')->willReturn([]); + + $this->assertSame([], $this->buildService()->read(42)); + } +} diff --git a/tests/Service/ShareServiceSharedPanoramaTest.php b/tests/Service/ShareServiceSharedPanoramaTest.php new file mode 100644 index 000000000..06358c8a5 --- /dev/null +++ b/tests/Service/ShareServiceSharedPanoramaTest.php @@ -0,0 +1,45 @@ +getMockBuilder(ShareService::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSharedItems']) + ->getMock(); + $shareService->method('getSharedItems') + ->with(ShareService::SHARE_ITEM_TYPE_PANORAMA) + ->willReturn($sharedItems); + return $shareService; + } + + public function testGetSharedPanoramaReturnsMatch(): void { + $shareService = $this->buildShareService([ + ['id' => 3, 'name' => 'Other'], + ['id' => 7, 'name' => 'Shared Panorama'], + ]); + + $result = $shareService->getSharedPanorama(7); + + $this->assertSame(7, $result['id']); + $this->assertSame('Shared Panorama', $result['name']); + } + + public function testGetSharedPanoramaReturnsEmptyWhenNotShared(): void { + $shareService = $this->buildShareService([ + ['id' => 3, 'name' => 'Other'], + ]); + + $this->assertSame([], $shareService->getSharedPanorama(99)); + } +} diff --git a/tests/Stubs/OC/Collaboration/Reference/ReferenceManager.php b/tests/Stubs/OC/Collaboration/Reference/ReferenceManager.php new file mode 100644 index 000000000..6db1b974e --- /dev/null +++ b/tests/Stubs/OC/Collaboration/Reference/ReferenceManager.php @@ -0,0 +1,15 @@ + $this->getId(), + 'title' => $this->getTitle(), + 'icon_url' => $this->getIconUrl(), + 'order' => $this->getOrder(), + ]; + } +} diff --git a/tests/Stubs/OCP/Collaboration/Reference/IReference.php b/tests/Stubs/OCP/Collaboration/Reference/IReference.php new file mode 100644 index 000000000..d133c4fda --- /dev/null +++ b/tests/Stubs/OCP/Collaboration/Reference/IReference.php @@ -0,0 +1,12 @@ +reference = $reference; + } + + public function getId(): string { + return $this->reference; + } + + public function setTitle(string $title): void { + $this->title = $title; + } + + public function getTitle(): string { + return $this->title ?? ''; + } + + public function setDescription(?string $description): void { + $this->description = $description; + } + + public function getDescription(): ?string { + return $this->description; + } + + public function setImageUrl(?string $imageUrl): void { + $this->imageUrl = $imageUrl; + } + + public function getImageUrl(): ?string { + return $this->imageUrl; + } + + public function setRichObject(string $type, ?array $richObject): void { + $this->richObjectType = $type; + $this->richObject = $richObject; + } + + public function getRichObjectType(): string { + return $this->richObjectType ?? ''; + } + + public function getRichObject(): array { + return $this->richObject ?? []; + } +} diff --git a/tests/Stubs/OCP/Constants.php b/tests/Stubs/OCP/Constants.php new file mode 100644 index 000000000..5c1fd368d --- /dev/null +++ b/tests/Stubs/OCP/Constants.php @@ -0,0 +1,18 @@ + Date: Wed, 16 Sep 2026 18:19:51 +0200 Subject: [PATCH 2/3] Optimize Analytics link preview loading and table paging Signed-off-by: Rello Assisted-by: Codex:GPT-5 --- CHANGELOG.md | 2 +- js/reference.js | 43 ++++--- js/visualization.js | 20 ++-- tests/INSTRUCTIONS.md | 1 + tests/playwright/49_table_preview.js | 35 ++++++ tests/playwright/52_reference_asset_split.js | 113 +++++++++++++++++++ tests/playwright/full_regression.js | 1 + tests/run-playwright.sh | 3 + 8 files changed, 193 insertions(+), 25 deletions(-) create mode 100644 tests/playwright/52_reference_asset_split.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f1b90689..8e9fd6a9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- Render live reports and the first panorama page as chart, table, KPI, text, and picture content in link previews (Smart Picker). +- Render live reports and the first panorama page as chart, table, KPI, text, and picture content in link previews (Smart Picker), with ten-row table pages, bottom-only pagination, and chart libraries loaded only for chart previews. - Add a V4 API endpoint for deleting legacy dataset rows through structured filters, including dynamic date variables. - Configure tables interactively with a seven-row live preview independent of report pagination, column formatting, layout controls with accurate empty-field placeholders, shared section navigation with linked documentation and an active-section underline that follows scrolling, a left settings panel paired with a live preview, and draft Apply/Cancel actions; use alternating rows by default in previews and live tables, keep section headings visible when navigating, immediately mark the chosen compact header button for column formatting, prevent Appearance controls from scrolling the dialog shell blank, use report headers as the sole persisted sort control, and support DataTables 3 column metadata. - New interactive chart configuration with flexible field mapping and a live-data preview in the shared options-dialog layout. diff --git a/js/reference.js b/js/reference.js index 607ab4af6..8619b1aaf 100644 --- a/js/reference.js +++ b/js/reference.js @@ -68,6 +68,7 @@ OCA.Analytics.Reference = { // widget root element => {canvases: [], tableUids: []}, used by the destroy callback widgetRegistry: new Map(), coreAssetsPromise: null, + chartAssetsPromise: null, tableAssetsPromise: null, scriptPromises: {}, @@ -300,7 +301,10 @@ OCA.Analytics.Reference = { } if (visualization === 'ct') { - await OCA.Analytics.Reference.ensureTableAssets(); + await Promise.all([ + OCA.Analytics.Reference.ensureChartAssets(), + OCA.Analytics.Reference.ensureTableAssets(), + ]); container.replaceChildren(); container.classList.add('analytics-reference-ct'); const chartArea = document.createElement('div'); @@ -315,6 +319,7 @@ OCA.Analytics.Reference = { } // 'chart' and anything unknown + await OCA.Analytics.Reference.ensureChartAssets(); container.replaceChildren(); OCA.Analytics.Reference.buildChart(container, data, compact, legend, registryEntry); }, @@ -338,7 +343,7 @@ OCA.Analytics.Reference = { const uid = OCA.Analytics.Reference.nextUid(); table.id = 'analyticsReferenceTable' + uid; container.appendChild(table); - OCA.Analytics.Visualization.buildDataTable(table, data, true, uid); + OCA.Analytics.Visualization.buildDataTable(table, data, true, uid, {referencePreview: true}); if (registryEntry) { registryEntry.tableUids.push(uid); } @@ -724,24 +729,13 @@ OCA.Analytics.Reference = { // *** lazy asset loading // ************* - // the chart stack (~600KB) is only loaded once an analytics reference is actually - // rendered, not on every page that might show references (Talk, Text, Tables) + // Data preparation and visualization helpers are shared by charts, tables, and KPIs. ensureCoreAssets: function () { if (OCA.Analytics.Reference.coreAssetsPromise) { return OCA.Analytics.Reference.coreAssetsPromise; } const load = OCA.Analytics.Reference.loadScript; - OCA.Analytics.Reference.coreAssetsPromise = Promise.all([ - load('3rdParty/moment.min', () => window.moment), - load('3rdParty/cloner', () => window.cloner), - ]) - .then(() => load('3rdParty/chart.umd', () => window.Chart)) - .then(() => Promise.all([ - load('3rdParty/chartjs-adapter-moment'), - load('3rdParty/chartjs-plugin-datalabels.min', () => window.ChartDataLabels), - load('3rdParty/chartjs-plugin-funnel.min'), - load('3rdParty/chartjs-plugin-annotation.min'), - ])) + OCA.Analytics.Reference.coreAssetsPromise = load('3rdParty/moment.min', () => window.moment) .then(() => load('flexible', () => OCA.Analytics.Flexible && OCA.Analytics.Flexible.seriesOptions)) .then(() => load('chartOptions', () => OCA.Analytics.ChartOptions && OCA.Analytics.ChartOptions.parseAndNormalize)) .then(() => load('visualization', () => OCA.Analytics.Visualization && OCA.Analytics.Visualization.buildChart)) @@ -757,6 +751,25 @@ OCA.Analytics.Reference = { return OCA.Analytics.Reference.coreAssetsPromise; }, + ensureChartAssets: function () { + if (OCA.Analytics.Reference.chartAssetsPromise) { + return OCA.Analytics.Reference.chartAssetsPromise; + } + const load = OCA.Analytics.Reference.loadScript; + OCA.Analytics.Reference.chartAssetsPromise = OCA.Analytics.Reference.ensureCoreAssets() + .then(() => Promise.all([ + load('3rdParty/cloner', () => window.cloner), + load('3rdParty/chart.umd', () => window.Chart) + .then(() => Promise.all([ + load('3rdParty/chartjs-adapter-moment'), + load('3rdParty/chartjs-plugin-datalabels.min', () => window.ChartDataLabels), + load('3rdParty/chartjs-plugin-funnel.min'), + load('3rdParty/chartjs-plugin-annotation.min'), + ])), + ])); + return OCA.Analytics.Reference.chartAssetsPromise; + }, + ensureTableAssets: function () { if (OCA.Analytics.Reference.tableAssetsPromise) { return OCA.Analytics.Reference.tableAssetsPromise; diff --git a/js/visualization.js b/js/visualization.js index 8fba473f9..40a6394c0 100644 --- a/js/visualization.js +++ b/js/visualization.js @@ -834,6 +834,7 @@ OCA.Analytics.Visualization = { */ buildDataTable: function (domTarget, jsondata, ordering = true, uniqueId, renderOptions = {}) { const preview = renderOptions.preview === true; + const referencePreview = renderOptions.referencePreview === true; if (!uniqueId) { uniqueId = jsondata.options.id; @@ -847,7 +848,7 @@ OCA.Analytics.Visualization = { OCA.Analytics.tableObject[uniqueId] = []; } - if (!preview) this.showElement('tableContainer'); + if (!preview && !referencePreview) this.showElement('tableContainer'); // get current table state let tableOptions = {...(jsondata.options.tableoptions || {})}; @@ -894,10 +895,11 @@ OCA.Analytics.Visualization = { // check table length => show/hide navigation let isDataLengthGreaterThanDefault = data.length > ((tableOptions && tableOptions.length) || defaultLength); - // never show table navigation in Panorama - if (OCA.Analytics.isPanorama || preview) { + // Keep the editor and Panorama tables free of navigation controls. + if (OCA.Analytics.isPanorama || preview || referencePreview) { isDataLengthGreaterThanDefault = false; } + const referenceHasMorePages = referencePreview && data.length > defaultLength; const footerRow = domTarget.createTFoot().insertRow(0); columns.forEach(() => footerRow.appendChild(document.createElement('td'))); @@ -908,13 +910,13 @@ OCA.Analytics.Visualization = { topStart: isDataLengthGreaterThanDefault ? 'pageLength' : null, topEnd: isDataLengthGreaterThanDefault ? 'search' : null, bottomStart: isDataLengthGreaterThanDefault ? 'info' : null, - bottomEnd: isDataLengthGreaterThanDefault ? 'paging' : null, + bottomEnd: isDataLengthGreaterThanDefault || referenceHasMorePages ? 'paging' : null, }, - colReorder: preview ? {...(typeof safeColReorder === 'object' ? safeColReorder : {}), enable: false} : safeColReorder, + colReorder: preview || referencePreview ? {...(typeof safeColReorder === 'object' ? safeColReorder : {}), enable: false} : safeColReorder, order: tableOptions.order || defaultOrder, - // Keep the editor preview compact and independent from the report's - // user-configurable pagination. Totals still use the complete data set. - pageLength: preview ? previewLength : tableOptions.length || defaultLength, + // Reference previews use ten-row pages with paging below the table; + // the editor preview remains a fixed excerpt. Totals use all data. + pageLength: preview ? previewLength : referencePreview ? defaultLength : tableOptions.length || defaultLength, pagingType: 'simple_numbers', //scrollX: true, autoWidth: false, @@ -939,7 +941,7 @@ OCA.Analytics.Visualization = { } if (!preview) OCA.Analytics.tableObject[uniqueId] = instance; - if (!preview && !OCA.Analytics.isPanorama) { + if (!preview && !referencePreview && !OCA.Analytics.isPanorama) { // reset initialization flag for this table OCA.Analytics.Visualization.dataTableInitialized[uniqueId] = false; diff --git a/tests/INSTRUCTIONS.md b/tests/INSTRUCTIONS.md index f0c6d5ad1..60a111c45 100644 --- a/tests/INSTRUCTIONS.md +++ b/tests/INSTRUCTIONS.md @@ -58,6 +58,7 @@ Supported identifiers: - `49`, `table-preview` (table preview, formatting, draft lifecycle, pivot validation, and stable column references) - `50`, `share`, `navigation-share` - `51`, `favorites`, `navigation-favorites` +- `52`, `reference-assets` (isolated link-preview asset loading) - `91`, `91-delete`, `report-delete`, `delete` - `92`, `92-delete`, `group-delete` diff --git a/tests/playwright/49_table_preview.js b/tests/playwright/49_table_preview.js index ec5bec7af..9ff1a7cde 100644 --- a/tests/playwright/49_table_preview.js +++ b/tests/playwright/49_table_preview.js @@ -67,6 +67,41 @@ const config = buildScenarioConfig('49'); assert.equal(liveStripeProbe.defaultEnabled, true); assert.equal(liveStripeProbe.disabled, true); assert.notEqual(liveStripeProbe.defaultShadows[0], liveStripeProbe.defaultShadows[1]); + const referencePreviewProbe = await page.evaluate(() => { + const host = document.createElement('div'); + host.style.width = '600px'; + const table = document.createElement('table'); + host.appendChild(table); + document.body.appendChild(host); + const uid = 'referencePreview888001'; + const before = DataTable.settings.length; + const instance = OCA.Analytics.Visualization.buildDataTable(table, { + header: ['Name', 'Value'], + data: Array.from({length: 15}, (_, index) => ['Row ' + index, index]), + thresholds: [], + options: {id: 888001, tableoptions: {length: 25}, filteroptions: {}}, + }, true, uid, {referencePreview: true}); + const container = instance.table().container(); + const result = { + rows: table.querySelectorAll('tbody tr').length, + pageLength: instance.page.len(), + total: instance.page.info().recordsTotal, + topControls: container.querySelectorAll('.dt-search, .dt-length, .dt-info').length, + pagination: container.querySelectorAll('.dt-paging').length, + registered: OCA.Analytics.tableObject[888001] === instance, + }; + instance.page(1).draw('page'); + result.secondPage = instance.page.info().page; + result.secondPageRows = table.querySelectorAll('tbody tr').length; + instance.destroy(); + delete OCA.Analytics.tableObject[888001]; + host.remove(); + return {...result, cleanedUp: DataTable.settings.length === before}; + }); + assert.deepEqual(referencePreviewProbe, { + rows: 10, pageLength: 10, total: 15, topControls: 0, pagination: 1, + registered: true, secondPage: 1, secondPageRows: 5, cleanedUp: true, + }); await open(); const navigationOffset = await page.locator('.analyticsEnhancedDialogNav').evaluate(nav => { const bounds = nav.getBoundingClientRect(); diff --git a/tests/playwright/52_reference_asset_split.js b/tests/playwright/52_reference_asset_split.js new file mode 100644 index 000000000..449384633 --- /dev/null +++ b/tests/playwright/52_reference_asset_split.js @@ -0,0 +1,113 @@ +/** + * Analytics + * + * SPDX-FileCopyrightText: 2026 Marcel Scherello + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +const assert = require('node:assert/strict'); +const path = require('node:path'); +const {chromium} = require('playwright'); + +const root = path.resolve(__dirname, '../..'); +const chartAssets = [ + '3rdParty/cloner.js', + '3rdParty/chart.umd.js', + '3rdParty/chartjs-adapter-moment.js', + '3rdParty/chartjs-plugin-datalabels.min.js', + '3rdParty/chartjs-plugin-funnel.min.js', + '3rdParty/chartjs-plugin-annotation.min.js', +]; + +(async () => { + const browser = await chromium.launch({headless: true}); + try { + const page = await browser.newPage(); + const requests = []; + const pageErrors = []; + page.on('pageerror', error => pageErrors.push(error.message)); + await page.route('https://analytics-assets.test/**', async route => { + const url = new URL(route.request().url()); + const relativePath = decodeURIComponent(url.pathname).replace(/^\//, ''); + assert.match(relativePath, /^(js|css)\/[\w/.-]+$/); + requests.push(relativePath); + await route.fulfill({path: path.join(root, relativePath)}); + }); + await page.evaluate(() => { + window.OCA = {}; + window.OC = { + filePath: (_app, type, name) => `https://analytics-assets.test/${type}/${name}`, + }; + window.t = (_app, message) => message; + window._registerWidget = () => {}; + }); + await page.addScriptTag({path: path.join(root, 'js/reference.js')}); + + const previewState = await page.evaluate(async () => { + const reference = OCA.Analytics.Reference; + await reference.ensureCoreAssets(); + const container = document.createElement('div'); + document.body.appendChild(container); + reference.buildTable = () => {}; + reference.buildChart = () => {}; + OCA.Analytics.Visualization.buildKpiDisplay = () => {}; + + await reference.renderVisualization(null, container, { + options: {visualization: 'table'}, data: [['KPI', 1]], + }, false); + const afterKpi = { + chart: !!window.Chart, + table: !!window.DataTable, + chartRequested: !!reference.chartAssetsPromise, + }; + + await reference.renderVisualization(null, container, { + options: {visualization: 'table'}, data: [['A', 1], ['B', 2]], + }, false); + const afterTable = { + chart: !!window.Chart, + table: !!window.DataTable, + chartRequested: !!reference.chartAssetsPromise, + }; + + await reference.renderVisualization(null, container, { + options: {visualization: 'chart'}, data: [['A', 1]], + }, false); + const canvas = document.createElement('canvas'); + container.appendChild(canvas); + const chart = new Chart(canvas, { + type: 'bar', + data: {labels: ['A'], datasets: [{data: [1]}]}, + }); + const afterChart = { + chart: !!window.Chart, + labels: !!window.ChartDataLabels, + cloner: !!window.cloner, + rendered: chart.getDatasetMeta(0).data.length === 1, + }; + chart.destroy(); + + await reference.renderVisualization(null, container, { + options: {visualization: 'ct'}, data: [['A', 1]], + }, false); + return {afterKpi, afterTable, afterChart}; + }); + + assert.deepEqual(previewState.afterKpi, {chart: false, table: false, chartRequested: false}); + assert.deepEqual(previewState.afterTable, {chart: false, table: true, chartRequested: false}); + assert.deepEqual(previewState.afterChart, { + chart: true, labels: true, cloner: true, rendered: true, + }); + assert.equal(requests.filter(name => name === 'js/3rdParty/datatables.min.js').length, 1); + for (const name of chartAssets) { + assert.equal(requests.filter(request => request === `js/${name}`).length, 1, name); + } + assert.deepEqual(pageErrors, []); + console.log('PASS: KPI and table previews skip chart libraries; chart and combined previews load them once.'); + } finally { + await browser.close(); + } +})().catch(error => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/playwright/full_regression.js b/tests/playwright/full_regression.js index 4ededa10d..d9ee9a64e 100644 --- a/tests/playwright/full_regression.js +++ b/tests/playwright/full_regression.js @@ -39,6 +39,7 @@ const scenarios = [ { id: '49', title: 'table_preview', script: 'tests/playwright/49_table_preview.js' }, { id: '50', title: 'navigation_share', script: 'tests/playwright/50_navigation_share.js' }, { id: '51', title: 'navigation_favorites', script: 'tests/playwright/51_navigation_favorites.js' }, + { id: '52', title: 'reference_asset_split', script: 'tests/playwright/52_reference_asset_split.js' }, { id: '91', title: 'report_delete', script: 'tests/playwright/91_report_delete.js' }, { id: '92', title: 'group_delete', script: 'tests/playwright/92_group_delete.js' }, ]; diff --git a/tests/run-playwright.sh b/tests/run-playwright.sh index 9eb53a2f8..152a9a13d 100755 --- a/tests/run-playwright.sh +++ b/tests/run-playwright.sh @@ -95,6 +95,9 @@ case "${SCENARIO}" in 51|favorites|navigation-favorites) SCRIPT_PATH="tests/playwright/51_navigation_favorites.js" ;; + 52|reference-assets) + SCRIPT_PATH="tests/playwright/52_reference_asset_split.js" + ;; 91|91-delete|report-delete|delete) SCRIPT_PATH="tests/playwright/91_report_delete.js" ;; From 2191833250d7e76e090f9014085f2016896aaccd Mon Sep 17 00:00:00 2001 From: Rello Date: Wed, 16 Sep 2026 23:04:46 +0200 Subject: [PATCH 3/3] Add native Smart Picker render-mode choices for reports and panoramas Preserve explicit chart and table selections, retain combined content mode, and keep the original picker with scoped heading and keyboard fixes. Add regression coverage and update the changelog. Signed-off-by: Rello Assisted-by: Codex:GPT-5 --- CHANGELOG.md | 2 +- appinfo/routes.php | 2 + css/reference.css | 5 ++ js/reference.js | 42 ++++++++- lib/AppInfo/Application.php | 2 + lib/Controller/PageController.php | 14 +++ lib/Db/ReportMapper.php | 1 + lib/Reference/ReferenceProvider.php | 15 ++-- lib/Search/ReferenceSearchProvider.php | 56 ++++++++++++ lib/Search/SearchProvider.php | 50 +++++++---- tests/Reference/ReferenceProviderTest.php | 37 ++++++-- tests/Search/ReferenceSearchProviderTest.php | 94 ++++++++++++++++++++ tests/Stubs/OCP/Search/IProvider.php | 16 ++++ tests/Stubs/OCP/Search/ISearchQuery.php | 11 +++ tests/Stubs/OCP/Search/SearchResult.php | 20 +++++ tests/Stubs/OCP/Search/SearchResultEntry.php | 23 +++++ tests/playwright/52_reference_asset_split.js | 35 ++++++++ 17 files changed, 393 insertions(+), 32 deletions(-) create mode 100644 lib/Search/ReferenceSearchProvider.php create mode 100644 tests/Search/ReferenceSearchProviderTest.php create mode 100644 tests/Stubs/OCP/Search/IProvider.php create mode 100644 tests/Stubs/OCP/Search/ISearchQuery.php create mode 100644 tests/Stubs/OCP/Search/SearchResult.php create mode 100644 tests/Stubs/OCP/Search/SearchResultEntry.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e9fd6a9f..d27d57986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased ### Added -- Render live reports and the first panorama page as chart, table, KPI, text, and picture content in link previews (Smart Picker), with ten-row table pages, bottom-only pagination, and chart libraries loaded only for chart previews. +- Offer applicable link, content, chart-only, and table-only choices in the native Smart Picker search results for Analytics reports and panoramas without the redundant Analytics group heading, preserving explicit chart/table selections when reports gain another view; content previews render live charts, tables, KPIs, text, and pictures with ten-row table pages, bottom-only pagination, and chart libraries loaded only for chart previews. - Add a V4 API endpoint for deleting legacy dataset rows through structured filters, including dynamic date variables. - Configure tables interactively with a seven-row live preview independent of report pagination, column formatting, layout controls with accurate empty-field placeholders, shared section navigation with linked documentation and an active-section underline that follows scrolling, a left settings panel paired with a live preview, and draft Apply/Cancel actions; use alternating rows by default in previews and live tables, keep section headings visible when navigating, immediately mark the chosen compact header button for column formatting, prevent Appearance controls from scrolling the dialog shell blank, use report headers as the sole persisted sort control, and support DataTables 3 column metadata. - New interactive chart configuration with flexible field mapping and a live-data preview in the shared options-dialog layout. diff --git a/appinfo/routes.php b/appinfo/routes.php index 4122aba81..04c99076d 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -11,8 +11,10 @@ 'routes' => [ ['name' => 'page#main', 'url' => '/', 'verb' => 'GET'], ['name' => 'page#report', 'url' => '/r/{id}', 'verb' => 'GET'], + ['name' => 'page#reportMode', 'url' => '/r/{id}/{mode}', 'verb' => 'GET'], ['name' => 'page#dataset', 'url' => '/d/{id}', 'verb' => 'GET'], ['name' => 'page#panorama', 'url' => '/pa/{id}', 'verb' => 'GET'], + ['name' => 'page#panoramaMode', 'url' => '/pa/{id}/{mode}', 'verb' => 'GET'], ['name' => 'page#indexPublic', 'url' => '/p/{token}', 'verb' => 'GET'], ['name' => 'page#indexPublicMin', 'url' => '/pm/{token}', 'verb' => 'GET'], ['name' => 'page#authenticatePassword', 'url' => '/p/{token}', 'verb' => 'POST'], diff --git a/css/reference.css b/css/reference.css index 7d255a349..b2ad294d4 100644 --- a/css/reference.css +++ b/css/reference.css @@ -5,6 +5,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +/* The native picker already names Analytics in its dialog title. */ +.smart-picker-search .vs__dropdown-option:has(.group-name-icon[src*="/analytics/img/app-dark.svg"]) { + display: none; +} + .analytics-reference-widget { display: flex; flex-direction: column; diff --git a/js/reference.js b/js/reference.js index 8619b1aaf..269071bcd 100644 --- a/js/reference.js +++ b/js/reference.js @@ -73,6 +73,24 @@ OCA.Analytics.Reference = { scriptPromises: {}, init: function () { + // NcSearch includes its group heading in keyboard navigation even when + // CSS hides it. Keep the first Analytics result reachable in one step. + document.addEventListener('keydown', (event) => { + if (!event.isTrusted || !['ArrowDown', 'ArrowUp'].includes(event.key)) return; + const input = event.target; + if (!(input instanceof Element) || !input.matches('.smart-picker-search input[role="combobox"]')) return; + requestAnimationFrame(() => { + if (document.activeElement !== input || input.getAttribute('aria-expanded') !== 'true') return; + const active = document.getElementById(input.getAttribute('aria-activedescendant')); + if (active?.querySelector('.group-name-icon[src*="/analytics/img/app-dark.svg"]') + && getComputedStyle(active).display === 'none') { + input.dispatchEvent(new KeyboardEvent('keydown', { + key: 'ArrowDown', code: 'ArrowDown', keyCode: 40, + bubbles: true, cancelable: true, + })); + } + }); + }, true); if (typeof _registerWidget !== 'function') { return; } @@ -98,6 +116,11 @@ OCA.Analytics.Reference = { return; } + if (!richObject.render_mode || richObject.render_mode === 'link') { + OCA.Analytics.Reference.renderStaticCard(el, richObject); + return; + } + const widget = document.createElement('div'); widget.classList.add('analytics-reference-widget'); @@ -155,13 +178,19 @@ OCA.Analytics.Reference = { headerLink.textContent = data.options.name; } + const configuredVisualization = data.options?.visualization; + if ((richObject.render_mode === 'chart' && configuredVisualization !== 'ct' && configuredVisualization !== 'chart') + || (richObject.render_mode === 'table' && configuredVisualization !== 'ct' && configuredVisualization !== 'table')) { + throw new Error('selected report view is no longer available'); + } + if (data.status === 'nodata' || !Array.isArray(data.data) || data.data.length === 0) { body.replaceChildren(OCA.Analytics.Reference.buildMessage(t('analytics', 'No data found'))); return; } data.data = OCA.Analytics.Visualization.formatDates(data.data); - await OCA.Analytics.Reference.renderVisualization(el, body, data, false); + await OCA.Analytics.Reference.renderVisualization(el, body, data, false, undefined, richObject.render_mode); }, renderPanorama: async function (el, richObject, headerLink, body) { @@ -280,8 +309,15 @@ OCA.Analytics.Reference = { * dispatch a processed data payload to chart / KPI / table rendering * compact = panorama cell; legend only applies to compact charts */ - renderVisualization: async function (el, container, data, compact, legend) { - const visualization = data.options.visualization; + renderVisualization: async function (el, container, data, compact, legend, renderMode = 'content') { + const configuredVisualization = data.options.visualization; + // A mode can hide one half of a combined report, never invent a view + // that the report itself does not provide. + const visualization = configuredVisualization === 'ct' && renderMode === 'chart' + ? 'chart' + : configuredVisualization === 'ct' && renderMode === 'table' + ? 'table' + : configuredVisualization; const registryEntry = OCA.Analytics.Reference.widgetRegistry.get(el); if (visualization === 'table' && data.data.length === 1) { diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index ed26df2a0..3cf0613af 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -16,6 +16,7 @@ use OCA\Analytics\UserMigration\AnalyticsMigrator; use OCA\Analytics\Notification\Notifier; use OCA\Analytics\Search\SearchProvider; +use OCA\Analytics\Search\ReferenceSearchProvider; use OCA\Analytics\Listener\ReferenceListener; use OCA\Analytics\Reference\ReferenceProvider; use OCA\Analytics\Capabilities; @@ -40,6 +41,7 @@ public function register(IRegistrationContext $context): void { $context->registerDashboardWidget(Widget::class); $context->registerSearchProvider(SearchProvider::class); + $context->registerSearchProvider(ReferenceSearchProvider::class); $context->registerCapability(Capabilities::class); diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 0baba293f..55b74c385 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -138,6 +138,13 @@ public function report() return $this->main(); } + #[NoAdminRequired] + #[NoCSRFRequired] + public function reportMode() + { + return $this->main(); + } + #[NoAdminRequired] #[NoCSRFRequired] public function dataset() @@ -152,6 +159,13 @@ public function panorama() return $this->main(); } + #[NoAdminRequired] + #[NoCSRFRequired] + public function panoramaMode() + { + return $this->main(); + } + /** * * @param string $token diff --git a/lib/Db/ReportMapper.php b/lib/Db/ReportMapper.php index cc1e53c00..276ecf9cd 100644 --- a/lib/Db/ReportMapper.php +++ b/lib/Db/ReportMapper.php @@ -353,6 +353,7 @@ public function search($searchString) ->select('id') ->addSelect('name') ->addSelect('type') + ->addSelect('visualization') ->where($sql->expr()->eq('user_id', $sql->createNamedParameter($this->userId))) ->andWhere($sql->expr()->iLike('name', $sql->createNamedParameter('%' . $this->db->escapeLikeParameter($searchString) . '%'))) ->orderBy('name', 'ASC'); diff --git a/lib/Reference/ReferenceProvider.php b/lib/Reference/ReferenceProvider.php index 33d7ab01f..819325144 100644 --- a/lib/Reference/ReferenceProvider.php +++ b/lib/Reference/ReferenceProvider.php @@ -9,6 +9,7 @@ namespace OCA\Analytics\Reference; use OCA\Analytics\Service\ReportService; +use OCA\Analytics\Search\ReferenceSearchProvider; use OCA\Analytics\Service\PanoramaService; use OCA\Analytics\Service\ShareService; use OCP\Collaboration\Reference\ADiscoverableReferenceProvider; @@ -80,7 +81,7 @@ public function getIconUrl(): string public function getSupportedSearchProviderIds(): array { - return ['analytics']; + return [ReferenceSearchProvider::ID]; } public function matchReference(string $referenceText): bool @@ -89,15 +90,16 @@ public function matchReference(string $referenceText): bool if (!$adminLinkPreviewEnabled) { return false; } - return preg_match('~/apps/analytics/(?:r|pa)/~', $referenceText) === 1; + return preg_match('~/apps/analytics/(?:r/\d+(?:/(?:chart|table|content))?|pa/\d+(?:/content)?)(?:[?#].*)?$~', $referenceText) === 1; } public function resolveReference(string $referenceText): ?IReference { if ($this->matchReference($referenceText)) { - preg_match("/\d+$/", $referenceText, $matches); // get the last integer - $itemId = isset($matches[0]) ? (int)$matches[0] : 0; - $isPanorama = str_contains($referenceText, '/pa/'); + preg_match('~/apps/analytics/(r|pa)/(\d+)(?:/(chart|table|content))?(?:[?#].*)?$~', $referenceText, $matches); + $itemId = (int)($matches[2] ?? 0); + $isPanorama = ($matches[1] ?? '') === 'pa'; + $renderMode = $matches[3] ?? 'link'; $item = []; if ($isPanorama) { if ($itemId !== 0) { @@ -139,6 +141,7 @@ public function resolveReference(string $referenceText): ?IReference 'image' => $imageUrl, 'id' => $itemId, 'item_type' => $isPanorama ? 'panorama' : 'report', + 'render_mode' => $renderMode, 'found' => !empty($item) ] ); @@ -161,4 +164,4 @@ public function invalidateUserCache(string $userId): void { $this->referenceManager->invalidateCache($userId); } -} \ No newline at end of file +} diff --git a/lib/Search/ReferenceSearchProvider.php b/lib/Search/ReferenceSearchProvider.php new file mode 100644 index 000000000..3beaf7756 --- /dev/null +++ b/lib/Search/ReferenceSearchProvider.php @@ -0,0 +1,56 @@ + $this->l10n->t('Link')]; + switch ($report['visualization'] ?? '') { + case 'ct': + $modes['content'] = $this->l10n->t('Chart and table'); + $modes['chart'] = $this->l10n->t('Chart only'); + $modes['table'] = $this->l10n->t('Table only'); + break; + case 'chart': + $modes['chart'] = $this->l10n->t('Chart only'); + break; + case 'table': + $modes['table'] = $this->l10n->t('Table only'); + break; + } + return $modes; + } + + protected function getPanoramaModes(): array + { + return ['' => $this->l10n->t('Link'), 'content' => $this->l10n->t('Content')]; + } +} diff --git a/lib/Search/SearchProvider.php b/lib/Search/SearchProvider.php index 51cf2fb88..9f6a7909d 100644 --- a/lib/Search/SearchProvider.php +++ b/lib/Search/SearchProvider.php @@ -27,7 +27,7 @@ class SearchProvider implements IProvider /** @var IAppManager */ private $appManager; /** @var IL10N */ - private $l10n; + protected $l10n; /** @var IURLGenerator */ private $urlGenerator; private $ReportService; @@ -62,23 +62,29 @@ public function search(IUser $user, ISearchQuery $query): SearchResult $result = []; foreach ($reports as $report) { - $result[] = new SearchResultEntry( - $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('analytics', 'report.svg')), - $report['name'], - '', - $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute('analytics.page.report', ['id' => $report['id']])), - '' - ); + $url = $this->urlGenerator->linkToRouteAbsolute('analytics.page.report', ['id' => $report['id']]); + foreach ($this->getReportModes($report) as $mode => $label) { + $result[] = new SearchResultEntry( + $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('analytics', 'report.svg')), + $report['name'], + $label, + $url . ($mode === '' ? '' : '/' . $mode), + '' + ); + } } foreach ($panoramas as $panorama) { - $result[] = new SearchResultEntry( - $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('analytics', 'panorama.svg')), - $panorama['name'], - '', - $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute('analytics.page.panorama', ['id' => $panorama['id']])), - '' - ); + $url = $this->urlGenerator->linkToRouteAbsolute('analytics.page.panorama', ['id' => $panorama['id']]); + foreach ($this->getPanoramaModes() as $mode => $label) { + $result[] = new SearchResultEntry( + $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('analytics', 'panorama.svg')), + $panorama['name'], + $label, + $url . ($mode === '' ? '' : '/' . $mode), + '' + ); + } } return SearchResult::complete( @@ -92,8 +98,18 @@ public function getName(): string return $this->l10n->t('Analytics'); } - public function getOrder(string $route, array $routeParameters): int + public function getOrder(string $route, array $routeParameters): ?int { return 10; } -} \ No newline at end of file + + protected function getReportModes(array $report): array + { + return ['' => '']; + } + + protected function getPanoramaModes(): array + { + return ['' => '']; + } +} diff --git a/tests/Reference/ReferenceProviderTest.php b/tests/Reference/ReferenceProviderTest.php index 56e51d281..b6fcbe6d3 100644 --- a/tests/Reference/ReferenceProviderTest.php +++ b/tests/Reference/ReferenceProviderTest.php @@ -66,7 +66,13 @@ public function testMatchReference(): void { $provider = $this->buildProvider(); $this->assertTrue($provider->matchReference('https://cloud.example.com/apps/analytics/r/5')); + $this->assertTrue($provider->matchReference('https://cloud.example.com/apps/analytics/r/5/content')); + $this->assertTrue($provider->matchReference('https://cloud.example.com/apps/analytics/r/5/chart')); + $this->assertTrue($provider->matchReference('https://cloud.example.com/apps/analytics/r/5/table')); $this->assertTrue($provider->matchReference('https://cloud.example.com/apps/analytics/pa/7')); + $this->assertTrue($provider->matchReference('https://cloud.example.com/apps/analytics/pa/7/content')); + $this->assertFalse($provider->matchReference('https://cloud.example.com/apps/analytics/pa/7/chart')); + $this->assertFalse($provider->matchReference('https://cloud.example.com/apps/analytics/r/5/unknown')); $this->assertFalse($provider->matchReference('https://cloud.example.com/apps/files/')); $this->assertFalse($provider->matchReference('https://cloud.example.com/apps/analytics/')); } @@ -95,6 +101,7 @@ public function testResolveOwnReport(): void { $richObject = $reference->getRichObject(); $this->assertSame(5, $richObject['id']); $this->assertSame('report', $richObject['item_type']); + $this->assertSame('link', $richObject['render_mode']); $this->assertTrue($richObject['found']); $this->assertSame('My Report', $richObject['subheader']); } @@ -116,6 +123,18 @@ public function testResolveSharedReportFallsBackToShareService(): void { $this->assertSame('Shared Report', $richObject['subheader']); } + public function testResolveContentModeUsesReportIdBeforeSuffix(): void { + $this->reportService->expects($this->once()) + ->method('read') + ->with(5) + ->willReturn(['id' => 5, 'name' => 'Combined report']); + + $reference = $this->buildProvider()->resolveReference('https://cloud.example.com/apps/analytics/r/5/table'); + + $this->assertSame(5, $reference->getRichObject()['id']); + $this->assertSame('table', $reference->getRichObject()['render_mode']); + } + public function testResolveMissingReport(): void { $this->reportService->method('read')->willReturn([]); $this->shareService->method('getSharedReport')->willReturn([]); @@ -141,20 +160,28 @@ public function testResolvePanorama(): void { $richObject = $reference->getRichObject(); $this->assertSame(7, $richObject['id']); $this->assertSame('panorama', $richObject['item_type']); + $this->assertSame('link', $richObject['render_mode']); $this->assertTrue($richObject['found']); $this->assertSame('My Panorama', $richObject['subheader']); } - public function testResolveWithoutTrailingIntegerReturnsNotFound(): void { + public function testResolvePanoramaContentMode(): void { + $this->panoramaService->expects($this->once()) + ->method('read') + ->with(7) + ->willReturn(['id' => 7, 'name' => 'My Panorama']); + + $reference = $this->buildProvider()->resolveReference('https://cloud.example.com/apps/analytics/pa/7/content'); + $this->assertSame('content', $reference->getRichObject()['render_mode']); + } + + public function testResolveWithoutIntegerDoesNotMatch(): void { $this->reportService->expects($this->never())->method('read'); $this->shareService->expects($this->never())->method('getSharedReport'); $reference = $this->buildProvider()->resolveReference('https://cloud.example.com/apps/analytics/r/'); - $this->assertInstanceOf(IReference::class, $reference); - $richObject = $reference->getRichObject(); - $this->assertSame(0, $richObject['id']); - $this->assertFalse($richObject['found']); + $this->assertNull($reference); } public function testResolveUnmatchedUrlReturnsNull(): void { diff --git a/tests/Search/ReferenceSearchProviderTest.php b/tests/Search/ReferenceSearchProviderTest.php new file mode 100644 index 000000000..b32255abe --- /dev/null +++ b/tests/Search/ReferenceSearchProviderTest.php @@ -0,0 +1,94 @@ +createMock(IAppManager::class); + $appManager->method('isEnabledForUser')->willReturn($enabled); + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('linkToRouteAbsolute')->willReturnCallback(static function ($route, $parameters) { + return 'https://cloud.example.com/nc/index.php/apps/analytics/' + . ($route === 'analytics.page.report' ? 'r/' : 'pa/') . $parameters['id']; + }); + $urlGenerator->method('imagePath')->willReturnCallback(static fn($app, $file) => '/img/' . $file); + $urlGenerator->method('getAbsoluteURL')->willReturnCallback(static fn($path) => 'https://cloud.example.com' . $path); + $reportService = $this->createMock(ReportService::class); + $reportService->expects($enabled ? $this->once() : $this->never())->method('search')->with('Demo')->willReturn($reports); + $panoramaService = $this->createMock(PanoramaService::class); + $panoramaService->expects($enabled ? $this->once() : $this->never())->method('search')->with('Demo')->willReturn($panoramas); + $providerClass = $picker ? ReferenceSearchProvider::class : SearchProvider::class; + $provider = new $providerClass($appManager, new FakeL10N(), $urlGenerator, $reportService, $panoramaService); + $this->assertSame($picker ? 'analytics-reference' : 'analytics', $provider->getId()); + $this->assertSame($picker ? null : 10, $provider->getOrder('', [])); + $query = $this->createMock(ISearchQuery::class); + $query->method('getTerm')->willReturn('Demo'); + return json_decode(json_encode($provider->search($this->createMock(IUser::class), $query)), true)['entries']; + } + + public function testCombinedReportOffersAllModes(): void { + $entries = $this->search([['id' => 4, 'name' => 'Demo: Finance', 'type' => 2, 'visualization' => 'ct']]); + $this->assertSame(['Link', 'Chart and table', 'Chart only', 'Table only'], array_column($entries, 'subline')); + $this->assertSame(array_fill(0, 4, 'Demo: Finance'), array_column($entries, 'title')); + $this->assertSame([ + 'https://cloud.example.com/nc/index.php/apps/analytics/r/4', + 'https://cloud.example.com/nc/index.php/apps/analytics/r/4/content', + 'https://cloud.example.com/nc/index.php/apps/analytics/r/4/chart', + 'https://cloud.example.com/nc/index.php/apps/analytics/r/4/table', + ], array_column($entries, 'resourceUrl')); + } + + public function testSingleViewReportsPinTheirDisplayMode(): void { + foreach (['chart' => 'Chart only', 'table' => 'Table only'] as $visualization => $label) { + $entries = $this->search([['id' => 4, 'name' => 'Demo', 'type' => 2, 'visualization' => $visualization]]); + $this->assertSame(['Link', $label], array_column($entries, 'subline')); + $this->assertStringEndsWith('/r/4/' . $visualization, $entries[1]['resourceUrl']); + } + } + + public function testUnknownVisualizationOffersOnlyLinkAndGroupsAreOmitted(): void { + $entries = $this->search([ + ['id' => 4, 'name' => 'Demo', 'type' => 2], + ['id' => 5, 'name' => 'Demo folder', 'type' => 0, 'visualization' => 'ct'], + ]); + $this->assertSame(['Link'], array_column($entries, 'subline')); + } + + public function testPanoramaOffersOnlyLinkAndContent(): void { + $entries = $this->search([], [['id' => 7, 'name' => 'Demo panorama']]); + $this->assertSame(['Link', 'Content'], array_column($entries, 'subline')); + $this->assertStringEndsWith('/pa/7', $entries[0]['resourceUrl']); + $this->assertStringEndsWith('/pa/7/content', $entries[1]['resourceUrl']); + } + + public function testGlobalSearchStillReturnsOnePlainLinkPerItem(): void { + $entries = $this->search( + [['id' => 4, 'name' => 'Demo report', 'type' => 2, 'visualization' => 'ct']], + [['id' => 7, 'name' => 'Demo panorama']], + false + ); + $this->assertCount(2, $entries); + $this->assertSame(['', ''], array_column($entries, 'subline')); + $this->assertStringEndsWith('/r/4', $entries[0]['resourceUrl']); + $this->assertStringEndsWith('/pa/7', $entries[1]['resourceUrl']); + } + + public function testDisabledAppReturnsNoResults(): void { + $this->assertSame([], $this->search([], [], true, false)); + } +} diff --git a/tests/Stubs/OCP/Search/IProvider.php b/tests/Stubs/OCP/Search/IProvider.php new file mode 100644 index 000000000..b31ba7181 --- /dev/null +++ b/tests/Stubs/OCP/Search/IProvider.php @@ -0,0 +1,16 @@ + $this->name, 'entries' => $this->entries, 'isPaginated' => false]; + } +} diff --git a/tests/Stubs/OCP/Search/SearchResultEntry.php b/tests/Stubs/OCP/Search/SearchResultEntry.php new file mode 100644 index 000000000..86ac2c04d --- /dev/null +++ b/tests/Stubs/OCP/Search/SearchResultEntry.php @@ -0,0 +1,23 @@ + `https://analytics-assets.test/${type}/${name}`, + generateUrl: path => `https://analytics-assets.test${path}`, + requestToken: 'test-token', }; window.t = (_app, message) => message; window._registerWidget = () => {}; }); await page.addScriptTag({path: path.join(root, 'js/reference.js')}); + await page.addStyleTag({path: path.join(root, 'css/reference.css')}); const previewState = await page.evaluate(async () => { const reference = OCA.Analytics.Reference; @@ -103,6 +106,38 @@ const chartAssets = [ assert.equal(requests.filter(request => request === `js/${name}`).length, 1, name); } assert.deepEqual(pageErrors, []); + + const linkIsCard = await page.evaluate(async () => { + const card = document.createElement('div'); + await OCA.Analytics.Reference.renderWidget(card, { + id: 12, found: true, item_type: 'report', render_mode: 'link', + url: 'https://analytics-assets.test/apps/analytics/r/12', name: 'Analytics Report', + subheader: 'Combined', + }); + return !!card.querySelector('.analytics-reference-fallback'); + }); + assert.equal(linkIsCard, true); + + const renderedParts = await page.evaluate(async () => { + const reference = OCA.Analytics.Reference; + const container = document.createElement('div'); + const parts = []; + reference.buildChart = () => { parts.push('chart'); }; + reference.buildTable = () => { parts.push('table'); }; + const data = {options: {visualization: 'ct'}, data: [['A', 1], ['B', 2]]}; + await reference.renderVisualization(null, container, data, false, undefined, 'chart'); + const chart = [...parts]; + parts.length = 0; + await reference.renderVisualization(null, container, data, false, undefined, 'table'); + const table = [...parts]; + parts.length = 0; + await reference.renderVisualization(null, container, data, false, undefined, 'content'); + return {chart, table, content: [...parts]}; + }); + assert.deepEqual(renderedParts, { + chart: ['chart'], table: ['table'], content: ['chart', 'table'], + }); + assert.deepEqual(pageErrors, []); console.log('PASS: KPI and table previews skip chart libraries; chart and combined previews load them once.'); } finally { await browser.close();