diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b67a22c..dde329190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased ### Added +- 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. @@ -16,6 +17,7 @@ - Store report option data as longtext instead of varchar(1000). ### Fixed +- Resolve reports and panoramas shared with the current user in link previews and respect the `link_preview_enabled` admin setting. - Enable panorama layout changes and page deletion only while editing. - Focus the dataset name when opening its creation wizard and create it with Enter. - Stack panorama tiles with balanced, full-width headers and preserved outer corner radii on narrow screens. 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 new file mode 100644 index 000000000..b2ad294d4 --- /dev/null +++ b/css/reference.css @@ -0,0 +1,224 @@ +/** + * Analytics + * + * SPDX-FileCopyrightText: 2026 Marcel Scherello + * 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; + 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..269071bcd 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,832 @@ 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, + chartAssetsPromise: null, + tableAssetsPromise: null, + 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; + } _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; + } + + 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'); + + 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; + } + + 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, undefined, richObject.render_mode); + }, + + 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']; + + 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, 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) { + // 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; + } + + if (visualization === 'ct') { + await Promise.all([ + OCA.Analytics.Reference.ensureChartAssets(), + 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 + await OCA.Analytics.Reference.ensureChartAssets(); + 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); + } + + 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, {referencePreview: true}); + 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'); - 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 (cachedVersion) { + xhr.setRequestHeader('If-None-Match', cachedVersion); } - widget.appendChild(content); + 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; + } - el.textContent = ''; - el.appendChild(widget); - }, () => {}, { hasInteractiveView: false }); + 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 + // ************* + + // 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 = 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)) + .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; + }, + + 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; + } + // DataTables 3 is standalone; Analytics no longer bundles or requires jQuery. + OCA.Analytics.Reference.tableAssetsPromise = OCA.Analytics.Reference.loadScript( + '3rdParty/datatables.min', + () => window.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/js/visualization.js b/js/visualization.js index bc4a8943e..93e5df8fc 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/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/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..819325144 100644 --- a/lib/Reference/ReferenceProvider.php +++ b/lib/Reference/ReferenceProvider.php @@ -9,7 +9,9 @@ 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; use OCP\Collaboration\Reference\ISearchableReferenceProvider; use OCP\Collaboration\Reference\Reference; @@ -32,6 +34,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 +43,7 @@ public function __construct(IConfig $config, ReferenceManager $referenceManager, ReportService $ReportService, PanoramaService $PanoramaService, + ShareService $ShareService, ?string $userId) { $this->userId = $userId; @@ -50,6 +54,7 @@ public function __construct(IConfig $config, $this->urlGenerator = $urlGenerator; $this->ReportService = $ReportService; $this->PanoramaService = $PanoramaService; + $this->ShareService = $ShareService; } public function getId(): string @@ -76,29 +81,41 @@ public function getIconUrl(): string public function getSupportedSearchProviderIds(): array { - return ['analytics']; + return [ReferenceSearchProvider::ID]; } 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; + 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 - $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) { - $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 +138,11 @@ public function resolveReference(string $referenceText): ?IReference 'name' => $name, 'subheader' => $subheader, 'url' => $referenceText, - 'image' => $imageUrl + 'image' => $imageUrl, + 'id' => $itemId, + 'item_type' => $isPanorama ? 'panorama' : 'report', + 'render_mode' => $renderMode, + 'found' => !empty($item) ] ); return $reference; @@ -143,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/lib/Service/PanoramaService.php b/lib/Service/PanoramaService.php index c0c5809c8..21479f014 100644 --- a/lib/Service/PanoramaService.php +++ b/lib/Service/PanoramaService.php @@ -103,6 +103,7 @@ public function index(): array { /** * get own report details + * falls back to panoramas shared with the current user * * @param int $panoramaId * @return array @@ -112,6 +113,14 @@ public function read(int $panoramaId) { $ownReport = $this->PanoramaMapper->readOwn($panoramaId); if ($ownReport) { $ownReport['permissions'] = \OCP\Constants::PERMISSION_UPDATE; + } else { + $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', 'filters', '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 3f89da765..bf1116529 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/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/Reference/ReferenceProviderTest.php b/tests/Reference/ReferenceProviderTest.php new file mode 100644 index 000000000..b6fcbe6d3 --- /dev/null +++ b/tests/Reference/ReferenceProviderTest.php @@ -0,0 +1,190 @@ +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/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/')); + } + + 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->assertSame('link', $richObject['render_mode']); + $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 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([]); + + $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->assertSame('link', $richObject['render_mode']); + $this->assertTrue($richObject['found']); + $this->assertSame('My Panorama', $richObject['subheader']); + } + + 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->assertNull($reference); + } + + public function testResolveUnmatchedUrlReturnsNull(): void { + $this->assertNull($this->buildProvider()->resolveReference('https://cloud.example.com/apps/files/')); + } +} 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/Service/PanoramaServiceReadTest.php b/tests/Service/PanoramaServiceReadTest.php new file mode 100644 index 000000000..fc4640a89 --- /dev/null +++ b/tests/Service/PanoramaServiceReadTest.php @@ -0,0 +1,93 @@ +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), + $this->createMock(IRootFolder::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 + ['permissions' => \OCP\Constants::PERMISSION_UPDATE], $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/PanoramaServiceTest.php b/tests/Service/PanoramaServiceTest.php index b6a393bb8..9c0cd3f6b 100644 --- a/tests/Service/PanoramaServiceTest.php +++ b/tests/Service/PanoramaServiceTest.php @@ -99,6 +99,11 @@ public function testReadIncludesOwnerPermissionsWithoutGrantingAccessToMissingPa $property = new \ReflectionProperty(PanoramaService::class, 'PanoramaMapper'); $property->setAccessible(true); $property->setValue($service, $mapper); + $shareService = $this->createMock(ShareService::class); + $shareService->expects($this->once())->method('getSharedPanorama')->with(2)->willReturn([]); + $property = new \ReflectionProperty(PanoramaService::class, 'ShareService'); + $property->setAccessible(true); + $property->setValue($service, $shareService); $this->assertSame(['id' => 1, 'permissions' => 2], $service->read(1)); $this->assertFalse($service->read(2)); } 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/IConfig.php b/tests/Stubs/OCP/IConfig.php index 191c9419d..f1af36e25 100644 --- a/tests/Stubs/OCP/IConfig.php +++ b/tests/Stubs/OCP/IConfig.php @@ -7,4 +7,5 @@ namespace OCP; interface IConfig { + public function getAppValue($appName, $key, $default = ''); } 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 @@ + { + 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..5f7448477 --- /dev/null +++ b/tests/playwright/52_reference_asset_split.js @@ -0,0 +1,148 @@ +/** + * 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}`, + 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; + 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, []); + + 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(); + } +})().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" ;;