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 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.
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 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),
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed and addressed in e71a8a4: the linked chart now keeps ApexCharts' native tooltip and hyperlinks only the text X title ("Tom" in your example). I explained the numeric/date and no-title tradeoff and attached before/after screenshots in this PR comment.

{"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
116 changes: 99 additions & 17 deletions frontend/src/apexcharts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>} */
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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(
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 "";
Expand Down Expand Up @@ -312,9 +370,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";
Expand All @@ -340,13 +402,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;
})();

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;
4 changes: 4 additions & 0 deletions tests/end-to-end/fixtures/chart/link-time.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, 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;
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;
Loading
Loading