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