From cfcfc4e3899ed2093785c983ddab9903d321a04e Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Mon, 7 Sep 2026 07:52:35 +0000 Subject: [PATCH 01/10] feat(chart): add links to data point tooltips --- CHANGELOG.md | 1 + .../sqlpage/migrations/01_documentation.sql | 5 ++- sqlpage/apexcharts.js | 42 +++++++++++++++---- sqlpage/templates/chart.handlebars | 5 ++- .../components/chart_point_serialization.sql | 1 + tests/end-to-end/fixtures/chart/link.sql | 5 +++ tests/end-to-end/fixtures/chart/test.ts | 12 ++++++ 7 files changed, 59 insertions(+), 12 deletions(-) create mode 100644 tests/end-to-end/fixtures/chart/link.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index eb4c7032..b2400de5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,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 ce61b247..698c409b 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 b822f9e1..d91fe719 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -51,7 +51,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 */ @@ -200,7 +200,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; @@ -210,7 +210,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; @@ -355,8 +361,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: { @@ -396,9 +403,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 = point.link || raw_points[dataPointIndex]?.[5]; const tooltip = document.createElement("div"); tooltip.className = "apexcharts-tooltip-text"; @@ -428,9 +437,26 @@ sqlpage_chart = (() => { axisValue.appendChild(valueSpan); tooltip.appendChild(axisValue); } + add_link_to_tooltip(tooltip, link); return tooltip.outerHTML; } + function bubbleTooltip(args) { + return chartTooltip(args, []); + } + + /** @param {HTMLElement} tooltip @param {string|undefined} link */ + function add_link_to_tooltip(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/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 index 246251e3..e5e32b75 100644 --- a/tests/components/chart_point_serialization.sql +++ b/tests/components/chart_point_serialization.sql @@ -3,4 +3,5 @@ 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 'linked' AS x, '5' AS y, '/edit.sql?id=5' AS link; SELECT '70' AS yline, 'limit' AS label, 'orange' AS color; 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..8318b6f9 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/link.sql @@ -0,0 +1,5 @@ +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; diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index d20553a9..3a1d1cd3 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -367,6 +367,18 @@ test("leaves a rangeBar chart on a category axis alone", async ({ page }) => { expect(chart.shapes).toHaveLength(2); }); +test("shows a data point link in its tooltip", async ({ page }) => { + await renderChart(page, "link"); + + await page.locator("#test-chart .apexcharts-rangebar-area").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", + ); +}); + test("leaves a treemap chart alone", async ({ page }) => { const chart = await renderChart(page, "treemap"); From d844c87343f81a898c2cf50bf6853d075f180bb7 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 9 Sep 2026 08:53:48 +0000 Subject: [PATCH 02/10] fix(chart): keep linked tooltips interactive --- sqlpage/sqlpage.css | 4 ++++ 1 file changed, 4 insertions(+) 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; From 8b1ae1ddf90ccac0c61fb1976e0e3fd2d57ebaab Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 9 Sep 2026 08:54:33 +0000 Subject: [PATCH 03/10] fix(chart): keep point links on their rows --- sqlpage/apexcharts.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index d91fe719..0cd0e95e 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -407,7 +407,8 @@ sqlpage_chart = (() => { const series = w.config.series[seriesIndex]; const name = series?.name || w.config.labels?.[dataPointIndex] || ""; const point = series?.data?.[dataPointIndex] || {}; - const link = point.link || raw_points[dataPointIndex]?.[5]; + const link = + typeof point === "object" ? point.link : raw_points[dataPointIndex]?.[5]; const tooltip = document.createElement("div"); tooltip.className = "apexcharts-tooltip-text"; From 87e4b1004352c8a65381b6fafa1a8fc19dc32f84 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 9 Sep 2026 08:54:51 +0000 Subject: [PATCH 04/10] fix(chart): format custom tooltip values --- sqlpage/apexcharts.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index 0cd0e95e..4d95f5de 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -434,7 +434,12 @@ 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); } From 2b7151b314634f42a182301971db42dee5efc602 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 9 Sep 2026 09:00:30 +0000 Subject: [PATCH 05/10] test(chart): cover links across tooltip types --- sqlpage/apexcharts.js | 5 +-- .../fixtures/chart/link-scatter.sql | 4 ++ tests/end-to-end/fixtures/chart/link.sql | 2 + tests/end-to-end/fixtures/chart/test.ts | 38 ++++++++++++++++++- 4 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 tests/end-to-end/fixtures/chart/link-scatter.sql diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index 4d95f5de..2363a7f0 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -406,9 +406,8 @@ sqlpage_chart = (() => { 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 = - typeof point === "object" ? point.link : raw_points[dataPointIndex]?.[5]; + 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"; 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 index 8318b6f9..3c5c7d9d 100644 --- a/tests/end-to-end/fixtures/chart/link.sql +++ b/tests/end-to-end/fixtures/chart/link.sql @@ -3,3 +3,5 @@ SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 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 3a1d1cd3..9f3a1b64 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -367,16 +367,50 @@ test("leaves a rangeBar chart on a category axis alone", async ({ page }) => { expect(chart.shapes).toHaveLength(2); }); -test("shows a data point link in its tooltip", async ({ page }) => { +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").hover(); + 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 }) => { From a00cecb01b5aa78f72b50fc5e4e5cbeae51536bf Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 9 Sep 2026 09:00:43 +0000 Subject: [PATCH 06/10] style(chart): use camel case for tooltip helper --- sqlpage/apexcharts.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index 2363a7f0..89983b19 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -442,7 +442,7 @@ sqlpage_chart = (() => { axisValue.appendChild(valueSpan); tooltip.appendChild(axisValue); } - add_link_to_tooltip(tooltip, link); + addLinkToTooltip(tooltip, link); return tooltip.outerHTML; } @@ -451,7 +451,7 @@ sqlpage_chart = (() => { } /** @param {HTMLElement} tooltip @param {string|undefined} link */ - function add_link_to_tooltip(tooltip, link) { + function addLinkToTooltip(tooltip, link) { if (!link) return; const linkContainer = document.createElement("div"); linkContainer.className = "apexcharts-tooltip-y-group"; From 443c6580340d9fe10d44b4d32b72000a899e42b9 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 9 Sep 2026 09:00:59 +0000 Subject: [PATCH 07/10] test(chart): remove unused serialization fixture --- tests/components/chart_point_serialization.sql | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 tests/components/chart_point_serialization.sql diff --git a/tests/components/chart_point_serialization.sql b/tests/components/chart_point_serialization.sql deleted file mode 100644 index e5e32b75..00000000 --- a/tests/components/chart_point_serialization.sql +++ /dev/null @@ -1,7 +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 'linked' AS x, '5' AS y, '/edit.sql?id=5' AS link; -SELECT '70' AS yline, 'limit' AS label, 'orange' AS color; From 9bb91a7fcb9f1d66eca9e15a0f30c990d2fa9432 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Wed, 9 Sep 2026 09:15:47 +0000 Subject: [PATCH 08/10] fix(chart): keep tooltip links accessible --- sqlpage/sqlpage.css | 5 +++++ tests/end-to-end/fixtures/chart/link-bar.sql | 4 ++++ tests/end-to-end/fixtures/chart/link-line.sql | 4 ++++ tests/end-to-end/fixtures/chart/test.ts | 15 ++++++++++++++- 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/end-to-end/fixtures/chart/link-bar.sql create mode 100644 tests/end-to-end/fixtures/chart/link-line.sql diff --git a/sqlpage/sqlpage.css b/sqlpage/sqlpage.css index 5ff110be..89672414 100644 --- a/sqlpage/sqlpage.css +++ b/sqlpage/sqlpage.css @@ -68,6 +68,11 @@ code { pointer-events: auto; } +.apexcharts-tooltip a { + color: currentColor; + text-decoration: underline; +} + /** table **/ .table-freeze-headers thead { position: sticky; diff --git a/tests/end-to-end/fixtures/chart/link-bar.sql b/tests/end-to-end/fixtures/chart/link-bar.sql new file mode 100644 index 00000000..271a9e9b --- /dev/null +++ b/tests/end-to-end/fixtures/chart/link-bar.sql @@ -0,0 +1,4 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, + 'bar' AS type; +SELECT 'Points' AS series, 'Linked' AS label, 10 AS value, '/linked.sql' AS link; +SELECT 'Points' AS series, 'Linked too' AS label, 20 AS value, '/linked-too.sql' AS link; diff --git a/tests/end-to-end/fixtures/chart/link-line.sql b/tests/end-to-end/fixtures/chart/link-line.sql new file mode 100644 index 00000000..055288a5 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/link-line.sql @@ -0,0 +1,4 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, + 'line' AS type, 8 AS marker; +SELECT 'Points' AS series, 1 AS x, 10 AS y, '/linked.sql' AS link; +SELECT 'Points' AS series, 2 AS x, 20 AS y, '/linked-too.sql' AS link; diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index 9f3a1b64..d20c841a 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -383,6 +383,15 @@ test("shows an interactive data point link in a rangeBar tooltip", async ({ "pointer-events", "auto", ); + const colors = await link.evaluate((anchor) => { + const tooltip = anchor.closest(".apexcharts-tooltip"); + if (!tooltip) throw new Error("Link has no tooltip"); + return { + link: getComputedStyle(anchor).color, + tooltip: getComputedStyle(tooltip).color, + }; + }); + expect(colors.link).toBe(colors.tooltip); await page.locator("#test-chart .apexcharts-rangebar-area").nth(1).hover(); await expect(page.locator("#test-chart .apexcharts-tooltip a")).toHaveCount( 0, @@ -392,7 +401,11 @@ test("shows an interactive data point link in a rangeBar tooltip", async ({ await expect(page).toHaveURL(/workpackage_edit/); }); -for (const [type, mark] of [["scatter", ".apexcharts-marker"]]) { +for (const [type, mark] of [ + ["bar", ".apexcharts-bar-area"], + ["line", ".apexcharts-marker"], + ["scatter", ".apexcharts-marker"], +]) { test(`shows a data point link in a ${type} tooltip`, async ({ page }) => { await renderChart(page, `link-${type}`); From cd395c40bbf1ac7e82c96b80e146ab4f139a936a Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Thu, 10 Sep 2026 14:46:12 +0000 Subject: [PATCH 09/10] docs(changelog): file the point link entry under unreleased --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2400de5..8ab665cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,11 @@ ## unreleased +- Chart data points can now include a `link`, which is shown as a clickable link in the point's tooltip. - Numeric x values on Cartesian charts now explicitly use a continuous numeric axis, preventing fractional tick positions from being displayed as misleading rounded integers. ## 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/`: From 8e965d2c4f0ac930d04f721c10728b5d523f0512 Mon Sep 17 00:00:00 2001 From: Ophir Lojkine Date: Thu, 10 Sep 2026 14:49:52 +0000 Subject: [PATCH 10/10] fix(chart): link every slice of a pie chart --- sqlpage/apexcharts.js | 11 ++++++++--- tests/end-to-end/fixtures/chart/link-pie.sql | 4 ++++ tests/end-to-end/fixtures/chart/test.ts | 12 ++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 tests/end-to-end/fixtures/chart/link-pie.sql diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index 89983b19..2935b212 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -405,9 +405,14 @@ sqlpage_chart = (() => { 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 has_series_data = Array.isArray(series?.data); + const point_index = has_series_data ? dataPointIndex : seriesIndex; + const raw_point = raw_points[point_index]; + const name = series?.name || w.config.labels?.[point_index] || ""; + const point = has_series_data + ? series.data[dataPointIndex] + : { y: raw_point?.[2], z: raw_point?.[4] }; + const link = has_series_data ? point?.link : raw_point?.[5]; const tooltip = document.createElement("div"); tooltip.className = "apexcharts-tooltip-text"; diff --git a/tests/end-to-end/fixtures/chart/link-pie.sql b/tests/end-to-end/fixtures/chart/link-pie.sql new file mode 100644 index 00000000..da0828ef --- /dev/null +++ b/tests/end-to-end/fixtures/chart/link-pie.sql @@ -0,0 +1,4 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, + 'pie' AS type; +SELECT 'Points' AS series, 'A' AS label, 10 AS value, '/linked.sql' AS link; +SELECT 'Points' AS series, 'B' AS label, 20 AS value, '/linked-too.sql' AS link; diff --git a/tests/end-to-end/fixtures/chart/test.ts b/tests/end-to-end/fixtures/chart/test.ts index d20c841a..7d06689a 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -417,6 +417,18 @@ for (const [type, mark] of [ }); } +test("links each slice of a pie chart from its own row", async ({ page }) => { + const chart = await renderChart(page, "link-pie"); + + expect(chart.failures).toEqual([]); + const slices = page.locator("#test-chart .apexcharts-pie-area"); + const link = page.locator("#test-chart .apexcharts-tooltip a"); + await slices.nth(0).hover(); + await expect(link).toHaveAttribute("href", "/linked.sql"); + await slices.nth(1).hover(); + await expect(link).toHaveAttribute("href", "/linked-too.sql"); +}); + test("formats date ranges in a linked tooltip", async ({ page }) => { await renderChart(page, "link");