diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ad6357..d713ec79 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 opens that URL; when the tooltip has a text x value as its title, that title links to it too. The tooltip remains open while the pointer moves onto the 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..ccb180e4 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 is clicked. When the tooltip has a text x value as its title, that title also links to the point.', '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 4721dc22..0d498d6a 100644 --- a/frontend/src/apexcharts.js +++ b/frontend/src/apexcharts.js @@ -4,9 +4,26 @@ import { add_init_fn } from "./init.js"; /** * @typedef {import("./chart_series.js").ChartSeries} ChartSeries + * @typedef {import("./chart_series.js").ChartPoint} ChartPoint * @typedef {import("./chart_series.js").Series} Series + * @typedef {object} DataPoint + * @property {string} name + * @property {string|number|null} x + * @property {string|number|number[]|null} y + * @property {string|null} [color] + * @property {string|number|null} [z] + * @property {string} [link] */ +/** @param {string|number|null} value @param {string|undefined} link */ +function formatTooltipX(value, link) { + if (!link || !value) return value; + const anchor = document.createElement("a"); + anchor.setAttribute("href", link); + anchor.textContent = String(value); + return anchor.outerHTML; +} + const sqlpage_chart = (() => { function sqlpage_chart() { /** @type {NodeListOf} */ @@ -107,23 +124,43 @@ const sqlpage_chart = (() => { APEXCHARTS_TYPE_ALIASES[data.type] || data.type || "line"; const is_stacked = !!data.stacked && STACKABLE_CHART_TYPES.includes(chart_type); - const points = data.points.filter(Array.isArray); + /** @type {DataPoint[]} */ + const points = data.points + .filter(Array.isArray) + .map(([name, x, y, color, z, link]) => ({ + name, + x, + y, + color, + z, + link: link ?? undefined, + })); + /** @type {ReferenceLine[]} */ 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, x: old_x, y: old_y, color, z, link } of points) { /** @type {ChartSeries} */ const point_series = series_map.get(name) ?? { name, data: [] }; series_map.set(name, point_series); + /** @type {string|number|Date|null} */ let x = old_x; let y = old_y; if (is_timeseries) { if (typeof x === "number") x = new Date(x * 1000); else if (chart_type === "rangeBar" && Array.isArray(y)) y = y.map((y) => new Date(y).getTime()); - else x = new Date(x); + else x = new Date(/** @type {string|number} */ (x ?? 0)); } - point_series.data.push({ x, y, z, fillColor: named_color(color) }); + point_series.data.push( + /** @type {ChartPoint} */ ({ + x, + y, + z, + link, + fillColor: named_color(color), + }), + ); } if (data.xmin == null) data.xmin = undefined; if (data.xmax == null) data.xmax = undefined; @@ -137,23 +174,26 @@ const sqlpage_chart = (() => { ]; let colors = palette; - let series = [...series_map.values()]; + const chart_series = [...series_map.values()]; const xaxis_type = xaxis_type_for( - series, + chart_series, chart_type, is_timeseries, !!data.horizontal, ); - let labels; - if (chart_type === "pie") { - labels = points.map(([name, x, _y]) => x || name); - series = points.map(([_name, _x, y]) => Number.parseFloat(y)); + const labels = + chart_type === "pie" ? points.map(({ name, x }) => x || name) : undefined; + const series = + chart_type === "pie" + ? points.map(({ y }) => Number.parseFloat(String(y))) + : chart_series.length > 1 + ? align_series_for(chart_series, chart_type, is_stacked) + : chart_series; + if (chart_type === "pie") colors = points.map( - ([, , , color], i) => named_color(color) || palette[i % palette.length], + ({ color }, i) => named_color(color) || palette[i % palette.length], ); - } else if (series.length > 1) - series = align_series_for(series, chart_type, is_stacked); const to_timestamp = (v) => (typeof v === "number" ? new Date(v * 1000) : new Date(v)).getTime(); @@ -165,6 +205,10 @@ 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.link); + const text_x_values = chart_series.every(({ data }) => + data.every(({ x }) => x == null || typeof x === "string"), + ); const options = { annotations: { [`${value_axis}axis`]: reference_lines( @@ -196,6 +240,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,10 +318,18 @@ const sqlpage_chart = (() => { }, tooltip: { fillSeriesColor: false, + interactive: has_point_links, custom: chart_type === "bubble" || chart_type === "scatter" ? bubbleTooltip : undefined, + x: { + formatter: + has_point_links && text_x_values + ? (value, args) => + formatTooltipX(value, args?.w && pointLink(args, points)) + : undefined, + }, y: { formatter: (value) => { if (value == null) return ""; @@ -311,9 +369,13 @@ const sqlpage_chart = (() => { c.removeAttribute("data-pre-init"); } - function bubbleTooltip({ seriesIndex, dataPointIndex, w }) { - const { name, data } = w.config.series[seriesIndex]; - const point = data[dataPointIndex]; + /** + * @param {{seriesIndex:number, dataPointIndex:number, w:any}} args + */ + function chartTooltip({ seriesIndex, dataPointIndex, w }) { + const series = w.config.series[seriesIndex]; + const name = series?.name || ""; + const point = series?.data[dataPointIndex]; const tooltip = document.createElement("div"); tooltip.className = "apexcharts-tooltip-text"; @@ -339,13 +401,33 @@ 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; } + /** + * @param {{seriesIndex:number, dataPointIndex:number, w:any}} args + * @param {DataPoint[]} points + */ + function pointLink({ seriesIndex, dataPointIndex, w }, points) { + const series = w.config.series[seriesIndex]; + return Array.isArray(series?.data) + ? series.data[dataPointIndex]?.link + : points[seriesIndex]?.link; + } + + 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-time.sql b/tests/end-to-end/fixtures/chart/link-time.sql new file mode 100644 index 00000000..1c21fea7 --- /dev/null +++ b/tests/end-to-end/fixtures/chart/link-time.sql @@ -0,0 +1,4 @@ +SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, + 'line' AS type, TRUE AS time, 8 AS marker; +SELECT 'Points' AS series, '2024-03-01' AS x, 10 AS y, '/linked.sql' AS link; +SELECT 'Points' AS series, '2024-03-02' AS x, 20 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 fad452e6..11bff5fc 100644 --- a/tests/end-to-end/fixtures/chart/test.ts +++ b/tests/end-to-end/fixtures/chart/test.ts @@ -140,6 +140,56 @@ const fills = (chart: Awaited>) => return `#${hex.join("")}`; }); +test("linked range bar uses the native tooltip with a linked x title", async ({ + page, +}) => { + await renderChart(page, "link"); + const bars = page.locator("#test-chart .apexcharts-rangebar-area"); + await bars.nth(1).hover(); + await expect( + page.locator("#test-chart .apexcharts-tooltip-title a"), + ).toHaveCount(0); + await bars.first().hover(); + const link = page.locator("#test-chart .apexcharts-tooltip-title a"); + await expect(link).toHaveText("Alice"); + await expect(link).toHaveAttribute( + "href", + "/workpackage_edit.sql?workpackage_name=Design", + ); + await link.hover(); + await expect(link).toBeVisible(); + await link.click(); + await expect(page).toHaveURL(/workpackage_edit/); +}); + +test("linked text x labels keep the native bar tooltip", async ({ page }) => { + await renderChart(page, "link-bar"); + await page.locator("#test-chart .apexcharts-bar-area").first().hover(); + const tooltip = page.locator("#test-chart .apexcharts-tooltip"); + await expect(tooltip.locator(".apexcharts-tooltip-title a")).toHaveText( + "Linked", + ); + await expect( + tooltip.locator(".apexcharts-tooltip-series-group.apexcharts-active"), + ).toHaveCount(1); + await tooltip.locator("a").click(); + await expect(page).toHaveURL(/\/linked\.sql$/); +}); + +test("linked date x values retain ApexCharts' native date formatting", async ({ + page, +}) => { + await renderChart(page, "link-time"); + await page + .locator("#test-chart .apexcharts-marker") + .first() + .hover({ force: true }); + const title = page.locator("#test-chart .apexcharts-tooltip-title"); + await expect(title).toBeVisible(); + await expect(title).not.toContainText(/\d{13}/); + await expect(title.locator("a")).toHaveCount(0); +}); + test("positions complete numeric bar series on an explicit numeric axis (#733)", async ({ page, }) => {