0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-09 21:45:25 +08:00
discourse/plugins/discourse-data-explorer/test/javascripts/integration/components/query-result-test.gjs
Sam 6321db1cce
FIX: render JSONB results as JSON (#41255)
Treat jsonb and json/jsonb array result columns like JSON so they use
the viewer action.

Avoid pre-escaping plain text values so output stays readable while
Glimmer escaping still prevents HTML injection.
2026-06-29 15:43:31 +10:00

809 lines
24 KiB
Text
Vendored

import { click, render } from "@ember/test-helpers";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { i18n } from "discourse-i18n";
import QueryResult from "../../discourse/components/query-result";
module("Integration | Component | QueryResult", function (hooks) {
setupRenderingTest(hooks);
test("renders query results", async function (assert) {
const content = {
colrender: [],
result_count: 2,
columns: ["user_name", "like_count"],
rows: [
["user1", 10],
["user2", 20],
],
};
await render(<template><QueryResult @content={{content}} /></template>);
await click(".result-actions .query-result-download-buttons");
assert
.dom(".query-result-export__results-json")
.hasText(
i18n("explorer.export_as.results_json"),
"renders the Results (JSON) item"
);
assert
.dom(".query-result-export__results-csv")
.hasText(
i18n("explorer.export_as.results_csv"),
"renders the Results (CSV) item"
);
assert.dom("div.result-about").exists("renders a query summary");
assert.dom("canvas").exists("renders the chart by default");
await click(".query-results-modes input[value='table']");
assert.dom("table thead tr th:nth-child(1)").hasText("user_name");
assert.dom("table thead tr th:nth-child(2)").hasText("like_count");
assert.dom("table tbody tr:nth-child(1) td:nth-child(1)").hasText("user1");
assert.dom("table tbody tr:nth-child(1) td:nth-child(2)").hasText("10");
assert.dom("table tbody tr:nth-child(2) td:nth-child(1)").hasText("user2");
assert.dom("table tbody tr:nth-child(2) td:nth-child(2)").hasText("20");
});
test("renders JSON columns as escaped text with a viewer action", async function (assert) {
const content = {
colrender: { 1: "json" },
result_count: 1,
columns: ["name", "response_format"],
rows: [["classifier", '[{"key":"<img src=x onerror=alert(1)>"}]']],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert
.dom("table tbody tr:nth-child(1) td:nth-child(2) .result-json-value")
.hasText(
'[{"key":"<img src=x onerror=alert(1)>"}]',
"renders JSON as readable text without HTML entities"
);
assert
.dom("table tbody tr:nth-child(1) td:nth-child(2) img")
.doesNotExist("does not render HTML from JSON values");
assert
.dom("table tbody tr:nth-child(1) td:nth-child(2) .result-json-button")
.exists("renders a JSON viewer action");
});
test("renders plain text without double-escaping HTML entities", async function (assert) {
const content = {
colrender: [],
result_count: 1,
columns: ["value"],
rows: [['{"key":"<script>alert(1)</script>"}']],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert
.dom("table tbody tr:nth-child(1) td:nth-child(1)")
.hasText('{"key":"<script>alert(1)</script>"}', "renders readable text");
assert
.dom("table tbody tr:nth-child(1) td:nth-child(1) script")
.doesNotExist("does not render HTML from text values");
});
test("renders badge names in query results", async function (assert) {
const content = {
colrender: { 0: "badge" },
relations: {
badge: [
{
description: "description",
icon: "user",
id: 1,
name: "badge name",
display_name: "badge display name",
},
],
},
result_count: 1,
columns: ["badge_id"],
rows: [[1]],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert
.dom("table tbody tr:nth-child(1) td:nth-child(1) span")
.hasText("badge display name");
});
test("renders a post in query results", async function (assert) {
const content = {
colrender: { 0: "post" },
relations: {
post: [
{
description: "description",
id: 1,
topic_id: 1,
post_number: 1,
excerpt: "foo",
username: "user1",
avatar_template: "",
},
],
},
result_count: 1,
columns: [""],
rows: [[1]],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert
.dom("table tbody tr:nth-child(1) td:nth-child(1) aside")
.hasAttribute("data-post", "1");
assert
.dom("table tbody tr:nth-child(1) td:nth-child(1) aside")
.hasAttribute("data-topic", "1");
});
test("renders a category_id in query results", async function (assert) {
const content = {
colrender: { 0: "category" },
relations: {
category: [
{
id: 1,
name: "foo",
slug: "foo",
topic_count: 0,
position: 1,
description: "a category",
can_edit: true,
},
],
},
result_count: 1,
columns: [""],
rows: [[1]],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert
.dom("table tbody tr:nth-child(1) td:nth-child(1) .badge-category__name")
.exists();
});
});
module("Integration | Component | QueryResult | Chart", function (hooks) {
setupRenderingTest(hooks);
test("renders the chart by default and the toggle for chartable results", async function (assert) {
const content = {
colrender: [],
result_count: 2,
columns: ["user_name", "like_count"],
rows: [
["user1", 10],
["user2", 20],
],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert.dom("canvas").exists("the chart was rendered by default");
assert
.dom(".query-results-modes")
.exists("the chart/table toggle is rendered");
await click(".query-results-modes input[value='table']");
assert.dom("table").exists("table renders after switching view");
});
test("renders a chart when data has two columns and numbers in the second column", async function (assert) {
const content = {
colrender: [],
result_count: 2,
columns: ["user_name", "like_count"],
rows: [
["user1", 10],
["user2", 20],
],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert.dom("canvas").exists();
});
test("doesn't render a chart when data contains identifiers in the second column", async function (assert) {
const content = {
colrender: { 1: "user" },
relations: {
user: [
{ id: 1, username: "user1" },
{ id: 2, username: "user2" },
],
},
result_count: 2,
columns: ["topic_id", "user_id"],
rows: [
[1, 10],
[2, 20],
],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert.dom("canvas").doesNotExist();
});
test("doesn't render a chart when data contains one column", async function (assert) {
const content = {
colrender: [],
result_count: 2,
columns: ["user_name"],
rows: [["user1"], ["user2"]],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert.dom("canvas").doesNotExist();
});
test("renders a chart when data contains multiple numeric columns", async function (assert) {
const content = {
colrender: [],
result_count: 2,
columns: ["user_name", "like_count", "post_count"],
rows: [
["user1", 10, 1],
["user2", 20, 2],
],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert.dom("canvas").exists();
});
test("defaults to the table when charting would drop columns", async function (assert) {
const content = {
colrender: [],
result_count: 2,
columns: ["user", "reason", "count"],
rows: [
["user1", "spam", 10],
["user2", "off-topic", 5],
],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert
.dom("table")
.exists("table is the default when some columns can't be charted");
assert.dom("canvas").doesNotExist("chart is not shown by default");
await click(".query-results-modes input[value='chart']");
assert.dom("canvas").exists("chart is still available via the toggle");
});
test("defaults to the table for large categorical result sets", async function (assert) {
const rows = Array.from({ length: 200 }, (_, i) => [`item-${i + 1}`, i]);
const content = {
colrender: [],
result_count: rows.length,
columns: ["name", "count"],
rows,
};
await render(<template><QueryResult @content={{content}} /></template>);
assert
.dom("table")
.exists("table is the default for a large categorical result set");
assert.dom("canvas").doesNotExist("chart is not shown by default");
await click(".query-results-modes input[value='chart']");
assert.dom("canvas").exists("chart remains available via the toggle");
});
test("defaults to the chart for large date-based result sets", async function (assert) {
const rows = Array.from({ length: 200 }, (_, i) => [
new Date(Date.UTC(2024, 0, i + 1)).toISOString().slice(0, 10),
i,
]);
const content = {
colrender: [],
result_count: rows.length,
columns: ["date", "count"],
rows,
};
await render(<template><QueryResult @content={{content}} /></template>);
assert.dom("canvas").exists("time series remain chart-first by default");
});
test("defaults to the table for sparse multi-series result sets", async function (assert) {
const content = {
colrender: [],
result_count: 6,
columns: ["name", "unassignable", "stale", "super_stale"],
rows: [
["JVM", 4, "", 19],
["CFamily", "", "", ""],
["DataMachineLearning", "", "", ""],
["DotNET", 2, "", 3],
["TaintAnalysis", 4, 1, 4],
["Web", "", "", ""],
],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert
.dom("table")
.exists("table is the default for sparse multi-series data");
assert.dom("canvas").doesNotExist("chart is not shown by default");
await click(".query-results-modes input[value='chart']");
assert.dom("canvas").exists("chart remains available via the toggle");
});
test("defaults to the table for wide date-based multi-series result sets", async function (assert) {
const numericColumns = Array.from(
{ length: 10 },
(_metric, index) => `metric_${index + 1}`
);
const rows = Array.from({ length: 12 }, (_row, rowIndex) => [
new Date(Date.UTC(2024, rowIndex, 1)).toISOString().slice(0, 10),
...numericColumns.map(
(_column, columnIndex) => rowIndex + columnIndex + 1
),
]);
const content = {
colrender: [],
result_count: rows.length,
columns: ["date", ...numericColumns],
rows,
};
await render(<template><QueryResult @content={{content}} /></template>);
assert
.dom("table")
.exists("table is the default for too many time-series metrics");
assert.dom("canvas").doesNotExist("chart is not shown by default");
await click(".query-results-modes input[value='chart']");
assert.dom("canvas").exists("chart remains available via the toggle");
assert
.dom(".query-results-chart__form input[value='stacked']")
.exists("stacked chart remains available");
assert
.dom(".query-results-chart__form input[value='dual-axis']")
.doesNotExist("dual-axis is not offered for more than two metrics");
});
test("doesn't render a chart when all non-label columns are relation types", async function (assert) {
const content = {
colrender: { 1: "user", 2: "badge" },
relations: {
user: [{ id: 1, username: "user1" }],
badge: [{ id: 1, name: "badge1" }],
},
result_count: 1,
columns: ["topic_id", "user_id", "badge_id"],
rows: [[1, 1, 1]],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert.dom("canvas").doesNotExist();
});
test("renders a chart for date-based data", async function (assert) {
const content = {
colrender: [],
result_count: 2,
columns: ["date", "count"],
rows: [
["2024-01-01", 10],
["2024-01-02", 20],
],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert.dom("canvas").exists("renders a chart canvas");
});
test("renders charts with null label values", async function (assert) {
const content = {
colrender: [],
result_count: 2,
columns: ["user_name", "like_count"],
rows: [
[null, 10],
["user2", 20],
],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert.dom("canvas").exists("renders a chart canvas");
});
test("renders a multi-series chart", async function (assert) {
const content = {
colrender: [],
result_count: 2,
columns: ["user_name", "like_count", "post_count"],
rows: [
["user1", 10, 5],
["user2", 20, 15],
],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert.dom("canvas").exists("renders a chart canvas for multi-series");
});
test("uses sensible chart form defaults for common result shapes", async function (assert) {
const cases = [
{
description: "text, number",
columns: ["name", "count"],
rows: [
["alpha", 10],
["beta", 20],
],
selected: "bar",
available: ["line", "bar"],
unavailable: ["stacked", "dual-axis"],
},
{
description: "date, number",
columns: ["date", "count"],
rows: [
["2024-01-01", 10],
["2024-01-02", 20],
],
selected: "line",
available: ["line", "bar"],
unavailable: ["stacked", "dual-axis"],
},
{
description: "month day, number",
columns: ["date", "count"],
rows: [
["Jan 01", 10],
["Jan 02", 20],
],
selected: "line",
available: ["line", "bar"],
unavailable: ["stacked", "dual-axis"],
},
{
description: "month year, number",
columns: ["date", "count"],
rows: [
["Jan 24", 10],
["Feb 24", 20],
],
selected: "line",
available: ["line", "bar"],
unavailable: ["stacked", "dual-axis"],
},
{
description: "text, number, number",
columns: ["name", "likes", "posts"],
rows: [
["alpha", 10, 5],
["beta", 20, 15],
],
selected: "bar",
available: ["line", "bar", "stacked"],
unavailable: ["dual-axis"],
},
{
description: "date, number, number",
columns: ["date", "likes", "posts"],
rows: [
["2024-01-01", 10, 5],
["2024-01-02", 20, 15],
],
selected: "line",
available: ["line", "bar", "stacked", "dual-axis"],
unavailable: [],
},
{
description: "date, number, number, number",
columns: ["date", "likes", "posts", "topics"],
rows: [
["2024-01-01", 10, 5, 1],
["2024-01-02", 20, 15, 2],
],
selected: "line",
available: ["line", "bar", "stacked"],
unavailable: ["dual-axis"],
},
{
description: "date, text, number",
columns: ["date", "note", "count"],
rows: [
["2024-01-01", "alpha", 10],
["2024-01-02", "beta", 20],
],
selected: "line",
available: ["line", "bar"],
unavailable: ["stacked", "dual-axis"],
},
{
description: "text, date, number",
columns: ["name", "date", "count"],
rows: [
["alpha", "2024-01-01", 10],
["beta", "2024-01-02", 20],
],
selected: "bar",
available: ["line", "bar"],
unavailable: ["stacked", "dual-axis"],
},
];
for (const testCase of cases) {
this.set("content", {
colrender: [],
result_count: testCase.rows.length,
columns: testCase.columns,
rows: testCase.rows,
});
await render(
<template>
<QueryResult @content={{this.content}} @view="chart" />
</template>
);
assert
.dom(`.query-results-chart__form input[value='${testCase.selected}']`)
.isChecked(`${testCase.description} defaults to ${testCase.selected}`);
for (const chartForm of testCase.available) {
assert
.dom(`.query-results-chart__form input[value='${chartForm}']`)
.exists(`${testCase.description} offers ${chartForm}`);
}
for (const chartForm of testCase.unavailable) {
assert
.dom(`.query-results-chart__form input[value='${chartForm}']`)
.doesNotExist(`${testCase.description} does not offer ${chartForm}`);
}
}
});
test("allows the chart form to be changed for date-based multi-series data", async function (assert) {
const content = {
colrender: [],
result_count: 2,
columns: ["date", "likes", "posts"],
rows: [
["2024-01-01", 10, 5],
["2024-01-02", 20, 15],
],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert
.dom(".query-results-chart__form input[value='line']")
.isChecked("date-based multi-series data defaults to a line chart");
assert
.dom(".query-results-chart__form input[value='dual-axis']")
.exists("two-metric time series can use a dual-axis line chart");
await click(".query-results-chart__form input[value='stacked']");
assert
.dom(".query-results-chart__form input[value='stacked']")
.isChecked("the user can switch to stacked bars");
assert.dom("canvas").exists("the chart remains visible");
});
test("does not offer a dual-axis chart for non-date multi-series data", async function (assert) {
const content = {
colrender: [],
result_count: 2,
columns: ["user", "likes", "posts"],
rows: [
["user1", 10, 5],
["user2", 20, 15],
],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert
.dom(".query-results-chart__form input[value='dual-axis']")
.doesNotExist("dual-axis is only offered for date-based data");
});
test("persists the selected chart form per query", async function (assert) {
const query = { id: 43 };
const content = {
colrender: [],
result_count: 2,
columns: ["date", "likes", "posts"],
rows: [
["2024-01-01", 10, 5],
["2024-01-02", 20, 15],
],
};
await render(
<template><QueryResult @content={{content}} @query={{query}} /></template>
);
await click(".query-results-chart__form input[value='dual-axis']");
await render(
<template><QueryResult @content={{content}} @query={{query}} /></template>
);
assert
.dom(".query-results-chart__form input[value='dual-axis']")
.isChecked("chart form is restored from localStorage");
});
test("charts numeric columns and ignores text columns alongside them", async function (assert) {
const content = {
colrender: [],
result_count: 2,
columns: ["name", "value", "description"],
rows: [
["item1", 10, "some text"],
["item2", 20, "other text"],
],
};
await render(<template><QueryResult @content={{content}} /></template>);
await click(".query-results-modes input[value='chart']");
assert.dom("canvas").exists("renders the chart for numeric columns");
assert
.dom(".query-results-chart__footnote")
.exists("shows a footnote listing ignored columns");
});
test("caps a long table and reveals it with the expand button", async function (assert) {
const rows = Array.from({ length: 100 }, (_, i) => [`user${i}`, i]);
const content = {
colrender: [],
result_count: rows.length,
columns: ["user_name", "like_count"],
rows,
};
await render(
<template>
<div class="query-results">
<QueryResult @content={{content}} @view="table" />
</div>
</template>
);
assert
.dom(".query-results-table-wrapper")
.doesNotHaveClass("--expanded", "the long table is capped by default");
assert
.dom(".query-results-expand-btn")
.exists("an expand button is offered for the overflowing table");
await click(".query-results-expand-btn");
assert
.dom(".query-results-table-wrapper.--expanded")
.exists("clicking expand removes the height cap");
assert
.dom(".query-results-expand-btn")
.doesNotExist("the expand button is gone once expanded");
});
test("chart/table toggle switches the view (XOR)", async function (assert) {
const content = {
colrender: [],
result_count: 2,
columns: ["user_name", "like_count"],
rows: [
["user1", 10],
["user2", 20],
],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert
.dom("canvas")
.exists("chart is visible by default for chartable data");
assert.dom("table").doesNotExist("table hidden when chart is shown");
await click(".query-results-modes input[value='table']");
assert.dom("table").exists("table shown after switching");
assert.dom("canvas").doesNotExist("chart hidden when table is shown");
await click(".query-results-modes input[value='chart']");
assert.dom("canvas").exists("chart shown after switching back");
assert.dom("table").doesNotExist("table hidden again");
});
test("persists view per query in localStorage", async function (assert) {
const query = { id: 42 };
const content = {
colrender: [],
result_count: 2,
columns: ["user_name", "like_count"],
rows: [
["user1", 10],
["user2", 20],
],
};
await render(
<template><QueryResult @content={{content}} @query={{query}} /></template>
);
await click(".query-results-modes input[value='table']");
assert.dom("table").exists("table shown after switching");
assert.dom("canvas").doesNotExist("chart is hidden");
await render(
<template><QueryResult @content={{content}} @query={{query}} /></template>
);
assert.dom("table").exists("table view restored from localStorage");
assert.dom("canvas").doesNotExist("chart still hidden after rerender");
});
test("toggle is always visible when rows exist (even for non-chartable data)", async function (assert) {
const content = {
colrender: [],
result_count: 2,
columns: ["user_name"],
rows: [["user1"], ["user2"]],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert.dom(".query-results-modes").exists("toggle is always shown");
await click(".query-results-modes input[value='chart']");
assert
.dom(".query-chart-empty-state")
.exists("shows empty state for non-chartable data");
});
test("handles no results", async function (assert) {
const content = {
colrender: [],
result_count: 0,
columns: ["user_name", "like_count", "post_count"],
rows: [],
};
await render(<template><QueryResult @content={{content}} /></template>);
assert.dom("table tbody tr").doesNotExist("renders no results");
});
});