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 }); + } + }); +});