From 72a1da11ea5fa6360dec3467e3f4e0bd1d82912b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Thu, 29 Oct 2026 17:21:38 +0100 Subject: [PATCH] A chart drawn as a Table shows every column of its results ac:sparql-results-data-table and ac:rdf-data-table cut the data table down to the chart's category and series whatever the chart type, so a Table, which plots nothing, showed two columns of a query that selected five, and nothing said the rest existed. The converters already take the whole projection, or every property, when they are given no columns. Both functions now take the columns instead of a category and series: $var-names, and $properties with $resource-ids. The callers, the two ldh:ChartDataTable templates, choose them: none for a Table, the category and series for any other chart. ac:rdf-data-table's two branches differed only in $resource-ids and become one. The series-order spec read its order off a Table's header, which now shows the projection whatever the series are, so its fixture is a bar chart and the spec reads the order off the data table the chart keeps. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../linkeddatahub/xsl/client/block/chart.xsl | 11 +- .../linkeddatahub/xsl/client/functions.xsl | 37 +++--- tests/ui/lib/chart.mjs | 7 ++ tests/ui/lib/fixtures.mjs | 6 +- .../blocks/chart/series-order.spec.mjs | 11 +- .../blocks/chart/table-columns.spec.mjs | 111 ++++++++++++++++++ 7 files changed, 150 insertions(+), 34 deletions(-) create mode 100644 tests/ui/specs/document/blocks/chart/table-columns.spec.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d04a70266..84e0f69f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ - A write whose `Accept` names no RDF syntax is no longer refused with `406` - `405` responses list the allowed methods, a `415` to `PATCH` names the accepted type, and access requests answer `201` - An anonymous `HEAD` on a document the requester may only append to is answered `200` with its entity tag instead of `403`: the front Varnish fetched a HEAD as a GET on a cache miss, so the platform decided it as a read. `HEAD` now bypasses the cache and is decided as a HEAD, the way a conditional write by an agent without `acl:Read` needs +- A chart drawn as a Table shows every variable its query selects, or every property its graph's resources carry, instead of only its category and series ### Security - A document's types, which authorizations match on, come from its own graph only, so a write to one document cannot change another's access diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl index 8107a9fe5..02178b545 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/block/chart.xsl @@ -431,7 +431,11 @@ exclude-result-prefixes="#all" - + + + + @@ -447,7 +451,10 @@ exclude-result-prefixes="#all" - + + + + diff --git a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/functions.xsl b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/functions.xsl index 18bb803b9..0ce5a47db 100644 --- a/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/functions.xsl +++ b/src/main/webapp/static/com/atomgraph/linkeddatahub/xsl/client/functions.xsl @@ -373,33 +373,23 @@ exclude-result-prefixes="#all" + - - + + - - - - - - - - - - - - - - - - - - + + + + + + @@ -407,16 +397,17 @@ exclude-result-prefixes="#all" + - - + - + diff --git a/tests/ui/lib/chart.mjs b/tests/ui/lib/chart.mjs index daab34e53..3180d802b 100644 --- a/tests/ui/lib/chart.mjs +++ b/tests/ui/lib/chart.mjs @@ -22,3 +22,10 @@ export const canvas = block => block.locator('.chart-canvas'); export const drawing = block => canvas(block).locator('svg, canvas, table').first(); export const chartType = block => block.locator('select.chart-type'); + +// The columns of the data table a chart was drawn from, as the chart keeps it on its block's cache: the order a chart +// that reads its columns by position takes them in, which bars and a legend do not show. Null until it has drawn. +export const columnLabels = (page, uri = fixtures.chart) => page.evaluate(key => { + const table = window.LinkedDataHub?.contents?.[key]?.['data-table']; + return table ? Array.from({ length: table.getNumberOfColumns() }, (_, i) => table.getColumnLabel(i)) : null; +}, uri); diff --git a/tests/ui/lib/fixtures.mjs b/tests/ui/lib/fixtures.mjs index 64071fdb1..4d856fa82 100644 --- a/tests/ui/lib/fixtures.mjs +++ b/tests/ui/lib/fixtures.mjs @@ -316,11 +316,11 @@ export async function seed() { writeFileSync(seriesQueryFile, seriesQuery); await ldh(['add', 'select', '--title', 'Fixture item measures', '--uri', fixtures.seriesQuery, '--query-file', seriesQueryFile, fixtures.seriesDocument]); - // A Table, because its header row IS the column order: a chart type draws the same columns as - // bars and legend entries, which say nothing about position. + // A bar chart, not a Table: a Table shows the whole projection in its own order whatever the + // series are, so only a chart that plots its series can show what order they are taken in. await ldh(['add', 'result-set-chart', '--title', 'Fixture measures chart', '--uri', fixtures.seriesChart, '--query', fixtures.seriesQuery, - '--chart-type', 'https://w3id.org/atomgraph/client#Table', + '--chart-type', 'https://w3id.org/atomgraph/client#BarChart', '--category-var-name', 'kind', ...seriesProjection.toReversed().flatMap(name => ['--series-var-name', name]), fixtures.seriesDocument]); diff --git a/tests/ui/specs/document/blocks/chart/series-order.spec.mjs b/tests/ui/specs/document/blocks/chart/series-order.spec.mjs index cb929cc6c..a4ca08a01 100644 --- a/tests/ui/specs/document/blocks/chart/series-order.spec.mjs +++ b/tests/ui/specs/document/blocks/chart/series-order.spec.mjs @@ -8,21 +8,20 @@ // notice, because every fixture chart had a single series. // // The fixture declares four series in the reverse of the order its query projects them, and draws -// them as a Table, whose header row is the DataTable's column order read straight off the page. +// them as bars; the order is read off the DataTable the chart was drawn from, which bars and a +// legend do not show. Not as a Table, which shows the whole projection whatever the series are. // Four, because a chart ordering by anything but the projection still matches it by chance one // time in 24; with two it would pass half the time without the fix. import { test, expect } from '../../../../lib/console.mjs'; import { goto } from '../../../../lib/settle.mjs'; import { fixtures, seriesProjection } from '../../../../lib/fixtures.mjs'; -import { canvas, chartBlock } from '../../../../lib/chart.mjs'; +import { chartBlock, columnLabels, drawing } from '../../../../lib/chart.mjs'; test('takes its series in the order the query projects them, not the order they are declared in', async ({ page }) => { await goto(page, fixtures.seriesDocument); - const block = chartBlock(page, fixtures.seriesChart); - const table = canvas(block).locator('.google-visualization-table'); - await expect(table).toBeVisible({ timeout: 30_000 }); + await expect(drawing(chartBlock(page, fixtures.seriesChart))).toBeVisible({ timeout: 30_000 }); // The category first, then the series as SELECT lists them. - await expect(table.locator('tr').first().locator('th')).toHaveText(['kind', ...seriesProjection]); + await expect.poll(() => columnLabels(page, fixtures.seriesChart)).toEqual(['kind', ...seriesProjection]); }); diff --git a/tests/ui/specs/document/blocks/chart/table-columns.spec.mjs b/tests/ui/specs/document/blocks/chart/table-columns.spec.mjs new file mode 100644 index 000000000..775b5ce11 --- /dev/null +++ b/tests/ui/specs/document/blocks/chart/table-columns.spec.mjs @@ -0,0 +1,111 @@ +// A chart drawn as a Table shows every column of its results, not only its category and series. +// +// The category and series pick what a chart plots: the axis it draws against and the values it draws. A table plots +// nothing, so it has no use for them, but the data table it was drawn from was cut down to them all the same. A +// ResultSetChart whose query selected five variables, saved with a category and one series, showed two columns, and +// nothing on the page said the other three existed. A chart of a graph lost every property but its category and +// series the same way. +// +// Each test seeds a document of its own: the shared fixtures chart one series, where a table and a chart of it would +// show the same columns and this could not fail. +import { test, expect } from '../../../../lib/console.mjs'; +import { goto } from '../../../../lib/settle.mjs'; +import { fixtures, ldh } from '../../../../lib/fixtures.mjs'; +import { endUserBase } from '../../../../lib/stack.mjs'; +import { canvas, chartBlock } from '../../../../lib/chart.mjs'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const AC = 'https://w3id.org/atomgraph/client#'; +const LDH = 'https://w3id.org/atomgraph/linkeddatahub#'; +const DCT = 'http://purl.org/dc/terms/'; +const SPIN = 'http://spinrdf.org/spin#'; + +// Three variables, of which the chart plots two +const countsQuery = `PREFIX sioc: +PREFIX dct: + +SELECT ?kind (COUNT(?item) AS ?items) (MAX(STRLEN(?title)) AS ?longest) +WHERE + { GRAPH ?g + { ?item sioc:has_container <${fixtures.container}> ; + dct:title ?title ; + dct:description ?kind + } + } +GROUP BY ?kind +ORDER BY ?kind`; + +// Three properties per item, of which the chart plots two +const itemsConstruct = `PREFIX sioc: +PREFIX dct: + +CONSTRUCT { ?item dct:title ?title ; dct:description ?kind ; sioc:has_container ?container } +WHERE + { GRAPH ?g + { ?item sioc:has_container ?container ; + dct:title ?title ; + dct:description ?kind + FILTER ( ?container = <${fixtures.container}> ) + } + }`; + +const queryFile = text => { + const file = join(mkdtempSync(join(tmpdir(), 'ui-chart-table-')), 'q.rq'); + writeFileSync(file, text); + return file; +}; + +async function seedResultSetChart(slug) { + const doc = `${endUserBase}${slug}/`; + await ldh(['delete', doc], { allowFailure: true }); + await ldh(['create', 'item', '--container', endUserBase, '--title', `Table columns ${slug}`, '--slug', slug]); + await ldh(['add', 'select', '--title', 'Counts', '--uri', `${doc}#query`, '--query-file', queryFile(countsQuery), doc]); + await ldh(['add', 'result-set-chart', '--title', 'Counts table', '--uri', `${doc}#chart`, '--query', `${doc}#query`, + '--chart-type', `${AC}Table`, '--category-var-name', 'kind', '--series-var-name', 'items', doc]); + await ldh(['add', 'object-block', '--title', 'Counts table block', '--uri', `${doc}#block`, '--value', `${doc}#chart`, doc]); + return { doc, chart: `${doc}#chart` }; +} + +// No CLI command writes a graph chart, so it is written as data, the way any client could +async function seedGraphChart(slug) { + const doc = `${endUserBase}${slug}/`; + await ldh(['delete', doc], { allowFailure: true }); + await ldh(['create', 'item', '--container', endUserBase, '--title', `Table columns ${slug}`, '--slug', slug]); + await ldh(['add', 'construct', '--title', 'Items', '--uri', `${doc}#query`, '--query-file', queryFile(itemsConstruct), doc]); + await ldh(['patch', doc], { stdin: `INSERT { <${doc}#chart> a <${LDH}GraphChart> ; + <${DCT}title> "Items table" ; + <${SPIN}query> <${doc}#query> ; + <${LDH}chartType> <${AC}Table> ; + <${LDH}categoryProperty> <${DCT}title> ; + <${LDH}seriesProperty> <${DCT}description> } WHERE {}` }); + await ldh(['add', 'object-block', '--title', 'Items table block', '--uri', `${doc}#block`, '--value', `${doc}#chart`, doc]); + return { doc, chart: `${doc}#chart` }; +} + +const header = (page, chart) => canvas(chartBlock(page, chart)).locator('.google-visualization-table tr').first().locator('th'); + +// the documents are the owner's, unpublished, so only the owner can read them +test.describe('a chart drawn as a table', { tag: '@owner' }, () => { + test('a table of a result set shows every variable the query selects, in the order it selects them', async ({ page }) => { + const { doc, chart } = await seedResultSetChart('ui-chart-table-results'); + try { + await goto(page, doc); + await expect(header(page, chart)).toHaveText(['kind', 'items', 'longest'], { timeout: 30_000 }); + } finally { + await ldh(['delete', doc], { allowFailure: true }); + } + }); + + test('a table of a graph shows every property its resources carry', async ({ page }) => { + const { doc, chart } = await seedGraphChart('ui-chart-table-graph'); + try { + await goto(page, doc); + // one column per property: the title and description the chart names, and the container it does not + await expect(header(page, chart)).toHaveCount(3, { timeout: 30_000 }); + } finally { + await ldh(['delete', doc], { allowFailure: true }); + } + }); +});