Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,11 @@ exclude-result-prefixes="#all"
<xsl:param name="chart-type" as="xs:anyURI"/>
<xsl:param name="object-metadata" as="document-node()?"/>

<xsl:sequence select="ac:rdf-data-table(root(.), $category, $series, $chart-type, $object-metadata)"/>
<!-- a table shows every property; the other charts plot their category and series, against the resource when
there is no category -->
<xsl:variable name="properties" select="if ($chart-type = '&ac;Table') then () else (for $property in ($category, $series) return xs:anyURI($property))" as="xs:anyURI*"/>

<xsl:sequence select="ac:rdf-data-table(root(.), $properties, empty($category), $chart-type, $object-metadata)"/>
</xsl:template>

<!-- the data table of a result set: its rows -->
Expand All @@ -447,7 +451,10 @@ exclude-result-prefixes="#all"
<xsl:variable name="projection" select="srx:head/srx:variable/@name/string()" as="xs:string*"/>
<xsl:variable name="ordered-series" select="$projection[. = $series], $series[not(. = $projection)]" as="xs:string*"/>

<xsl:sequence select="ac:sparql-results-data-table(root(.), $category, $ordered-series, $chart-type)"/>
<!-- a table shows the whole projection; the other charts plot their category and series -->
<xsl:variable name="var-names" select="if ($chart-type = '&ac;Table') then () else ($category, $ordered-series)" as="xs:string*"/>

<xsl:sequence select="ac:sparql-results-data-table(root(.), $var-names, $chart-type)"/>
</xsl:template>

<!-- what is plotted when the caller does not say: every property a graph's resources carry -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,50 +373,41 @@ exclude-result-prefixes="#all"
<json:string key="v"><xsl:value-of select="replace(replace(., '&lt;', '&amp;lt;'), '&gt;', '&amp;gt;')"/></json:string>
</xsl:template>

<!-- the data table of a graph, with the columns the caller chose: $properties in that order, every property when
there are none, and the resource URI/ID first when $resource-ids -->
<xsl:function name="ac:rdf-data-table">
<xsl:param name="results" as="document-node()"/>
<xsl:param name="category" as="xs:string?"/>
<xsl:param name="series" as="xs:string*"/>
<xsl:param name="properties" as="xs:anyURI*"/>
<xsl:param name="resource-ids" as="xs:boolean"/>
<xsl:param name="chart-type" as="xs:anyURI"/>
<xsl:param name="object-metadata" as="document-node()?"/> <!-- labels of the resources the results link to, where the caller has them -->

<xsl:variable name="json" as="xs:string">
<xsl:value-of>
<xsl:choose>
<xsl:when test="$category">
<xsl:apply-templates select="$results" mode="ac:DataTable">
<xsl:with-param name="properties" select="xs:anyURI($category), for $i in $series return xs:anyURI($i)" tunnel="yes"/>
<xsl:with-param name="chart-type" select="$chart-type" tunnel="yes"/>
<xsl:with-param name="object-metadata" select="$object-metadata" tunnel="yes"/>
</xsl:apply-templates>
</xsl:when>
<xsl:otherwise>
<!-- if no $category specified, show resource URI/ID as category -->
<xsl:apply-templates select="$results" mode="ac:DataTable">
<xsl:with-param name="resource-ids" select="true()" tunnel="yes"/>
<xsl:with-param name="properties" select="xs:anyURI($category), for $i in $series return xs:anyURI($i)" tunnel="yes"/>
<xsl:with-param name="chart-type" select="$chart-type" tunnel="yes"/>
<xsl:with-param name="object-metadata" select="$object-metadata" tunnel="yes"/>
</xsl:apply-templates>
</xsl:otherwise>
</xsl:choose>
<xsl:apply-templates select="$results" mode="ac:DataTable">
<xsl:with-param name="resource-ids" select="$resource-ids" tunnel="yes"/>
<xsl:with-param name="properties" select="$properties" tunnel="yes"/>
<xsl:with-param name="chart-type" select="$chart-type" tunnel="yes"/>
<xsl:with-param name="object-metadata" select="$object-metadata" tunnel="yes"/>
</xsl:apply-templates>
</xsl:value-of>
</xsl:variable>

<xsl:variable name="json-obj" select="ixsl:call(ixsl:get(ixsl:window(), 'JSON'), 'parse', [ $json ])"/>
<xsl:sequence select="ixsl:new('google.visualization.DataTable', [ $json-obj ])"/>
</xsl:function>

<!-- the data table of a result set, with the columns the caller chose: $var-names in that order, every projected
variable when there are none -->
<xsl:function name="ac:sparql-results-data-table">
<xsl:param name="results" as="document-node()"/>
<xsl:param name="category" as="xs:string?"/>
<xsl:param name="series" as="xs:string*"/>
<xsl:param name="var-names" as="xs:string*"/>
<xsl:param name="chart-type" as="xs:anyURI"/>

<xsl:variable name="json" as="xs:string">
<xsl:value-of>
<xsl:apply-templates select="$results" mode="ac:DataTable">
<xsl:with-param name="var-names" select="$category, $series" tunnel="yes"/>
<xsl:with-param name="var-names" select="$var-names" tunnel="yes"/>
<xsl:with-param name="chart-type" select="$chart-type" tunnel="yes"/>
</xsl:apply-templates>
</xsl:value-of>
Expand Down
7 changes: 7 additions & 0 deletions tests/ui/lib/chart.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
6 changes: 3 additions & 3 deletions tests/ui/lib/fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
11 changes: 5 additions & 6 deletions tests/ui/specs/document/blocks/chart/series-order.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
111 changes: 111 additions & 0 deletions tests/ui/specs/document/blocks/chart/table-columns.spec.mjs
Original file line number Diff line number Diff line change
@@ -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: <http://rdfs.org/sioc/ns#>
PREFIX dct: <http://purl.org/dc/terms/>

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: <http://rdfs.org/sioc/ns#>
PREFIX dct: <http://purl.org/dc/terms/>

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