diff --git a/CHANGELOG.md b/CHANGELOG.md index cc1c6e9c..01ccef83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## v0.46.1 +- Chart data points can now include a `link`, which is shown as a clickable link in the point's tooltip. - Upgraded the bundled ApexCharts from v5.13.0 to [v7.1.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v7.1.0) and the Tabler core from v1.4.0 to v1.5.0. The ApexCharts upgrade fixes logarithmic-axis scaling, stacked baselines on irregular data, and annotations on charts with no data, and ships a smaller default bundle. - Fixed modal dialog boxes appearing behind their backdrop, which made them impossible to close by clicking their close button. Tabler 1.5 sets `contain: layout` on the page container, which broke the fixed positioning of modals rendered inside it; modals are now moved to the top level of the page, as recommended by Bootstrap. - Fixed a regression introduced in v0.46 that could replace a variable with `NULL` while building a value that also used database expressions and `sqlpage.*` functions. For example, this API request could lose `john.doe` and produce a URL ending at `https://api.example.com/`: diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index f93a527e..c0db7f5c 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -688,6 +688,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('label', 'An alias for parameter "x". On a row that draws a reference line, the text to display next to the line.', 'TEXT', FALSE, TRUE), ('value', 'An alias for parameter "y"', 'REAL', FALSE, TRUE), ('series', 'If multiple series are represented and share the same y-axis, this parameter can be used to distinguish between them.', 'TEXT', FALSE, TRUE), + ('link', 'Adds a clickable link to this point in its tooltip.', 'URL', FALSE, TRUE), ('yline', 'Draws a reference line across the chart at this value of the y axis instead of plotting a point, to show a limit such as a quota or an alarm threshold. Not drawn if it falls outside of the axis, so set ymax when the limit is above the data.', 'REAL', FALSE, TRUE), ('yline_end', 'Makes the yline a band instead of a line, reaching to this value.', 'REAL', FALSE, TRUE), ('xline', 'Draws a reference line across the chart at this position of the x axis instead of plotting a point, to mark an event such as a deployment. A date or a timestamp when time is set, otherwise one of the x values.', 'TEXT', FALSE, TRUE), @@ -789,10 +790,10 @@ The `color` property sets the color of each series separately, in order. { "series": "PostgreSQL", "x": "2010", "y": 65},{ "series": "SQLite", "x": "2010", "y": 62},{ "series": "MySQL", "x": "2010", "y": 83}, { "series": "PostgreSQL", "x": "2020", "y": 73},{ "series": "SQLite", "x": "2020", "y": 38},{ "series": "MySQL", "x": "2020", "y": 87} ]')), - ('chart', 'A timeline displaying events with a start and an end date', + ('chart', 'A timeline displaying events with a start and an end date. A data row can include a `link` to make it available as a clickable action in the tooltip.', json('[ {"component":"chart", "title": "Project Timeline", "type": "rangeBar", "time": true, "color": ["teal", "cyan"], "labels": true, "xmin": "2021-12-28", "xmax": "2022-01-04" }, - {"series": "Phase 1", "label": "Operations", "value": ["2021-12-29", "2022-01-02"]}, + {"series": "Phase 1", "label": "Operations", "value": ["2021-12-29", "2022-01-02"], "link": "/examples/chart.sql?phase=1"}, {"series": "Phase 2", "label": "Operations", "value": ["2022-01-03", "2022-01-04"]}, {"series": "Yearly maintenance", "label": "Maintenance", "value": ["2022-01-01", "2022-01-03"]} ]')), diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index 133d9139..0f92a3e8 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -50,7 +50,7 @@ sqlpage_chart = (() => { }; /** @typedef {number|string|Date} XValue */ - /** @typedef { {x:XValue, y:number|null, z?:number, fillColor?:string} } ChartPoint */ + /** @typedef { {x:XValue, y:number|null, z?:number, fillColor?:string, link?:string} } ChartPoint */ /** @typedef { {name:string, data:ChartPoint[]} } ChartSeries */ /** @typedef { { [name:string]: ChartSeries } } Series */ @@ -182,7 +182,7 @@ sqlpage_chart = (() => { const reference_rows = data.points.filter((row) => !Array.isArray(row)); /** @type { Series } */ const series_map = {}; - for (const [name, old_x, old_y, color, z] of points) { + for (const [name, old_x, old_y, color, z, link] of points) { series_map[name] = series_map[name] || { name, data: [] }; let x = old_x; let y = old_y; @@ -192,7 +192,13 @@ sqlpage_chart = (() => { y = y.map((y) => new Date(y).getTime()); else x = new Date(x); } - series_map[name].data.push({ x, y, z, fillColor: named_color(color) }); + series_map[name].data.push({ + x, + y, + z, + link, + fillColor: named_color(color), + }); } if (data.xmin == null) data.xmin = undefined; if (data.xmax == null) data.xmax = undefined; @@ -332,8 +338,9 @@ sqlpage_chart = (() => { }, tooltip: { fillSeriesColor: false, - custom: - chart_type === "bubble" || chart_type === "scatter" + custom: points.some((point) => point[5]) + ? (args) => chartTooltip(args, points) + : chart_type === "bubble" || chart_type === "scatter" ? bubbleTooltip : undefined, y: { @@ -372,9 +379,11 @@ sqlpage_chart = (() => { c.removeAttribute("data-pre-init"); } - function bubbleTooltip({ seriesIndex, dataPointIndex, w }) { - const { name, data } = w.config.series[seriesIndex]; - const point = data[dataPointIndex]; + function chartTooltip({ seriesIndex, dataPointIndex, w }, raw_points) { + const series = w.config.series[seriesIndex]; + const name = series?.name || w.config.labels?.[dataPointIndex] || ""; + const point = series?.data?.[dataPointIndex]; + const link = series?.data ? point?.link : raw_points[dataPointIndex]?.[5]; const tooltip = document.createElement("div"); tooltip.className = "apexcharts-tooltip-text"; @@ -400,13 +409,35 @@ sqlpage_chart = (() => { axisValue.appendChild(labelSpan); const valueSpan = document.createElement("span"); valueSpan.className = "apexcharts-tooltip-text-y-value"; - valueSpan.innerText = value; + const formatter = axis === "y" && w.config.tooltip.y.formatter; + const format = (v) => + formatter ? formatter(v, { seriesIndex, dataPointIndex, w }) : v; + valueSpan.innerText = Array.isArray(value) + ? value.map(format).join(" - ") + : format(value); axisValue.appendChild(valueSpan); tooltip.appendChild(axisValue); } + addLinkToTooltip(tooltip, link); return tooltip.outerHTML; } + function bubbleTooltip(args) { + return chartTooltip(args, []); + } + + /** @param {HTMLElement} tooltip @param {string|undefined} link */ + function addLinkToTooltip(tooltip, link) { + if (!link) return; + const linkContainer = document.createElement("div"); + linkContainer.className = "apexcharts-tooltip-y-group"; + const anchor = document.createElement("a"); + anchor.href = link; + anchor.textContent = "Open link"; + linkContainer.appendChild(anchor); + tooltip.appendChild(linkContainer); + } + return sqlpage_chart; })(); diff --git a/sqlpage/sqlpage.css b/sqlpage/sqlpage.css index 2976dd62..5ff110be 100644 --- a/sqlpage/sqlpage.css +++ b/sqlpage/sqlpage.css @@ -64,6 +64,10 @@ code { color: inherit; } +.apexcharts-tooltip:has(a) { + pointer-events: auto; +} + /** table **/ .table-freeze-headers thead { position: sticky; diff --git a/sqlpage/templates/chart.handlebars b/sqlpage/templates/chart.handlebars index e0f88e6d..17962b04 100644 --- a/sqlpage/templates/chart.handlebars +++ b/sqlpage/templates/chart.handlebars @@ -51,8 +51,9 @@ {{~ stringify (default series (default ../title "")) ~}}, {{~ stringify (default x label) ~}}, {{~ stringify (default y value) ~}} - {{~#if (or color z)}}, {{~ stringify color ~}} {{~/if~}} - {{~#if z}}, {{~ stringify z ~}} {{~/if~}} + {{~#if (or color z link)}}, {{~ stringify color ~}} {{~/if~}} + {{~#if (or z link)}}, {{~ stringify z ~}} {{~/if~}} + {{~#if link}}, {{~ stringify link ~}} {{~/if~}} ] {{~/if~}} {{~/each_row~}} diff --git a/tests/components/chart_point_serialization.sql b/tests/components/chart_point_serialization.sql deleted file mode 100644 index 246251e3..00000000 --- a/tests/components/chart_point_serialization.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT 'chart' AS component, 'It works !' AS title; -SELECT 'plain' AS x, '1' AS y; -SELECT 'colored' AS x, '2' AS y, 'red' AS color; -SELECT 'sized' AS x, '3' AS y, '30' AS z; -SELECT 'both' AS x, '4' AS y, 'green' AS color, '40' AS z; -SELECT '70' AS yline, 'limit' AS label, 'orange' AS color; diff --git a/tests/end-to-end/fixtures/chart/link-scatter.sql b/tests/end-to-end/fixtures/chart/link-scatter.sql new file mode 100644 index 00000000..984c90e3 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/link-scatter.sql @@ -0,0 +1,4 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, + 'scatter' AS type, 8 AS marker; +SELECT 'Points' AS series, 1 AS x, 1 AS y, '/linked.sql' AS link; +SELECT 'Points' AS series, 2 AS x, 2 AS y, '/linked-too.sql' AS link; diff --git a/tests/end-to-end/fixtures/chart/link.sql b/tests/end-to-end/fixtures/chart/link.sql new file mode 100644 index 00000000..3c5c7d9d --- /dev/null +++ b/tests/end-to-end/fixtures/chart/link.sql @@ -0,0 +1,7 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, + 'rangeBar' AS type, TRUE AS time; +SELECT 'Design' AS series, 'Alice' AS label, + '2024-03-01' AS value, '2024-03-05' AS value, + '/workpackage_edit.sql?workpackage_name=Design' AS link; +SELECT 'Research' AS series, 'Bob' AS label, + '2024-03-06' AS value, '2024-03-10' AS value; diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index ff3d5f17..2d18ff06 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -257,6 +257,52 @@ test("leaves a rangeBar chart on a category axis alone", async ({ page }) => { expect(chart.shapes).toHaveLength(2); }); +test("shows an interactive data point link in a rangeBar tooltip", async ({ + page, +}) => { + await renderChart(page, "link"); + + await page.locator("#test-chart .apexcharts-rangebar-area").first().hover(); + const link = page.locator("#test-chart .apexcharts-tooltip a"); + await expect(link).toHaveText("Open link"); + await expect(link).toHaveAttribute( + "href", + "/workpackage_edit.sql?workpackage_name=Design", + ); + await expect(page.locator("#test-chart .apexcharts-tooltip")).toHaveCSS( + "pointer-events", + "auto", + ); + await page.locator("#test-chart .apexcharts-rangebar-area").nth(1).hover(); + await expect(page.locator("#test-chart .apexcharts-tooltip a")).toHaveCount( + 0, + ); + await page.locator("#test-chart .apexcharts-rangebar-area").first().hover(); + await link.click(); + await expect(page).toHaveURL(/workpackage_edit/); +}); + +for (const [type, mark] of [["scatter", ".apexcharts-marker"]]) { + test(`shows a data point link in a ${type} tooltip`, async ({ page }) => { + await renderChart(page, `link-${type}`); + + const marks = page.locator(`#test-chart ${mark}`); + await marks.nth(0).hover({ force: true }); + await expect(page.locator("#test-chart .apexcharts-tooltip a")).toHaveCount( + 1, + ); + }); +} + +test("formats date ranges in a linked tooltip", async ({ page }) => { + await renderChart(page, "link"); + + await page.locator("#test-chart .apexcharts-rangebar-area").first().hover(); + await expect( + page.locator("#test-chart .apexcharts-tooltip"), + ).not.toContainText("1709251200000"); +}); + test("leaves a treemap chart alone", async ({ page }) => { const chart = await renderChart(page, "treemap");