diff --git a/assets/javascripts/discourse/components/data-explorer-bar-chart.js.es6 b/assets/javascripts/discourse/components/data-explorer-bar-chart.js.es6 new file mode 100644 index 0000000..a6fa2c8 --- /dev/null +++ b/assets/javascripts/discourse/components/data-explorer-bar-chart.js.es6 @@ -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); + }); + }, +}); diff --git a/assets/javascripts/discourse/components/query-result.js.es6 b/assets/javascripts/discourse/components/query-result.js.es6 index 5d15edc..a352377 100644 --- a/assets/javascripts/discourse/components/query-result.js.es6 +++ b/assets/javascripts/discourse/components/query-result.js.es6 @@ -32,6 +32,9 @@ const QueryResultComponent = Ember.Component.extend({ params: Ember.computed.alias("content.params"), explainText: Ember.computed.alias("content.explain"), hasExplain: Ember.computed.notEmpty("content.explain"), + chartDatasetName: Ember.computed.reads("columnDispNames.1"), + chartValues: Ember.computed.mapBy("content.rows", "1"), + showChart: false, init() { this._super(...arguments); @@ -139,6 +142,51 @@ const QueryResultComponent = Ember.Component.extend({ 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) { 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: { downloadResultJson() { this.downloadResult("json"); @@ -218,6 +275,12 @@ const QueryResultComponent = Ember.Component.extend({ downloadResultCsv() { this.downloadResult("csv"); }, + showChart() { + this.set("showChart", true); + }, + showTable() { + this.set("showChart", false); + }, }, }); diff --git a/assets/javascripts/discourse/components/query-row-content.js.es6 b/assets/javascripts/discourse/components/query-row-content.js.es6 index 82cde2e..da30def 100644 --- a/assets/javascripts/discourse/components/query-row-content.js.es6 +++ b/assets/javascripts/discourse/components/query-row-content.js.es6 @@ -80,7 +80,7 @@ const QueryRowContentComponent = Ember.Component.extend({ const lookupFunc = parentView[`lookup${t.name.capitalize()}`]; if (lookupFunc) { - ctx[t.name] = parentView[`lookup${t.name.capitalize()}`](id); + ctx[t.name] = lookupFunc.call(parentView, id); } if (t.name === "url") { diff --git a/assets/javascripts/discourse/lib/binary-search.js.es6 b/assets/javascripts/discourse/lib/binary-search.js.es6 deleted file mode 100644 index 70d8792..0000000 --- a/assets/javascripts/discourse/lib/binary-search.js.es6 +++ /dev/null @@ -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; -} diff --git a/assets/javascripts/discourse/lib/themeColor.js.es6 b/assets/javascripts/discourse/lib/themeColor.js.es6 new file mode 100644 index 0000000..84af962 --- /dev/null +++ b/assets/javascripts/discourse/lib/themeColor.js.es6 @@ -0,0 +1,4 @@ +export default function themeColor(name) { + const style = getComputedStyle(document.body); + return style.getPropertyValue(name); +} diff --git a/assets/javascripts/discourse/templates/components/data-explorer-bar-chart.hbs b/assets/javascripts/discourse/templates/components/data-explorer-bar-chart.hbs new file mode 100644 index 0000000..aa8cc32 --- /dev/null +++ b/assets/javascripts/discourse/templates/components/data-explorer-bar-chart.hbs @@ -0,0 +1 @@ + diff --git a/assets/javascripts/discourse/templates/explorer-query-result.hbs b/assets/javascripts/discourse/templates/explorer-query-result.hbs index c773cc5..65dbdfa 100644 --- a/assets/javascripts/discourse/templates/explorer-query-result.hbs +++ b/assets/javascripts/discourse/templates/explorer-query-result.hbs @@ -3,6 +3,13 @@
{{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}} + {{#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}}
@@ -22,22 +29,29 @@
- - - - {{#each columnDispNames as |col|}} - + {{#if showChart}} + {{data-explorer-bar-chart + labels=chartLabels + values=chartValues + datasetName=chartDatasetName}} + {{else}} +
{{col}}
+ + + {{#each columnDispNames as |col|}} + + {{/each}} + + + + {{#each rows as |row|}} + {{query-row-content + row=row + fallbackTemplate=fallbackTemplate + columnTemplates=columnTemplates}} {{/each}} - - - - {{#each rows as |row|}} - {{query-row-content - row=row - fallbackTemplate=fallbackTemplate - columnTemplates=columnTemplates}} - {{/each}} - -
{{col}}
+ + + {{/if}}
\ No newline at end of file diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 844b1c5..71488c0 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -34,6 +34,8 @@ en: recover: "Undelete Query" download_json: "JSON" download_csv: "CSV" + show_table: "Table" + show_graph: "Graph" others_dirty: "A query has unsaved changes that will be lost if you navigate away." run_time: "Query completed in %{value} ms." result_count: diff --git a/test/javascripts/acceptance/run-query-test.js.es6 b/test/javascripts/acceptance/run-query-test.js.es6 new file mode 100644 index 0000000..b26db61 --- /dev/null +++ b/test/javascripts/acceptance/run-query-test.js.es6 @@ -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" + ); + }); +}); diff --git a/test/javascripts/integration/components/data-explorer-bar-chart-test.js.es6 b/test/javascripts/integration/components/data-explorer-bar-chart-test.js.es6 new file mode 100644 index 0000000..a687738 --- /dev/null +++ b/test/javascripts/integration/components/data-explorer-bar-chart-test.js.es6 @@ -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 " + ); + }, + }); + } +); diff --git a/test/javascripts/integration/components/query-result-test.js.es6 b/test/javascripts/integration/components/query-result-test.js.es6 index 3a1db7f..ca7eea3 100644 --- a/test/javascripts/integration/components/query-result-test.js.es6 +++ b/test/javascripts/integration/components/query-result-test.js.es6 @@ -3,15 +3,72 @@ import componentTest, { } from "discourse/tests/helpers/component-test"; import { discourseModule, - queryAll, + exists, + query, } from "discourse/tests/helpers/qunit-helpers"; +import { click } from "@ember/test-helpers"; import hbs from "htmlbars-inline-precompile"; +import I18n from "I18n"; discourseModule( "Data Explorer Plugin | Integration | Component | query-result", function (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", { template: hbs`{{query-result content=content}}`, @@ -38,11 +95,165 @@ discourseModule( test(assert) { assert.ok( - queryAll( - "table tbody tr:nth-child(1) td:nth-child(1) span" - ).text() === "badge display name" + query("table tbody tr:nth-child(1) td:nth-child(1) span") + .innerText === "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)")); + }, + } + ); + } +); diff --git a/test/javascripts/unit/components/query-result-test.js.es6 b/test/javascripts/unit/components/query-result-test.js.es6 new file mode 100644 index 0000000..6ffb6be --- /dev/null +++ b/test/javascripts/unit/components/query-result-test.js.es6 @@ -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...", + ]); +});