Skip to content
Open
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 @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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"},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the intention for the tooltip to change like this when a link has been added? IMO it makes more sense not to do this and just hyperlink the title (ie :: Tom in this case)

Image Image

{"series": "Phase 2", "label": "Operations", "value": ["2022-01-03", "2022-01-04"]},
{"series": "Yearly maintenance", "label": "Maintenance", "value": ["2022-01-01", "2022-01-03"]}
]')),
Expand Down
51 changes: 42 additions & 9 deletions frontend/src/apexcharts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why use magic numbers as indexes if we have all the named vars deconstructed here

for (const [name, old_x, old_y, color, z, link] of points) {

const options = {
annotations: {
[`${value_axis}axis`]: reference_lines(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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);

Expand All @@ -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;
})();

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/chart_series.js
Original file line number Diff line number Diff line change
@@ -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<string, ChartSeries> } Series */

Expand Down
10 changes: 10 additions & 0 deletions frontend/src/sqlpage.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 3 additions & 2 deletions sqlpage/templates/chart.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -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~}}
Expand Down
4 changes: 4 additions & 0 deletions tests/end-to-end/fixtures/chart/link-bar.sql
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions tests/end-to-end/fixtures/chart/link-line.sql
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions tests/end-to-end/fixtures/chart/link-pie.sql
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions tests/end-to-end/fixtures/chart/link-scatter.sql
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions tests/end-to-end/fixtures/chart/link.sql
Original file line number Diff line number Diff line change
@@ -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;
40 changes: 40 additions & 0 deletions tests/end-to-end/fixtures/chart/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,46 @@ const fills = (chart: Awaited<ReturnType<typeof renderChart>>) =>
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,
}) => {
Expand Down
Loading