diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f0f895dc..304c21404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,17 @@ ## Unreleased ### Added +- Visual field picker for JSON datasources, with a browsable hierarchy and selection preview. - 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. +### Changed +- Simplify folder navigation and icons, using the URL to select content and keeping Overview groups collapsed. + ### Fixed - Fit report title, subheader, and group fields to the report wizard's left column. -- Update bundled Moment.js to 2.31.0, which includes the fix for CVE-2026-17495. -- Update bundled PhpSpreadsheet to 5.10.0 to address known spreadsheet processing vulnerabilities. +- Update bundled Moment.js to 2.31.0 and PhpSpreadsheet to 5.10.0 to address security vulnerabilities, including CVE-2026-17495 in Moment.js. - Restore report tiles for recipients of shared panoramas. -- Replace the report folder icon with a downward arrow when expanded and remove redundant trailing folder arrows. -- Use the URL to select content and open its navigation folders without saving navigation state or changing the URL when toggling folders; keep Overview groups collapsed. ## 7.0.2 - 2026-10-12 ### Fixed diff --git a/REUSE.toml b/REUSE.toml index 4b194cf45..f60ceb813 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -5,6 +5,12 @@ SPDX-PackageName = "analytics" SPDX-PackageSupplier = "Marcel Scherello " SPDX-PackageDownloadLocation = "https://github.com/rello/analytics" +[[annotations]] +path = "img/select.svg" +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 Google LLC" +SPDX-License-Identifier = "Apache-2.0" + [[annotations]] path = "l10n/**" precedence = "aggregate" @@ -23,6 +29,12 @@ precedence = "aggregate" SPDX-FileCopyrightText = "2026 Marcel Scherello" SPDX-License-Identifier = "AGPL-3.0-or-later" +[[annotations]] +path = "sample_data/sample_orders.json" +precedence = "override" +SPDX-FileCopyrightText = "2026 Marcel Scherello" +SPDX-License-Identifier = "AGPL-3.0-or-later" + [[annotations]] path = ["img/app.svg", "img/app-dark.svg", "img/app-color.svg", "img/app-integration.svg", "img/infoIntro.svg", "img/infoReport.svg", "img/infoWiki.svg", "img/save.svg", "img/warning.svg", "img/**.png", "img/**.gif", "img/logo.svg", "img/rename.svg"] precedence = "aggregate" diff --git a/appinfo/routes.php b/appinfo/routes.php index a903f3769..ab8f3d918 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -94,6 +94,7 @@ ['name' => 'datasource#own', 'url' => '/datasource/own', 'verb' => 'GET'], ['name' => 'datasource#registered', 'url' => '/datasource/registered', 'verb' => 'GET'], ['name' => 'datasource#spreadsheetSheets', 'url' => '/datasource/spreadsheet/sheets', 'verb' => 'GET'], + ['name' => 'datasource#jsonContent', 'url' => '/datasource/json/content', 'verb' => 'POST'], ['name' => 'datasource#indexFiltered', 'url' => '/datasource/{datasourceType}', 'verb' => 'GET'], // Share diff --git a/css/style.css b/css/style.css index cc3a9cdc7..4dffab58f 100644 --- a/css/style.css +++ b/css/style.css @@ -892,7 +892,8 @@ button, .button { vertical-align: middle; } -button.datasourceTextVariableEdit { +button.datasourceTextVariableEdit, +button.jsonPickerBrowse { width: 20px; min-width: 20px; height: 32px; @@ -908,7 +909,10 @@ button.datasourceTextVariableEdit { button.datasourceTextVariableEdit:hover, button.datasourceTextVariableEdit:focus, -button.datasourceTextVariableEdit:active { +button.datasourceTextVariableEdit:active, +button.jsonPickerBrowse:hover, +button.jsonPickerBrowse:focus, +button.jsonPickerBrowse:active { border: 0; background-color: transparent; box-shadow: none; @@ -2188,6 +2192,81 @@ div[id^="analytics-content"] .button:not(.analyticsPrimary):hover, flex: 0 0 5rem; } +.jsonPicker { + width: min(620px, calc(100vw - 80px)); +} + +.icon-analytics-select { + background-image: url(../img/select.svg); + background-size: 16px; + filter: var(--background-invert-if-dark); +} + +.jsonPickerTree { + max-height: 340px; + overflow: auto; + margin-block: 12px; + padding: 8px 12px; + border: 1px solid var(--color-border); + border-radius: var(--analytics-surface-radius); + box-sizing: border-box; +} + +.jsonPickerTree details > :not(summary) { + margin-inline-start: 16px; +} + +.jsonPickerTree button { + max-width: calc(100% - 16px); + white-space: normal; + overflow-wrap: anywhere; + height: auto; +} + +.jsonPickerTree summary, +.jsonPickerValue { + display: grid; + grid-template-columns: 14px minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 6px 0; + overflow-wrap: anywhere; +} + +.jsonPickerTree summary { + list-style: none; +} + +.jsonPickerTree summary::-webkit-details-marker { + display: none; +} + +.jsonPickerTree summary::before { + content: '▸'; + text-align: center; +} + +.jsonPickerTree details[open] > summary::before { + content: '▾'; +} + +.jsonPickerValue input[type="checkbox"] { + min-height: 0; + min-width: 0; + width: 14px; + height: 14px; + margin: 0; + padding: 0; + flex-shrink: 0; +} + +.jsonPickerPath { + color: var(--color-text-maxcontrast); + font-size: 12px; + margin-bottom: 16px; + overflow-wrap: anywhere; +} + .analyticsTransformPreview { overflow-x: auto; max-height: 18rem; diff --git a/img/select.svg b/img/select.svg new file mode 100644 index 000000000..95138b961 --- /dev/null +++ b/img/select.svg @@ -0,0 +1 @@ + diff --git a/js/app.js b/js/app.js index ddfe02d8e..fbb861e16 100644 --- a/js/app.js +++ b/js/app.js @@ -577,6 +577,11 @@ Object.assign(OCA.Analytics.Datasource = { infoColumn.classList.add('datasourceTextVariableCell'); infoColumn.appendChild(OCA.Analytics.Datasource.buildTextVariableButton(input)); } + if (templateOption.id === 'path' && [6, 8].includes(Number(datasource))) { + const picker = document.importNode(document.getElementById('templateJsonPickerButton').content, true); + picker.querySelector('button').addEventListener('click', () => OCA.Analytics.JsonPicker.open(form, datasource, input)); + infoColumn.appendChild(picker); + } form.appendChild(tableRow); tableRow.appendChild(label); tableRow.appendChild(input); diff --git a/js/jsonPicker.js b/js/jsonPicker.js new file mode 100644 index 000000000..fab340cea --- /dev/null +++ b/js/jsonPicker.js @@ -0,0 +1,273 @@ +/** + * SPDX-FileCopyrightText: 2026 Marcel Scherello + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +OCA.Analytics.JsonPicker = { + // The legacy grammar has no escaping for separators or trimmed field names. + selectable: parts => parts.every(key => key !== '' && key.trim() === key && !/[/{},]/.test(key)), + isContainer: value => value !== null && typeof value === 'object', + + decode: function (text) { + try { + return JSON.parse(text); + } catch (error) { + // Match the datasource's support for line-delimited JSON, but do not + // silently hide malformed lines in the browser. + const lines = text.trim().split(/\r?\n/); + if (lines.length < 2) throw error; + return lines.map(line => JSON.parse(line)); + } + }, + + open: async function (form, datasource, input) { + const picker = OCA.Analytics.JsonPicker; + const options = {}; + for (const field of form.querySelectorAll('input, select')) { + if (field.id !== 'dataSourceType') options[field.id] = field.value; + } + const controller = new AbortController(); + let previewController; + let revision = 0; + let selected = []; + let rowPath = null; + const content = document.importNode(document.getElementById('templateJsonPicker').content, true); + const root = content.querySelector('.jsonPicker'); + const tree = root.querySelector('[data-role="tree"]'); + const pathDisplay = root.querySelector('#jsonPickerPath'); + let extractionPath = input.value; + const status = root.querySelector('[data-role="status"]'); + const preview = root.querySelector('[data-role="preview"]'); + const previewButton = root.querySelector('[data-action="preview"]'); + const back = root.querySelector('[data-action="root"]'); + pathDisplay.textContent = extractionPath; + OCA.Analytics.Notification.htmlDialogInitiate(t('analytics', 'Select JSON data'), () => { + if (!extractionPath) return; + input.value = extractionPath; + input.dispatchEvent(new Event('input', {bubbles: true})); + input.dispatchEvent(new Event('change', {bubbles: true})); + OCA.Analytics.Notification.dialogClose(); + }); + const dialog = document.getElementById('analyticsDialogContainer'); + OCA.Analytics.Notification.htmlDialogUpdate(content, ''); + dialog._analyticsDialogCleanup = () => { + controller.abort(); + previewController?.abort(); + }; + const apply = dialog.querySelector('#analyticsDialogBtnGo'); + apply.textContent = t('analytics', 'Apply selection'); + apply.disabled = true; + previewButton.disabled = true; + const active = () => document.getElementById('analyticsDialogContainer') === dialog; + const update = () => { + revision++; + previewController?.abort(); + extractionPath = rowPath === null ? selected.join(',') + : (selected.length ? rowPath + '{' + selected.join(',') + '}' : ''); + pathDisplay.textContent = extractionPath; + apply.disabled = !extractionPath; + previewButton.disabled = !extractionPath; + preview.replaceChildren(); + status.textContent = ''; + }; + const request = async (url, payload, signal) => { + const response = await fetch(OC.generateUrl(url), { + method: 'POST', headers: OCA.Analytics.headers(), signal, + body: JSON.stringify(payload) + }); + let result; + try { result = await response.json(); } + catch (error) { throw new Error(t('analytics', 'The datasource request failed (HTTP {status}).', {status: response.status})); } + if (!response.ok || (result.error && String(result.error) !== '0')) { + throw new Error(result.error || t('analytics', 'Could not load JSON data. Check the datasource settings.')); + } + return result; + }; + previewButton.addEventListener('click', async () => { + previewController?.abort(); + previewController = new AbortController(); + const current = ++revision; + previewButton.disabled = true; + status.textContent = t('analytics', 'Loading'); + preview.replaceChildren(); + try { + const result = await request('apps/analytics/data', { + type: Number(datasource), options: JSON.stringify({...options, path: extractionPath}), limit: 20 + }, previewController.signal); + if (!active() || current !== revision) return; + const table = document.createElement('table'); + // Preserve the actual legacy result, including padded columns. + const rows = result.data || []; + const width = Math.max(result.header?.length || 0, ...rows.map(row => row.length)); + const heading = table.createTHead().insertRow(); + for (let i = 0; i < width; i++) { + const cell = document.createElement('th'); + cell.scope = 'col'; + cell.textContent = result.header?.[i] ?? t('analytics', 'Column {number}', {number: i + 1}); + heading.appendChild(cell); + } + const body = table.createTBody(); + rows.forEach(row => { + const tr = body.insertRow(); + row.forEach(value => { + tr.insertCell().textContent = typeof value === 'string' ? value : JSON.stringify(value); + }); + }); + preview.appendChild(table); + status.textContent = rows.length ? t('analytics', 'Preview of the first 20 rows.') : t('analytics', 'No sample rows available.'); + } catch (error) { + if (active() && current === revision && error.name !== 'AbortError') status.textContent = error.message; + } finally { + if (active() && current === revision) { + previewButton.disabled = !extractionPath; + if (document.activeElement === document.body) previewButton.focus(); + } + } + }); + + const action = (label, callback, enabled = true) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'button analyticsSecondary'; + button.textContent = label; + button.disabled = !enabled; + button.addEventListener('click', callback); + return button; + }; + const choose = (key, parts, value, parent) => { + const label = document.createElement('label'); + label.className = 'jsonPickerValue'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.disabled = !picker.selectable(parts); + const path = parts.join('/'); + checkbox.checked = selected.includes(path); + checkbox.addEventListener('change', () => { + selected = checkbox.checked ? [...selected, path] : selected.filter(item => item !== path); + update(); + }); + const text = document.createElement('span'); + text.textContent = key + ': ' + JSON.stringify(value)?.slice(0, 120); + label.append(checkbox, text); + parent.appendChild(label); + }; + // Render at most 50 entries at once, and only expand objects on demand. + const children = (samples, parts, parent, fields = false) => { + const entries = new Map(); + samples.forEach(sample => { + if (!picker.isContainer(sample)) return; + Object.entries(sample).forEach(([key, value]) => { + if (!entries.has(key)) entries.set(key, []); + entries.get(key).push(value); + }); + }); + const items = [...entries]; + let offset = 0; + const more = action(t('analytics', 'Show more'), () => render()); + const render = () => { + more.remove(); + items.slice(offset, offset + 50).forEach(([key, values]) => { + const path = [...parts, key]; + const value = values.find(picker.isContainer) ?? values[0]; + if (!picker.isContainer(value)) { + choose(key, path, value, parent); + return; + } + const details = document.createElement('details'); + const summary = document.createElement('summary'); + const isArray = Array.isArray(value); + const name = document.createElement('span'); + name.textContent = key + ' (' + Object.keys(value).length + ')'; + summary.appendChild(name); + details.appendChild(summary); + details.addEventListener('toggle', () => { + if (!details.open || details.dataset.loaded) return; + details.dataset.loaded = 'true'; + if (!fields && isArray && value.some(item => picker.isContainer(item))) { + details.appendChild(action(t('analytics', 'Use all {name} and choose the relevant fields', {name: key}), () => showFields(value, path), picker.selectable(path))); + } else if (!fields && Object.values(value).every(item => !picker.isContainer(item))) { + details.appendChild(action(t('analytics', 'Use entries as rows'), () => { + rowPath = null; + selected = [path.join('/')]; + update(); + tree.querySelectorAll('input[type="checkbox"]').forEach(box => { box.checked = false; }); + }, picker.selectable(path))); + } + if (Object.keys(value).length === 0) { + const empty = document.createElement('p'); + empty.className = 'userGuidance'; + empty.textContent = t('analytics', 'No sample rows available.'); + details.appendChild(empty); + } else if (parts.length < 20) { + children(fields ? values : [value], path, details, fields); + } else { + const hint = document.createElement('p'); + hint.className = 'userGuidance'; + hint.textContent = t('analytics', 'This nesting is too deep to browse. Enter the Object path manually.'); + details.appendChild(hint); + } + }); + parent.appendChild(details); + }); + offset += 50; + if (offset < items.length) parent.appendChild(more); + }; + render(); + }; + const showFields = (records, parts, initialFields = []) => { + rowPath = parts.join('/'); + selected = initialFields; + update(); + tree.replaceChildren(); + back.hidden = false; + children(records.slice(0, 50), [], tree, true); + back.focus(); + }; + status.textContent = t('analytics', 'Loading'); + try { + const result = await request('apps/analytics/datasource/json/content', { + type: Number(datasource), options: JSON.stringify(options) + }, controller.signal); + if (!active()) return; + let json; + try { json = picker.decode(result.content); } + catch (error) { throw new Error(t('analytics', 'The datasource did not return valid JSON.')); } + const browse = (initial = false) => { + rowPath = null; + selected = initial && !/[{}]/.test(extractionPath) ? extractionPath.split(',').filter(Boolean) : []; + tree.replaceChildren(); + back.hidden = true; + if (Array.isArray(json) && json.some(picker.isContainer)) { + tree.appendChild(action(t('analytics', 'Use all entries and choose the relevant fields'), () => showFields(json, []))); + } + children([json], [], tree); + if (!picker.isContainer(json) || Object.keys(json).length === 0) { + const hint = document.createElement('p'); + hint.className = 'userGuidance'; + hint.textContent = t('analytics', 'No selectable fields found. You can still enter the Object path manually.'); + tree.appendChild(hint); + } + }; + browse(true); + const savedArray = extractionPath.match(/^([^{}]*)\{([^{}]+)\}$/); + if (savedArray) { + const parts = savedArray[1] ? savedArray[1].split('/') : []; + const records = parts.reduce((value, key) => picker.isContainer(value) && Object.hasOwn(value, key) ? value[key] : undefined, json); + if (Array.isArray(records)) showFields(records, parts, savedArray[2].split(',').map(field => field.trim())); + } + back.addEventListener('click', () => { + browse(); + update(); + (tree.querySelector('button, summary, input') || previewButton).focus(); + }); + status.textContent = ''; + apply.disabled = !extractionPath; + previewButton.disabled = !extractionPath; + } catch (error) { + if (active() && error.name !== 'AbortError') { + status.textContent = error.message; + status.setAttribute('role', 'alert'); + } + } + } +}; diff --git a/js/userGuidance.js b/js/userGuidance.js index 84db1688d..0bfe4caa0 100644 --- a/js/userGuidance.js +++ b/js/userGuidance.js @@ -19,7 +19,7 @@ if (!OCA.Analytics) { /** Shared keyboard behavior for the app's existing modal surfaces. */ OCA.Analytics.Dialog = { focusable: function (container) { - return [...container.querySelectorAll('a[href], button, input, select, textarea, [tabindex]')] + return [...container.querySelectorAll('a[href], button, input, select, textarea, summary, [tabindex]')] .filter(element => element.tabIndex >= 0 && !element.disabled && element.getClientRects().length && getComputedStyle(element).visibility !== 'hidden'); }, diff --git a/lib/Controller/DatasourceController.php b/lib/Controller/DatasourceController.php index 590a54cb6..c65bdac9b 100644 --- a/lib/Controller/DatasourceController.php +++ b/lib/Controller/DatasourceController.php @@ -164,6 +164,37 @@ public function spreadsheetSheets(string $link = ''): DataResponse { } } + /** Browse only a JSON source supplied by the authenticated user, never a shared report. */ + #[NoAdminRequired] + public function jsonContent(int $type, string $options = '{}'): DataResponse { + $user = $this->userSession->getUser(); + $settings = json_decode($options, true); + if ($user === null || !is_array($settings) + || !in_array($type, [self::DATASET_TYPE_LOCAL_JSON, self::DATASET_TYPE_EXTERNAL_JSON], true) + || !isset($this->getDatasources()[$type])) { + return new DataResponse(['error' => $this->l10n->t('Data source not available anymore')], 400); + } + // Browsing must work before an extraction path has been chosen. + $settings['path'] = ''; + $settings['timestamp'] = 'false'; + try { + $result = $this->read($type, [ + 'link' => json_encode($settings), 'user_id' => $user->getUID(), 'filteroptions' => '', + ], false); + if (!empty($result['error']) || !is_string($result['rawdata'] ?? null)) { + return new DataResponse(['error' => $this->l10n->t('Could not load JSON data. Check the datasource settings.')], 400); + } + if (strlen($result['rawdata']) > 2 * 1024 * 1024) { + return new DataResponse(['error' => $this->l10n->t('This JSON is too large to browse. Enter the Object path manually.')], 400); + } + // Do not return request headers, authentication, or other datasource metadata. + return new DataResponse(['content' => $result['rawdata']]); + } catch (\Throwable $e) { + $this->logger->debug('Could not browse JSON data', ['exception' => $e]); + return new DataResponse(['error' => $this->l10n->t('Could not load JSON data. Check the datasource settings.')], 400); + } + } + /** * Get the data from a data source; * diff --git a/sample_data/sample_orders.json b/sample_data/sample_orders.json new file mode 100644 index 000000000..9a4b51122 --- /dev/null +++ b/sample_data/sample_orders.json @@ -0,0 +1,94 @@ +{ + "metadata": { + "generatedAt": "2026-10-16T12:00:00Z", + "currency": "EUR", + "totalOrders": 4, + "description": "Fictional orders for exploring JSON datasource paths" + }, + "data": { + "orders": [ + { + "id": "ORD-1001", + "createdAt": "2026-10-12T09:15:00Z", + "status": "shipped", + "customer": { + "id": "CUS-001", + "name": "Example Office GmbH", + "address": {"city": "Berlin", "country": "DE", "postalCode": "10115"} + }, + "totals": {"net": 1250, "tax": 237.5, "gross": 1487.5, "discount": 0}, + "items": [ + {"sku": "HW-001", "product": "Laptop", "quantity": 1, "price": 1200, "net": 1200}, + {"sku": "HW-002", "product": "Mouse", "quantity": 2, "price": 25, "net": 50} + ], + "paid": true, + "tags": ["business", "hardware"], + "shipping": { + "method": "standard", + "tracking": "DEMO-DE-1001", + "events": [ + {"at": "2026-10-12T14:00:00Z", "status": "packed"}, + {"at": "2026-10-13T08:30:00Z", "status": "dispatched"} + ] + }, + "notes": "Deliver to reception" + }, + { + "id": "ORD-1002", + "createdAt": "2026-10-13T11:40:00Z", + "status": "processing", + "customer": { + "id": "CUS-002", + "name": "Example Studio", + "address": {"city": "Paris", "country": "FR", "postalCode": "75002"} + }, + "totals": {"net": 600, "tax": 120, "gross": 720, "discount": 0}, + "items": [ + {"sku": "HW-003", "product": "Monitor", "quantity": 2, "price": 250, "net": 500}, + {"sku": "HW-004", "product": "Keyboard", "quantity": 2, "price": 50, "net": 100} + ], + "paid": false, + "tags": ["business"], + "shipping": {"method": "express", "tracking": null, "events": []}, + "notes": "", + "purchaseOrder": "PO-2026-042" + }, + { + "id": "ORD-1003", + "createdAt": "2026-10-14T15:05:00Z", + "status": "completed", + "customer": { + "id": "CUS-003", + "name": "Example Workshop", + "address": {"city": "Vienna", "country": "AT", "postalCode": "1010"} + }, + "totals": {"net": 180, "tax": 36, "gross": 216, "discount": 20}, + "items": [ + {"sku": "SV-001", "product": "Setup service", "quantity": 2, "price": 100, "net": 200} + ], + "paid": true, + "tags": [], + "shipping": {"method": "digital", "tracking": null, "events": []} + }, + { + "id": "ORD-1004", + "createdAt": "2026-10-15T08:20:00Z", + "status": "draft", + "customer": { + "id": "CUS-001", + "name": "Example Office GmbH", + "address": {"city": "Berlin", "country": "DE", "postalCode": "10115"} + }, + "totals": {"net": 0, "tax": 0, "gross": 0, "discount": 0}, + "items": [], + "paid": false, + "tags": [], + "notes": null + } + ], + "summary": { + "byStatus": {"shipped": 1, "processing": 1, "completed": 1, "draft": 1}, + "byCountry": {"DE": 2, "FR": 1, "AT": 1} + } + } +} diff --git a/templates/main.php b/templates/main.php index 419674147..0c36e465b 100644 --- a/templates/main.php +++ b/templates/main.php @@ -17,6 +17,7 @@ Util::addStyle('analytics', 'print'); Util::addStyle('analytics', 'splash'); Util::addScript('analytics', 'app'); +Util::addScript('analytics', 'jsonPicker'); Util::addScript('analytics', 'flexible'); Util::addScript('analytics', 'chartOptions'); Util::addScript('analytics', '3rdParty/moment.min'); diff --git a/templates/part.templates.php b/templates/part.templates.php index a4be6b415..fb4555bba 100644 --- a/templates/part.templates.php +++ b/templates/part.templates.php @@ -1389,3 +1389,19 @@ class="analyticsModelCardIcon analyticsModelCardIcon--time" alt=""> + + + + diff --git a/templates/public.php b/templates/public.php index e0fed86a0..6515e7361 100644 --- a/templates/public.php +++ b/templates/public.php @@ -13,6 +13,7 @@ Util::addScript('analytics', '3rdParty/moment.min'); Util::addScript('analytics', '3rdParty/cloner'); Util::addScript('analytics', 'app'); +Util::addScript('analytics', 'jsonPicker'); Util::addScript('analytics', 'flexible'); Util::addScript('analytics', 'chartOptions'); Util::addScript('analytics', 'filter'); diff --git a/tests/Controller/DatasourceControllerTest.php b/tests/Controller/DatasourceControllerTest.php index 5c3ae3177..8eb067212 100644 --- a/tests/Controller/DatasourceControllerTest.php +++ b/tests/Controller/DatasourceControllerTest.php @@ -6,6 +6,50 @@ use PHPUnit\Framework\TestCase; class DatasourceControllerTest extends TestCase { + public function testJsonBrowseUsesCurrentUserAndReturnsOnlyContent(): void { + $json = $this->createMock(\OCA\Analytics\Datasource\LocalJson::class); + $json->expects($this->once())->method('readData')->with($this->callback(function ($options) { + $this->assertSame('u1', $options['user_id']); + $this->assertSame('', $options['path']); + $this->assertSame('false', $options['timestamp']); + $this->assertArrayNotHasKey('cacheKey', $options); + return true; + }))->willReturn(['rawdata' => '{"value":0}', 'auth' => 'secret', 'error' => 0, 'data' => []]); + $user = $this->createMock(\OCP\IUser::class); + $user->method('getUID')->willReturn('u1'); + $session = $this->createMock(\OCP\IUserSession::class); + $session->method('getUser')->willReturn($user); + $controller = $this->createController(userSession: $session, localJson: $json); + $response = $controller->jsonContent(8, '{"link":"/test.json","path":"old","user_id":"other","cacheKey":"old","timestamp":"true"}'); + $this->assertSame(200, $response->getStatus()); + $this->assertSame(['content' => '{"value":0}'], $response->getData()); + } + + public function testJsonBrowseRejectsUnauthenticatedAndOtherDatasourceTypes(): void { + $json = $this->createMock(\OCA\Analytics\Datasource\LocalJson::class); + $json->expects($this->never())->method('readData'); + $controller = $this->createController(localJson: $json); + $this->assertSame(400, $controller->jsonContent(8)->getStatus()); + $user = $this->createMock(\OCP\IUser::class); + $session = $this->createMock(\OCP\IUserSession::class); + $session->method('getUser')->willReturn($user); + $controller = $this->createController(userSession: $session, localJson: $json); + $this->assertSame(400, $controller->jsonContent(1)->getStatus()); + $this->assertSame(400, $controller->jsonContent(8, 'invalid')->getStatus()); + } + + public function testJsonBrowseLimitsResponseSize(): void { + $json = $this->createMock(\OCA\Analytics\Datasource\LocalJson::class); + $json->method('readData')->willReturn(['rawdata' => str_repeat(' ', 2 * 1024 * 1024 + 1), 'error' => 0, 'data' => []]); + $user = $this->createMock(\OCP\IUser::class); + $user->method('getUID')->willReturn('u1'); + $session = $this->createMock(\OCP\IUserSession::class); + $session->method('getUser')->willReturn($user); + $response = $this->createController(userSession: $session, localJson: $json)->jsonContent(8); + $this->assertSame(400, $response->getStatus()); + $this->assertArrayNotHasKey('content', $response->getData()); + } + public function testSpreadsheetSheetsListsNamesForCurrentUsersFile(): void { $spreadsheet = $this->createMock(\OCA\Analytics\Datasource\LocalSpreadsheet::class); $spreadsheet->expects($this->once())->method('listWorksheetNames') @@ -752,7 +796,8 @@ private function createController( ?\OCP\EventDispatcher\IEventDispatcher $dispatcher = null, ?\OCA\Analytics\Service\VariableService $variableService = null, ?\OCA\Analytics\Datasource\LocalSpreadsheet $spreadsheet = null, - ?\OCP\IUserSession $userSession = null + ?\OCP\IUserSession $userSession = null, + ?\OCA\Analytics\Datasource\LocalJson $localJson = null ): DatasourceController { $appConfig = $this->createMock(\OCP\IAppConfig::class); $appConfig->method('getValueString')->willReturn(''); @@ -769,7 +814,7 @@ private function createController( $localCsv ?? $this->createMock(\OCA\Analytics\Datasource\LocalCsv::class), $this->createMock(\OCA\Analytics\Datasource\Regex::class), $this->createMock(\OCA\Analytics\Datasource\ExternalJson::class), - $this->createMock(\OCA\Analytics\Datasource\LocalJson::class), + $localJson ?? $this->createMock(\OCA\Analytics\Datasource\LocalJson::class), $this->createMock(\OCA\Analytics\Datasource\ExternalCsv::class), $spreadsheet ?? $this->createMock(\OCA\Analytics\Datasource\LocalSpreadsheet::class), new FakeL10N(), diff --git a/tests/INSTRUCTIONS.md b/tests/INSTRUCTIONS.md index 4dc194581..b6ea3f598 100644 --- a/tests/INSTRUCTIONS.md +++ b/tests/INSTRUCTIONS.md @@ -64,6 +64,7 @@ Supported identifiers: - `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/json_picker.js` (JSON hierarchy, existing path syntax, live local extraction preview, and picker lifecycle) - `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/json_picker.js b/tests/playwright/json_picker.js new file mode 100644 index 000000000..f0085c429 --- /dev/null +++ b/tests/playwright/json_picker.js @@ -0,0 +1,126 @@ +/** + * 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} = require('./common'); +const config = buildScenarioConfig('json-picker'); +(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', error => errors.push(error.message)); + page.on('response', response => { + if (response.url().endsWith('/datasource/json/content') && response.status() !== 200) { + console.error('JSON browse HTTP', response.status()); + } + }); + const name = 'analytics-json-picker-' + Date.now() + '.json'; + const fixture = { + 'by-channel': {stable: 381081, enterprise: 113, beta: 1282, daily: 0}, + records: [{name: 'First', detail: {count: 0}, active: false}, {name: 'Second', detail: {count: 5}, active: true, extra: 7}], + empty: [], 'unsafe/key': 12, '': 5, + many: Object.fromEntries(Array.from({length: 55}, (_, i) => ['key' + i, i])) + }; + let uploaded = false; + try { + await ensureAnalyticsLoaded(page, config); + const code = await page.evaluate(async ({name, fixture, user}) => { + const response = await fetch(OC.generateUrl('/remote.php/dav/files/') + encodeURIComponent(user) + '/' + name, + {method: 'PUT', headers: OCA.Analytics.headers(), body: JSON.stringify(fixture)}); + return response.status; + }, {name, fixture, user: config.user}); + assert.ok([201, 204].includes(code), 'Upload fixture: ' + code); + uploaded = true; + 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: 'Local: JSON'}); + await page.locator('#dataSourceOptions #link').fill('/' + name); + await capture('picker_icon'); + const open = async () => { + await page.locator('.jsonPickerBrowse').click(); + await page.locator('.jsonPickerTree summary').filter({hasText:'by-channel'}).waitFor(); + }; + const group = key => page.locator('.jsonPickerTree summary').filter({hasText: key}).first(); + await open(); + assert.equal(await page.locator('.jsonPickerTree details[open]').count(), 0); + assert.equal(await page.locator('.jsonPickerTree unsafe').count(), 0); + assert.equal(await page.getByLabel('unsafe/key: 12', {exact:true}).isDisabled(), true); + await group('by-channel').click(); + await page.getByRole('button', {name:'Use entries as rows', exact:true}).click(); + assert.equal(await page.locator('#jsonPickerPath').textContent(), 'by-channel'); + await page.getByRole('button', {name:'Preview result', exact:true}).click(); + await page.locator('.jsonPicker [data-role="preview"] td').filter({hasText:'381081'}).waitFor(); + assert.equal(await page.locator('.jsonPicker tbody tr').count(), 4); + await capture('named_values'); + await page.getByRole('button', {name:'Apply selection', exact:true}).click(); + assert.equal(await page.locator('#dataSourceOptions #path').inputValue(), 'by-channel'); + await open(); + await group('records').click(); + await page.getByRole('button', {name:'Use all records and choose the relevant fields', exact:true}).click(); + await page.getByLabel('name: "First"', {exact:true}).check(); + await group('detail').click(); + await page.getByLabel('count: 0', {exact:true}).check(); + await page.getByLabel('active: false', {exact:true}).check(); + assert.equal(await page.locator('#jsonPickerPath').textContent(), 'records{name,detail/count,active}'); + assert.equal(await page.getByLabel('extra: 7', {exact:true}).count(), 1); + await page.getByRole('button', {name:'Preview result', exact:true}).click(); + await page.locator('.jsonPicker tbody tr').first().waitFor(); + assert.deepEqual(await page.locator('.jsonPicker tbody tr').first().locator('td').allTextContents(), ['First','0','false']); + await capture('array_fields'); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#dataSourceOptions #path').inputValue(), 'by-channel'); + await open(); + await group('many').click(); + await page.getByRole('button', {name:'Show more', exact:true}).click(); + assert.equal(await page.getByLabel('key54: 54', {exact:true}).count(), 1); + await group('empty').click(); + await page.getByText('No sample rows available.', {exact:true}).waitFor(); + await page.setViewportSize({width:390,height:844}); + await capture('mobile'); + assert.ok(await page.locator('#analyticsDialogContainer').evaluate(el => el.scrollWidth <= el.clientWidth + 1)); + await page.keyboard.press('Escape'); + await page.locator('#dataSourceOptions #path').fill('by-channel/beta'); + await page.locator('#wizardNewDatasource').selectOption({label:'External: JSON'}); + await page.route('**/apps/analytics/datasource/json/content', route => route.fulfill({json:{content:JSON.stringify(fixture)}})); + await open(); + await group('by-channel').click(); + await page.getByLabel('daily: 0', {exact:true}).check(); + await page.getByRole('button', {name:'Apply selection', exact:true}).click(); + assert.equal(await page.locator('#dataSourceOptions #path').inputValue(), 'by-channel/daily'); + await page.unroute('**/apps/analytics/datasource/json/content'); + // Root arrays and JSON Lines use the same legacy {field,...} syntax. + await page.route('**/apps/analytics/datasource/json/content', route => route.fulfill({json:{content:fixture.records.map(row => JSON.stringify(row)).join('\n')}})); + await page.locator('.jsonPickerBrowse').click(); + await page.getByRole('button', {name:'Use all entries and choose the relevant fields', exact:true}).click(); + await page.getByLabel('name: "First"', {exact:true}).check(); + await page.getByLabel('active: false', {exact:true}).check(); + assert.equal(await page.locator('#jsonPickerPath').textContent(), '{name,active}'); + await page.getByRole('button', {name:'Apply selection', exact:true}).click(); + await page.locator('.jsonPickerBrowse').click(); + await page.getByLabel('name: "First"', {exact:true}).waitFor(); + assert.equal(await page.getByLabel('name: "First"', {exact:true}).isChecked(), true); + assert.equal(await page.getByLabel('active: false', {exact:true}).isChecked(), true); + await page.keyboard.press('Escape'); + await page.unroute('**/apps/analytics/datasource/json/content'); + await page.route('**/apps/analytics/datasource/json/content', route => route.fulfill({json:{content:'invalid json'}})); + await page.locator('.jsonPickerBrowse').click(); + await page.getByRole('alert').filter({hasText:'valid JSON'}).waitFor(); + assert.equal(await page.getByRole('button', {name:'Apply selection', exact:true}).isDisabled(), true); + await page.keyboard.press('Escape'); + assert.deepEqual(errors, []); + console.log('PASS: live local JSON browse and parser preview, array fields, cancel, pagination, empty groups, mobile, external picker (mocked response), invalid JSON'); + } catch (error) { + await capture('failure'); + throw error; + } finally { + if (uploaded) await page.evaluate(async ({name, user}) => fetch(OC.generateUrl('/remote.php/dav/files/') + encodeURIComponent(user) + '/' + name, + {method:'DELETE', headers:OCA.Analytics.headers()}), {name, user:config.user}); + await browser.close(); + } +})().catch(error => {console.error(error); process.exitCode = 1;});