From b48f9c1b6b3f6243ce05b728936bcea765467ec8 Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 16 Oct 2026 22:09:36 +0200 Subject: [PATCH] Add live datasource previews to report wizard and sidebar Signed-off-by: Rello Assisted-by: Codex:GPT-5 --- CHANGELOG.md | 1 + js/app.js | 45 +++++++++++++++- js/sidebar.js | 4 +- lib/Controller/OutputController.php | 6 +-- templates/part.templates.php | 4 ++ tests/INSTRUCTIONS.md | 1 + tests/playwright/datasource_preview.js | 73 ++++++++++++++++++++++++++ 7 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 tests/playwright/datasource_preview.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c63fa057..3f0f895d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased ### Added +- Preview live datasource data from the report wizard and sidebar before saving. - OCS endpoints to list accessible reports and owned datasets, retrieve report metadata and filtered data including shared reports, and add or delete dataset rows. ### Fixed diff --git a/js/app.js b/js/app.js index d90ac512..ddfe02d8 100644 --- a/js/app.js +++ b/js/app.js @@ -516,7 +516,7 @@ Object.assign(OCA.Analytics.Datasource = { /** * Generate configuration form for the selected data source */ - buildDatasourceRelatedForm: function (datasource) { + buildDatasourceRelatedForm: function (datasource, showPreview = false) { let template = OCA.Analytics.datasources.options[datasource]; let form = document.createElement('div'); let insideSection = false; @@ -582,9 +582,52 @@ Object.assign(OCA.Analytics.Datasource = { tableRow.appendChild(input); tableRow.appendChild(infoColumn); } + if (showPreview) { + const preview = document.importNode(document.getElementById('templateDatasourcePreviewButton').content, true); + preview.querySelector('button').addEventListener('click', () => OCA.Analytics.Datasource.preview(form, datasource)); + form.appendChild(preview); + } return form; }, + /** + * Preview the current datasource settings without saving the report. + */ + preview: async function (form, datasource) { + const options = {}; + for (const input of form.querySelectorAll('input, select')) { + if (input.id !== 'dataSourceType') options[input.id] = input.value; + } + const controller = new AbortController(); + OCA.Analytics.Notification.htmlDialogInitiate( + t('analytics', 'Data preview'), OCA.Analytics.Notification.dialogClose + ); + const dialog = document.getElementById('analyticsDialogContainer'); + dialog._analyticsDialogCleanup = () => controller.abort(); + try { + const response = await fetch(OC.generateUrl('apps/analytics/data'), { + method: 'POST', + headers: OCA.Analytics.headers(), + signal: controller.signal, + body: JSON.stringify({type: Number(datasource), options: JSON.stringify(options), limit: 20}) + }); + if (!response.ok) throw new Error(t('analytics', 'The preview request failed.')); + const data = await response.json(); + if (data.error && String(data.error) !== '0') throw new Error(String(data.error)); + if (document.getElementById('analyticsDialogContainer') !== dialog) return; + const content = document.createElement('pre'); + content.id = 'simulationData'; + content.textContent = JSON.stringify({header: data.header || [], data: data.data || []}, null, 2); + OCA.Analytics.Notification.htmlDialogUpdate(content, t('analytics', 'Preview of the first 20 rows.')); + } catch (error) { + if (error.name === 'AbortError' || document.getElementById('analyticsDialogContainer') !== dialog) return; + const content = document.createElement('p'); + content.setAttribute('role', 'alert'); + content.textContent = error.message || t('analytics', 'The preview could not be prepared.'); + OCA.Analytics.Notification.htmlDialogUpdate(content, ''); + } + }, + /** * Fill the sheet dropdown from the selected workbook. */ diff --git a/js/sidebar.js b/js/sidebar.js index 1e3cc825..eb3ec0ac 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -212,7 +212,7 @@ OCA.Analytics.Sidebar.Report = { } else if (data['type'] === OCA.Analytics.TYPE_INTERNAL_DB) { // OCA.Analytics.Sidebar.Report.fillDatasetRelatedFields(); } else { - let optionForm = OCA.Analytics.Datasource.buildDatasourceRelatedForm(data['type']); + let optionForm = OCA.Analytics.Datasource.buildDatasourceRelatedForm(data['type'], true); document.getElementById('reportDatasourceSection').appendChild(optionForm); OCA.Analytics.Sidebar.Report.fillDatasourceRelatedFields(data); } @@ -410,7 +410,7 @@ OCA.Analytics.Sidebar.Report = { const type = parseInt(document.getElementById('wizardNewDatasource').value); document.getElementById('wizardNewTypeOptionsRow').style.display = 'table-row'; document.getElementById('wizardNewDatasourceSection').innerHTML = ''; - document.getElementById('wizardNewDatasourceSection').appendChild(OCA.Analytics.Datasource.buildDatasourceRelatedForm(type)); + document.getElementById('wizardNewDatasourceSection').appendChild(OCA.Analytics.Datasource.buildDatasourceRelatedForm(type, true)); OCA.Analytics.Sidebar.Report.buildTemplateDropdownWizard(type); OCA.Analytics.Sidebar.Report.updateWizardVisualTemplateState(OCA.Analytics.Sidebar.Report.getSelectedTemplateWizard()); OCA.Analytics.Sidebar.Report.syncGlobalTemplateSelection(); diff --git a/lib/Controller/OutputController.php b/lib/Controller/OutputController.php index 67801232..3c635dfc 100644 --- a/lib/Controller/OutputController.php +++ b/lib/Controller/OutputController.php @@ -270,7 +270,7 @@ private function extractCacheMetadata(array $result): array { } /** - * Preview the data + * Preview the data (one row for column pickers, up to 20 for datasource previews) * * @param $type * @param $options @@ -278,7 +278,7 @@ private function extractCacheMetadata(array $result): array { * @throws Exception */ #[NoAdminRequired] - public function readPreview($type, $options) { + public function readPreview($type, $options, int $limit = 1) { $reportMetadata = []; $array = json_decode($options, true); foreach ($array as $key => $value) { @@ -293,7 +293,7 @@ public function readPreview($type, $options) { $result = $this->getData($reportMetadata); unset($result['options'], $result['dimensions'], $result['filterApplied'], $result['thresholds']); - $result['data'] = array_slice($result['data'], 0, 1); + $result['data'] = array_slice($result['data'] ?? [], 0, max(1, min($limit, 20))); return new DataResponse($result, HTTP::STATUS_OK); } diff --git a/templates/part.templates.php b/templates/part.templates.php index 4c26d95c..a4be6b41 100644 --- a/templates/part.templates.php +++ b/templates/part.templates.php @@ -1385,3 +1385,7 @@ class="analyticsModelCardIcon analyticsModelCardIcon--time" alt=""> + + diff --git a/tests/INSTRUCTIONS.md b/tests/INSTRUCTIONS.md index fa8c2f57..4dc19458 100644 --- a/tests/INSTRUCTIONS.md +++ b/tests/INSTRUCTIONS.md @@ -63,6 +63,7 @@ Supported identifiers: - `54`, `report-transformations` (shared calculated measures, hidden inputs, sorting, preview, and table-calculation migration) - `55`, `dialog-keyboard-mobile` (keyboard navigation, modal focus, and mobile report creation) - `56`, `demo-sales-ranking` (two demo reports sharing one dataset, raw table, Top N, mixed chart, pivot calculations, column order, formatting, indicators, and reload) +- `tests/playwright/datasource_preview.js` (live datasource previews in the wizard and sidebar, unsaved options, and error handling) - `tests/playwright/dashboard_number_format.js` (standalone dashboard values, locales, and thresholds) - `91`, `91-delete`, `report-delete`, `delete` - `92`, `92-delete`, `group-delete` diff --git a/tests/playwright/datasource_preview.js b/tests/playwright/datasource_preview.js new file mode 100644 index 00000000..51e6ee3c --- /dev/null +++ b/tests/playwright/datasource_preview.js @@ -0,0 +1,73 @@ +/** + * SPDX-FileCopyrightText: 2026 Marcel Scherello + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +const assert = require('node:assert/strict'); +const {chromium} = require('playwright'); +const {buildScenarioConfig, ensureAnalyticsLoaded, createCapture, openReportBasicSettings} = require('./common'); +const config = buildScenarioConfig('datasource-preview'); +(async () => { + const browser = await chromium.launch({headless: config.headless}); + const page = await browser.newPage({viewport: config.viewport}); + const capture = createCapture(page, config); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + let reportId; + const source = 'https://raw.githubusercontent.com/Rello/analytics/master/sample_data/sample1.csv'; + try { + await ensureAnalyticsLoaded(page, config); + await page.locator('#newReportButton').click(); + await page.locator('#newMenuReport').click(); + await page.locator('#wizardNewTemplateOwnReport').click(); + await page.locator('#wizardNext').click(); + await page.locator('#wizardNewTypeRealtime').click(); + await page.locator('#wizardNewDatasource').selectOption({label: 'External: csv'}); + await page.locator('#dataSourceOptions #link').fill(source); + const type = await page.locator('#wizardNewDatasource').inputValue(); + await page.locator('#datasourcePreviewButton').click(); + await page.locator('#simulationData').waitFor(); + assert.equal(JSON.parse(await page.locator('#simulationData').innerText()).data.length, 20); + await capture('wizard_preview'); + await page.locator('#analyticsDialogBtnClose').click(); + assert.equal(await page.locator('#dataSourceOptions #link').inputValue(), source); + await page.keyboard.press('Escape'); + const name = 'Datasource preview ' + Date.now(); + reportId = await page.evaluate(async ({name, type, source}) => { + const response = await fetch(OC.generateUrl('apps/analytics/report'), { + method: 'POST', headers: OCA.Analytics.headers(), + body: JSON.stringify({name, subheader:'', parent:0, type:Number(type), dataset:0, + link:JSON.stringify({link:source, columns:"", offset:""}), visualization:'table', chart:'line', dimension1:'', dimension2:'', value:''}) + }); + if (!response.ok) throw new Error("Create fixture HTTP " + response.status()); + return response.json(); + }, {name, type, source}); + assert.ok(Number(reportId) > 0, JSON.stringify(reportId)); + await page.reload(); + await ensureAnalyticsLoaded(page, config); + await openReportBasicSettings(page, name); + await page.locator('#reportDatasourceSectionHeader').click(); + if (await page.locator('#optionSectionHeader').isVisible()) await page.locator('#optionSectionHeader').click(); + await page.locator('#dataSourceOptions #columns').fill('1,3'); + await page.locator('#dataSourceOptions #offset').fill('59'); + await page.locator('#datasourcePreviewButton').click(); + await page.locator('#simulationData').waitFor(); + assert.deepEqual(JSON.parse(await page.locator('#simulationData').innerText()).data, [['Germany', 83073100]]); + await capture('sidebar_unsaved_preview'); + await page.locator('#analyticsDialogBtnClose').click(); + await page.route('**/apps/analytics/data', route => route.fulfill({json:{error:'Preview failure ', data:[]}})); + await page.locator('#datasourcePreviewButton').click(); + await page.getByRole('alert').filter({hasText:'Preview failure '}).waitFor(); + assert.equal(await page.locator('#analyticsDialogContent unsafe').count(), 0); + await page.locator('#analyticsDialogBtnClose').click(); + await page.unroute('**/apps/analytics/data'); + assert.deepEqual(errors, []); + console.log('PASS: wizard preview, sidebar unsaved settings, errors, dialog return'); + } catch (error) { + await capture('failure'); + throw error; + } finally { + if (Number(reportId) > 0) await page.evaluate(async id => fetch(OC.generateUrl('apps/analytics/report/') + id, + {method:'DELETE', headers:OCA.Analytics.headers()}), reportId); + await browser.close(); + } +})().catch(error => {console.error(error); process.exitCode = 1;});