diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ad6357..3d46fb6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## v0.47.0 (unreleased) - **Mac users:** the downloadable `sqlpage-macos.tgz` now runs natively on Apple silicon (M-series Macs) and no longer runs on Intel Macs. Homebrew remains the recommended and easiest installation method. On an Intel Mac, [install Homebrew](https://brew.sh/) if needed, then run `brew install sqlpage` (or `brew update` followed by `brew upgrade sqlpage` if you already installed it with Homebrew). Open Terminal in your existing website folder and run `sqlpage` instead of `./sqlpage.bin`; keep your SQL files, database, and `sqlpage` configuration folder in place. Intel installations may build from source and take longer; see the [macOS installation guide](https://sql-page.com/your-first-sql-website/?os=macos#download) for setup and older macOS requirements. +- Chart data points can now include a `link`. Clicking a point or its tooltip title opens that URL. The tooltip remains open while the pointer moves onto its link. ApexCharts was updated to [v7.6.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v7.6.0) to support this. - Updated sqlx-oldapi to v0.6.57 to fix SQL Server fallback expressions such as `ISNULL($missing, 'default')` truncating defaults or failing for date values when the bound variable is `NULL`. - Fixed MSSQL `JSON_OBJECT('key': value)` expressions being rejected by SQLPage's parser, including when used in `SET` statements or nested in `sqlpage.*` function calls. - OIDC now checks both normalized request paths and their resolved SQL files against protected prefixes, closing authentication bypasses through path and clean-URL aliases. Nonce verification also rejects provider-returned Argon2 parameters outside SQLPage's fixed low-cost profile before hashing. diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index ce61b247..170de31f 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -685,6 +685,7 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('x', 'The value of the point on the horizontal axis. Numeric values use continuous, proportionate positioning; text values are evenly spaced categories. Set the top-level time property for dates and timestamps.', 'REAL', FALSE, FALSE), ('y', 'The value of the point on the vertical axis', 'REAL', FALSE, FALSE), ('z', 'A third value carried by the point. Used as the bubble radius in a bubble chart, and shown in the tooltip under the name given by the top-level "ztitle".', 'REAL', FALSE, TRUE), + ('link', 'A URL to open when the data point or its tooltip title is clicked.', 'URL', FALSE, TRUE), ('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), @@ -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 open when its bar or tooltip title is clicked.', 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/frontend/src/apexcharts.js b/frontend/src/apexcharts.js index 820a7266..9d221e39 100644 --- a/frontend/src/apexcharts.js +++ b/frontend/src/apexcharts.js @@ -111,7 +111,7 @@ const sqlpage_chart = (() => { const reference_rows = data.points.filter((row) => !Array.isArray(row)); /** @type { Series } */ const series_map = new Map(); - for (const [name, old_x, old_y, color, z] of points) { + for (const [name, old_x, old_y, color, z, link] of points) { /** @type {ChartSeries} */ const point_series = series_map.get(name) ?? { name, data: [] }; series_map.set(name, point_series); @@ -123,7 +123,7 @@ const sqlpage_chart = (() => { y = y.map((y) => new Date(y).getTime()); else x = new Date(x); } - point_series.data.push({ x, y, z, fillColor: named_color(color) }); + point_series.data.push({ x, y, z, link, fillColor: named_color(color) }); } if (data.xmin == null) data.xmin = undefined; if (data.xmax == null) data.xmax = undefined; @@ -165,6 +165,7 @@ const sqlpage_chart = (() => { chart_type === "rangeBar" || (chart_type === "bar" && !!data.horizontal); const value_axis = inverted ? "x" : "y"; const category_axis = inverted ? "y" : "x"; + const has_point_links = points.some((point) => point[5]); const options = { annotations: { [`${value_axis}axis`]: reference_lines( @@ -196,6 +197,12 @@ const sqlpage_chart = (() => { zoom: { enabled: false, }, + events: { + dataPointSelection: (_event, _chart, args) => { + const link = pointLink(args, points); + if (link) window.location.assign(link); + }, + }, }, theme: { mode: isDarkTheme ? "dark" : "light", @@ -268,8 +275,10 @@ const sqlpage_chart = (() => { }, tooltip: { fillSeriesColor: false, - custom: - chart_type === "bubble" || chart_type === "scatter" + interactive: has_point_links, + custom: has_point_links + ? (args) => chartTooltip(args, points) + : chart_type === "bubble" || chart_type === "scatter" ? bubbleTooltip : undefined, y: { @@ -312,17 +321,25 @@ const 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 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 = pointLink({ seriesIndex, dataPointIndex, w }, raw_points); const tooltip = document.createElement("div"); tooltip.className = "apexcharts-tooltip-text"; tooltip.style.fontFamily = "inherit"; - const seriesName = document.createElement("div"); + const seriesName = document.createElement(link ? "a" : "div"); seriesName.className = "apexcharts-tooltip-y-group"; seriesName.style.fontWeight = "bold"; + if (seriesName instanceof HTMLAnchorElement) seriesName.href = link; seriesName.innerText = name; tooltip.appendChild(seriesName); @@ -340,13 +357,29 @@ const 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); } return tooltip.outerHTML; } + function pointLink({ seriesIndex, dataPointIndex, w }, raw_points) { + const series = w.config.series[seriesIndex]; + return Array.isArray(series?.data) + ? series.data[dataPointIndex]?.link + : raw_points[seriesIndex]?.[5]; + } + + function bubbleTooltip(args) { + return chartTooltip(args, []); + } + return sqlpage_chart; })(); diff --git a/frontend/src/chart_series.js b/frontend/src/chart_series.js index d3134101..b84c154a 100644 --- a/frontend/src/chart_series.js +++ b/frontend/src/chart_series.js @@ -1,5 +1,5 @@ /** @typedef {number|string|Date} XValue */ -/** @typedef { {x:XValue, y:number|string|number[]|null, z?:number, fillColor?:string} } ChartPoint */ +/** @typedef { {x:XValue, y:number|string|number[]|null, z?:number, fillColor?:string, link?:string} } ChartPoint */ /** @typedef { {name:string, data:ChartPoint[]} } ChartSeries */ /** @typedef { Map } Series */ diff --git a/frontend/src/sqlpage.css b/frontend/src/sqlpage.css index ed65bc90..71acb924 100644 --- a/frontend/src/sqlpage.css +++ b/frontend/src/sqlpage.css @@ -60,6 +60,16 @@ code { color: inherit; } +.apexcharts-tooltip a { + color: currentColor; + text-decoration: underline; +} + +.apexcharts-canvas:has(.apexcharts-tooltip.apexcharts-active a) + .apexcharts-svg { + cursor: pointer; +} + /** table **/ .table-freeze-headers thead { position: sticky; diff --git a/package-lock.json b/package-lock.json index 4566dbbf..e59d0948 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "dependencies": { "@tabler/core": "1.5.0", "@tabler/icons-sprite": "3.46.0", - "apexcharts": "7.1.0", + "apexcharts": "7.6.0", "tom-select": "2.6.2" }, "devDependencies": { @@ -890,18 +890,18 @@ } }, "node_modules/apex-commons": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/apex-commons/-/apex-commons-0.5.0.tgz", - "integrity": "sha512-xz83SgPREE1wGtoflvxjwJtcjzl5bKTz2/bDxWCZNL6QVjUNE47kMg/1Zqt0bSh6eOnmFCWlTDc3RU8o85N+wg==", - "license": "MIT" + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/apex-commons/-/apex-commons-0.8.1.tgz", + "integrity": "sha512-F7fqJVh0smeTu1d86uuRSNNZvaM/l11GNQMbu6oAiSvuXp+K8KCufg0oZTy2VQBEEipRLAdFqkHnphlVpTG0Gw==", + "license": "SEE LICENSE IN LICENSE" }, "node_modules/apexcharts": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-7.1.0.tgz", - "integrity": "sha512-zO43jUhHBNz+riahvkVvlqYw8Zpog5KtpdVSc9PbvOiXAmPBaSSoleUtO83NktObHZ9LnNt3M4MdYx7m+aSDMw==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-7.6.0.tgz", + "integrity": "sha512-8l5l1Fou9Yvef/Y/XGn9g22pkIvV/zZxVW1pWRP5sWJyf4eVIwWFfKMmVry6U751GGAwJ2e1iDB25QUi3pA8XA==", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "apex-commons": "^0.5.0" + "apex-commons": "^0.8.0" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" diff --git a/package.json b/package.json index 05efb54e..fcca2ba5 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "dependencies": { "@tabler/core": "1.5.0", "@tabler/icons-sprite": "3.46.0", - "apexcharts": "7.1.0", + "apexcharts": "7.6.0", "tom-select": "2.6.2" }, "devDependencies": { 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/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/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/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 b2cdcba2..873d811a 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -140,6 +140,46 @@ const fills = (chart: Awaited>) => return `#${hex.join("")}`; }); +test("linked range bar tooltip stays open and its point navigates", async ({ + page, +}) => { + await renderChart(page, "link"); + const bars = page.locator("#test-chart .apexcharts-rangebar-area"); + await bars.first().hover(); + const link = page.locator("#test-chart .apexcharts-tooltip a"); + await expect(link).toHaveAttribute( + "href", + "/workpackage_edit.sql?workpackage_name=Design", + ); + await link.hover(); + await expect(link).toBeVisible(); + await bars.first().click(); + await expect(page).toHaveURL(/workpackage_edit/); +}); + +for (const type of ["bar", "line", "scatter"]) { + test(`shows a linked ${type} point in its tooltip`, async ({ page }) => { + await renderChart(page, `link-${type}`); + const mark = type === "bar" ? ".apexcharts-bar-area" : ".apexcharts-marker"; + await page.locator(`#test-chart ${mark}`).first().hover({ force: true }); + await expect(page.locator("#test-chart .apexcharts-tooltip a")).toHaveCount( + 1, + ); + }); +} + +test("pie slices use the links from their respective rows", async ({ + page, +}) => { + await renderChart(page, "link-pie"); + 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("positions complete numeric bar series on an explicit numeric axis (#733)", async ({ page, }) => {