FEATURE: Add a graph report to query results (#93)

This commit is contained in:
Andrew Prigorshnev 2021-02-18 15:06:22 +04:00 committed by GitHub
parent 3151fde1e7
commit 4f33c22344
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 736 additions and 48 deletions

View File

@ -0,0 +1,97 @@
import loadScript from "discourse/lib/load-script";
import { default as computed } from "discourse-common/utils/decorators";
import themeColor from "../lib/themeColor";
export default Ember.Component.extend({
barsColor: themeColor("--tertiary"),
barsHoverColor: themeColor("--tertiary-high"),
gridColor: themeColor("--primary-low"),
labelsColor: themeColor("--primary-medium"),
chart: null,
@computed("data", "options")
config(data, options) {
return {
type: "bar",
data,
options,
};
},
@computed("labels.[]", "values.[]", "datasetName")
data(labels, values, datasetName) {
return {
labels,
datasets: [
{
label: datasetName,
data: values,
backgroundColor: this.barsColor,
hoverBackgroundColor: this.barsHoverColor,
},
],
};
},
@computed
options() {
return {
scales: {
legend: {
labels: {
fontColor: this.labelsColor,
},
},
xAxes: [
{
gridLines: {
color: this.gridColor,
zeroLineColor: this.gridColor,
},
ticks: {
fontColor: this.labelsColor,
},
},
],
yAxes: [
{
gridLines: {
color: this.gridColor,
zeroLineColor: this.gridColor,
},
ticks: {
beginAtZero: true,
fontColor: this.labelsColor,
},
},
],
},
};
},
didInsertElement() {
this._super(...arguments);
this._initChart();
},
didUpdate() {
this._super(...arguments);
this.chart.data = this.data;
this.chart.update();
},
willDestroyElement() {
this._super(...arguments);
this.chart.destroy();
},
_initChart() {
loadScript("/javascripts/Chart.min.js").then(() => {
const canvas = this.element.querySelector("canvas");
const context = canvas.getContext("2d");
const config = this.config;
// eslint-disable-next-line
this.chart = new Chart(context, config);
});
},
});

View File

@ -32,6 +32,9 @@ const QueryResultComponent = Ember.Component.extend({
params: Ember.computed.alias("content.params"), params: Ember.computed.alias("content.params"),
explainText: Ember.computed.alias("content.explain"), explainText: Ember.computed.alias("content.explain"),
hasExplain: Ember.computed.notEmpty("content.explain"), hasExplain: Ember.computed.notEmpty("content.explain"),
chartDatasetName: Ember.computed.reads("columnDispNames.1"),
chartValues: Ember.computed.mapBy("content.rows", "1"),
showChart: false,
init() { init() {
this._super(...arguments); this._super(...arguments);
@ -139,6 +142,51 @@ const QueryResultComponent = Ember.Component.extend({
return transformedRelTable(groups); return transformedRelTable(groups);
}, },
@computed(
"rows.[]",
"content.colrender.[]",
"content.result_count",
"colCount"
)
canShowChart(rows, colRender, resultCount, colCount) {
const hasTwoColumns = colCount === 2;
const secondColumnContainsNumber =
resultCount > 0 && typeof rows[0][1] === "number";
const secondColumnContainsId = colRender[1];
return (
hasTwoColumns && secondColumnContainsNumber && !secondColumnContainsId
);
},
@computed("content.rows.[]", "content.colrender.[]")
chartLabels(rows, colRender) {
const labelSelectors = {
user: (user) => user.username,
badge: (badge) => badge.name,
topic: (topic) => topic.title,
group: (group) => group.name,
category: (category) => category.name,
};
const relationName = colRender[0];
if (relationName) {
const lookupFunc = this[`lookup${relationName.capitalize()}`];
const labelSelector = labelSelectors[relationName];
if (lookupFunc && labelSelector) {
return rows.map((r) => {
const relation = lookupFunc.call(this, r[0]);
const label = labelSelector(relation);
return this._cutChartLabel(label);
});
}
}
return rows.map((r) => this._cutChartLabel(r[0]));
},
lookupUser(id) { lookupUser(id) {
return this.transformedUserTable[id]; return this.transformedUserTable[id];
}, },
@ -211,6 +259,15 @@ const QueryResultComponent = Ember.Component.extend({
}); });
}, },
_cutChartLabel(label) {
const labelString = label.toString();
if (labelString.length > 25) {
return `${labelString.substring(0, 25)}...`;
} else {
return labelString;
}
},
actions: { actions: {
downloadResultJson() { downloadResultJson() {
this.downloadResult("json"); this.downloadResult("json");
@ -218,6 +275,12 @@ const QueryResultComponent = Ember.Component.extend({
downloadResultCsv() { downloadResultCsv() {
this.downloadResult("csv"); this.downloadResult("csv");
}, },
showChart() {
this.set("showChart", true);
},
showTable() {
this.set("showChart", false);
},
}, },
}); });

View File

@ -80,7 +80,7 @@ const QueryRowContentComponent = Ember.Component.extend({
const lookupFunc = parentView[`lookup${t.name.capitalize()}`]; const lookupFunc = parentView[`lookup${t.name.capitalize()}`];
if (lookupFunc) { if (lookupFunc) {
ctx[t.name] = parentView[`lookup${t.name.capitalize()}`](id); ctx[t.name] = lookupFunc.call(parentView, id);
} }
if (t.name === "url") { if (t.name === "url") {

View File

@ -1,27 +0,0 @@
// The binarySearch() function is licensed under the UNLICENSE
// https://github.com/Olical/binary-search
// Modified for use in Discourse
export default function binarySearch(list, target, keyProp) {
let min = 0;
let max = list.length - 1;
let guess;
const keyProperty = keyProp || "id";
while (min <= max) {
guess = Math.floor((min + max) / 2);
if (Ember.get(list[guess], keyProperty) === target) {
return guess;
} else {
if (Ember.get(list[guess], keyProperty) < target) {
min = guess + 1;
} else {
max = guess - 1;
}
}
}
return -1;
}

View File

@ -0,0 +1,4 @@
export default function themeColor(name) {
const style = getComputedStyle(document.body);
return style.getPropertyValue(name);
}

View File

@ -0,0 +1 @@
<canvas></canvas>

View File

@ -3,6 +3,13 @@
<div class="result-info"> <div class="result-info">
{{d-button action=(action "downloadResultJson") icon="download" label="explorer.download_json" group=group}} {{d-button action=(action "downloadResultJson") icon="download" label="explorer.download_json" group=group}}
{{d-button action=(action "downloadResultCsv") icon="download" label="explorer.download_csv" group=group}} {{d-button action=(action "downloadResultCsv") icon="download" label="explorer.download_csv" group=group}}
{{#if canShowChart}}
{{#if showChart}}
{{d-button action=(action "showTable") icon="table" label="explorer.show_table" group=group}}
{{else}}
{{d-button action=(action "showChart") icon="chart-bar" label="explorer.show_graph" group=group}}
{{/if}}
{{/if}}
</div> </div>
<div class="result-about"> <div class="result-about">
@ -22,22 +29,29 @@
</header> </header>
<section> <section>
<table> {{#if showChart}}
<thead> {{data-explorer-bar-chart
<tr class="headers"> labels=chartLabels
{{#each columnDispNames as |col|}} values=chartValues
<th>{{col}}</th> datasetName=chartDatasetName}}
{{else}}
<table>
<thead>
<tr class="headers">
{{#each columnDispNames as |col|}}
<th>{{col}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each rows as |row|}}
{{query-row-content
row=row
fallbackTemplate=fallbackTemplate
columnTemplates=columnTemplates}}
{{/each}} {{/each}}
</tr> </tbody>
</thead> </table>
<tbody> {{/if}}
{{#each rows as |row|}}
{{query-row-content
row=row
fallbackTemplate=fallbackTemplate
columnTemplates=columnTemplates}}
{{/each}}
</tbody>
</table>
</section> </section>
</article> </article>

View File

@ -34,6 +34,8 @@ en:
recover: "Undelete Query" recover: "Undelete Query"
download_json: "JSON" download_json: "JSON"
download_csv: "CSV" download_csv: "CSV"
show_table: "Table"
show_graph: "Graph"
others_dirty: "A query has unsaved changes that will be lost if you navigate away." others_dirty: "A query has unsaved changes that will be lost if you navigate away."
run_time: "Query completed in %{value} ms." run_time: "Query completed in %{value} ms."
result_count: result_count:

View File

@ -0,0 +1,194 @@
import {
acceptance,
exists,
query,
queryAll,
} from "discourse/tests/helpers/qunit-helpers";
import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer";
import I18n from "I18n";
acceptance("Data Explorer Plugin | Run Query", function (needs) {
needs.user();
needs.settings({ data_explorer_enabled: true });
needs.hooks.beforeEach(() => {
clearPopupMenuOptionsCallback();
});
needs.pretender((server, helper) => {
server.get("/admin/plugins/explorer/groups.json", () => {
return helper.response([
{
id: 1,
name: "admins",
},
{
id: 2,
name: "moderators",
},
{
id: 3,
name: "staff",
},
{
id: 0,
name: "everyone",
},
{
id: 10,
name: "trust_level_0",
},
{
id: 11,
name: "trust_level_1",
},
{
id: 12,
name: "trust_level_2",
},
{
id: 13,
name: "trust_level_3",
},
{
id: 14,
name: "trust_level_4",
},
]);
});
server.get("/admin/plugins/explorer/schema.json", () => {
return helper.response({
anonymous_users: [
{
column_name: "id",
data_type: "serial",
primary: true,
},
{
column_name: "user_id",
data_type: "integer",
fkey_info: "users",
},
{
column_name: "master_user_id",
data_type: "integer",
fkey_info: "users",
},
{
column_name: "active",
data_type: "boolean",
},
{
column_name: "created_at",
data_type: "timestamp",
},
{
column_name: "updated_at",
data_type: "timestamp",
},
],
});
});
server.get("/admin/plugins/explorer/queries", () => {
return helper.response({
queries: [
{
id: -6,
sql:
"-- [params]\n-- int :months_ago = 1\n\nWITH query_period AS (\n SELECT\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' as period_start,\n date_trunc('month', CURRENT_DATE) - INTERVAL ':months_ago months' + INTERVAL '1 month' - INTERVAL '1 second' as period_end\n )\n\n SELECT\n ua.user_id,\n count(1) AS like_count\n FROM user_actions ua\n INNER JOIN query_period qp\n ON ua.created_at >= qp.period_start\n AND ua.created_at <= qp.period_end\n WHERE ua.action_type = 1\n GROUP BY ua.user_id\n ORDER BY like_count DESC\n LIMIT 100\n",
name: "Top 100 Likers",
description:
"returns the top 100 likers for a given monthly period ordered by like_count. It accepts a months_ago parameter, defaults to 1 to give results for the last calendar month.",
param_info: [
{
identifier: "months_ago",
type: "int",
default: "1",
nullable: false,
},
],
created_at: "2021-02-02T12:21:11.449Z",
username: "system",
group_ids: [],
last_run_at: "2021-02-11T08:29:59.337Z",
hidden: false,
user_id: -1,
},
],
});
});
server.post("/admin/plugins/explorer/queries/-6/run", () => {
return helper.response({
success: true,
errors: [],
duration: 27.5,
result_count: 2,
params: { months_ago: "1" },
columns: ["user_id", "like_count"],
default_limit: 1000,
relations: {
user: [
{
id: -2,
username: "discobot",
name: null,
avatar_template: "/user_avatar/localhost/discobot/{size}/2_2.png",
},
{
id: 2,
username: "andrey1",
name: null,
avatar_template:
"/letter_avatar_proxy/v4/letter/a/c0e974/{size}.png",
},
],
},
colrender: {
0: "user",
},
rows: [
[-2, 2],
[2, 2],
],
});
});
});
test("it runs query and renders data and a chart", async function (assert) {
await visit("admin/plugins/explorer?id=-6");
assert.ok(
query("div.name h1").innerText.trim() === "Top 100 Likers",
"the query name was rendered"
);
assert.ok(exists("div.query-edit"), "the query code was rendered");
assert.ok(
query("form.query-run button span").innerText.trim() ===
I18n.t("explorer.run"),
"the run button was rendered"
);
await click("form.query-run button");
assert.ok(
queryAll("div.query-results table tbody tr").length === 2,
"the table with query results was rendered"
);
assert.ok(
query("div.result-info button:nth-child(3) span").innerText.trim() ===
I18n.t("explorer.show_graph"),
"the chart button was rendered"
);
await click("div.result-info button:nth-child(3)");
assert.ok(
exists("canvas.chartjs-render-monitor"),
"the chart was rendered"
);
});
});

View File

@ -0,0 +1,30 @@
import componentTest, {
setupRenderingTest,
} from "discourse/tests/helpers/component-test";
import { discourseModule, exists } from "discourse/tests/helpers/qunit-helpers";
import hbs from "htmlbars-inline-precompile";
discourseModule(
"Data Explorer Plugin | Integration | Component | data-explorer-bar-chart",
function (hooks) {
setupRenderingTest(hooks);
componentTest("it renders a chart", {
template: hbs`{{data-explorer-bar-chart}}`,
beforeEach() {
this.set("labels", ["label_1", "label_2"]);
this.set("values", [115, 1000]);
this.set("datasetName", "data");
},
async test(assert) {
assert.ok(exists("canvas"), "it renders a canvas");
assert.ok(
exists("canvas.chartjs-render-monitor"),
"it initializes chart.js "
);
},
});
}
);

View File

@ -3,15 +3,72 @@ import componentTest, {
} from "discourse/tests/helpers/component-test"; } from "discourse/tests/helpers/component-test";
import { import {
discourseModule, discourseModule,
queryAll, exists,
query,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
import { click } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile"; import hbs from "htmlbars-inline-precompile";
import I18n from "I18n";
discourseModule( discourseModule(
"Data Explorer Plugin | Integration | Component | query-result", "Data Explorer Plugin | Integration | Component | query-result",
function (hooks) { function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
componentTest("it renders query results", {
template: hbs`{{query-result content=content}}`,
beforeEach() {
const results = {
colrender: [],
result_count: 2,
columns: ["user_name", "like_count"],
rows: [
["user1", 10],
["user2", 20],
],
};
this.set("content", results);
},
test(assert) {
assert.ok(
query("div.result-info button:nth-child(1) span").innerText ===
I18n.t("explorer.download_json"),
"it renders the JSON button"
);
assert.ok(
query("div.result-info button:nth-child(2) span").innerText ===
I18n.t("explorer.download_csv"),
"it renders the CSV button"
);
assert.ok(
query("div.result-info button:nth-child(3) span").innerText ===
I18n.t("explorer.show_graph"),
"it renders the chart button"
);
assert.ok(exists("div.result-about"), "it renders a query summary");
assert.ok(
query("table thead tr th:nth-child(1)").innerText === "user_name" &&
query("table thead tr th:nth-child(2)").innerText ===
"like_count" &&
query("table tbody tr:nth-child(1) td:nth-child(1)").innerText ===
"user1" &&
query("table tbody tr:nth-child(1) td:nth-child(2)").innerText ===
"10" &&
query("table tbody tr:nth-child(2) td:nth-child(1)").innerText ===
"user2" &&
query("table tbody tr:nth-child(2) td:nth-child(2)").innerText ===
"20",
"it renders a table with data"
);
},
});
componentTest("it renders badge names in query results", { componentTest("it renders badge names in query results", {
template: hbs`{{query-result content=content}}`, template: hbs`{{query-result content=content}}`,
@ -38,11 +95,165 @@ discourseModule(
test(assert) { test(assert) {
assert.ok( assert.ok(
queryAll( query("table tbody tr:nth-child(1) td:nth-child(1) span")
"table tbody tr:nth-child(1) td:nth-child(1) span" .innerText === "badge display name"
).text() === "badge display name"
); );
}, },
}); });
} }
); );
discourseModule(
"Data Explorer Plugin | Integration | Component | query-result | chart",
function (hooks) {
setupRenderingTest(hooks);
componentTest("navigation between a table and a chart works", {
template: hbs`{{query-result content=content}}`,
beforeEach() {
const results = {
colrender: [],
result_count: 2,
columns: ["user_name", "like_count"],
rows: [
["user1", 10],
["user2", 20],
],
};
this.set("content", results);
},
async test(assert) {
assert.equal(
query("div.result-info button:nth-child(3) span").innerText,
I18n.t("explorer.show_graph"),
"the chart button was rendered"
);
assert.ok(exists("table"), "the table was rendered");
await click("div.result-info button:nth-child(3)");
assert.equal(
query("div.result-info button:nth-child(3) span").innerText,
I18n.t("explorer.show_table"),
"the chart button was changed to the table button"
);
assert.ok(
exists("canvas.chartjs-render-monitor"),
"the chart was rendered"
);
await click("div.result-info button:nth-child(3)");
assert.equal(
query("div.result-info button:nth-child(3) span").innerText,
I18n.t("explorer.show_graph"),
"the table button was changed to the chart button"
);
assert.ok(exists("table"), "the table was rendered");
},
});
componentTest(
"it renders a chart button when data has two columns and numbers in the second column",
{
template: hbs`{{query-result content=content}}`,
beforeEach() {
const results = {
colrender: [],
result_count: 2,
columns: ["user_name", "like_count"],
rows: [
["user1", 10],
["user2", 20],
],
};
this.set("content", results);
},
test(assert) {
assert.equal(
query("div.result-info button:nth-child(3) span").innerText,
I18n.t("explorer.show_graph")
);
},
}
);
componentTest(
"it doesn't render a chart button when data contains identifiers in the second column",
{
template: hbs`{{query-result content=content}}`,
beforeEach() {
const results = {
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],
],
};
this.set("content", results);
},
test(assert) {
assert.ok(!exists("div.result-info button:nth-child(3)"));
},
}
);
componentTest(
"it doesn't render a chart button when data contains one column",
{
template: hbs`{{query-result content=content}}`,
beforeEach() {
const results = {
colrender: [],
result_count: 2,
columns: ["user_name"],
rows: [["user1"], ["user2"]],
};
this.set("content", results);
},
test(assert) {
assert.ok(!exists("div.result-info button:nth-child(3)"));
},
}
);
componentTest(
"it doesn't render a chart button when data contains more than two columns",
{
template: hbs`{{query-result content=content}}`,
beforeEach() {
const results = {
colrender: [],
result_count: 2,
columns: ["user_name", "like_count", "post_count"],
rows: [
["user1", 10, 1],
["user2", 20, 2],
],
};
this.set("content", results);
},
test(assert) {
assert.ok(!exists("div.result-info button:nth-child(3)"));
},
}
);
}
);

View File

@ -0,0 +1,99 @@
import { moduleFor } from "ember-qunit";
import { test } from "qunit";
moduleFor("component:query-result");
test("it transforms data for a chart", function (assert) {
const results = {
colrender: [],
result_count: 2,
columns: ["user", "like_count"],
rows: [
["user1", 10],
["user2", 20],
],
};
this.subject().setProperties({
content: results,
});
assert.deepEqual(
this.subject().chartLabels,
["user1", "user2"],
"labels are correct"
);
assert.deepEqual(this.subject().chartValues, [10, 20], "values are correct");
assert.deepEqual(
this.subject().chartDatasetName,
"like_count",
"the dataset name is correct"
);
});
test("it uses descriptive chart labels instead of identifiers", function (assert) {
const results = {
colrender: { 0: "user" },
relations: {
user: [
{ id: 1, username: "user1" },
{ id: 2, username: "user2" },
],
},
result_count: 2,
columns: ["user", "like_count"],
rows: [
[1, 10],
[2, 20],
],
};
this.subject().setProperties({
content: results,
});
assert.deepEqual(this.subject().chartLabels, ["user1", "user2"]);
});
test("it uses an identifier as a chart label if labelSelector doesn't exist", function (assert) {
const results = {
colrender: { 0: "unknown_entity" },
relations: {
unknown_entity: [
{ id: 1, username: "user1" },
{ id: 2, username: "user2" },
],
},
result_count: 2,
columns: ["user", "like_count"],
rows: [
[1, 10],
[2, 20],
],
};
this.subject().setProperties({
content: results,
});
assert.deepEqual(this.subject().chartLabels, ["1", "2"]);
});
test("it cuts too long chart labels", function (assert) {
const results = {
colrender: [],
result_count: 2,
columns: ["user", "like_count"],
rows: [
["This string is too long to be used as a label on a chart", 10],
["This string is too long to be used as a label on a chart", 20],
],
};
this.subject().setProperties({
content: results,
});
assert.deepEqual(this.subject().chartLabels, [
"This string is too long t...",
"This string is too long t...",
]);
});