diff --git a/assets/javascripts/discourse/templates/explorer-query-result.hbs b/assets/javascripts/discourse/components/query-result.hbs similarity index 54% rename from assets/javascripts/discourse/templates/explorer-query-result.hbs rename to assets/javascripts/discourse/components/query-result.hbs index c390e44..cc6c60c 100644 --- a/assets/javascripts/discourse/templates/explorer-query-result.hbs +++ b/assets/javascripts/discourse/components/query-result.hbs @@ -1,76 +1,73 @@
- {{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 - }} + {{#if this.canShowChart}} + {{#if this.chartDisplayed}} + {{else}} - {{d-button - action=(action "showChart") - icon="chart-bar" - label="explorer.show_graph" - group=group - }} + {{/if}} {{/if}}
- {{resultCount}} - {{duration}} + {{this.resultCount}} + {{this.duration}}

- {{~#if hasExplain}} -

-          {{~content.explain}}
-        
+ {{~#if this.explainText}} +
+        
+          {{~this.explainText}}
+        
+      
{{~/if}}
- {{#if showChart}} + {{#if this.chartDisplayed}} {{else}} - {{#each columnDispNames as |col|}} + {{#each this.columnNames as |col|}} {{/each}} - {{#each rows as |row|}} + {{#each this.rows as |row|}} { + if (colName.endsWith("_id")) { + return colName.slice(0, -3); + } + const dIdx = colName.indexOf("$"); + if (dIdx >= 0) { + return colName.substring(dIdx + 1); + } + return colName; + }); + } + + get columnTemplates() { + if (!this.columns) { + return []; + } + return this.columns.map((_, idx) => { + let viewName = "text"; + if (this.colRender[idx]) { + viewName = this.colRender[idx]; + } + + const template = findRawTemplate(`javascripts/explorer/${viewName}`); + return { name: viewName, template }; + }); + } + + get chartValues() { + // return an array with the second value of this.row + return this.rows.mapBy(1); + } + + get colCount() { + return this.columns.length; + } + + get resultCount() { + const count = this.args.content.result_count; + if (count === this.args.content.default_limit) { + return I18n.t("explorer.max_result_count", { count }); + } else { + return I18n.t("explorer.result_count", { count }); + } + } + + get duration() { + return I18n.t("explorer.run_time", { + value: I18n.toNumber(this.args.content.duration, { precision: 1 }), + }); + } + + get parameterAry() { + let arr = []; + for (let key in this.params) { + if (this.params.hasOwnProperty(key)) { + arr.push({ key, value: this.params[key] }); + } + } + return arr; + } + + get transformedUserTable() { + return transformedRelTable(this.args.content.relations.user); + } + + get transformedBadgeTable() { + return transformedRelTable(this.args.content.relations.badge, Badge); + } + + get transformedPostTable() { + return transformedRelTable(this.args.content.relations.post); + } + + get transformedTopicTable() { + return transformedRelTable(this.args.content.relations.topic); + } + + get transformedGroupTable() { + return transformedRelTable(this.site.groups); + } + + get canShowChart() { + const hasTwoColumns = this.colCount === 2; + const secondColumnContainsNumber = + this.resultCount.length && typeof this.rows[0][1] === "number"; + const secondColumnContainsId = this.colRender[1]; + + return ( + hasTwoColumns && secondColumnContainsNumber && !secondColumnContainsId + ); + } + + get chartLabels() { + const labelSelectors = { + user: (user) => user.username, + badge: (badge) => badge.name, + topic: (topic) => topic.title, + group: (group) => group.name, + category: (category) => category.name, + }; + + const relationName = this.colRender[0]; + if (relationName) { + const lookupFunc = this[`lookup${capitalize(relationName)}`]; + const labelSelector = labelSelectors[relationName]; + + if (lookupFunc && labelSelector) { + return this.rows.map((r) => { + const relation = lookupFunc.call(this, r[0]); + const label = labelSelector(relation); + return this._cutChartLabel(label); + }); + } + } + + return this.rows.map((r) => this._cutChartLabel(r[0])); + } + + lookupUser(id) { + return this.transformedUserTable[id]; + } + lookupBadge(id) { + return this.transformedBadgeTable[id]; + } + lookupPost(id) { + return this.transformedPostTable[id]; + } + lookupTopic(id) { + return this.transformedTopicTable[id]; + } + lookupGroup(id) { + return this.transformedGroupTable[id]; + } + + lookupCategory(id) { + return this.site.categoriesById[id]; + } + + _cutChartLabel(label) { + const labelString = label.toString(); + if (labelString.length > 25) { + return `${labelString.substring(0, 25)}...`; + } else { + return labelString; + } + } + + @action + downloadResultJson() { + this._downloadResult("json"); + } + + @action + downloadResultCsv() { + this._downloadResult("csv"); + } + + @action + showChart() { + this.chartDisplayed = true; + } + + @action + hideChart() { + this.chartDisplayed = false; + } + + _download_url() { + return this.group + ? `/g/${this.group.name}/reports/` + : "/admin/plugins/explorer/queries/"; + } + + _downloadResult(format) { + // Create a frame to submit the form in (?) + // to avoid leaving an about:blank behind + let windowName = randomIdShort(); + const newWindowContents = + "Click anywhere to close this window once the download finishes."; + + window.open("data:text/html;base64," + btoa(newWindowContents), windowName); + + let form = document.createElement("form"); + form.setAttribute("id", "query-download-result"); + form.setAttribute("method", "post"); + form.setAttribute( + "action", + getURL( + this._download_url() + + this.get("query.id") + + "/run." + + format + + "?download=1" + ) + ); + form.setAttribute("target", windowName); + form.setAttribute("style", "display:none;"); + + function addInput(name, value) { + let field; + field = document.createElement("input"); + field.setAttribute("name", name); + field.setAttribute("value", value); + form.appendChild(field); + } + + addInput("params", JSON.stringify(this.params)); + addInput("explain", this.explainText); + addInput("limit", "1000000"); + + ajax("/session/csrf.json").then((csrf) => { + addInput("authenticity_token", csrf.csrf); + + document.body.appendChild(form); + form.submit(); + schedule("afterRender", () => document.body.removeChild(form)); + }); + } +} function randomIdShort() { return "xxxxxxxx".replace(/[xy]/g, () => { @@ -28,249 +281,3 @@ function transformedRelTable(table, modelClass) { }); return result; } - -const QueryResultComponent = Component.extend({ - layoutName: "explorer-query-result", - - rows: alias("content.rows"), - columns: alias("content.columns"), - params: alias("content.params"), - explainText: alias("content.explain"), - hasExplain: notEmpty("content.explain"), - chartDatasetName: reads("columnDispNames.1"), - chartValues: mapBy("content.rows", "1"), - showChart: false, - - @discourseComputed("content.result_count") - resultCount(count) { - if (count === this.get("content.default_limit")) { - return I18n.t("explorer.max_result_count", { count }); - } else { - return I18n.t("explorer.result_count", { count }); - } - }, - - colCount: reads("content.columns.length"), - - @discourseComputed("content.duration") - duration(contentDuration) { - return I18n.t("explorer.run_time", { - value: I18n.toNumber(contentDuration, { precision: 1 }), - }); - }, - - @discourseComputed("params.[]") - parameterAry(params) { - let arr = []; - for (let key in params) { - if (params.hasOwnProperty(key)) { - arr.push({ key, value: params[key] }); - } - } - return arr; - }, - - @discourseComputed("content", "columns.[]") - columnDispNames(content, columns) { - if (!columns) { - return []; - } - return columns.map((colName) => { - if (colName.endsWith("_id")) { - return colName.slice(0, -3); - } - const dIdx = colName.indexOf("$"); - if (dIdx >= 0) { - return colName.substring(dIdx + 1); - } - return colName; - }); - }, - - @discourseComputed - fallbackTemplate() { - return findRawTemplate("javascripts/explorer/text"); - }, - - @discourseComputed("content", "columns.[]") - columnTemplates(content, columns) { - if (!columns) { - return []; - } - return columns.map((colName, idx) => { - let viewName = "text"; - if (this.get("content.colrender")[idx]) { - viewName = this.get("content.colrender")[idx]; - } - - const template = findRawTemplate(`javascripts/explorer/${viewName}`); - - return { name: viewName, template }; - }); - }, - - @discourseComputed("content.relations.user") - transformedUserTable(contentRelationsUser) { - return transformedRelTable(contentRelationsUser); - }, - @discourseComputed("content.relations.badge") - transformedBadgeTable(contentRelationsBadge) { - return transformedRelTable(contentRelationsBadge, Badge); - }, - @discourseComputed("content.relations.post") - transformedPostTable(contentRelationsPost) { - return transformedRelTable(contentRelationsPost); - }, - @discourseComputed("content.relations.topic") - transformedTopicTable(contentRelationsTopic) { - return transformedRelTable(contentRelationsTopic); - }, - - @discourseComputed("site.groups") - transformedGroupTable(groups) { - return transformedRelTable(groups); - }, - - @discourseComputed( - "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 - ); - }, - - @discourseComputed("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${capitalize(relationName)}`]; - 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]; - }, - lookupBadge(id) { - return this.transformedBadgeTable[id]; - }, - lookupPost(id) { - return this.transformedPostTable[id]; - }, - lookupTopic(id) { - return this.transformedTopicTable[id]; - }, - lookupGroup(id) { - return this.transformedGroupTable[id]; - }, - - lookupCategory(id) { - return this.site.categoriesById[id]; - }, - - download_url() { - return this.group - ? `/g/${this.group.name}/reports/` - : "/admin/plugins/explorer/queries/"; - }, - - downloadResult(format) { - // Create a frame to submit the form in (?) - // to avoid leaving an about:blank behind - let windowName = randomIdShort(); - const newWindowContents = - "Click anywhere to close this window once the download finishes."; - - window.open("data:text/html;base64," + btoa(newWindowContents), windowName); - - let form = document.createElement("form"); - form.setAttribute("id", "query-download-result"); - form.setAttribute("method", "post"); - form.setAttribute( - "action", - getURL( - this.download_url() + - this.get("query.id") + - "/run." + - format + - "?download=1" - ) - ); - form.setAttribute("target", windowName); - form.setAttribute("style", "display:none;"); - - function addInput(name, value) { - let field; - field = document.createElement("input"); - field.setAttribute("name", name); - field.setAttribute("value", value); - form.appendChild(field); - } - - addInput("params", JSON.stringify(this.params)); - addInput("explain", this.hasExplain); - addInput("limit", "1000000"); - - ajax("/session/csrf.json").then((csrf) => { - addInput("authenticity_token", csrf.csrf); - - document.body.appendChild(form); - form.submit(); - schedule("afterRender", () => document.body.removeChild(form)); - }); - }, - - _cutChartLabel(label) { - const labelString = label.toString(); - if (labelString.length > 25) { - return `${labelString.substring(0, 25)}...`; - } else { - return labelString; - } - }, - - actions: { - downloadResultJson() { - this.downloadResult("json"); - }, - downloadResultCsv() { - this.downloadResult("csv"); - }, - showChart() { - this.set("showChart", true); - }, - showTable() { - this.set("showChart", false); - }, - }, -}); - -export default QueryResultComponent; diff --git a/assets/javascripts/discourse/components/query-results-wrapper.hbs b/assets/javascripts/discourse/components/query-results-wrapper.hbs new file mode 100644 index 0000000..9293a82 --- /dev/null +++ b/assets/javascripts/discourse/components/query-results-wrapper.hbs @@ -0,0 +1,11 @@ +{{#if @results}} +
+ {{#if @showResults}} + + {{else}} + {{#each @results.errors as |err|}} +
{{~err}}
+ {{/each}} + {{/if}} +
+{{/if}} diff --git a/assets/javascripts/discourse/components/query-row-content.js b/assets/javascripts/discourse/components/query-row-content.js index 297f211..41f395b 100644 --- a/assets/javascripts/discourse/components/query-row-content.js +++ b/assets/javascripts/discourse/components/query-row-content.js @@ -9,8 +9,19 @@ import { get } from "@ember/object"; import { isEmpty } from "@ember/utils"; import { escapeExpression } from "discourse/lib/utilities"; import { cached } from "@glimmer/tracking"; +import { findRawTemplate } from "discourse-common/lib/raw-templates"; export default class QueryRowContent extends Component { + constructor() { + super(...arguments); + + this.helpers = { + "icon-or-image": icon_or_image_replacement, + "category-link": category_badge_replacement, + reltime: bound_date_replacement, + }; + } + @cached get results() { return this.args.columnTemplates.map((t, idx) => { @@ -46,23 +57,15 @@ export default class QueryRowContent extends Component { } try { - return htmlSafe( - (t.template || this.args.fallbackTemplate)(ctx, params) - ); + return htmlSafe((t.template || this.fallbackTemplate)(ctx, params)); } catch (e) { return "error"; } }); } - constructor() { - super(...arguments); - - this.helpers = { - "icon-or-image": icon_or_image_replacement, - "category-link": category_badge_replacement, - reltime: bound_date_replacement, - }; + fallbackTemplate() { + return findRawTemplate("javascripts/explorer/text"); } } diff --git a/assets/javascripts/discourse/templates/admin/plugins-explorer.hbs b/assets/javascripts/discourse/templates/admin/plugins-explorer.hbs index 5c54dc1..cc1c1a8 100644 --- a/assets/javascripts/discourse/templates/admin/plugins-explorer.hbs +++ b/assets/javascripts/discourse/templates/admin/plugins-explorer.hbs @@ -252,17 +252,12 @@ {{conditional-loading-spinner condition=loading}} {{#unless selectedItem.fake}} - {{#if results}} -
- {{#if showResults}} - {{query-result query=selectedItem content=results}} - {{else}} - {{#each results.errors as |err|}} -
{{~err}}
- {{/each}} - {{/if}} -
- {{/if}} + {{/unless}} {{#if showRecentQueries}} diff --git a/test/javascripts/acceptance/run-query-test.js b/test/javascripts/acceptance/run-query-test.js index 5775ead..146bd5f 100644 --- a/test/javascripts/acceptance/run-query-test.js +++ b/test/javascripts/acceptance/run-query-test.js @@ -199,6 +199,7 @@ acceptance("Data Explorer Plugin | Run Query", function (needs) { 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"), diff --git a/test/javascripts/integration/components/query-result-test.js b/test/javascripts/integration/components/query-result-test.js index acb375f..5d957f4 100644 --- a/test/javascripts/integration/components/query-result-test.js +++ b/test/javascripts/integration/components/query-result-test.js @@ -16,7 +16,7 @@ discourseModule( setupRenderingTest(hooks); componentTest("it renders query results", { - template: hbs`{{query-result content=content}}`, + template: hbs``, beforeEach() { const results = { @@ -70,7 +70,7 @@ discourseModule( }); componentTest("it renders badge names in query results", { - template: hbs`{{query-result content=content}}`, + template: hbs``, beforeEach() { const results = { @@ -102,7 +102,7 @@ discourseModule( }); componentTest("it renders a post in query results", { - template: hbs`{{query-result content=content}}`, + template: hbs``, beforeEach() { const results = { @@ -141,7 +141,7 @@ discourseModule( }); componentTest("it renders a category_id in query results", { - template: hbs`{{query-result content=content}}`, + template: hbs``, beforeEach() { const results = { @@ -183,7 +183,7 @@ discourseModule( setupRenderingTest(hooks); componentTest("navigation between a table and a chart works", { - template: hbs`{{query-result content=content}}`, + template: hbs``, beforeEach() { const results = { @@ -228,7 +228,7 @@ discourseModule( componentTest( "it renders a chart button when data has two columns and numbers in the second column", { - template: hbs`{{query-result content=content}}`, + template: hbs``, beforeEach() { const results = { @@ -255,7 +255,7 @@ discourseModule( componentTest( "it doesn't render a chart button when data contains identifiers in the second column", { - template: hbs`{{query-result content=content}}`, + template: hbs``, beforeEach() { const results = { @@ -285,7 +285,7 @@ discourseModule( componentTest( "it doesn't render a chart button when data contains one column", { - template: hbs`{{query-result content=content}}`, + template: hbs``, beforeEach() { const results = { @@ -306,7 +306,7 @@ discourseModule( componentTest( "it doesn't render a chart button when data contains more than two columns", { - template: hbs`{{query-result content=content}}`, + template: hbs``, beforeEach() { const results = { diff --git a/test/javascripts/unit/components/query-result-test.js b/test/javascripts/unit/components/query-result-test.js deleted file mode 100644 index 1c8fc89..0000000 --- a/test/javascripts/unit/components/query-result-test.js +++ /dev/null @@ -1,111 +0,0 @@ -import { discourseModule } from "discourse/tests/helpers/qunit-helpers"; -import { test } from "qunit"; - -discourseModule("Unit | Component | query-result", function () { - test("it transforms data for a chart", function (assert) { - const component = this.container - .factoryFor("component:query-result") - .create({ renderer: {} }); - - component.setProperties({ - content: { - colrender: [], - result_count: 2, - columns: ["user", "like_count"], - rows: [ - ["user1", 10], - ["user2", 20], - ], - }, - }); - - assert.deepEqual( - component.chartLabels, - ["user1", "user2"], - "labels are correct" - ); - - assert.deepEqual(component.chartValues, [10, 20], "values are correct"); - - assert.deepEqual( - component.chartDatasetName, - "like_count", - "the dataset name is correct" - ); - }); - - test("it uses descriptive chart labels instead of identifiers", function (assert) { - const component = this.container - .factoryFor("component:query-result") - .create({ renderer: {} }); - - component.setProperties({ - content: { - 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], - ], - }, - }); - - assert.deepEqual(component.chartLabels, ["user1", "user2"]); - }); - - test("it uses an identifier as a chart label if labelSelector doesn't exist", function (assert) { - const component = this.container - .factoryFor("component:query-result") - .create({ renderer: {} }); - - component.setProperties({ - content: { - 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], - ], - }, - }); - - assert.deepEqual(component.chartLabels, ["1", "2"]); - }); - - test("it cuts too long chart labels", function (assert) { - const component = this.container - .factoryFor("component:query-result") - .create({ renderer: {} }); - - component.setProperties({ - content: { - 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], - ], - }, - }); - - assert.deepEqual(component.chartLabels, [ - "This string is too long t...", - "This string is too long t...", - ]); - }); -});
{{col}}