diff --git a/app/assets/javascripts/admin/components/admin-report-chart.js.es6 b/app/assets/javascripts/admin/components/admin-report-chart.js.es6 new file mode 100644 index 00000000000..830daf11a5c --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-report-chart.js.es6 @@ -0,0 +1,117 @@ +import { number } from "discourse/lib/formatter"; +import loadScript from "discourse/lib/load-script"; + +export default Ember.Component.extend({ + classNames: ["admin-report-chart"], + limit: 8, + primaryColor: "rgb(0,136,204)", + total: 0, + + willDestroyElement() { + this._super(...arguments); + + this._resetChart(); + }, + + didReceiveAttrs() { + this._super(...arguments); + + Ember.run.schedule("afterRender", () => { + const $chartCanvas = this.$(".chart-canvas"); + if (!$chartCanvas || !$chartCanvas.length) return; + + const context = $chartCanvas[0].getContext("2d"); + const model = this.get("model"); + const chartData = Ember.makeArray( + model.get("chartData") || model.get("data") + ); + const prevChartData = Ember.makeArray( + model.get("prevChartData") || model.get("prev_data") + ); + + const labels = chartData.map(d => d.x); + + const data = { + labels, + datasets: [ + { + data: chartData.map(d => Math.round(parseFloat(d.y))), + backgroundColor: prevChartData.length + ? "transparent" + : "rgba(200,220,240,0.3)", + borderColor: this.get("primaryColor") + } + ] + }; + + if (prevChartData.length) { + data.datasets.push({ + data: prevChartData.map(d => Math.round(parseFloat(d.y))), + borderColor: this.get("primaryColor"), + borderDash: [5, 5], + backgroundColor: "transparent", + borderWidth: 1, + pointRadius: 0 + }); + } + + loadScript("/javascripts/Chart.min.js").then(() => { + this._resetChart(); + this._chart = new window.Chart(context, this._buildChartConfig(data)); + }); + }); + }, + + _buildChartConfig(data) { + return { + type: "line", + data, + options: { + tooltips: { + callbacks: { + title: tooltipItem => + moment(tooltipItem[0].xLabel, "YYYY-MM-DD").format("LL") + } + }, + legend: { + display: false + }, + responsive: true, + maintainAspectRatio: false, + layout: { + padding: { + left: 0, + top: 0, + right: 0, + bottom: 0 + } + }, + scales: { + yAxes: [ + { + display: true, + ticks: { callback: label => number(label) } + } + ], + xAxes: [ + { + display: true, + gridLines: { display: false }, + type: "time", + time: { + parser: "YYYY-MM-DD" + } + } + ] + } + } + }; + }, + + _resetChart() { + if (this._chart) { + this._chart.destroy(); + this._chart = null; + } + } +}); diff --git a/app/assets/javascripts/admin/components/admin-report-inline-table.js.es6 b/app/assets/javascripts/admin/components/admin-report-inline-table.js.es6 new file mode 100644 index 00000000000..7e4933381ce --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-report-inline-table.js.es6 @@ -0,0 +1,3 @@ +export default Ember.Component.extend({ + classNames: ["admin-report-inline-table"] +}); diff --git a/app/assets/javascripts/admin/components/admin-report-table-header.js.es6 b/app/assets/javascripts/admin/components/admin-report-table-header.js.es6 new file mode 100644 index 00000000000..b7c569cc25f --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-report-table-header.js.es6 @@ -0,0 +1,18 @@ +import computed from "ember-addons/ember-computed-decorators"; + +export default Ember.Component.extend({ + tagName: "th", + classNames: ["admin-report-table-header"], + classNameBindings: ["label.property", "isCurrentSort"], + attributeBindings: ["label.title:title"], + + @computed("currentSortLabel.sort_property", "label.sort_property") + isCurrentSort(currentSortField, labelSortField) { + return currentSortField === labelSortField; + }, + + @computed("currentSortDirection") + sortIcon(currentSortDirection) { + return currentSortDirection === 1 ? "caret-up" : "caret-down"; + } +}); diff --git a/app/assets/javascripts/admin/components/admin-report-table-row.js.es6 b/app/assets/javascripts/admin/components/admin-report-table-row.js.es6 new file mode 100644 index 00000000000..3c4de4970e3 --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-report-table-row.js.es6 @@ -0,0 +1,13 @@ +import computed from "ember-addons/ember-computed-decorators"; + +export default Ember.Component.extend({ + tagName: "tr", + classNames: ["admin-report-table-row"], + + @computed("data", "labels") + cells(row, labels) { + return labels.map(label => { + return label.compute(row); + }); + } +}); diff --git a/app/assets/javascripts/admin/components/admin-report-table.js.es6 b/app/assets/javascripts/admin/components/admin-report-table.js.es6 new file mode 100644 index 00000000000..b39afc8a898 --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-report-table.js.es6 @@ -0,0 +1,147 @@ +import computed from "ember-addons/ember-computed-decorators"; +import { registerTooltip, unregisterTooltip } from "discourse/lib/tooltip"; +import { isNumeric } from "discourse/lib/utilities"; + +const PAGES_LIMIT = 8; + +export default Ember.Component.extend({ + classNameBindings: ["sortable", "twoColumns"], + classNames: ["admin-report-table"], + sortable: false, + sortDirection: 1, + perPage: Ember.computed.alias("options.perPage"), + page: 0, + + didRender() { + this._super(...arguments); + + unregisterTooltip($(".text[data-tooltip]")); + registerTooltip($(".text[data-tooltip]")); + }, + + willDestroyElement() { + this._super(...arguments); + + unregisterTooltip($(".text[data-tooltip]")); + }, + + @computed("model.computedLabels.length") + twoColumns(labelsLength) { + return labelsLength === 2; + }, + + @computed("totalsForSample", "options.total", "model.dates_filtering") + showTotalForSample(totalsForSample, total, datesFiltering) { + // check if we have at least one cell which contains a value + const sum = totalsForSample + .map(t => t.value) + .compact() + .reduce((s, v) => s + v, 0); + + return sum >= 1 && total && datesFiltering; + }, + + @computed("model.total", "options.total", "twoColumns") + showTotal(reportTotal, total, twoColumns) { + return reportTotal && total && twoColumns; + }, + + @computed("model.data.length") + showSortingUI(dataLength) { + return dataLength >= 5; + }, + + @computed("totalsForSampleRow", "model.computedLabels") + totalsForSample(row, labels) { + return labels.map(label => label.compute(row)); + }, + + @computed("model.data", "model.computedLabels") + totalsForSampleRow(rows, labels) { + if (!rows || !rows.length) return {}; + + let totalsRow = {}; + + labels.forEach(label => { + const reducer = (sum, row) => { + const computedLabel = label.compute(row); + const value = computedLabel.value; + + if (computedLabel.type === "link" || (value && !isNumeric(value))) { + return undefined; + } else { + return sum + value; + } + }; + + totalsRow[label.property] = rows.reduce(reducer, 0); + }); + + return totalsRow; + }, + + @computed("sortLabel", "sortDirection", "model.data.[]") + sortedData(sortLabel, sortDirection, data) { + data = Ember.makeArray(data); + + if (sortLabel) { + const compare = (label, direction) => { + return (a, b) => { + let aValue = label.compute(a).value; + let bValue = label.compute(b).value; + const result = aValue < bValue ? -1 : aValue > bValue ? 1 : 0; + return result * direction; + }; + }; + + return data.sort(compare(sortLabel, sortDirection)); + } + + return data; + }, + + @computed("sortedData.[]", "perPage", "page") + paginatedData(data, perPage, page) { + if (perPage < data.length) { + const start = perPage * page; + return data.slice(start, start + perPage); + } + + return data; + }, + + @computed("model.data", "perPage", "page") + pages(data, perPage, page) { + if (!data || data.length <= perPage) return []; + + let pages = [...Array(Math.ceil(data.length / perPage)).keys()].map(v => { + return { + page: v + 1, + index: v, + class: v === page ? "current" : null + }; + }); + + if (pages.length > PAGES_LIMIT) { + const before = Math.max(0, page - PAGES_LIMIT / 2); + const after = Math.max(PAGES_LIMIT, page + PAGES_LIMIT / 2); + pages = pages.slice(before, after); + } + + return pages; + }, + + actions: { + changePage(page) { + this.set("page", page); + }, + + sortByLabel(label) { + if (this.get("sortLabel") === label) { + this.set("sortDirection", this.get("sortDirection") === 1 ? -1 : 1); + } else { + this.set("sortLabel", label); + } + } + } +}); diff --git a/app/assets/javascripts/admin/components/admin-report.js.es6 b/app/assets/javascripts/admin/components/admin-report.js.es6 new file mode 100644 index 00000000000..1688432962d --- /dev/null +++ b/app/assets/javascripts/admin/components/admin-report.js.es6 @@ -0,0 +1,366 @@ +import { exportEntity } from "discourse/lib/export-csv"; +import { outputExportResult } from "discourse/lib/export-result"; +import { ajax } from "discourse/lib/ajax"; +import Report from "admin/models/report"; +import computed from "ember-addons/ember-computed-decorators"; +import { registerTooltip, unregisterTooltip } from "discourse/lib/tooltip"; + +const TABLE_OPTIONS = { + perPage: 8, + total: true, + limit: 20 +}; + +const CHART_OPTIONS = {}; + +function collapseWeekly(data, average) { + let aggregate = []; + let bucket, i; + let offset = data.length % 7; + for (i = offset; i < data.length; i++) { + if (bucket && i % 7 === offset) { + if (average) { + bucket.y = parseFloat((bucket.y / 7.0).toFixed(2)); + } + aggregate.push(bucket); + bucket = null; + } + + bucket = bucket || { x: data[i].x, y: 0 }; + bucket.y += data[i].y; + } + return aggregate; +} + +export default Ember.Component.extend({ + classNameBindings: [ + "isEnabled", + "isLoading", + "dasherizedDataSourceName", + "currentMode" + ], + classNames: ["admin-report"], + isEnabled: true, + disabledLabel: "admin.dashboard.disabled", + isLoading: false, + dataSourceName: null, + report: null, + model: null, + reportOptions: null, + forcedModes: null, + showAllReportsLink: false, + startDate: null, + endDate: null, + showTrend: false, + showHeader: true, + showTitle: true, + showFilteringUI: false, + showCategoryOptions: Ember.computed.alias("model.category_filtering"), + showDatesOptions: Ember.computed.alias("model.dates_filtering"), + showGroupOptions: Ember.computed.alias("model.group_filtering"), + showExport: Ember.computed.not("model.onlyTable"), + hasFilteringActions: Ember.computed.or( + "showCategoryOptions", + "showDatesOptions", + "showGroupOptions", + "showExport" + ), + + init() { + this._super(...arguments); + + this._reports = []; + }, + + didReceiveAttrs() { + this._super(...arguments); + + if (this.get("report")) { + this._renderReport( + this.get("report"), + this.get("forcedModes"), + this.get("currentMode") + ); + } else if (this.get("dataSourceName")) { + this._fetchReport().finally(() => this._computeReport()); + } + }, + + didRender() { + this._super(...arguments); + + unregisterTooltip($(".info[data-tooltip]")); + registerTooltip($(".info[data-tooltip]")); + }, + + willDestroyElement() { + this._super(...arguments); + + unregisterTooltip($(".info[data-tooltip]")); + }, + + showTimeoutError: Ember.computed.alias("model.timeout"), + + @computed("dataSourceName", "model.type") + dasherizedDataSourceName(dataSourceName, type) { + return (dataSourceName || type || "undefined").replace(/_/g, "-"); + }, + + @computed("dataSourceName", "model.type") + dataSource(dataSourceName, type) { + dataSourceName = dataSourceName || type; + return `/admin/reports/${dataSourceName}`; + }, + + @computed("displayedModes.length") + showModes(displayedModesLength) { + return displayedModesLength > 1; + }, + + @computed("currentMode", "model.modes", "forcedModes") + displayedModes(currentMode, reportModes, forcedModes) { + const modes = forcedModes ? forcedModes.split(",") : reportModes; + + return Ember.makeArray(modes).map(mode => { + const base = `mode-button ${mode}`; + const cssClass = currentMode === mode ? `${base} current` : base; + + return { + mode, + cssClass, + icon: mode === "table" ? "table" : "signal" + }; + }); + }, + + @computed() + categoryOptions() { + const arr = [{ name: I18n.t("category.all"), value: "all" }]; + return arr.concat( + Discourse.Site.currentProp("sortedCategories").map(i => { + return { name: i.get("name"), value: i.get("id") }; + }) + ); + }, + + @computed() + groupOptions() { + const arr = [ + { name: I18n.t("admin.dashboard.reports.groups"), value: "all" } + ]; + return arr.concat( + this.site.groups.map(i => { + return { name: i["name"], value: i["id"] }; + }) + ); + }, + + @computed("currentMode") + modeComponent(currentMode) { + return `admin-report-${currentMode}`; + }, + + actions: { + exportCsv() { + exportEntity("report", { + name: this.get("model.type"), + start_date: this.get("startDate"), + end_date: this.get("endDate"), + category_id: + this.get("categoryId") === "all" ? undefined : this.get("categoryId"), + group_id: + this.get("groupId") === "all" ? undefined : this.get("groupId") + }).then(outputExportResult); + }, + + changeMode(mode) { + this.set("currentMode", mode); + } + }, + + _computeReport() { + if (!this.element || this.isDestroying || this.isDestroyed) { + return; + } + + if (!this._reports || !this._reports.length) { + return; + } + + // on a slow network _fetchReport could be called multiple times between + // T and T+x, and all the ajax responses would occur after T+(x+y) + // to avoid any inconsistencies we filter by period and make sure + // the array contains only unique values + let filteredReports = this._reports.uniqBy("report_key"); + let report; + + const sort = r => { + if (r.length > 1) { + return r.findBy("type", this.get("dataSourceName")); + } else { + return r; + } + }; + + let startDate = this.get("startDate"); + let endDate = this.get("endDate"); + + startDate = + startDate && typeof startDate.isValid === "function" + ? startDate.format("YYYYMMDD") + : startDate; + endDate = + startDate && typeof endDate.isValid === "function" + ? endDate.format("YYYYMMDD") + : endDate; + + if (!startDate || !endDate) { + report = sort(filteredReports)[0]; + } else { + let reportKey = `reports:${this.get("dataSourceName")}`; + + if (this.get("categoryId") && this.get("categoryId") !== "all") { + reportKey += `:${this.get("categoryId")}`; + } else { + reportKey += `:`; + } + + reportKey += `:${startDate.replace(/-/g, "")}`; + reportKey += `:${endDate.replace(/-/g, "")}`; + + if (this.get("groupId") && this.get("groupId") !== "all") { + reportKey += `:${this.get("groupId")}`; + } else { + reportKey += `:`; + } + + reportKey += `:`; + + report = sort( + filteredReports.filter(r => r.report_key.includes(reportKey)) + )[0]; + + if (!report) { + console.log( + "failed to find a report to render", + `expected key: ${reportKey}`, + `existing keys: ${filteredReports.map(f => f.report_key)}` + ); + return; + } + } + + this._renderReport( + report, + this.get("forcedModes"), + this.get("currentMode") + ); + }, + + _renderReport(report, forcedModes, currentMode) { + const modes = forcedModes ? forcedModes.split(",") : report.modes; + currentMode = currentMode || modes[0]; + + this.setProperties({ + model: report, + currentMode, + options: this._buildOptions(currentMode) + }); + }, + + _fetchReport() { + this._super(); + + this.set("isLoading", true); + + let payload = this._buildPayload(["prev_period"]); + + return ajax(this.get("dataSource"), payload) + .then(response => { + if (response && response.report) { + this._reports.push(this._loadReport(response.report)); + } else { + console.log("failed loading", this.get("dataSource")); + } + }) + .finally(() => { + if (this.element && !this.isDestroying && !this.isDestroyed) { + this.set("isLoading", false); + } + }); + }, + + _buildPayload(facets) { + let payload = { data: { cache: true, facets } }; + + if (this.get("startDate")) { + payload.data.start_date = moment( + this.get("startDate"), + "YYYY-MM-DD" + ).format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ"); + } + + if (this.get("endDate")) { + payload.data.end_date = moment(this.get("endDate"), "YYYY-MM-DD").format( + "YYYY-MM-DD[T]HH:mm:ss.SSSZZ" + ); + } + + if (this.get("groupId") && this.get("groupId") !== "all") { + payload.data.group_id = this.get("groupId"); + } + + if (this.get("categoryId") && this.get("categoryId") !== "all") { + payload.data.category_id = this.get("categoryId"); + } + + if (this.get("reportOptions.table.limit")) { + payload.data.limit = this.get("reportOptions.table.limit"); + } + + return payload; + }, + + _buildOptions(mode) { + if (mode === "table") { + const tableOptions = JSON.parse(JSON.stringify(TABLE_OPTIONS)); + return Ember.Object.create( + _.assign(tableOptions, this.get("reportOptions.table") || {}) + ); + } else { + const chartOptions = JSON.parse(JSON.stringify(CHART_OPTIONS)); + return Ember.Object.create( + _.assign(chartOptions, this.get("reportOptions.chart") || {}) + ); + } + }, + + _loadReport(jsonReport) { + Report.fillMissingDates(jsonReport, { filledField: "chartData" }); + + if (jsonReport.chartData && jsonReport.chartData.length > 40) { + jsonReport.chartData = collapseWeekly( + jsonReport.chartData, + jsonReport.average + ); + } + + if (jsonReport.prev_data) { + Report.fillMissingDates(jsonReport, { + filledField: "prevChartData", + dataField: "prev_data", + starDate: jsonReport.prev_start_date, + endDate: jsonReport.prev_end_date + }); + + if (jsonReport.prevChartData && jsonReport.prevChartData.length > 40) { + jsonReport.prevChartData = collapseWeekly( + jsonReport.prevChartData, + jsonReport.average + ); + } + } + + return Report.create(jsonReport); + } +}); diff --git a/app/assets/javascripts/admin/components/admin-table-report.js.es6 b/app/assets/javascripts/admin/components/admin-table-report.js.es6 deleted file mode 100644 index aa7efce66af..00000000000 --- a/app/assets/javascripts/admin/components/admin-table-report.js.es6 +++ /dev/null @@ -1,9 +0,0 @@ -import computed from "ember-addons/ember-computed-decorators"; - -export default Ember.Component.extend({ - @computed("model.sortedData") - totalForPeriod(data) { - const values = data.map(d => d.y); - return values.reduce((sum, v) => sum + v); - } -}); diff --git a/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 b/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 deleted file mode 100644 index 0435f8237e9..00000000000 --- a/app/assets/javascripts/admin/components/dashboard-inline-table.js.es6 +++ /dev/null @@ -1,20 +0,0 @@ -import { ajax } from "discourse/lib/ajax"; -import AsyncReport from "admin/mixins/async-report"; - -export default Ember.Component.extend(AsyncReport, { - classNames: ["dashboard-inline-table"], - - fetchReport() { - this._super(); - - let payload = this.buildPayload(["total", "prev30Days"]); - - return Ember.RSVP.Promise.all( - this.get("dataSources").map(dataSource => { - return ajax(dataSource, payload).then(response => { - this.get("reports").pushObject(this.loadReport(response.report)); - }); - }) - ); - } -}); diff --git a/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 b/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 deleted file mode 100644 index 1f0d4b7cba1..00000000000 --- a/app/assets/javascripts/admin/components/dashboard-mini-chart.js.es6 +++ /dev/null @@ -1,176 +0,0 @@ -import { ajax } from "discourse/lib/ajax"; -import AsyncReport from "admin/mixins/async-report"; -import Report from "admin/models/report"; -import { number } from "discourse/lib/formatter"; -import loadScript from "discourse/lib/load-script"; -import { registerTooltip, unregisterTooltip } from "discourse/lib/tooltip"; - -function collapseWeekly(data, average) { - let aggregate = []; - let bucket, i; - let offset = data.length % 7; - for (i = offset; i < data.length; i++) { - if (bucket && i % 7 === offset) { - if (average) { - bucket.y = parseFloat((bucket.y / 7.0).toFixed(2)); - } - aggregate.push(bucket); - bucket = null; - } - - bucket = bucket || { x: data[i].x, y: 0 }; - bucket.y += data[i].y; - } - return aggregate; -} - -export default Ember.Component.extend(AsyncReport, { - classNames: ["chart", "dashboard-mini-chart"], - total: 0, - - init() { - this._super(); - - this._colorsPool = ["rgb(0,136,204)", "rgb(235,83,148)"]; - }, - - didRender() { - this._super(); - registerTooltip($(this.element).find("[data-tooltip]")); - }, - - willDestroyElement() { - this._super(); - unregisterTooltip($(this.element).find("[data-tooltip]")); - }, - - pickColorAtIndex(index) { - return this._colorsPool[index] || this._colorsPool[0]; - }, - - fetchReport() { - this._super(); - - let payload = this.buildPayload(["prev_period"]); - - if (this._chart) { - this._chart.destroy(); - this._chart = null; - } - - return Ember.RSVP.Promise.all( - this.get("dataSources").map(dataSource => { - return ajax(dataSource, payload).then(response => { - this.get("reports").pushObject(this.loadReport(response.report)); - }); - }) - ); - }, - - loadReport(report, previousReport) { - Report.fillMissingDates(report); - - if (report.data && report.data.length > 40) { - report.data = collapseWeekly(report.data, report.average); - } - - if (previousReport && previousReport.color.length) { - report.color = previousReport.color; - } else { - const dataSourceNameIndex = this.get("dataSourceNames") - .split(",") - .indexOf(report.type); - report.color = this.pickColorAtIndex(dataSourceNameIndex); - } - - return Report.create(report); - }, - - renderReport() { - this._super(); - - Ember.run.schedule("afterRender", () => { - const $chartCanvas = this.$(".chart-canvas"); - if (!$chartCanvas.length) return; - const context = $chartCanvas[0].getContext("2d"); - - const reportsForPeriod = this.get("reportsForPeriod"); - - const labels = Ember.makeArray( - reportsForPeriod.get("firstObject.data") - ).map(d => d.x); - - const data = { - labels, - datasets: reportsForPeriod.map(report => { - return { - data: Ember.makeArray(report.data).map(d => - Math.round(parseFloat(d.y)) - ), - backgroundColor: "rgba(200,220,240,0.3)", - borderColor: report.color - }; - }) - }; - - if (this._chart) { - this._chart.destroy(); - this._chart = null; - } - - loadScript("/javascripts/Chart.min.js").then(() => { - if (this._chart) { - this._chart.destroy(); - } - - this._chart = new window.Chart(context, this._buildChartConfig(data)); - }); - }); - }, - - _buildChartConfig(data) { - return { - type: "line", - data, - options: { - tooltips: { - callbacks: { - title: context => - moment(context[0].xLabel, "YYYY-MM-DD").format("LL") - } - }, - legend: { - display: false - }, - responsive: true, - maintainAspectRatio: false, - layout: { - padding: { - left: 0, - top: 0, - right: 0, - bottom: 0 - } - }, - scales: { - yAxes: [ - { - display: true, - ticks: { callback: label => number(label) } - } - ], - xAxes: [ - { - display: true, - gridLines: { display: false }, - type: "time", - time: { - parser: "YYYY-MM-DD" - } - } - ] - } - } - }; - } -}); diff --git a/app/assets/javascripts/admin/components/dashboard-table.js.es6 b/app/assets/javascripts/admin/components/dashboard-table.js.es6 deleted file mode 100644 index fad090efacb..00000000000 --- a/app/assets/javascripts/admin/components/dashboard-table.js.es6 +++ /dev/null @@ -1,20 +0,0 @@ -import { ajax } from "discourse/lib/ajax"; -import AsyncReport from "admin/mixins/async-report"; - -export default Ember.Component.extend(AsyncReport, { - classNames: ["dashboard-table"], - - fetchReport() { - this._super(); - - let payload = this.buildPayload(["total", "prev30Days"]); - - return Ember.RSVP.Promise.all( - this.get("dataSources").map(dataSource => { - return ajax(dataSource, payload).then(response => { - this.get("reports").pushObject(this.loadReport(response.report)); - }); - }) - ); - } -}); diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard-next-general.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard-next-general.js.es6 new file mode 100644 index 00000000000..413213b36d9 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-dashboard-next-general.js.es6 @@ -0,0 +1,102 @@ +import { setting } from "discourse/lib/computed"; +import computed from "ember-addons/ember-computed-decorators"; +import AdminDashboardNext from "admin/models/admin-dashboard-next"; +import Report from "admin/models/report"; +import PeriodComputationMixin from "admin/mixins/period-computation"; + +export default Ember.Controller.extend(PeriodComputationMixin, { + isLoading: false, + dashboardFetchedAt: null, + exceptionController: Ember.inject.controller("exception"), + diskSpace: Ember.computed.alias("model.attributes.disk_space"), + logSearchQueriesEnabled: setting("log_search_queries"), + lastBackupTakenAt: Ember.computed.alias( + "model.attributes.last_backup_taken_at" + ), + shouldDisplayDurability: Ember.computed.and("lastBackupTakenAt", "diskSpace"), + + @computed + topReferredTopicsTopions() { + return { table: { total: false, limit: 8 } }; + }, + + @computed + trendingSearchOptions() { + return { table: { total: false, limit: 8 } }; + }, + + @computed("reports.[]") + topReferredTopicsReport(reports) { + return reports.find(x => x.type === "top_referred_topics"); + }, + + @computed("reports.[]") + trendingSearchReport(reports) { + return reports.find(x => x.type === "trending_search"); + }, + + @computed("reports.[]") + usersByTypeReport(reports) { + return reports.find(x => x.type === "users_by_type"); + }, + + @computed("reports.[]") + usersByTrustLevelReport(reports) { + return reports.find(x => x.type === "users_by_trust_level"); + }, + + @computed("reports.[]") + activityMetricsReports(reports) { + return reports.filter(report => { + return [ + "page_view_total_reqs", + "visits", + "time_to_first_response", + "likes", + "flags", + "user_to_user_private_messages_with_replies" + ].includes(report.type); + }); + }, + + fetchDashboard() { + if (this.get("isLoading")) return; + + if ( + !this.get("dashboardFetchedAt") || + moment() + .subtract(30, "minutes") + .toDate() > this.get("dashboardFetchedAt") + ) { + this.set("isLoading", true); + + AdminDashboardNext.fetchGeneral() + .then(adminDashboardNextModel => { + this.setProperties({ + dashboardFetchedAt: new Date(), + model: adminDashboardNextModel, + reports: adminDashboardNextModel.reports.map(x => Report.create(x)) + }); + }) + .catch(e => { + this.get("exceptionController").set("thrown", e.jqXHR); + this.replaceRoute("exception"); + }) + .finally(() => this.set("isLoading", false)); + } + }, + + @computed("model.attributes.updated_at") + updatedTimestamp(updatedAt) { + return moment(updatedAt).format("LLL"); + }, + + @computed("lastBackupTakenAt") + backupTimestamp(lastBackupTakenAt) { + return moment(lastBackupTakenAt).format("LLL"); + }, + + _reportsForPeriodURL(period) { + return Discourse.getURL(`/admin/dashboard/general?period=${period}`); + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard-next-moderation.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard-next-moderation.js.es6 new file mode 100644 index 00000000000..95075ef906c --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-dashboard-next-moderation.js.es6 @@ -0,0 +1,64 @@ +import computed from "ember-addons/ember-computed-decorators"; +import Report from "admin/models/report"; +import AdminDashboardNext from "admin/models/admin-dashboard-next"; +import PeriodComputationMixin from "admin/mixins/period-computation"; + +export default Ember.Controller.extend(PeriodComputationMixin, { + isLoading: false, + dashboardFetchedAt: null, + exceptionController: Ember.inject.controller("exception"), + + @computed + flagsStatusOptions() { + return { + table: { + total: false, + perPage: 10 + } + }; + }, + + @computed("reports.[]") + flagsStatusReport(reports) { + return reports.find(x => x.type === "flags_status"); + }, + + @computed("reports.[]") + postEditsReport(reports) { + return reports.find(x => x.type === "post_edits"); + }, + + fetchDashboard() { + if (this.get("isLoading")) return; + + if ( + !this.get("dashboardFetchedAt") || + moment() + .subtract(30, "minutes") + .toDate() > this.get("dashboardFetchedAt") + ) { + this.set("isLoading", true); + + AdminDashboardNext.fetchModeration() + .then(model => { + const reports = model.reports.map(x => Report.create(x)); + this.setProperties({ + dashboardFetchedAt: new Date(), + model, + reports + }); + }) + .catch(e => { + this.get("exceptionController").set("thrown", e.jqXHR); + this.replaceRoute("exception"); + }) + .finally(() => { + this.set("isLoading", false); + }); + } + }, + + _reportsForPeriodURL(period) { + return Discourse.getURL(`/admin/dashboard/moderation?period=${period}`); + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 index e809df69513..1be0a8a7fa1 100644 --- a/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-dashboard-next.js.es6 @@ -1,34 +1,38 @@ import { setting } from "discourse/lib/computed"; -import DiscourseURL from "discourse/lib/url"; import computed from "ember-addons/ember-computed-decorators"; import AdminDashboardNext from "admin/models/admin-dashboard-next"; -import Report from "admin/models/report"; import VersionCheck from "admin/models/version-check"; const PROBLEMS_CHECK_MINUTES = 1; export default Ember.Controller.extend({ - queryParams: ["period"], - period: "monthly", isLoading: false, dashboardFetchedAt: null, exceptionController: Ember.inject.controller("exception"), showVersionChecks: setting("version_checks"), - diskSpace: Ember.computed.alias("model.attributes.disk_space"), - lastBackupTakenAt: Ember.computed.alias( - "model.attributes.last_backup_taken_at" - ), - logSearchQueriesEnabled: setting("log_search_queries"), - availablePeriods: ["yearly", "quarterly", "monthly", "weekly"], - shouldDisplayDurability: Ember.computed.and("lastBackupTakenAt", "diskSpace"), @computed("problems.length") foundProblems(problemsLength) { return this.currentUser.get("admin") && (problemsLength || 0) > 0; }, + fetchProblems() { + if (this.get("isLoadingProblems")) return; + + if ( + !this.get("problemsFetchedAt") || + moment() + .subtract(PROBLEMS_CHECK_MINUTES, "minutes") + .toDate() > this.get("problemsFetchedAt") + ) { + this._loadProblems(); + } + }, + fetchDashboard() { - if (this.get("isLoading")) return; + const versionChecks = this.siteSettings.version_checks; + + if (this.get("isLoading") || !versionChecks) return; if ( !this.get("dashboardFetchedAt") || @@ -38,22 +42,17 @@ export default Ember.Controller.extend({ ) { this.set("isLoading", true); - const versionChecks = this.siteSettings.version_checks; + AdminDashboardNext.fetch() + .then(model => { + let properties = { + dashboardFetchedAt: new Date() + }; - AdminDashboardNext.find() - .then(adminDashboardNextModel => { if (versionChecks) { - this.set( - "versionCheck", - VersionCheck.create(adminDashboardNextModel.version_check) - ); + properties.versionCheck = VersionCheck.create(model.version_check); } - this.setProperties({ - dashboardFetchedAt: new Date(), - model: adminDashboardNextModel, - reports: adminDashboardNextModel.reports.map(x => Report.create(x)) - }); + this.setProperties(properties); }) .catch(e => { this.get("exceptionController").set("thrown", e.jqXHR); @@ -63,27 +62,17 @@ export default Ember.Controller.extend({ this.set("isLoading", false); }); } - - if ( - !this.get("problemsFetchedAt") || - moment() - .subtract(PROBLEMS_CHECK_MINUTES, "minutes") - .toDate() > this.get("problemsFetchedAt") - ) { - this.loadProblems(); - } }, - loadProblems() { - this.set("loadingProblems", true); - this.set("problemsFetchedAt", new Date()); + _loadProblems() { + this.setProperties({ + loadingProblems: true, + problemsFetchedAt: new Date() + }); + AdminDashboardNext.fetchProblems() - .then(d => { - this.set("problems", d.problems); - }) - .finally(() => { - this.set("loadingProblems", false); - }); + .then(model => this.set("problems", model.problems)) + .finally(() => this.set("loadingProblems", false)); }, @computed("problemsFetchedAt") @@ -93,69 +82,9 @@ export default Ember.Controller.extend({ .format("LLL"); }, - @computed("period") - startDate(period) { - let fullDay = moment() - .locale("en") - .utc() - .subtract(1, "day"); - - switch (period) { - case "yearly": - return fullDay.subtract(1, "year").startOf("day"); - break; - case "quarterly": - return fullDay.subtract(3, "month").startOf("day"); - break; - case "weekly": - return fullDay.subtract(1, "week").startOf("day"); - break; - case "monthly": - return fullDay.subtract(1, "month").startOf("day"); - break; - default: - return fullDay.subtract(1, "month").startOf("day"); - } - }, - - @computed() - lastWeek() { - return moment() - .locale("en") - .utc() - .endOf("day") - .subtract(1, "week"); - }, - - @computed() - endDate() { - return moment() - .locale("en") - .utc() - .subtract(1, "day") - .endOf("day"); - }, - - @computed("model.attributes.updated_at") - updatedTimestamp(updatedAt) { - return moment(updatedAt).format("LLL"); - }, - - @computed("lastBackupTakenAt") - backupTimestamp(lastBackupTakenAt) { - return moment(lastBackupTakenAt).format("LLL"); - }, - actions: { - changePeriod(period) { - DiscourseURL.routeTo(this._reportsForPeriodURL(period)); - }, refreshProblems() { - this.loadProblems(); + this._loadProblems(); } - }, - - _reportsForPeriodURL(period) { - return Discourse.getURL(`/admin?period=${period}`); } }); diff --git a/app/assets/javascripts/admin/controllers/admin-reports-show.js.es6 b/app/assets/javascripts/admin/controllers/admin-reports-show.js.es6 index bd6ff8c49c8..0288b3f7d0e 100644 --- a/app/assets/javascripts/admin/controllers/admin-reports-show.js.es6 +++ b/app/assets/javascripts/admin/controllers/admin-reports-show.js.es6 @@ -1,108 +1,36 @@ -import { exportEntity } from "discourse/lib/export-csv"; -import { outputExportResult } from "discourse/lib/export-result"; -import Report from "admin/models/report"; import computed from "ember-addons/ember-computed-decorators"; export default Ember.Controller.extend({ - queryParams: ["mode", "start_date", "end_date", "category_id", "group_id"], - viewMode: "graph", - viewingTable: Em.computed.equal("viewMode", "table"), - viewingGraph: Em.computed.equal("viewMode", "graph"), - startDate: null, - endDate: null, + queryParams: ["start_date", "end_date", "category_id", "group_id"], categoryId: null, groupId: null, - refreshing: false, - - @computed() - categoryOptions() { - const arr = [{ name: I18n.t("category.all"), value: "all" }]; - return arr.concat( - Discourse.Site.currentProp("sortedCategories").map(i => { - return { name: i.get("name"), value: i.get("id") }; - }) - ); - }, - - @computed() - groupOptions() { - const arr = [ - { name: I18n.t("admin.dashboard.reports.groups"), value: "all" } - ]; - return arr.concat( - this.site.groups.map(i => { - return { name: i["name"], value: i["id"] }; - }) - ); - }, @computed("model.type") - showCategoryOptions(modelType) { - return [ - "topics", - "posts", - "time_to_first_response_total", - "topics_with_no_response", - "flags", - "likes", - "bookmarks" - ].includes(modelType); - }, + reportOptions(type) { + let options = { table: { perPage: 50, limit: 50 } }; - @computed("model.type") - showGroupOptions(modelType) { - return ( - modelType === "visits" || - modelType === "signups" || - modelType === "profile_views" - ); + if (type === "top_referred_topics") { + options.table.limit = 10; + } + + return options; }, actions: { - refreshReport() { - var q; - this.set("refreshing", true); - - this.setProperties({ - start_date: this.get("startDate"), - end_date: this.get("endDate"), - category_id: this.get("categoryId") - }); - - if (this.get("groupId")) { - this.set("group_id", this.get("groupId")); - } - - q = Report.find( - this.get("model.type"), - this.get("startDate"), - this.get("endDate"), - this.get("categoryId"), - this.get("groupId") - ); - q.then(m => this.set("model", m)).finally(() => - this.set("refreshing", false) - ); + onSelectStartDate(startDate) { + this.set("start_date", startDate); }, - viewAsTable() { - this.set("viewMode", "table"); + onSelectCategory(categoryId) { + this.set("category_id", categoryId); }, - viewAsGraph() { - this.set("viewMode", "graph"); + onSelectGroup(groupId) { + this.set("group_id", groupId); }, - exportCsv() { - exportEntity("report", { - name: this.get("model.type"), - start_date: this.get("startDate"), - end_date: this.get("endDate"), - category_id: - this.get("categoryId") === "all" ? undefined : this.get("categoryId"), - group_id: - this.get("groupId") === "all" ? undefined : this.get("groupId") - }).then(outputExportResult); + onSelectEndDate(endDate) { + this.set("end_date", endDate); } } }); diff --git a/app/assets/javascripts/admin/mixins/async-report.js.es6 b/app/assets/javascripts/admin/mixins/async-report.js.es6 deleted file mode 100644 index e788284208f..00000000000 --- a/app/assets/javascripts/admin/mixins/async-report.js.es6 +++ /dev/null @@ -1,108 +0,0 @@ -import computed from "ember-addons/ember-computed-decorators"; -import Report from "admin/models/report"; - -export default Ember.Mixin.create({ - classNameBindings: ["isLoading", "dataSourceNames"], - reports: null, - isLoading: false, - dataSourceNames: "", - title: null, - - init() { - this._super(); - this.set("reports", []); - }, - - @computed("dataSourceNames") - dataSources(dataSourceNames) { - return dataSourceNames.split(",").map(source => `/admin/reports/${source}`); - }, - - buildPayload(facets) { - let payload = { data: { cache: true, facets } }; - - if (this.get("startDate")) { - payload.data.start_date = this.get("startDate").format( - "YYYY-MM-DD[T]HH:mm:ss.SSSZZ" - ); - } - - if (this.get("endDate")) { - payload.data.end_date = this.get("endDate").format( - "YYYY-MM-DD[T]HH:mm:ss.SSSZZ" - ); - } - - if (this.get("limit")) { - payload.data.limit = this.get("limit"); - } - - return payload; - }, - - @computed("reports.[]", "startDate", "endDate", "dataSourceNames") - reportsForPeriod(reports, startDate, endDate, dataSourceNames) { - // on a slow network fetchReport could be called multiple times between - // T and T+x, and all the ajax responses would occur after T+(x+y) - // to avoid any inconsistencies we filter by period and make sure - // the array contains only unique values - reports = reports.uniqBy("report_key"); - - const sort = r => { - if (r.length > 1) { - return dataSourceNames.split(",").map(name => r.findBy("type", name)); - } else { - return r; - } - }; - - if (!startDate || !endDate) { - return sort(reports); - } - - return sort( - reports.filter(report => { - return ( - report.report_key.includes(startDate.format("YYYYMMDD")) && - report.report_key.includes(endDate.format("YYYYMMDD")) - ); - }) - ); - }, - - didInsertElement() { - this._super(); - - this.fetchReport().finally(() => { - this.renderReport(); - }); - }, - - didUpdateAttrs() { - this._super(); - - this.fetchReport().finally(() => { - this.renderReport(); - }); - }, - - renderReport() { - if (!this.element || this.isDestroying || this.isDestroyed) return; - this.set( - "title", - this.get("reportsForPeriod") - .map(r => r.title) - .join(", ") - ); - this.set("isLoading", false); - }, - - loadReport(jsonReport) { - return Report.create(jsonReport); - }, - - fetchReport() { - this.set("reports", []); - this.set("isLoading", true); - } -}); diff --git a/app/assets/javascripts/admin/mixins/period-computation.js.es6 b/app/assets/javascripts/admin/mixins/period-computation.js.es6 new file mode 100644 index 00000000000..8ed2346cd53 --- /dev/null +++ b/app/assets/javascripts/admin/mixins/period-computation.js.es6 @@ -0,0 +1,59 @@ +import DiscourseURL from "discourse/lib/url"; +import computed from "ember-addons/ember-computed-decorators"; + +export default Ember.Mixin.create({ + queryParams: ["period"], + + period: "monthly", + + availablePeriods: ["yearly", "quarterly", "monthly", "weekly"], + + @computed("period") + startDate(period) { + let fullDay = moment() + .locale("en") + .utc() + .subtract(1, "day"); + + switch (period) { + case "yearly": + return fullDay.subtract(1, "year").startOf("day"); + break; + case "quarterly": + return fullDay.subtract(3, "month").startOf("day"); + break; + case "weekly": + return fullDay.subtract(1, "week").startOf("day"); + break; + case "monthly": + return fullDay.subtract(1, "month").startOf("day"); + break; + default: + return fullDay.subtract(1, "month").startOf("day"); + } + }, + + @computed() + lastWeek() { + return moment() + .locale("en") + .utc() + .endOf("day") + .subtract(1, "week"); + }, + + @computed() + endDate() { + return moment() + .locale("en") + .utc() + .subtract(1, "day") + .endOf("day"); + }, + + actions: { + changePeriod(period) { + DiscourseURL.routeTo(this._reportsForPeriodURL(period)); + } + } +}); diff --git a/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 index 7e755a18582..bec0c139bae 100644 --- a/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 +++ b/app/assets/javascripts/admin/models/admin-dashboard-next.js.es6 @@ -1,29 +1,41 @@ import { ajax } from "discourse/lib/ajax"; -const ATTRIBUTES = ["disk_space", "updated_at", "last_backup_taken_at"]; +const GENERAL_ATTRIBUTES = ["disk_space", "updated_at", "last_backup_taken_at"]; const AdminDashboardNext = Discourse.Model.extend({}); AdminDashboardNext.reopenClass({ - /** - Fetch all dashboard data. This can be an expensive request when the cached data - has expired and the server must collect the data again. - - @method find - @return {jqXHR} a jQuery Promise object - **/ - find() { - return ajax("/admin/dashboard-next.json").then(function(json) { - var model = AdminDashboardNext.create(); - - model.set("reports", json.reports); + fetch() { + return ajax("/admin/dashboard-next.json").then(json => { + const model = AdminDashboardNext.create(); model.set("version_check", json.version_check); + return model; + }); + }, + + fetchModeration() { + return ajax("/admin/dashboard/moderation.json").then(json => { + const model = AdminDashboardNext.create(); + model.setProperties({ + reports: json.reports, + loaded: true + }); + return model; + }); + }, + + fetchGeneral() { + return ajax("/admin/dashboard/general.json").then(json => { + const model = AdminDashboardNext.create(); const attributes = {}; - ATTRIBUTES.forEach(a => (attributes[a] = json[a])); - model.set("attributes", attributes); + GENERAL_ATTRIBUTES.forEach(a => (attributes[a] = json[a])); - model.set("loaded", true); + model.setProperties({ + reports: json.reports, + attributes, + loaded: true + }); return model; }); diff --git a/app/assets/javascripts/admin/models/report.js.es6 b/app/assets/javascripts/admin/models/report.js.es6 index c8f4cf3be02..cd31cc779a2 100644 --- a/app/assets/javascripts/admin/models/report.js.es6 +++ b/app/assets/javascripts/admin/models/report.js.es6 @@ -1,22 +1,105 @@ +import { escapeExpression } from "discourse/lib/utilities"; import { ajax } from "discourse/lib/ajax"; import round from "discourse/lib/round"; -import { fillMissingDates } from "discourse/lib/utilities"; +import { fillMissingDates, isNumeric } from "discourse/lib/utilities"; import computed from "ember-addons/ember-computed-decorators"; -import { number } from "discourse/lib/formatter"; +import { number, durationTiny } from "discourse/lib/formatter"; const Report = Discourse.Model.extend({ average: false, percent: false, higher_is_better: true, + @computed("labels") + computedLabels(labels) { + return labels.map(label => { + const type = label.type; + const properties = label.properties; + const property = properties[0]; + + return { + title: label.title, + sort_property: label.sort_property || property, + property, + compute: row => { + let value = row[property]; + let escapedValue = escapeExpression(value); + let tooltip; + let base = { property, value, type }; + + if (value === null || typeof value === "undefined") { + return _.assign(base, { + value: null, + formatedValue: "-", + type: "undefined" + }); + } + + if (type === "seconds") { + return _.assign(base, { + formatedValue: escapeExpression(durationTiny(value)) + }); + } + + if (type === "link") { + return _.assign(base, { + formatedValue: `${escapedValue}` + }); + } + + if (type === "percent") { + return _.assign(base, { + formatedValue: `${escapedValue}%` + }); + } + + if (type === "number" || isNumeric(value)) + return _.assign(base, { + type: "number", + formatedValue: number(value) + }); + + if (type !== "string" && type !== "text") { + const date = moment(value, "YYYY-MM-DD"); + if (type === "date" || date.isValid()) { + return _.assign(base, { + type: "date", + formatedValue: date.format("LL") + }); + } + } + + if (type === "text") tooltip = escapedValue; + + return _.assign(base, { + tooltip, + type: type || "string", + formatedValue: escapedValue + }); + } + }; + }); + }, + + @computed("modes") + onlyTable(modes) { + return modes.length === 1 && modes[0] === "table"; + }, + @computed("type", "start_date", "end_date") reportUrl(type, start_date, end_date) { - start_date = moment(start_date) + start_date = moment + .utc(start_date) .locale("en") .format("YYYY-MM-DD"); - end_date = moment(end_date) + + end_date = moment + .utc(end_date) .locale("en") .format("YYYY-MM-DD"); + return Discourse.getURL( `/admin/reports/${type}?start_date=${start_date}&end_date=${end_date}` ); @@ -142,29 +225,6 @@ const Report = Discourse.Model.extend({ return this._computeTrend(prev30Days, lastThirtyDaysCount, higherIsBetter); }, - @computed("type") - icon(type) { - if (type.indexOf("message") > -1) { - return "envelope"; - } - switch (type) { - case "page_view_total_reqs": - return "file"; - case "visits": - return "user"; - case "time_to_first_response": - return "reply"; - case "flags": - return "flag"; - case "likes": - return "heart"; - case "bookmarks": - return "bookmark"; - default: - return null; - } - }, - @computed("type") method(type) { if (type === "time_to_first_response") { @@ -290,18 +350,23 @@ const Report = Discourse.Model.extend({ }); Report.reopenClass({ - fillMissingDates(report) { - if (_.isArray(report.data)) { + fillMissingDates(report, options = {}) { + const dataField = options.dataField || "data"; + const filledField = options.filledField || "data"; + const startDate = options.startDate || "start_date"; + const endDate = options.endDate || "end_date"; + + if (_.isArray(report[dataField])) { const startDateFormatted = moment - .utc(report.start_date) + .utc(report[startDate]) .locale("en") .format("YYYY-MM-DD"); const endDateFormatted = moment - .utc(report.end_date) + .utc(report[endDate]) .locale("en") .format("YYYY-MM-DD"); - report.data = fillMissingDates( - report.data, + report[filledField] = fillMissingDates( + JSON.parse(JSON.stringify(report[dataField])), startDateFormatted, endDateFormatted ); @@ -317,8 +382,12 @@ Report.reopenClass({ group_id: groupId } }).then(json => { - // Add zero values for missing dates - Report.fillMissingDates(json.report); + // don’t fill for large multi column tables + // which are not date based + const modes = json.report.modes; + if (modes.length !== 1 && modes[0] !== "table") { + Report.fillMissingDates(json.report); + } const model = Report.create({ type: type }); model.setProperties(json.report); diff --git a/app/assets/javascripts/admin/routes/admin-dashboard-next-general.js.es6 b/app/assets/javascripts/admin/routes/admin-dashboard-next-general.js.es6 new file mode 100644 index 00000000000..e73b7247f37 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-dashboard-next-general.js.es6 @@ -0,0 +1,5 @@ +export default Discourse.Route.extend({ + activate() { + this.controllerFor("admin-dashboard-next-general").fetchDashboard(); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-dashboard-next-moderation.js.es6 b/app/assets/javascripts/admin/routes/admin-dashboard-next-moderation.js.es6 new file mode 100644 index 00000000000..6c6dfc7864a --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-dashboard-next-moderation.js.es6 @@ -0,0 +1,5 @@ +export default Discourse.Route.extend({ + activate() { + this.controllerFor("admin-dashboard-next-moderation").fetchDashboard(); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 b/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 index 48d9430f154..5aa907b55ce 100644 --- a/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-dashboard-next.js.es6 @@ -2,7 +2,22 @@ import { scrollTop } from "discourse/mixins/scroll-top"; export default Discourse.Route.extend({ activate() { + this.controllerFor("admin-dashboard-next").fetchProblems(); this.controllerFor("admin-dashboard-next").fetchDashboard(); scrollTop(); + }, + + afterModel(model, transition) { + if (transition.targetName === "admin.dashboardNext.index") { + this.transitionTo("admin.dashboardNext.general"); + } + }, + + actions: { + willTransition(transition) { + if (transition.targetName === "admin.dashboardNext.index") { + this.transitionTo("admin.dashboardNext.general"); + } + } } }); diff --git a/app/assets/javascripts/admin/routes/admin-reports-show.js.es6 b/app/assets/javascripts/admin/routes/admin-reports-show.js.es6 index 9c485c41ef1..fbcd9106439 100644 --- a/app/assets/javascripts/admin/routes/admin-reports-show.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-reports-show.js.es6 @@ -1,35 +1,18 @@ -import Report from "admin/models/report"; - export default Discourse.Route.extend({ - queryParams: { - mode: {}, - start_date: {}, - end_date: {}, - category_id: {}, - group_id: {} - }, + setupController(controller) { + this._super(...arguments); - model(params) { - return Report.find( - params.type, - params["start_date"], - params["end_date"], - params["category_id"], - params["group_id"] - ); - }, + if (!controller.get("start_date")) { + controller.set( + "start_date", + moment() + .subtract("30", "day") + .format("YYYY-MM-DD") + ); + } - setupController(controller, model) { - controller.setProperties({ - model: model, - categoryId: model.get("category_id") || "all", - groupId: model.get("group_id"), - startDate: moment(model.get("start_date")) - .utc() - .format("YYYY-MM-DD"), - endDate: moment(model.get("end_date")) - .utc() - .format("YYYY-MM-DD") - }); + if (!controller.get("end_date")) { + controller.set("end_date", moment().format("YYYY-MM-DD")); + } } }); diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index 2bf0591af98..bdcdcd4a96f 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -1,7 +1,12 @@ export default function() { this.route("admin", { resetNamespace: true }, function() { this.route("dashboard", { path: "/dashboard-old" }); - this.route("dashboardNext", { path: "/" }); + + this.route("dashboardNext", { path: "/" }, function() { + this.route("general", { path: "/dashboard/general" }); + this.route("moderation", { path: "/dashboard/moderation" }); + }); + this.route( "adminSiteSettings", { path: "/site_settings", resetNamespace: true }, diff --git a/app/assets/javascripts/admin/templates/components/admin-report-chart.hbs b/app/assets/javascripts/admin/templates/components/admin-report-chart.hbs new file mode 100644 index 00000000000..dfc603d0cd9 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/admin-report-chart.hbs @@ -0,0 +1,3 @@ +
+ +
diff --git a/app/assets/javascripts/admin/templates/components/admin-report-inline-table.hbs b/app/assets/javascripts/admin/templates/components/admin-report-inline-table.hbs new file mode 100644 index 00000000000..70138f829db --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/admin-report-inline-table.hbs @@ -0,0 +1,15 @@ +
+ {{#each model.data as |data|}} + + + {{#if data.icon}} + {{d-icon data.icon}} + {{/if}} + {{data.x}} + + + {{number data.y}} + + + {{/each}} +
diff --git a/app/assets/javascripts/admin/templates/components/admin-report-table-header.hbs b/app/assets/javascripts/admin/templates/components/admin-report-table-header.hbs new file mode 100644 index 00000000000..4e8f2736c7a --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/admin-report-table-header.hbs @@ -0,0 +1,5 @@ +{{#if showSortingUI}} + {{d-button action=sortByLabel icon=sortIcon class="sort-button"}} +{{/if}} + +{{label.title}} diff --git a/app/assets/javascripts/admin/templates/components/admin-report-table-row.hbs b/app/assets/javascripts/admin/templates/components/admin-report-table-row.hbs new file mode 100644 index 00000000000..b9d0c950227 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/admin-report-table-row.hbs @@ -0,0 +1,5 @@ +{{#each cells as |cell|}} + + {{{cell.formatedValue}}} + +{{/each}} diff --git a/app/assets/javascripts/admin/templates/components/admin-report-table.hbs b/app/assets/javascripts/admin/templates/components/admin-report-table.hbs new file mode 100644 index 00000000000..330dc6f8679 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/admin-report-table.hbs @@ -0,0 +1,60 @@ + + + + {{#if model.computedLabels}} + {{#each model.computedLabels as |label index|}} + {{admin-report-table-header + showSortingUI=showSortingUI + currentSortDirection=sortDirection + currentSortLabel=sortLabel + label=label + sortByLabel=(action "sortByLabel" label)}} + {{/each}} + {{else}} + {{#each model.data as |data|}} + + {{/each}} + {{/if}} + + + + {{#each paginatedData as |data|}} + {{admin-report-table-row data=data labels=model.computedLabels}} + {{/each}} + +
{{data.x}}
+ +{{#if showTotalForSample}} + {{i18n 'admin.dashboard.reports.totals_for_sample'}} + + + + {{#each totalsForSample as |total|}} + + {{/each}} + + +
{{total.formatedValue}}
+{{/if}} + +{{#if showTotal}} + {{i18n 'admin.dashboard.reports.total'}} + + + + + + + +
-{{number model.total}}
+{{/if}} + + diff --git a/app/assets/javascripts/admin/templates/components/admin-report.hbs b/app/assets/javascripts/admin/templates/components/admin-report.hbs new file mode 100644 index 00000000000..0c4c4ee24e0 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/admin-report.hbs @@ -0,0 +1,154 @@ +{{#if isEnabled}} + {{#conditional-loading-section isLoading=isLoading}} + {{#if showTimeoutError}} +
+ {{i18n "admin.dashboard.timeout_error"}} +
+ {{/if}} + + {{#if showHeader}} +
+ {{#if showTitle}} +
+

+ {{#if showAllReportsLink}} + {{#link-to "adminReports" class="all-report-link"}} + {{i18n "admin.dashboard.all_reports"}} + {{/link-to}} + | + {{/if}} + + + {{model.title}} + +

+ + {{#if model.description}} + + {{d-icon "question-circle"}} + + {{/if}} +
+ {{/if}} + + {{#if showTrend}} + {{#if model.prev_period}} +
+ + {{#if model.average}} + {{number model.currentAverage}}{{#if model.percent}}%{{/if}} + {{else}} + {{number model.currentTotal noTitle="true"}}{{#if model.percent}}%{{/if}} + {{/if}} + + + {{#if model.trendIcon}} + {{d-icon model.trendIcon class="trend-icon"}} + {{/if}} +
+ {{/if}} + {{/if}} + + {{#if showModes}} + + {{/if}} +
+ {{/if}} + +
+ {{#unless showTimeoutError}} + {{#if currentMode}} + {{component modeComponent model=model options=options}} + {{/if}} + {{/unless}} + + {{#if showFilteringUI}} + {{#if hasFilteringActions}} +
+ {{#if showDatesOptions}} +
+ + {{i18n 'admin.dashboard.reports.start_date'}} + + +
+ {{date-picker-past + value=startDate + defaultDate=startDate + onSelect=onSelectStartDate}} +
+
+ +
+ + {{i18n 'admin.dashboard.reports.end_date'}} + + +
+ {{date-picker-past + value=endDate + defaultDate=endDate + onSelect=onSelectEndDate}} +
+
+ {{/if}} + + {{#if showCategoryOptions}} +
+
+ {{combo-box + onSelect=onSelectCategory + filterable=true + valueAttribute="value" + content=categoryOptions + castInteger=true + value=categoryId}} +
+
+ {{/if}} + + {{#if showGroupOptions}} +
+
+ {{combo-box + onSelect=onSelectGroup + castInteger=true + filterable=true + valueAttribute="value" + content=groupOptions + value=groupId}} +
+
+ {{/if}} + + {{#if showExport}} +
+
+ {{d-button class="export-btn" action="exportCsv" label="admin.export_csv.button_text" icon="download"}} +
+
+ {{/if}} +
+ {{/if}} + {{/if}} +
+ + {{#if model.relatedReport}} + {{admin-report dataSourceName=model.relatedReport.type}} + {{/if}} + {{/conditional-loading-section}} +{{else}} +
+ {{{i18n disabledLabel}}} +
+{{/if}} diff --git a/app/assets/javascripts/admin/templates/components/admin-table-report.hbs b/app/assets/javascripts/admin/templates/components/admin-table-report.hbs deleted file mode 100644 index 50dfc7526da..00000000000 --- a/app/assets/javascripts/admin/templates/components/admin-table-report.hbs +++ /dev/null @@ -1,37 +0,0 @@ -{{#if model.sortedData}} - - - - - - - {{#each model.sortedData as |row|}} - - - - - {{/each}} - - - - - - - {{#if model.total}} - - - - - {{/if}} -
{{model.xaxis}}{{model.yaxis}}
{{row.x}} - {{row.y}} -
- {{i18n 'admin.dashboard.reports.total_for_period'}} - - {{totalForPeriod}} -
- {{i18n 'admin.dashboard.reports.total'}} - - {{model.total}} -
-{{/if}} diff --git a/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs b/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs deleted file mode 100644 index 9e976f46d26..00000000000 --- a/app/assets/javascripts/admin/templates/components/dashboard-inline-table.hbs +++ /dev/null @@ -1,27 +0,0 @@ -{{#conditional-loading-section isLoading=isLoading}} -
-

{{title}}

-
- - {{#each reportsForPeriod as |report|}} -
- {{#unless hasBlock}} - {{#each report.data as |data|}} - - - {{#if data.icon}} - {{d-icon data.icon}} - {{/if}} - {{data.x}} - - - {{number data.y}} - - - {{/each}} - {{else}} - {{yield (hash report=report)}} - {{/unless}} -
- {{/each}} -{{/conditional-loading-section}} diff --git a/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs b/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs deleted file mode 100644 index 328e0829ff5..00000000000 --- a/app/assets/javascripts/admin/templates/components/dashboard-mini-chart.hbs +++ /dev/null @@ -1,33 +0,0 @@ -{{#conditional-loading-section isLoading=isLoading}} - {{#each reportsForPeriod as |report|}} -
-

- - {{report.title}} - - - - {{d-icon "question-circle"}} - -

- -
- - {{#if report.average}} - {{number report.currentAverage}}{{#if report.percent}}%{{/if}} - {{else}} - {{number report.currentTotal noTitle="true"}}{{#if report.percent}}%{{/if}} - {{/if}} - - - {{#if report.trendIcon}} - {{d-icon report.trendIcon class="trend-icon"}} - {{/if}} -
-
- {{/each}} - -
- -
-{{/conditional-loading-section}} diff --git a/app/assets/javascripts/admin/templates/components/dashboard-table.hbs b/app/assets/javascripts/admin/templates/components/dashboard-table.hbs deleted file mode 100644 index 29ab8332cb7..00000000000 --- a/app/assets/javascripts/admin/templates/components/dashboard-table.hbs +++ /dev/null @@ -1,36 +0,0 @@ -{{#conditional-loading-section isLoading=isLoading}} -
-

{{title}}

-
- - {{#each reportsForPeriod as |report|}} -
- - - - {{#if report.labels}} - {{#each report.labels as |label|}} - - {{/each}} - {{else}} - {{#each report.data as |data|}} - - {{/each}} - {{/if}} - - - - {{#unless hasBlock}} - {{#each report.data as |data|}} - - - - {{/each}} - {{else}} - {{yield (hash report=report)}} - {{/unless}} - -
{{label}}{{data.x}}
{{number data.y}}
-
- {{/each}} -{{/conditional-loading-section}} diff --git a/app/assets/javascripts/admin/templates/dashboard_next.hbs b/app/assets/javascripts/admin/templates/dashboard_next.hbs index e6db196eb96..737593ea3e6 100644 --- a/app/assets/javascripts/admin/templates/dashboard_next.hbs +++ b/app/assets/javascripts/admin/templates/dashboard_next.hbs @@ -3,179 +3,26 @@ {{#if showVersionChecks}}
- {{partial 'admin/templates/version-checks'}} + {{partial "admin/templates/version-checks"}}
{{/if}} -{{partial 'admin/templates/dashboard-problems'}} +{{partial "admin/templates/dashboard-problems"}} -
-
-

{{i18n "admin.dashboard.community_health"}}

- {{period-chooser period=period action="changePeriod" content=availablePeriods fullDay=true}} -
- -
-
- {{dashboard-mini-chart - dataSourceNames="signups" - startDate=startDate - endDate=endDate}} - - {{dashboard-mini-chart - dataSourceNames="topics" - startDate=startDate - endDate=endDate}} - - {{dashboard-mini-chart - dataSourceNames="posts" - startDate=startDate - endDate=endDate}} - - {{dashboard-mini-chart - dataSourceNames="dau_by_mau" - startDate=startDate - endDate=endDate}} - - {{dashboard-mini-chart - dataSourceNames="daily_engaged_users" - startDate=startDate - endDate=endDate}} - - {{dashboard-mini-chart - dataSourceNames="new_contributors" - startDate=startDate - endDate=endDate}} -
-
-
- -
-
-
- {{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.activity_metrics")}} -
-

{{i18n "admin.dashboard.activity_metrics"}}

-
- -
- - - - - - - - - - - - {{#each reports as |report|}} - {{admin-report-counts report=report allTime=false}} - {{/each}} - -
{{i18n 'admin.dashboard.reports.today'}}{{i18n 'admin.dashboard.reports.yesterday'}}{{i18n 'admin.dashboard.reports.last_7_days'}}{{i18n 'admin.dashboard.reports.last_30_days'}}
-
- {{/conditional-loading-section}} -
- {{#link-to "adminReports"}} - {{i18n "admin.dashboard.all_reports"}} + -
- {{dashboard-inline-table dataSourceNames="users_by_type" lastRefreshedAt=lastRefreshedAt}} +{{outlet}} - {{dashboard-inline-table dataSourceNames="users_by_trust_level" lastRefreshedAt=lastRefreshedAt}} -
- {{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.backups")}} -
- {{#if shouldDisplayDurability}} -
- {{#if currentUser.admin}} -
-

- {{d-icon "archive"}} {{i18n "admin.dashboard.backups"}} -

-

- {{diskSpace.backups_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.backups_free}}) -
- {{{i18n "admin.dashboard.lastest_backup" date=backupTimestamp}}} -

-
- {{/if}} - -
-

{{d-icon "upload"}} {{i18n "admin.dashboard.uploads"}}

-

- {{diskSpace.uploads_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.uploads_free}}) -

-
-
- {{/if}} - -
-
-

{{i18n "admin.dashboard.last_updated"}}

-

{{updatedTimestamp}}

- - {{i18n "admin.dashboard.whats_new_in_discourse"}} - -
-
-
- -

- {{i18n 'admin.dashboard.find_old'}} {{#link-to 'admin.dashboard'}}{{i18n "admin.dashboard.old_link"}}{{/link-to}} -

- {{/conditional-loading-section}} -
- -
-
- {{#dashboard-table - dataSourceNames="top_referred_topics" - lastRefreshedAt=lastRefreshedAt - limit=8 - as |context|}} - {{#each context.report.data as |data|}} - - - - {{data.topic_title}} - - - - {{data.num_clicks}} - - - {{/each}} - {{/dashboard-table}} -
- - -
-
+{{plugin-outlet name="admin-dashboard-bottom"}} diff --git a/app/assets/javascripts/admin/templates/dashboard_next_general.hbs b/app/assets/javascripts/admin/templates/dashboard_next_general.hbs new file mode 100644 index 00000000000..ea05c6cc9ec --- /dev/null +++ b/app/assets/javascripts/admin/templates/dashboard_next_general.hbs @@ -0,0 +1,180 @@ +{{#conditional-loading-spinner condition=isLoading}} + {{plugin-outlet name="admin-dashboard-general-top"}} + +
+
+
+

{{i18n "admin.dashboard.community_health"}}

+ {{period-chooser period=period action="changePeriod" content=availablePeriods fullDay=true}} +
+ +
+
+ {{admin-report + dataSourceName="signups" + showTrend=true + forcedModes="chart" + startDate=startDate + endDate=endDate}} + + {{admin-report + dataSourceName="topics" + showTrend=true + forcedModes="chart" + startDate=startDate + endDate=endDate}} + + {{admin-report + dataSourceName="posts" + showTrend=true + forcedModes="chart" + startDate=startDate + endDate=endDate}} + + {{admin-report + dataSourceName="dau_by_mau" + showTrend=true + forcedModes="chart" + startDate=startDate + endDate=endDate}} + + {{admin-report + dataSourceName="daily_engaged_users" + showTrend=true + forcedModes="chart" + startDate=startDate + endDate=endDate}} + + {{admin-report + dataSourceName="new_contributors" + showTrend=true + forcedModes="chart" + startDate=startDate + endDate=endDate}} +
+
+
+
+ +
+
+
+ {{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.activity_metrics")}} +
+
+

+ {{#link-to "adminReports" class="report-link"}} + {{i18n "admin.dashboard.activity_metrics"}} + {{/link-to}} +

+
+
+ +
+
+ + + + + + + + + + + + {{#each activityMetricsReports as |report|}} + {{admin-report-counts report=report allTime=false class="admin-report-table-row"}} + {{/each}} + +
+ {{i18n 'admin.dashboard.reports.today'}} + + {{i18n 'admin.dashboard.reports.yesterday'}} + + {{i18n 'admin.dashboard.reports.last_7_days'}} + + {{i18n 'admin.dashboard.reports.last_30_days'}} +
+
+
+ {{/conditional-loading-section}} +
+ {{#link-to "adminReports"}} + {{i18n "admin.dashboard.all_reports"}} + {{/link-to}} + +
+ {{admin-report + forcedModes="inline-table" + report=usersByTypeReport + lastRefreshedAt=lastRefreshedAt}} + + {{admin-report + forcedModes="inline-table" + report=usersByTrustLevelReport + lastRefreshedAt=lastRefreshedAt}} +
+ + {{#conditional-loading-section isLoading=isLoading title=(i18n "admin.dashboard.backups")}} +
+ + {{#if shouldDisplayDurability}} +
+ {{#if currentUser.admin}} +
+

+ {{d-icon "archive"}} {{i18n "admin.dashboard.backups"}} +

+

+ {{diskSpace.backups_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.backups_free}}) +
+ {{{i18n "admin.dashboard.lastest_backup" date=backupTimestamp}}} +

+
+ {{/if}} + +
+

{{d-icon "upload"}} {{i18n "admin.dashboard.uploads"}}

+

+ {{diskSpace.uploads_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.uploads_free}}) +

+
+
+ {{/if}} + +
+
+

{{i18n "admin.dashboard.last_updated"}}

+

{{updatedTimestamp}}

+ + {{i18n "admin.dashboard.whats_new_in_discourse"}} + +
+
+
+ +

+ {{i18n 'admin.dashboard.find_old'}} {{#link-to 'admin.dashboard'}}{{i18n "admin.dashboard.old_link"}}{{/link-to}} +

+ {{/conditional-loading-section}} +
+ +
+ {{admin-report + report=topReferredTopicsReport + reportOptions=topReferredTopicsTopions}} + + {{admin-report + reportOptions=trendingSearchOptions + report=trendingSearchReport + isEnabled=logSearchQueriesEnabled + disabledLabel="admin.dashboard.reports.trending_search.disabled" + startDate=startDate + endDate=endDate}} + {{{i18n "admin.dashboard.reports.trending_search.more"}}} +
+
+ + {{plugin-outlet name="admin-dashboard-general-bottom"}} +{{/conditional-loading-spinner}} diff --git a/app/assets/javascripts/admin/templates/dashboard_next_moderation.hbs b/app/assets/javascripts/admin/templates/dashboard_next_moderation.hbs new file mode 100644 index 00000000000..950d2a7176f --- /dev/null +++ b/app/assets/javascripts/admin/templates/dashboard_next_moderation.hbs @@ -0,0 +1,40 @@ +{{#conditional-loading-spinner condition=isLoading}} +
+ {{plugin-outlet name="admin-dashboard-moderation-top"}} + +
+
+

{{i18n "admin.dashboard.moderators_activity"}}

+ {{period-chooser + period=period + action="changePeriod" + content=availablePeriods + fullDay=true}} +
+ +
+ {{admin-report + startDate=startDate + endDate=endDate + showHeader=false + dataSourceName="moderators_activity"}} +
+
+ +
+ {{admin-report + report=flagsStatusReport + startDate=lastWeek + reportOptions=flagsStatusOptions + endDate=endDate}} + + {{admin-report + report=postEditsReport + startDate=lastWeek + endDate=endDate}} + + {{plugin-outlet name="admin-dashboard-moderation-bottom"}} +
+
+ +{{/conditional-loading-spinner}} diff --git a/app/assets/javascripts/admin/templates/reports-show.hbs b/app/assets/javascripts/admin/templates/reports-show.hbs index bfd8781989b..6be7401f8b8 100644 --- a/app/assets/javascripts/admin/templates/reports-show.hbs +++ b/app/assets/javascripts/admin/templates/reports-show.hbs @@ -1,60 +1,16 @@ -

- {{#link-to "adminReports"}} - {{i18n "admin.dashboard.all_reports"}} - {{/link-to}} - - | - - {{model.title}} -

- -{{#if model.description}} -

{{model.description}}

-{{/if}} -
- {{#conditional-loading-spinner condition=refreshing}} -
- {{#if viewingTable}} - {{i18n 'admin.dashboard.reports.view_table'}} - {{else}} - {{i18n 'admin.dashboard.reports.view_table'}} - {{/if}} - | - {{#if viewingGraph}} - {{i18n 'admin.dashboard.reports.view_graph'}} - {{else}} - {{i18n 'admin.dashboard.reports.view_graph'}} - {{/if}} -
- - {{#if viewingGraph}} - {{admin-graph model=model}} - {{else}} - {{admin-table-report model=model}} - {{/if}} - - {{#if model.relatedReport}} - {{admin-table-report model=model.relatedReport}} - {{/if}} - {{/conditional-loading-spinner}} -
- -
- - {{i18n 'admin.dashboard.reports.start_date'}} {{date-picker-past value=startDate defaultDate=startDate}} - - - {{i18n 'admin.dashboard.reports.end_date'}} {{date-picker-past value=endDate defaultDate=endDate}} - - {{#if showCategoryOptions}} - {{combo-box filterable=true valueAttribute="value" content=categoryOptions value=categoryId}} - {{/if}} - {{#if showGroupOptions}} - {{combo-box filterable=true valueAttribute="value" content=groupOptions value=groupId}} - {{/if}} - {{d-button action="refreshReport" class="btn-primary" label="admin.dashboard.reports.refresh_report" icon="refresh"}} - {{d-button action="exportCsv" label="admin.export_csv.button_text" icon="download"}} + {{admin-report + showAllReportsLink=true + dataSourceName=model.type + categoryId=category_id + groupId=group_id + reportOptions=reportOptions + startDate=start_date + endDate=end_date + showFilteringUI=true + onSelectCategory=(action "onSelectCategory") + onSelectStartDate=(action "onSelectStartDate") + onSelectEndDate=(action "onSelectEndDate")}}
diff --git a/app/assets/javascripts/discourse/components/date-picker-past.js.es6 b/app/assets/javascripts/discourse/components/date-picker-past.js.es6 index d858580d04a..027798d2eaf 100644 --- a/app/assets/javascripts/discourse/components/date-picker-past.js.es6 +++ b/app/assets/javascripts/discourse/components/date-picker-past.js.es6 @@ -5,7 +5,8 @@ export default DatePicker.extend({ _opts() { return { - defaultDate: new Date(this.get("defaultDate")) || new Date(), + defaultDate: + moment(this.get("defaultDate"), "YYYY-MM-DD").toDate() || new Date(), setDefaultDate: !!this.get("defaultDate"), maxDate: new Date() }; diff --git a/app/assets/javascripts/discourse/components/date-picker.js.es6 b/app/assets/javascripts/discourse/components/date-picker.js.es6 index 982221fd906..35241ae67b0 100644 --- a/app/assets/javascripts/discourse/components/date-picker.js.es6 +++ b/app/assets/javascripts/discourse/components/date-picker.js.es6 @@ -29,13 +29,17 @@ export default Ember.Component.extend({ weekdays: moment.weekdays(), weekdaysShort: moment.weekdaysShort() }, - onSelect: date => - this.set( - "value", - moment(date) - .locale("en") - .format("YYYY-MM-DD") - ) + onSelect: date => { + const formattedDate = moment(date).format("YYYY-MM-DD"); + + if (this.attrs.onSelect) { + this.attrs.onSelect(formattedDate); + } + + if (!this.element || this.isDestroying || this.isDestroyed) return; + + this.set("value", formattedDate); + } }; this._picker = new Pikaday(_.merge(default_opts, this._opts())); diff --git a/app/assets/javascripts/discourse/lib/utilities.js.es6 b/app/assets/javascripts/discourse/lib/utilities.js.es6 index be5ef8cd9af..708b6639566 100644 --- a/app/assets/javascripts/discourse/lib/utilities.js.es6 +++ b/app/assets/javascripts/discourse/lib/utilities.js.es6 @@ -591,6 +591,10 @@ export function clipboardData(e, canUpload) { return { clipboard, types, canUpload, canPasteHtml }; } +export function isNumeric(input) { + return !isNaN(parseFloat(input)) && isFinite(input); +} + export function fillMissingDates(data, startDate, endDate) { const startMoment = moment(startDate, "YYYY-MM-DD"); const endMoment = moment(endDate, "YYYY-MM-DD"); diff --git a/app/assets/javascripts/select-kit/components/select-kit.js.es6 b/app/assets/javascripts/select-kit/components/select-kit.js.es6 index 150b5442540..18e45adfa93 100644 --- a/app/assets/javascripts/select-kit/components/select-kit.js.es6 +++ b/app/assets/javascripts/select-kit/components/select-kit.js.es6 @@ -447,7 +447,7 @@ export default Ember.Component.extend( if (get(this.actions, actionName)) { run.next(() => this.send(actionName, ...params)); } else if (this.get(actionName)) { - run.next(() => this.get(actionName)()); + run.next(() => this.get(actionName)(...params)); } }, diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 9572c81aa9e..b424d4dac53 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -932,4 +932,8 @@ table#user-badges { @import "common/admin/backups"; @import "common/admin/plugins"; @import "common/admin/admin_reports"; +@import "common/admin/admin_report"; +@import "common/admin/admin_report_chart"; +@import "common/admin/admin_report_table"; +@import "common/admin/admin_report_inline_table"; @import "common/admin/dashboard_previous"; diff --git a/app/assets/stylesheets/common/admin/admin_report.scss b/app/assets/stylesheets/common/admin/admin_report.scss new file mode 100644 index 00000000000..7ca934f8b40 --- /dev/null +++ b/app/assets/stylesheets/common/admin/admin_report.scss @@ -0,0 +1,127 @@ +.admin-report { + .report-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1em; + + .report-title { + align-items: center; + display: flex; + justify-content: space-between; + + .title { + margin: 0; + padding: 0; + border: 0; + font-size: $font-up-1; + .separator { + font-weight: normal; + } + + .report-link { + color: $primary; + } + + .separator + .report-link { + font-weight: normal; + } + } + + .info { + cursor: pointer; + margin-left: 0.25em; + color: $primary-low-mid; + + &:hover { + color: $primary-medium; + } + } + } + + .trend { + align-items: center; + + &.trending-down, + &.high-trending-down { + color: $danger; + } + + &.trending-up, + &.high-trending-up { + color: $success; + } + + &.no-change { + color: $primary-medium; + } + + .trend-value { + font-size: $font-up-1; + } + + .trend-icon { + font-size: $font-up-1; + font-weight: 700; + } + } + + .mode-switch { + list-style: none; + display: flex; + margin: 0; + + .mode { + display: inline; + + .mode-button.current { + color: $tertiary; + } + } + } + } + + .report-body { + display: flex; + justify-content: space-between; + + .admin-report-table, + .admin-report-chart { + width: 100%; + } + + .report-filters { + margin-left: 1em; + min-width: 250px; + display: flex; + flex-direction: column; + + .filtering-control { + display: flex; + flex-direction: column; + margin-bottom: 1em; + } + .filtering-label { + } + .filtering-input { + width: 100%; + + .date-picker-wrapper, + .combo-box, + .export-btn { + width: 100%; + + .date-picker { + width: 100%; + box-sizing: border-box; + margin: 0; + } + } + } + } + + .report-filters:only-child { + margin-left: auto; + } + } +} diff --git a/app/assets/stylesheets/common/admin/admin_report_chart.scss b/app/assets/stylesheets/common/admin/admin_report_chart.scss new file mode 100644 index 00000000000..b3c3489c4ea --- /dev/null +++ b/app/assets/stylesheets/common/admin/admin_report_chart.scss @@ -0,0 +1,2 @@ +.admin-report-chart { +} diff --git a/app/assets/stylesheets/common/admin/admin_report_inline_table.scss b/app/assets/stylesheets/common/admin/admin_report_inline_table.scss new file mode 100644 index 00000000000..4c906fa7a37 --- /dev/null +++ b/app/assets/stylesheets/common/admin/admin_report_inline_table.scss @@ -0,0 +1,7 @@ +.admin-report-inline-table { + .table-container { + display: flex; + flex-wrap: wrap; + flex: 1 1 auto; + } +} diff --git a/app/assets/stylesheets/common/admin/admin_report_table.scss b/app/assets/stylesheets/common/admin/admin_report_table.scss new file mode 100644 index 00000000000..4f042e08650 --- /dev/null +++ b/app/assets/stylesheets/common/admin/admin_report_table.scss @@ -0,0 +1,101 @@ +.admin-report-table { + @media screen and (max-width: 650px) { + table { + tbody tr td { + font-size: $font-down-1; + } + } + } + + .pagination { + display: flex; + justify-content: flex-end; + margin-top: 0.5em; + + button { + margin-left: 0.5em; + + &.current { + color: $tertiary; + } + } + } + + &.two-columns { + .report-table tbody tr td:first-child, + .report-table thead tr th:first-child { + text-align: left; + } + + .report-table { + table-layout: auto; + } + } + + table { + table-layout: fixed; + border: 1px solid $primary-low; + margin-top: 0; + margin-bottom: 10px; + + tbody { + border: none; + + tr { + td { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + td { + text-align: center; + padding: 8px; + } + } + } + + thead { + border: 1px solid $primary-low; + + .admin-report-table-header { + .sort-button { + outline: none; + background: none; + + overflow: hidden; + text-overflow: ellipsis; + } + + &.is-current-sort { + .d-icon { + color: $tertiary; + } + + .sort-button:hover { + color: $primary-medium; + background: $primary-low; + } + } + + &:not(.is-current-sort) .sort-button { + background: none; + + &:hover { + color: $primary-medium; + background: $primary-low; + } + } + } + + tr { + th { + text-align: center; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } + } + } +} diff --git a/app/assets/stylesheets/common/admin/admin_reports.scss b/app/assets/stylesheets/common/admin/admin_reports.scss index 8fab3da17ff..137bf814e57 100644 --- a/app/assets/stylesheets/common/admin/admin_reports.scss +++ b/app/assets/stylesheets/common/admin/admin_reports.scss @@ -1,8 +1,16 @@ .admin-reports { - h3 { - border-bottom: 1px solid $primary-low; - margin-bottom: 0.5em; - padding-bottom: 0.5em; + .admin-report { + width: 100%; + + .report-header { + padding-bottom: 0.5em; + margin-bottom: 1em; + border-bottom: 1px solid $primary-low; + } + } + + .admin-report-chart .chart-canvas-container .chart-canvas { + height: 400px; } .reports-list { diff --git a/app/assets/stylesheets/common/admin/dashboard_next.scss b/app/assets/stylesheets/common/admin/dashboard_next.scss index e49893f894a..846d89c20bf 100644 --- a/app/assets/stylesheets/common/admin/dashboard_next.scss +++ b/app/assets/stylesheets/common/admin/dashboard_next.scss @@ -10,6 +10,40 @@ margin-bottom: 1em; } + .navigation { + display: flex; + margin: 0 0 1em 0; + + .navigation-item { + display: inline; + background: $secondary; + padding: 0.5em 1em; + } + + .navigation-link { + font-weight: 700; + } + } + + @mixin active-navigation-item { + border-radius: 3px 3px 0 0; + border: 1px solid $primary-low; + border-bottom: 10px solid $secondary; + } + + &.moderation .navigation-item.moderation { + @include active-navigation-item; + } + + &.general .navigation-item.general { + @include active-navigation-item; + } + + .sections { + display: flex; + flex-direction: column; + } + .section-columns { display: flex; justify-content: space-between; @@ -177,7 +211,7 @@ } } - .community-health { + .section { .period-chooser .period-chooser-header { .selected-name, .d-icon { @@ -203,105 +237,7 @@ } } -.dashboard-mini-chart { - .status { - display: flex; - justify-content: space-between; - margin-bottom: 0.5em; - padding: 0 0.45em 0 0; - - .title { - font-size: $font-up-1; - font-weight: 700; - margin: 0; - - a { - color: $primary; - } - - .info { - cursor: pointer; - margin-left: 0.25em; - color: $primary-low-mid; - - &:hover { - color: $primary-medium; - } - } - } - - .trend { - align-items: center; - - &.trending-down, - &.high-trending-down { - color: $danger; - } - - &.trending-up, - &.high-trending-up { - color: $success; - } - - &.no-change { - color: $primary-medium; - } - - .trend-value { - font-size: $font-up-1; - } - - .trend-icon { - font-size: $font-up-1; - font-weight: 700; - } - } - } - - .conditional-loading-section { - display: flex; - flex-direction: column; - justify-content: space-between; - flex: 1; - width: 100%; - min-width: 0; - } - - @include breakpoint(medium) { - max-width: 100%; - } - - &.is-loading { - height: 200px; - } - - .loading-container.visible { - display: flex; - align-items: center; - height: 100%; - width: 100%; - } - - .tooltip { - cursor: pointer; - } - - .chart-title { - align-items: center; - display: flex; - justify-content: space-between; - - h3 { - margin: 1em 0; - a, - a:visited { - color: $primary; - } - } - } -} - -.dashboard-table { +.admin-report-table { margin-bottom: 1em; &.is-disabled { @@ -320,92 +256,14 @@ &.is-loading { height: 150px; } - - .table-title { - align-items: center; - display: flex; - justify-content: space-between; - - h3 { - margin: 1em 0 0 0; - } - } - - table { - table-layout: fixed; - border: 1px solid $primary-low; - - thead { - border: 1px solid $primary-low; - tr { - th { - text-align: center; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - } - } - - tbody { - border-top: none; - tr { - td:first-child { - text-overflow: ellipsis; - overflow: hidden; - white-space: normal; - } - - td { - text-align: center; - padding: 8px; - } - - td.left { - text-align: left; - } - - td.title { - text-align: left; - } - - td.value { - text-align: right; - padding: 8px 21px 8px 8px; // accounting for negative right caret margin - &:nth-of-type(2) { - padding: 8px 12px 8px; - } - i { - margin-right: -12px; // align on caret - @media screen and (max-width: 650px) { - margin-right: -9px; - } - } - - &.high-trending-up, - &.trending-up { - i { - color: $success; - } - } - &.high-trending-down, - &.trending-down { - i { - color: $danger; - } - } - } - } - } - } } .user-metrics { display: flex; flex-wrap: wrap; justify-content: space-between; - margin-left: -5%; - margin: 2em 0 0.75em -5%; // Negative margin allows for a margin when in 2-columns, + flex-direction: column; + .dashboard-inline-table { // and "hides" margin when the item spans 100% width flex: 1 0 auto; @@ -468,23 +326,7 @@ } } -.dashboard-inline-table { - margin-left: 5%; - margin-bottom: 1.25em; - - .table-title { - border-bottom: 1px solid $primary-low; - margin-bottom: 1em; - } - - .table-container { - display: flex; - flex-wrap: wrap; - flex: 1 1 auto; - } -} - -.dashboard-table.activity-metrics { +.admin-report.activity-metrics { table { @media screen and (min-width: 400px) { table-layout: auto; @@ -505,6 +347,60 @@ padding: 8px 0 8px 4px; } } + + tr { + td { + text-overflow: unset; + overflow: auto; + white-space: normal; + } + + td:first-child { + text-overflow: ellipsis; + overflow: hidden; + white-space: normal; + } + } + + td { + text-align: center; + padding: 8px; + } + + td.left { + text-align: left; + } + + td.title { + text-align: left; + } + + td.value { + text-align: right; + padding: 8px 21px 8px 8px; // accounting for negative right caret margin + &:nth-of-type(2) { + padding: 8px 12px 8px; + } + i { + margin-right: -12px; // align on caret + @media screen and (max-width: 650px) { + margin-right: -9px; + } + } + + &.high-trending-up, + &.trending-up { + i { + color: $success; + } + } + &.high-trending-down, + &.trending-down { + i { + color: $danger; + } + } + } } } @@ -547,3 +443,81 @@ } } } + +.community-health.section { + margin-bottom: 1em; +} + +.admin-report.activity-metrics { + table { + table-layout: auto; + } +} + +.admin-report.users-by-type { + margin-top: 1em; +} + +.admin-report.users-by-type, +.admin-report.users-by-trust-level { + margin-bottom: 1em; + flex: 1; + .report-header { + border-bottom: 1px solid $primary-medium; + padding-bottom: 0.5em; + border-bottom: 1px solid #e9e9e9; + } +} + +.admin-report.moderators-activity { + tbody tr td.username, + thead tr th.username { + text-align: left; + } +} + +.admin-report.trending-search { + tbody tr td.term, + thead tr th.term { + text-align: left; + } +} + +.admin-report.top-traffic-sources { + tbody tr td.domain, + thead tr th.domain { + text-align: left; + } +} + +.dashboard-next.moderation { + .admin-dashboard-moderation-top { + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-column-gap: 1em; + } + + .main-section { + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-column-gap: 1em; + + > * { + grid-column: span 12; + } + + .admin-dashboard-moderation-bottom-outlet { + display: grid; + grid-template-columns: repeat(12, 1fr); + grid-column-gap: 1em; + } + } + + .admin-report.flags-status { + grid-column: span 6; + } + + .admin-report.post-edits { + grid-column: span 6; + } +} diff --git a/app/controllers/admin/dashboard_next_controller.rb b/app/controllers/admin/dashboard_next_controller.rb index a564fd64ee2..4ce90949535 100644 --- a/app/controllers/admin/dashboard_next_controller.rb +++ b/app/controllers/admin/dashboard_next_controller.rb @@ -2,9 +2,35 @@ require 'disk_space' class Admin::DashboardNextController < Admin::AdminController def index - dashboard_data = AdminDashboardNextData.fetch_cached_stats - dashboard_data.merge!(version_check: DiscourseUpdates.check_version.as_json) if SiteSetting.version_checks? - dashboard_data[:disk_space] = DiskSpace.cached_stats if SiteSetting.enable_backups - render json: dashboard_data + data = AdminDashboardNextIndexData.fetch_cached_stats + + if SiteSetting.version_checks? + data.merge!(version_check: DiscourseUpdates.check_version.as_json) + end + + render json: data + end + + def moderation + render json: AdminDashboardNextModerationData.fetch_cached_stats + end + + def general + data = AdminDashboardNextGeneralData.fetch_cached_stats + + if SiteSetting.enable_backups + data[:last_backup_taken_at] = last_backup_taken_at + data[:disk_space] = DiskSpace.cached_stats + end + + render json: data + end + + private + + def last_backup_taken_at + if last_backup = Backup.all.last + File.ctime(last_backup.path).utc + end end end diff --git a/app/jobs/scheduled/dashboard_stats.rb b/app/jobs/scheduled/dashboard_stats.rb index d475075c4df..9fa0056ff71 100644 --- a/app/jobs/scheduled/dashboard_stats.rb +++ b/app/jobs/scheduled/dashboard_stats.rb @@ -1,4 +1,7 @@ require_dependency 'admin_dashboard_data' +require_dependency 'admin_dashboard_next_index_data' +require_dependency 'admin_dashboard_next_general_data' +require_dependency 'admin_dashboard_next_moderation_data' require_dependency 'group' require_dependency 'group_message' @@ -15,7 +18,9 @@ module Jobs end # TODO: decide if we want to keep caching this every 30 minutes - AdminDashboardNextData.refresh_stats + AdminDashboardNextIndexData.refresh_stats + AdminDashboardNextGeneralData.refresh_stats + AdminDashboardNextModerationData.refresh_stats AdminDashboardData.refresh_stats end end diff --git a/app/models/admin_dashboard_next_data.rb b/app/models/admin_dashboard_next_data.rb index 210fff51cd1..292f78a63be 100644 --- a/app/models/admin_dashboard_next_data.rb +++ b/app/models/admin_dashboard_next_data.rb @@ -1,51 +1,31 @@ class AdminDashboardNextData include StatsCacheable - REPORTS = %w{ - page_view_total_reqs - visits - time_to_first_response - likes - flags - user_to_user_private_messages_with_replies - } - def initialize(opts = {}) @opts = opts end def self.fetch_stats - AdminDashboardNextData.new.as_json + self.class.new.as_json end - def self.stats_cache_key - 'dash-next-stats' + def self.fetch_stats + new.as_json + end + + def get_json + {} end def as_json(_options = nil) @json ||= get_json end - def get_json - json = { - reports: AdminDashboardNextData.reports(REPORTS), - updated_at: Time.zone.now.as_json - } - - if SiteSetting.enable_backups - json[:last_backup_taken_at] = last_backup_taken_at - end - - json - end - - def last_backup_taken_at - if last_backup = Backup.all.last - File.ctime(last_backup.path).utc - end - end - def self.reports(source) source.map { |type| Report.find(type).as_json } end + + def self.stats_cache_key + 'dashboard-next-data' + end end diff --git a/app/models/admin_dashboard_next_general_data.rb b/app/models/admin_dashboard_next_general_data.rb new file mode 100644 index 00000000000..36c5d90cbb2 --- /dev/null +++ b/app/models/admin_dashboard_next_general_data.rb @@ -0,0 +1,27 @@ +class AdminDashboardNextGeneralData < AdminDashboardNextData + def reports + @reports ||= %w{ + page_view_total_reqs + visits + time_to_first_response + likes + flags + user_to_user_private_messages_with_replies + top_referred_topics + users_by_type + users_by_trust_level + trending_search + } + end + + def get_json + { + reports: self.class.reports(reports), + updated_at: Time.zone.now.as_json + } + end + + def self.stats_cache_key + 'general-dashboard-data' + end +end diff --git a/app/models/admin_dashboard_next_index_data.rb b/app/models/admin_dashboard_next_index_data.rb new file mode 100644 index 00000000000..d5eb0677eb8 --- /dev/null +++ b/app/models/admin_dashboard_next_index_data.rb @@ -0,0 +1,14 @@ +class AdminDashboardNextIndexData < AdminDashboardNextData + def get_json + { + updated_at: Time.zone.now.as_json + } + end + + def self.stats_cache_key + 'index-dashboard-data' + end + + # TODO: problems should be loaded from this model + # and not from a separate model/route +end diff --git a/app/models/admin_dashboard_next_moderation_data.rb b/app/models/admin_dashboard_next_moderation_data.rb new file mode 100644 index 00000000000..b97375b048f --- /dev/null +++ b/app/models/admin_dashboard_next_moderation_data.rb @@ -0,0 +1,19 @@ +class AdminDashboardNextModerationData < AdminDashboardNextData + def reports + @reports ||= %w{ + flags_status + post_edits + } + end + + def get_json + { + reports: self.class.reports(reports), + updated_at: Time.zone.now.as_json + } + end + + def self.stats_cache_key + 'moderation-dashboard-data' + end +end diff --git a/app/models/incoming_links_report.rb b/app/models/incoming_links_report.rb index 897bf3e50ac..38e58d0e531 100644 --- a/app/models/incoming_links_report.rb +++ b/app/models/incoming_links_report.rb @@ -1,6 +1,6 @@ class IncomingLinksReport - attr_accessor :type, :data, :y_titles, :start_date, :limit + attr_accessor :type, :data, :y_titles, :start_date, :end_date, :limit def initialize(type) @type = type @@ -15,7 +15,8 @@ class IncomingLinksReport xaxis: I18n.t("reports.#{self.type}.xaxis"), ytitles: self.y_titles, data: self.data, - start_date: start_date + start_date: start_date, + end_date: end_date } end @@ -27,6 +28,7 @@ class IncomingLinksReport report = IncomingLinksReport.new(type) report.start_date = _opts[:start_date] || 30.days.ago + report.end_date = _opts[:end_date] || Time.now.end_of_day report.limit = _opts[:limit].to_i if _opts[:limit] send(report_method, report) @@ -38,8 +40,8 @@ class IncomingLinksReport report.y_titles[:num_clicks] = I18n.t("reports.#{report.type}.num_clicks") report.y_titles[:num_topics] = I18n.t("reports.#{report.type}.num_topics") - num_clicks = link_count_per_user(start_date: report.start_date) - num_topics = topic_count_per_user(start_date: report.start_date) + num_clicks = link_count_per_user(start_date: report.start_date, end_date: report.end_date) + num_topics = topic_count_per_user(start_date: report.start_date, end_date: report.end_date) user_id_lookup = User.where(username: num_clicks.keys).select(:id, :username).inject({}) { |sum, v| sum[v.username] = v.id; sum; } report.data = [] num_clicks.each_key do |username| @@ -48,19 +50,19 @@ class IncomingLinksReport report.data = report.data.sort_by { |x| x[:num_clicks] }.reverse[0, 10] end - def self.per_user(start_date:) + def self.per_user(start_date:, end_date:) @per_user_query ||= public_incoming_links - .where('incoming_links.created_at > ? AND incoming_links.user_id IS NOT NULL', start_date) + .where('incoming_links.created_at > ? AND incoming_links.created_at < ? AND incoming_links.user_id IS NOT NULL', start_date, end_date) .joins(:user) .group('users.username') end - def self.link_count_per_user(start_date:) - per_user(start_date: start_date).count + def self.link_count_per_user(start_date:, end_date:) + per_user(start_date: start_date, end_date: end_date).count end - def self.topic_count_per_user(start_date:) - per_user(start_date: start_date).joins(:post).count("DISTINCT posts.topic_id") + def self.topic_count_per_user(start_date:, end_date:) + per_user(start_date: start_date, end_date: end_date).joins(:post).count("DISTINCT posts.topic_id") end # Return top 10 domains that brought traffic to the site within the last 30 days @@ -69,7 +71,7 @@ class IncomingLinksReport report.y_titles[:num_topics] = I18n.t("reports.#{report.type}.num_topics") report.y_titles[:num_users] = I18n.t("reports.#{report.type}.num_users") - num_clicks = link_count_per_domain(start_date: report.start_date) + num_clicks = link_count_per_domain(start_date: report.start_date, end_date: report.end_date) num_topics = topic_count_per_domain(num_clicks.keys) report.data = [] num_clicks.each_key do |domain| @@ -78,9 +80,9 @@ class IncomingLinksReport report.data = report.data.sort_by { |x| x[:num_clicks] }.reverse[0, 10] end - def self.link_count_per_domain(limit: 10, start_date:) + def self.link_count_per_domain(limit: 10, start_date:, end_date:) public_incoming_links - .where('incoming_links.created_at > ?', start_date) + .where('incoming_links.created_at > ? AND incoming_links.created_at < ?', start_date, end_date) .joins(incoming_referer: :incoming_domain) .group('incoming_domains.name') .order('count_all DESC') @@ -102,7 +104,7 @@ class IncomingLinksReport def self.report_top_referred_topics(report) report.y_titles[:num_clicks] = I18n.t("reports.#{report.type}.num_clicks") - num_clicks = link_count_per_topic(start_date: report.start_date) + num_clicks = link_count_per_topic(start_date: report.start_date, end_date: report.end_date) num_clicks = num_clicks.to_a.sort_by { |x| x[1] }.last(report.limit || 10).reverse report.data = [] topics = Topic.select('id, slug, title').where('id in (?)', num_clicks.map { |z| z[0] }) @@ -115,9 +117,9 @@ class IncomingLinksReport report.data end - def self.link_count_per_topic(start_date:) + def self.link_count_per_topic(start_date:, end_date:) public_incoming_links - .where('incoming_links.created_at > ? AND topic_id IS NOT NULL', start_date) + .where('incoming_links.created_at > ? AND incoming_links.created_at < ? AND topic_id IS NOT NULL', start_date, end_date) .group('topic_id') .count end diff --git a/app/models/report.rb b/app/models/report.rb index 218a614136f..d0366d517cd 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -1,11 +1,12 @@ require_dependency 'topic_subtype' class Report - attr_accessor :type, :data, :total, :prev30Days, :start_date, :end_date, :category_id, :group_id, :labels, :async, :prev_period, :facets, :limit, :processing, :average, :percent, - :higher_is_better + :higher_is_better, :icon, :modes, :category_filtering, + :group_filtering, :prev_data, :prev_start_date, :prev_end_date, + :dates_filtering, :timeout def self.default_days 30 @@ -15,9 +16,15 @@ class Report @type = type @start_date ||= Report.default_days.days.ago.beginning_of_day @end_date ||= Time.zone.now.end_of_day + @prev_end_date = @start_date @average = false @percent = false @higher_is_better = true + @category_filtering = false + @group_filtering = false + @modes = [:table, :chart] + @prev_data = nil + @dates_filtering = true end def self.cache_key(report) @@ -39,6 +46,28 @@ class Report end end + def self.wrap_slow_query(timeout = 20000) + begin + ActiveRecord::Base.connection.transaction do + # Set a statement timeout so we can't tie up the server + DB.exec "SET LOCAL statement_timeout = #{timeout}" + yield + end + rescue ActiveRecord::QueryCanceled + return :timeout + end + + nil + end + + def prev_start_date + self.start_date - (self.end_date - self.start_date) + end + + def prev_end_date + self.start_date + end + def as_json(options = nil) description = I18n.t("reports.#{type}.description", default: "") @@ -51,18 +80,29 @@ class Report data: data, start_date: start_date&.iso8601, end_date: end_date&.iso8601, + prev_data: self.prev_data, + prev_start_date: prev_start_date&.iso8601, + prev_end_date: prev_end_date&.iso8601, category_id: category_id, group_id: group_id, prev30Days: self.prev30Days, + dates_filtering: self.dates_filtering, report_key: Report.cache_key(self), - labels: labels, + labels: labels || [ + { type: :date, properties: [:x], title: I18n.t("reports.default.labels.day") }, + { type: :number, properties: [:y], title: I18n.t("reports.default.labels.count") }, + ], processing: self.processing, average: self.average, percent: self.percent, - higher_is_better: self.higher_is_better + higher_is_better: self.higher_is_better, + category_filtering: self.category_filtering, + group_filtering: self.group_filtering, + modes: self.modes }.tap do |json| - json[:total] = total if total - json[:prev_period] = prev_period if prev_period + json[:timeout] = self.timeout if self.timeout + json[:total] = self.total if self.total + json[:prev_period] = self.prev_period if self.prev_period json[:prev30Days] = self.prev30Days if self.prev30Days json[:limit] = self.limit if self.limit @@ -105,6 +145,8 @@ class Report end def self.find(type, opts = nil) + clear_cache + report = _get(type, opts) report_method = :"report_#{type}" @@ -129,6 +171,10 @@ class Report ApplicationRequest.where(req_type: ApplicationRequest.req_types[filter]) end + if filter == :page_view_total + report.icon = 'file' + end + report.data = [] data.where('date >= ? AND date <= ?', report.start_date, report.end_date) .order(date: :asc) @@ -147,6 +193,9 @@ class Report end def self.report_visits(report) + report.group_filtering = true + report.icon = 'user' + basic_report_about report, UserVisit, :by_day, report.start_date, report.end_date, report.group_id add_counts report, UserVisit, 'visited_at' @@ -159,13 +208,16 @@ class Report end def self.report_signups(report) + report.group_filtering = true + if report.group_id basic_report_about report, User.real, :count_by_signup_date, report.start_date, report.end_date, report.group_id add_counts report, User.real, 'users.created_at' else - report_about report, User.real, :count_by_signup_date end + + add_prev_data report, User.real, :count_by_signup_date, report.prev_start_date, report.prev_end_date end def self.report_new_contributors(report) @@ -183,8 +235,9 @@ class Report end if report.facets.include?(:prev_period) - prev_period_data = User.real.count_by_first_post(report.start_date - (report.end_date - report.start_date), report.start_date) + prev_period_data = User.real.count_by_first_post(report.prev_start_date, report.prev_end_date) report.prev_period = prev_period_data.sum { |k, v| v } + report.prev_data = prev_period_data.map { |k, v| { x: k, y: v } } end data.each do |key, value| @@ -209,7 +262,7 @@ class Report end if report.facets.include?(:prev_period) - prev_data = UserAction.count_daily_engaged_users(report.start_date - (report.end_date - report.start_date), report.start_date) + prev_data = UserAction.count_daily_engaged_users(report.prev_start_date, report.prev_end_date) prev = prev_data.sum { |k, v| v } if prev > 0 @@ -252,7 +305,7 @@ class Report end if report.facets.include?(:prev_period) - report.prev_period = dau_avg.call(report.start_date - (report.end_date - report.start_date), report.start_date) + report.prev_period = dau_avg.call(report.prev_start_date, report.prev_end_date) end if report.facets.include?(:prev30Days) @@ -261,6 +314,7 @@ class Report end def self.report_profile_views(report) + report.group_filtering = true start_date = report.start_date end_date = report.end_date basic_report_about report, UserProfileView, :profile_views_by_day, start_date, end_date, report.group_id @@ -270,6 +324,7 @@ class Report end def self.report_topics(report) + report.category_filtering = true basic_report_about report, Topic, :listable_count_per_day, report.start_date, report.end_date, report.category_id countable = Topic.listable_topics countable = countable.where(category_id: report.category_id) if report.category_id @@ -277,6 +332,8 @@ class Report end def self.report_posts(report) + report.modes = [:table, :chart] + report.category_filtering = true basic_report_about report, Post, :public_posts_count_per_day, report.start_date, report.end_date, report.category_id countable = Post.public_posts.where(post_type: Post.types[:regular]) countable = countable.joins(:topic).where("topics.category_id = ?", report.category_id) if report.category_id @@ -284,6 +341,8 @@ class Report end def self.report_time_to_first_response(report) + report.category_filtering = true + report.icon = 'reply' report.higher_is_better = false report.data = [] Topic.time_to_first_response_per_day(report.start_date, report.end_date, category_id: report.category_id).each do |r| @@ -294,6 +353,7 @@ class Report end def self.report_topics_with_no_response(report) + report.category_filtering = true report.data = [] Topic.with_no_response_per_day(report.start_date, report.end_date, report.category_id).each do |r| report.data << { x: r["date"], y: r["count"].to_i } @@ -319,12 +379,21 @@ class Report end end + def self.add_prev_data(report, subject_class, report_method, *args) + if report.modes.include?(:chart) && report.facets.include?(:prev_period) + prev_data = subject_class.send(report_method, *args) + report.prev_data = prev_data.map { |k, v| { x: k, y: v } } + end + end + def self.add_counts(report, subject_class, query_column = 'created_at') if report.facets.include?(:prev_period) - report.prev_period = subject_class + prev_data = subject_class .where("#{query_column} >= ? and #{query_column} < ?", - (report.start_date - (report.end_date - report.start_date)), - report.start_date).count + report.prev_start_date, + report.prev_end_date) + + report.prev_period = prev_data.count end if report.facets.include?(:total) @@ -342,6 +411,15 @@ class Report def self.report_users_by_trust_level(report) report.data = [] + report.modes = [:table] + + report.dates_filtering = false + + report.labels = [ + { properties: [:key], title: I18n.t("reports.users_by_trust_level.labels.level") }, + { properties: [:y], title: I18n.t("reports.default.labels.count") }, + ] + User.real.group('trust_level').count.sort.each do |level, count| key = TrustLevel.levels[level.to_i] url = Proc.new { |k| "/admin/users/list/#{k}" } @@ -351,6 +429,8 @@ class Report # Post action counts: def self.report_flags(report) + report.category_filtering = true + report.icon = 'flag' report.higher_is_better = false basic_report_about report, PostAction, :flag_count_by_date, report.start_date, report.end_date, report.category_id @@ -360,10 +440,14 @@ class Report end def self.report_likes(report) + report.category_filtering = true + report.icon = 'heart' post_action_report report, PostActionType.types[:like] end def self.report_bookmarks(report) + report.category_filtering = true + report.icon = 'bookmark' post_action_report report, PostActionType.types[:bookmark] end @@ -380,47 +464,68 @@ class Report # Private messages counts: def self.private_messages_report(report, topic_subtype) + report.icon = 'envelope' basic_report_about report, Topic, :private_message_topics_count_per_day, report.start_date, report.end_date, topic_subtype add_counts report, Topic.private_messages.with_subtype(topic_subtype), 'topics.created_at' end def self.report_user_to_user_private_messages(report) + report.icon = 'envelope' private_messages_report report, TopicSubtype.user_to_user end def self.report_user_to_user_private_messages_with_replies(report) + report.icon = 'envelope' topic_subtype = TopicSubtype.user_to_user basic_report_about report, Post, :private_messages_count_per_day, report.start_date, report.end_date, topic_subtype add_counts report, Post.private_posts.with_topic_subtype(topic_subtype), 'posts.created_at' end def self.report_system_private_messages(report) + report.icon = 'envelope' private_messages_report report, TopicSubtype.system_message end def self.report_moderator_warning_private_messages(report) + report.icon = 'envelope' private_messages_report report, TopicSubtype.moderator_warning end def self.report_notify_moderators_private_messages(report) + report.icon = 'envelope' private_messages_report report, TopicSubtype.notify_moderators end def self.report_notify_user_private_messages(report) + report.icon = 'envelope' private_messages_report report, TopicSubtype.notify_user end def self.report_web_crawlers(report) + report.labels = [ + { type: :string, properties: [:user_agent], title: I18n.t("reports.web_crawlers.labels.user_agent") }, + { properties: [:count], title: I18n.t("reports.web_crawlers.labels.page_views") } + ] + report.modes = [:table] report.data = WebCrawlerRequest.where('date >= ? and date <= ?', report.start_date, report.end_date) .limit(200) .order('sum_count DESC') .group(:user_agent).sum(:count) - .map { |ua, count| { x: ua, y: count } } + .map { |ua, count| { user_agent: ua, count: count } } end def self.report_users_by_type(report) report.data = [] + report.modes = [:table] + + report.dates_filtering = false + + report.labels = [ + { properties: [:x], title: I18n.t("reports.users_by_type.labels.type") }, + { properties: [:y], title: I18n.t("reports.default.labels.count") }, + ] + label = Proc.new { |x| I18n.t("reports.users_by_type.xaxis_labels.#{x}") } url = Proc.new { |key| "/admin/users/list/#{key}" } @@ -438,15 +543,49 @@ class Report end def self.report_top_referred_topics(report) - report.labels = [I18n.t("reports.top_referred_topics.xaxis"), - I18n.t("reports.top_referred_topics.num_clicks")] - result = IncomingLinksReport.find(:top_referred_topics, start_date: 7.days.ago, limit: report.limit) - report.data = result.data + report.modes = [:table] + + report.labels = [ + { type: :link, properties: [:topic_title, :topic_url], title: I18n.t("reports.top_referred_topics.labels.topic") }, + { properties: [:num_clicks], title: I18n.t("reports.top_referred_topics.labels.num_clicks") } + ] + + options = { end_date: report.end_date, start_date: report.start_date, limit: report.limit || 8 } + result = nil + report.timeout = wrap_slow_query do + result = IncomingLinksReport.find(:top_referred_topics, options) + report.data = result.data + end + end + + def self.report_top_traffic_sources(report) + report.modes = [:table] + + report.labels = [ + { properties: [:domain], title: I18n.t("reports.top_traffic_sources.labels.domain") }, + { properties: [:num_clicks], title: I18n.t("reports.top_traffic_sources.labels.num_clicks") }, + { properties: [:num_topics], title: I18n.t("reports.top_traffic_sources.labels.num_topics") } + ] + + options = { end_date: report.end_date, start_date: report.start_date, limit: report.limit || 8 } + result = nil + report.timeout = wrap_slow_query do + result = IncomingLinksReport.find(:top_traffic_sources, options) + report.data = result.data + end end def self.report_trending_search(report) + report.labels = [ + { properties: [:term], title: I18n.t("reports.trending_search.labels.term") }, + { properties: [:unique_searches], title: I18n.t("reports.trending_search.labels.searches") }, + { type: :percent, properties: [:ctr], title: I18n.t("reports.trending_search.labels.click_through") } + ] + report.data = [] + report.modes = [:table] + select_sql = <<~SQL lower(term) term, COUNT(*) AS searches, @@ -463,10 +602,6 @@ class Report .order('unique_searches DESC, click_through ASC, term ASC') .limit(report.limit || 20).to_a - report.labels = [:term, :searches, :click_through].map { |key| - I18n.t("reports.trending_search.labels.#{key}") - } - trends.each do |trend| ctr = if trend.click_through == 0 || trend.searches == 0 @@ -478,8 +613,300 @@ class Report report.data << { term: trend.term, unique_searches: trend.unique_searches, - ctr: (ctr * 100).ceil(1).to_s + "%" + ctr: (ctr * 100).ceil(1) } end end + + def self.report_moderators_activity(report) + report.labels = [ + { type: :link, properties: [:username, :user_url], title: I18n.t("reports.moderators_activity.labels.moderator") }, + { properties: [:flag_count], title: I18n.t("reports.moderators_activity.labels.flag_count") }, + { type: :seconds, properties: [:time_read], title: I18n.t("reports.moderators_activity.labels.time_read") }, + { properties: [:topic_count], title: I18n.t("reports.moderators_activity.labels.topic_count") }, + { properties: [:post_count], title: I18n.t("reports.moderators_activity.labels.post_count") } + ] + + report.modes = [:table] + + report.data = [] + mod_data = {} + + User.real.where(moderator: true).find_each do |u| + mod_data[u.id] = { + user_id: u.id, + username: u.username, + user_url: "/admin/users/#{u.id}/#{u.username}" + } + end + + time_read_query = <<~SQL + SELECT SUM(uv.time_read) AS time_read, + uv.user_id + FROM user_visits uv + JOIN users u + ON u.id = uv.user_id + WHERE u.moderator = 'true' + AND u.id > 0 + AND uv.visited_at >= '#{report.start_date}' + AND uv.visited_at <= '#{report.end_date}' + GROUP BY uv.user_id + SQL + + flag_count_query = <<~SQL + WITH period_actions AS ( + SELECT agreed_by_id, + disagreed_by_id + FROM post_actions + WHERE post_action_type_id IN (#{PostActionType.flag_types_without_custom.values.join(',')}) + AND created_at >= '#{report.start_date}' + AND created_at <= '#{report.end_date}' + ), + agreed_flags AS ( + SELECT pa.agreed_by_id AS user_id, + COUNT(*) AS flag_count + FROM period_actions pa + JOIN users u + ON u.id = pa.agreed_by_id + WHERE u.moderator = 'true' + AND u.id > 0 + GROUP BY agreed_by_id + ), + disagreed_flags AS ( + SELECT pa.disagreed_by_id AS user_id, + COUNT(*) AS flag_count + FROM period_actions pa + JOIN users u + ON u.id = pa.disagreed_by_id + WHERE u.moderator = 'true' + AND u.id > 0 + GROUP BY disagreed_by_id + ) + SELECT + COALESCE(af.user_id, df.user_id) AS user_id, + COALESCE(af.flag_count, 0) + COALESCE(df.flag_count, 0) AS flag_count + FROM agreed_flags af + FULL OUTER JOIN disagreed_flags df + ON df.user_id = af.user_id + SQL + + topic_count_query = <<~SQL + SELECT t.user_id, + COUNT(*) AS topic_count + FROM topics t + JOIN users u + ON u.id = t.user_id + AND u.moderator = 'true' + AND u.id > 0 + AND t.created_at >= '#{report.start_date}' + AND t.created_at <= '#{report.end_date}' + GROUP BY t.user_id + SQL + + post_count_query = <<~SQL + SELECT p.user_id, + COUNT(*) AS post_count + FROM posts p + JOIN users u + ON u.id = p.user_id + WHERE u.moderator = 'true' + AND u.id > 0 + AND p.created_at >= '#{report.start_date}' + AND p.created_at <= '#{report.end_date}' + GROUP BY user_id + SQL + + DB.query(time_read_query).each do |row| + mod_data[row.user_id][:time_read] = row.time_read + end + + DB.query(flag_count_query).each do |row| + mod_data[row.user_id][:flag_count] = row.flag_count + end + + DB.query(topic_count_query).each do |row| + mod_data[row.user_id][:topic_count] = row.topic_count + end + + DB.query(post_count_query).each do |row| + mod_data[row.user_id][:post_count] = row.post_count + end + + report.data = mod_data.values + end + + def self.report_flags_status(report) + report.modes = [:table] + + report.labels = [ + { properties: [:action_type], title: I18n.t("reports.flags_status.labels.flag") }, + { type: :link, properties: [:staff_username, :staff_url], title: I18n.t("reports.flags_status.labels.assigned") }, + { type: :link, properties: [:poster_username, :poster_url], title: I18n.t("reports.flags_status.labels.poster") }, + { type: :link, properties: [:flagger_username, :flagger_url], title: I18n.t("reports.flags_status.labels.flagger") }, + { type: :seconds, properties: [:response_time], title: I18n.t("reports.flags_status.labels.time_to_resolution") } + ] + + report.data = [] + + flag_types = PostActionType.flag_types_without_custom + + sql = <<~SQL + WITH period_actions AS ( + SELECT id, + post_action_type_id, + created_at, + agreed_at, + disagreed_at, + deferred_at, + agreed_by_id, + disagreed_by_id, + deferred_by_id, + post_id, + user_id, + COALESCE(disagreed_at, agreed_at, deferred_at) AS responded_at + FROM post_actions + WHERE post_action_type_id IN (#{PostActionType.flag_types_without_custom.values.join(',')}) + AND created_at >= '#{report.start_date}' + AND created_at <= '#{report.end_date}' + ), + poster_data AS ( + SELECT pa.id, + p.user_id AS poster_id, + u.username AS poster_username + FROM period_actions pa + JOIN posts p + ON p.id = pa.post_id + JOIN users u + ON u.id = p.user_id + ), + flagger_data AS ( + SELECT pa.id, + u.id AS flagger_id, + u.username AS flagger_username + FROM period_actions pa + JOIN users u + ON u.id = pa.user_id + ), + staff_data AS ( + SELECT pa.id, + u.id AS staff_id, + u.username AS staff_username + FROM period_actions pa + JOIN users u + ON u.id = COALESCE(pa.agreed_by_id, pa.disagreed_by_id, pa.deferred_by_id) + ) + SELECT + sd.staff_username, + sd.staff_id, + pd.poster_username, + pd.poster_id, + fd.flagger_username, + fd.flagger_id, + pa.post_action_type_id, + pa.created_at, + pa.agreed_at, + pa.disagreed_at, + pa.deferred_at, + pa.agreed_by_id, + pa.disagreed_by_id, + pa.deferred_by_id, + COALESCE(pa.disagreed_at, pa.agreed_at, pa.deferred_at) AS responded_at + FROM period_actions pa + FULL OUTER JOIN staff_data sd + ON sd.id = pa.id + FULL OUTER JOIN flagger_data fd + ON fd.id = pa.id + FULL OUTER JOIN poster_data pd + ON pd.id = pa.id + SQL + + DB.query(sql).each do |row| + data = {} + data[:action_type] = flag_types.key(row.post_action_type_id).to_s + data[:staff_username] = row.staff_username + data[:staff_id] = row.staff_id + if row.staff_username && row.staff_id + data[:staff_url] = "/admin/users/#{row.staff_id}/#{row.staff_username}" + end + data[:poster_username] = row.poster_username + data[:poster_id] = row.poster_id + data[:poster_url] = "/admin/users/#{row.poster_id}/#{row.poster_username}" + data[:flagger_id] = row.flagger_id + data[:flagger_username] = row.flagger_username + data[:flagger_url] = "/admin/users/#{row.flagger_id}/#{row.flagger_username}" + if row.agreed_by_id + data[:resolution] = I18n.t("reports.flags_status.values.agreed") + elsif row.disagreed_by_id + data[:resolution] = I18n.t("reports.flags_status.values.disagreed") + elsif row.deferred_by_id + data[:resolution] = I18n.t("reports.flags_status.values.deferred") + else + data[:resolution] = I18n.t("reports.flags_status.values.no_action") + end + data[:response_time] = row.responded_at ? row.responded_at - row.created_at : nil + report.data << data + end + end + + def self.report_post_edits(report) + report.modes = [:table] + + report.labels = [ + { type: :link, properties: [:post_id, :post_url], title: I18n.t("reports.post_edits.labels.post") }, + { type: :link, properties: [:editor_username, :editor_url], title: I18n.t("reports.post_edits.labels.editor") }, + { type: :link, properties: [:author_username, :author_url], title: I18n.t("reports.post_edits.labels.author") }, + { type: :text, properties: [:edit_reason], title: I18n.t("reports.post_edits.labels.edit_reason") } + ] + + report.data = [] + + sql = <<~SQL + WITH period_revisions AS ( + SELECT pr.user_id AS editor_id, + pr.number AS revision_version, + pr.created_at, + pr.post_id, + u.username AS editor_username + FROM post_revisions pr + JOIN users u + ON u.id = pr.user_id + WHERE pr.created_at >= '#{report.start_date}' + AND pr.created_at <= '#{report.end_date}' + ORDER BY pr.created_at DESC + LIMIT 20 + ) + SELECT pr.editor_id, + pr.editor_username, + p.user_id AS author_id, + u.username AS author_username, + pr.revision_version, + p.version AS post_version, + pr.post_id, + p.topic_id, + p.post_number, + p.edit_reason, + pr.created_at + FROM period_revisions pr + JOIN posts p + ON p.id = pr.post_id + JOIN users u + ON u.id = p.user_id + SQL + + DB.query(sql).each do |r| + revision = {} + revision[:editor_id] = r.editor_id + revision[:editor_username] = r.editor_username + revision[:editor_url] = "/admin/users/#{r.editor_id}/#{r.editor_username}" + revision[:author_id] = r.author_id + revision[:author_username] = r.author_username + revision[:author_url] = "/admin/users/#{r.author_id}/#{r.author_username}" + revision[:edit_reason] = r.revision_version == r.post_version ? r.edit_reason : nil + revision[:created_at] = r.created_at + revision[:post_id] = r.post_id + revision[:post_url] = "/t/-/#{r.topic_id}/#{r.post_number}" + + report.data << revision + end + end end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index ff1e33ac11c..cb8c382cf7b 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2777,9 +2777,14 @@ en: page_views_short: "Pageviews" show_traffic_report: "Show Detailed Traffic Report" community_health: Community health + moderators_activity: Moderators activity whats_new_in_discourse: What’s new in Discourse? activity_metrics: Activity Metrics all_reports: "All reports" + general_tab: "General" + moderation_tab: "Moderation" + disabled: Disabled + timeout_error: Sorry, query is taking too long, please pick a shorter interval reports: trend_title: "%{percent} change. Currently %{current}, was %{prev} in previous period." @@ -2798,8 +2803,8 @@ en: end_date: "End Date" groups: "All groups" disabled: "This report is disabled" - total_for_period: "Total for period" - total: "Total" + totals_for_sample: "Totals for sample" + total: "All time total" trending_search: more: 'Search logs' disabled: 'Trending search report is disabled. Enable log search queries to collect data.' diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index de9dbd06363..7a0b01f3cd0 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -840,6 +840,38 @@ en: write: "Write all" reports: + default: + labels: + count: Count + day: Day + post_edits: + title: "Post edits" + labels: + post: Post + editor: Editor + author: Author + edit_reason: Reason + moderators_activity: + title: "Moderators activity" + labels: + moderator: Moderator + flag_count: Flags reviewed + time_read: Time reading + topic_count: Topics + post_count: Posts + flags_status: + title: "Flags status" + values: + agreed: Agreed + disagreed: Disagreed + deferred: Deferred + no_action: No action + labels: + flag: Type + assigned: Assigned + poster: Poster + flagger: Flagger + time_to_resolution: Resolution time visits: title: "User Visits" xaxis: "Day" @@ -898,10 +930,14 @@ en: title: "Users per Trust Level" xaxis: "Trust Level" yaxis: "Number of Users" + labels: + level: Level users_by_type: title: "Users per Type" xaxis: "Type" yaxis: "Number of Users" + labels: + type: Type xaxis_labels: admin: Admin moderator: Moderator @@ -952,10 +988,15 @@ en: num_clicks: "Clicks" num_topics: "Topics" num_users: "Users" + labels: + domain: Domain + num_clicks: Clicks + num_topics: Topics top_referred_topics: title: "Top Referred Topics" - xaxis: "Topic" - num_clicks: "Clicks" + labels: + num_clicks: "Clicks" + topic: "Topic" page_view_anon_reqs: title: "Anonymous" xaxis: "Day" @@ -1018,8 +1059,9 @@ en: yaxis: "Number of visits" web_crawlers: title: "Web Crawler Requests" - xaxis: "User Agent" - yaxis: "Pageviews" + labels: + user_agent: "User Agent" + page_views: "Pageviews" dashboard: rails_env_warning: "Your server is running in %{env} mode." diff --git a/config/routes.rb b/config/routes.rb index f969cdd3e1d..936ea26cc80 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -238,6 +238,8 @@ Discourse::Application.routes.draw do get "dashboard-next" => "dashboard_next#index" get "dashboard-old" => "dashboard#index" + get "dashboard/moderation" => "dashboard_next#moderation" + get "dashboard/general" => "dashboard_next#general" resources :dashboard, only: [:index] do collection do diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index 1f36ce5ff37..95ba43485b0 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -346,7 +346,7 @@ describe Report do it "returns a report with data" do expect(report.data[0][:term]).to eq("ruby") expect(report.data[0][:unique_searches]).to eq(2) - expect(report.data[0][:ctr]).to eq('33.4%') + expect(report.data[0][:ctr]).to eq(33.4) expect(report.data[1][:term]).to eq("php") expect(report.data[1][:unique_searches]).to eq(1) @@ -435,4 +435,173 @@ describe Report do expect(r.data[0][:y]).to eq(1) end end + + describe 'flags_status' do + let(:report) { Report.find('flags_status') } + + context "no flags" do + it "returns an empty report" do + expect(report.data).to be_blank + end + end + + context "with flags" do + let(:flagger) { Fabricate(:user) } + let(:post) { Fabricate(:post) } + + before do + PostAction.act(flagger, post, PostActionType.types[:spam], message: 'bad') + end + + it "returns a report with data" do + expect(report.data).to be_present + + row = report.data[0] + expect(row[:action_type]).to eq("spam") + expect(row[:staff_username]).to eq(nil) + expect(row[:staff_id]).to eq(nil) + expect(row[:staff_url]).to eq(nil) + expect(row[:poster_username]).to eq(post.user.username) + expect(row[:poster_id]).to eq(post.user.id) + expect(row[:poster_url]).to eq("/admin/users/#{post.user.id}/#{post.user.username}") + expect(row[:flagger_id]).to eq(flagger.id) + expect(row[:flagger_username]).to eq(flagger.username) + expect(row[:flagger_url]).to eq("/admin/users/#{flagger.id}/#{flagger.username}") + expect(row[:resolution]).to eq("No action") + expect(row[:response_time]).to eq(nil) + end + end + end + + describe 'post_edits' do + let(:report) { Report.find('post_edits') } + + context "no edits" do + it "returns an empty report" do + expect(report.data).to be_blank + end + end + + context "with edits" do + let(:editor) { Fabricate(:user) } + let(:post) { Fabricate(:post) } + + before do + post.revise(editor, raw: 'updated body', edit_reason: 'not cool') + end + + it "returns a report with data" do + expect(report.data).to be_present + expect(report.data.count).to be(1) + + row = report.data[0] + expect(row[:editor_id]).to eq(editor.id) + expect(row[:editor_username]).to eq(editor.username) + expect(row[:editor_url]).to eq("/admin/users/#{editor.id}/#{editor.username}") + expect(row[:author_id]).to eq(post.user.id) + expect(row[:author_username]).to eq(post.user.username) + expect(row[:author_url]).to eq("/admin/users/#{post.user.id}/#{post.user.username}") + expect(row[:edit_reason]).to eq("not cool") + expect(row[:post_id]).to eq(post.id) + expect(row[:post_url]).to eq("/t/-/#{post.topic.id}/#{post.post_number}") + end + end + end + + describe 'moderator activity' do + let(:current_report) { Report.find('moderators_activity', start_date: 1.months.ago.beginning_of_day, end_date: Date.today) } + let(:previous_report) { Report.find('moderators_activity', start_date: 2.months.ago.beginning_of_day, end_date: 1.month.ago.end_of_day) } + + context "no moderators" do + it "returns an empty report" do + expect(current_report.data).to be_blank + end + end + + context "with moderators" do + before do + freeze_time(Date.today) + + bob = Fabricate(:user, moderator: true, username: 'bob') + bob.user_visits.create(visited_at: 2.days.ago, time_read: 200) + bob.user_visits.create(visited_at: 1.day.ago, time_read: 100) + Fabricate(:topic, user: bob, created_at: 1.day.ago) + sally = Fabricate(:user, moderator: true, username: 'sally') + sally.user_visits.create(visited_at: 2.days.ago, time_read: 1000) + sally.user_visits.create(visited_at: 1.day.ago, time_read: 2000) + topic = Fabricate(:topic) + 2.times { + Fabricate(:post, user: sally, topic: topic, created_at: 1.day.ago) + } + flag_user = Fabricate(:user) + flag_post = Fabricate(:post, user: flag_user) + action = PostAction.new(user_id: flag_user.id, + post_action_type_id: PostActionType.types[:off_topic], + post_id: flag_post.id, + agreed_by_id: sally.id, + created_at: 1.day.ago, + agreed_at: Time.now) + action.save + bob.user_visits.create(visited_at: 45.days.ago, time_read: 200) + old_topic = Fabricate(:topic, user: bob, created_at: 45.days.ago) + 3.times { + Fabricate(:post, user: bob, topic: old_topic, created_at: 45.days.ago) + } + old_flag_user = Fabricate(:user) + old_flag_post = Fabricate(:post, user: old_flag_user, created_at: 45.days.ago) + old_action = PostAction.new(user_id: old_flag_user.id, + post_action_type_id: PostActionType.types[:spam], + post_id: old_flag_post.id, + agreed_by_id: bob.id, + created_at: 44.days.ago, + agreed_at: 44.days.ago) + old_action.save + end + + it "returns a report with data" do + expect(current_report.data).to be_present + end + + it "returns data for two moderators" do + expect(current_report.data.count).to eq(2) + end + + it "returns the correct usernames" do + expect(current_report.data[0][:username]).to eq('bob') + expect(current_report.data[1][:username]).to eq('sally') + end + + it "returns the correct read times" do + expect(current_report.data[0][:time_read]).to eq(300) + expect(current_report.data[1][:time_read]).to eq(3000) + end + + it "returns the correct agreed flag count" do + expect(current_report.data[0][:flag_count]).to be_blank + expect(current_report.data[1][:flag_count]).to eq(1) + end + + it "returns the correct topic count" do + expect(current_report.data[0][:topic_count]).to eq(1) + expect(current_report.data[1][:topic_count]).to be_blank + end + + it "returns the correct post count" do + expect(current_report.data[0][:post_count]).to be_blank + expect(current_report.data[1][:post_count]).to eq(2) + end + + it "returns the correct data for the time period" do + expect(previous_report.data[0][:flag_count]).to eq(1) + expect(previous_report.data[0][:topic_count]).to eq(1) + expect(previous_report.data[0][:post_count]).to eq(3) + expect(previous_report.data[0][:time_read]).to eq(200) + + expect(previous_report.data[1][:flag_count]).to be_blank + expect(previous_report.data[1][:topic_count]).to be_blank + expect(previous_report.data[1][:post_count]).to be_blank + expect(previous_report.data[1][:time_read]).to be_blank + end + end + end end diff --git a/test/javascripts/acceptance/dashboard-next-test.js.es6 b/test/javascripts/acceptance/dashboard-next-test.js.es6 index 34d69d77ba6..fa8fef4bfb5 100644 --- a/test/javascripts/acceptance/dashboard-next-test.js.es6 +++ b/test/javascripts/acceptance/dashboard-next-test.js.es6 @@ -7,25 +7,18 @@ acceptance("Dashboard Next", { QUnit.test("Visit dashboard next page", async assert => { await visit("/admin"); - assert.ok($(".dashboard-next").length, "has dashboard-next class"); - - assert.ok($(".dashboard-mini-chart.signups").length, "has a signups chart"); - - assert.ok($(".dashboard-mini-chart.posts").length, "has a posts chart"); + assert.ok(exists(".dashboard-next"), "has dashboard-next class"); + assert.ok(exists(".admin-report.signups"), "signups report"); + assert.ok(exists(".admin-report.posts"), "posts report"); + assert.ok(exists(".admin-report.dau-by-mau"), "dau-by-mau report"); assert.ok( - $(".dashboard-mini-chart.dau_by_mau").length, - "has a dau_by_mau chart" + exists(".admin-report.daily-engaged-users"), + "daily-engaged-users report" ); - assert.ok( - $(".dashboard-mini-chart.daily_engaged_users").length, - "has a daily_engaged_users chart" - ); - - assert.ok( - $(".dashboard-mini-chart.new_contributors").length, - "has a new_contributors chart" + exists(".admin-report.new-contributors"), + "new-contributors report" ); assert.equal( diff --git a/test/javascripts/components/admin-report-test.js.es6 b/test/javascripts/components/admin-report-test.js.es6 new file mode 100644 index 00000000000..5aa62740a68 --- /dev/null +++ b/test/javascripts/components/admin-report-test.js.es6 @@ -0,0 +1,130 @@ +import componentTest from "helpers/component-test"; + +moduleForComponent("admin-report", { + integration: true +}); + +componentTest("default", { + template: "{{admin-report dataSourceName='signups'}}", + + test(assert) { + andThen(() => { + assert.ok(exists(".admin-report.signups")); + + assert.ok( + exists(".admin-report.table.signups", "it defaults to table mode") + ); + + assert.equal( + find(".report-header .title") + .text() + .trim(), + "Signups", + "it has a title" + ); + + assert.equal( + find(".report-header .info").attr("data-tooltip"), + "New account registrations for this period", + "it has a description" + ); + + assert.equal( + find(".report-body .report-table thead tr th:first-child") + .text() + .trim(), + "Day", + "it has col headers" + ); + + assert.equal( + find(".report-body .report-table thead tr th:nth-child(2)") + .text() + .trim(), + "Count", + "it has col headers" + ); + + assert.equal( + find(".report-body .report-table tbody tr:nth-child(1) td:nth-child(1)") + .text() + .trim(), + "June 16, 2018", + "it has rows" + ); + + assert.equal( + find(".report-body .report-table tbody tr:nth-child(1) td:nth-child(2)") + .text() + .trim(), + "12", + "it has rows" + ); + + assert.ok(exists(".totals-sample-table"), "it has totals"); + }); + + click(".admin-report-table-header.y .sort-button"); + andThen(() => { + assert.equal( + find(".report-body .report-table tbody tr:nth-child(1) td:nth-child(2)") + .text() + .trim(), + "7", + "it can sort rows" + ); + }); + } +}); + +componentTest("options", { + template: "{{admin-report dataSourceName='signups' reportOptions=options}}", + + beforeEach() { + this.set("options", { + table: { + perPage: 4, + total: false + } + }); + }, + + test(assert) { + andThen(() => { + assert.ok(exists(".pagination"), "it paginates the results"); + assert.equal( + find(".pagination button").length, + 3, + "it creates the correct number of pages" + ); + + assert.notOk(exists(".totals-sample-table"), "it hides totals"); + }); + } +}); + +componentTest("switch modes", { + template: "{{admin-report dataSourceName='signups'}}", + + test(assert) { + click(".mode-button.chart"); + + andThen(() => { + assert.notOk( + exists(".admin-report.table.signups"), + "it removes the table" + ); + assert.ok(exists(".admin-report.chart.signups"), "it shows the chart"); + }); + } +}); + +componentTest("timeout", { + template: "{{admin-report dataSourceName='signups_timeout'}}", + + test(assert) { + andThen(() => { + assert.ok(exists(".alert-error"), "it displays a timeout error"); + }); + } +}); diff --git a/test/javascripts/fixtures/admin-general.js.es6 b/test/javascripts/fixtures/admin-general.js.es6 new file mode 100644 index 00000000000..6e57a8d0bc8 --- /dev/null +++ b/test/javascripts/fixtures/admin-general.js.es6 @@ -0,0 +1,13 @@ +export default { + "/admin/general.json": { + reports: [], + last_backup_taken_at: "2018-04-13T12:51:19.926Z", + updated_at: "2018-04-25T08:06:11.292Z", + disk_space: { + uploads_used: "74.5 KB", + uploads_free: "117 GB", + backups_used: "4.24 GB", + backups_free: "117 GB" + } + } +}; diff --git a/test/javascripts/fixtures/daily-engaged-users.js.es6 b/test/javascripts/fixtures/daily-engaged-users.js.es6 index b734c47b74b..462e3898553 100644 --- a/test/javascripts/fixtures/daily-engaged-users.js.es6 +++ b/test/javascripts/fixtures/daily-engaged-users.js.es6 @@ -1,20 +1,7 @@ export default { "/admin/reports/daily_engaged_users": { report: { - type: "daily_engaged_users", - title: "Daily Engaged Users", - xaxis: "Day", - yaxis: "Engaged Users", - description: "Number of users that have liked or posted in the last day", - data: null, - total: null, - start_date: "2018-04-03", - end_date: "2018-05-03", - category_id: null, - group_id: null, - prev30Days: null, - labels: null, - report_key: "" + report_key: "daily_engaged_users" } } }; diff --git a/test/javascripts/fixtures/dashboard-next.js.es6 b/test/javascripts/fixtures/dashboard-next.js.es6 index f1dfb0b7d03..257c435089a 100644 --- a/test/javascripts/fixtures/dashboard-next.js.es6 +++ b/test/javascripts/fixtures/dashboard-next.js.es6 @@ -1,13 +1,5 @@ export default { "/admin/dashboard-next.json": { - reports: [], - last_backup_taken_at: "2018-04-13T12:51:19.926Z", - updated_at: "2018-04-25T08:06:11.292Z", - disk_space: { - uploads_used: "74.5 KB", - uploads_free: "117 GB", - backups_used: "4.24 GB", - backups_free: "117 GB" - } + updated_at: "2018-04-25T08:06:11.292Z" } }; diff --git a/test/javascripts/fixtures/dau-by-mau.js.es6 b/test/javascripts/fixtures/dau-by-mau.js.es6 index 221486bcf6d..77b5a25f2e4 100644 --- a/test/javascripts/fixtures/dau-by-mau.js.es6 +++ b/test/javascripts/fixtures/dau-by-mau.js.es6 @@ -1,20 +1,7 @@ export default { "/admin/reports/dau_by_mau": { report: { - type: "dau_by_mau", - title: "DAU/MAU", - xaxis: "Day", - yaxis: "DAU/MAY", - description: "Percentage of daily active users on monthly active users", - data: null, - total: null, - start_date: "2018-01-26T00:00:00.000Z", - end_date: "2018-04-27T23:59:59.999Z", - category_id: null, - group_id: null, - prev30Days: 46, - labels: null, - report_key: "" + report_key: "dau_by_mau" } } }; diff --git a/test/javascripts/fixtures/new-contributors.js.es6 b/test/javascripts/fixtures/new-contributors.js.es6 index 5b89bc059b4..8d688a1a1c2 100644 --- a/test/javascripts/fixtures/new-contributors.js.es6 +++ b/test/javascripts/fixtures/new-contributors.js.es6 @@ -1,60 +1,7 @@ export default { "/admin/reports/new_contributors": { report: { - type: "new_contributors", - title: "New Contributors", - xaxis: "", - yaxis: "", - data: [ - { - x: "2018-04-11", - y: 10 - }, - { - x: "2018-04-12", - y: 10 - }, - { - x: "2018-04-13", - y: 60 - }, - { - x: "2018-04-14", - y: 60 - }, - { - x: "2018-04-15", - y: 10 - }, - { - x: "2018-04-16", - y: 10 - }, - { - x: "2018-04-17", - y: 10 - }, - { - x: "2018-04-19", - y: 10 - }, - { - x: "2018-04-18", - y: 10 - }, - { - x: "2018-04-20", - y: 1 - } - ], - total: 121, - start_date: "2018-03-26T00:00:00.000Z", - end_date: "2018-04-25T23:59:59.999Z", - category_id: null, - group_id: null, - prev30Days: null, - labels: null, - report_key: "" + report_key: "new_contributors" } } }; diff --git a/test/javascripts/fixtures/posts.js.es6 b/test/javascripts/fixtures/posts.js.es6 index caa70dc3370..0f690625fee 100644 --- a/test/javascripts/fixtures/posts.js.es6 +++ b/test/javascripts/fixtures/posts.js.es6 @@ -1,19 +1,7 @@ export default { "/admin/reports/posts": { report: { - type: "topics", - title: "Topics", - xaxis: "Day", - yaxis: "Number of new posts", - data: null, - total: null, - start_date: "2018-03-26T00:00:00.000Z", - end_date: "2018-04-25T23:59:59.999Z", - category_id: null, - group_id: null, - prev30Days: 0, - labels: null, - report_key: "" + report_key: "posts" } } }; diff --git a/test/javascripts/fixtures/signups.js.es6 b/test/javascripts/fixtures/signups.js.es6 index 93d6cdd9cc2..f6319902fed 100644 --- a/test/javascripts/fixtures/signups.js.es6 +++ b/test/javascripts/fixtures/signups.js.es6 @@ -4,53 +4,76 @@ export default { type: "signups", title: "Signups", xaxis: "Day", - yaxis: "Number of new users", + yaxis: "Number of signups", + description: "New account registrations for this period", data: [ - { - x: "2018-04-11", - y: 10 - }, - { - x: "2018-04-12", - y: 10 - }, - { - x: "2018-04-13", - y: 22 - }, - { - x: "2018-04-14", - y: 58 - }, - { - x: "2018-04-15", - y: 10 - }, - { - x: "2018-04-16", - y: 10 - }, - { - x: "2018-04-17", - y: 19 - }, - { - x: "2018-04-18", - y: 12 - }, - { - x: "2018-04-19", - y: 19 - } + { x: "2018-06-16", y: 12 }, + { x: "2018-06-17", y: 16 }, + { x: "2018-06-18", y: 42 }, + { x: "2018-06-19", y: 38 }, + { x: "2018-06-20", y: 41 }, + { x: "2018-06-21", y: 32 }, + { x: "2018-06-22", y: 23 }, + { x: "2018-06-23", y: 23 }, + { x: "2018-06-24", y: 17 }, + { x: "2018-06-25", y: 27 }, + { x: "2018-06-26", y: 32 }, + { x: "2018-06-27", y: 7 } ], - total: 136, - start_date: "2018-03-26T00:00:00.000Z", - end_date: "2018-04-25T23:59:59.999Z", + start_date: "2018-06-16T00:00:00Z", + end_date: "2018-07-16T23:59:59Z", + prev_data: [ + { x: "2018-05-17", y: 32 }, + { x: "2018-05-18", y: 30 }, + { x: "2018-05-19", y: 12 }, + { x: "2018-05-20", y: 23 }, + { x: "2018-05-21", y: 50 }, + { x: "2018-05-22", y: 39 }, + { x: "2018-05-23", y: 51 }, + { x: "2018-05-24", y: 48 }, + { x: "2018-05-25", y: 37 }, + { x: "2018-05-26", y: 17 }, + { x: "2018-05-27", y: 6 }, + { x: "2018-05-28", y: 20 }, + { x: "2018-05-29", y: 37 }, + { x: "2018-05-30", y: 37 }, + { x: "2018-05-31", y: 37 }, + { x: "2018-06-01", y: 38 }, + { x: "2018-06-02", y: 23 }, + { x: "2018-06-03", y: 18 }, + { x: "2018-06-04", y: 39 }, + { x: "2018-06-05", y: 26 }, + { x: "2018-06-06", y: 39 }, + { x: "2018-06-07", y: 52 }, + { x: "2018-06-08", y: 35 }, + { x: "2018-06-09", y: 19 }, + { x: "2018-06-10", y: 15 }, + { x: "2018-06-11", y: 31 }, + { x: "2018-06-12", y: 38 }, + { x: "2018-06-13", y: 30 }, + { x: "2018-06-14", y: 45 }, + { x: "2018-06-15", y: 37 }, + { x: "2018-06-16", y: 12 } + ], + prev_start_date: "2018-05-17T00:00:00Z", + prev_end_date: "2018-06-17T00:00:00Z", category_id: null, group_id: null, - prev30Days: 0, - labels: null, - report_key: "" + prev30Days: null, + dates_filtering: true, + report_key: "reports:signups::20180616:20180716::[:prev_period]:", + labels: [ + { type: "date", properties: ["x"], title: "Day" }, + { type: "number", properties: ["y"], title: "Count" } + ], + processing: false, + average: false, + percent: false, + higher_is_better: true, + category_filtering: false, + group_filtering: true, + modes: ["table", "chart"], + prev_period: 961 } } }; diff --git a/test/javascripts/fixtures/signups_timeout.js.es6 b/test/javascripts/fixtures/signups_timeout.js.es6 new file mode 100644 index 00000000000..0c92485b111 --- /dev/null +++ b/test/javascripts/fixtures/signups_timeout.js.es6 @@ -0,0 +1,35 @@ +export default { + "/admin/reports/signups_timeout": { + report: { + type: "signups", + title: "Signups", + xaxis: "Day", + yaxis: "Number of signups", + description: "New account registrations for this period", + data: null, + start_date: "2018-06-16T00:00:00Z", + end_date: "2018-07-16T23:59:59Z", + prev_data: null, + prev_start_date: "2018-05-17T00:00:00Z", + prev_end_date: "2018-06-17T00:00:00Z", + category_id: null, + group_id: null, + prev30Days: null, + dates_filtering: true, + report_key: "reports:signups_timeout::20180616:20180716::[:prev_period]:", + labels: [ + { type: "date", properties: ["x"], title: "Day" }, + { type: "number", properties: ["y"], title: "Count" } + ], + processing: false, + average: false, + percent: false, + higher_is_better: true, + category_filtering: false, + group_filtering: true, + modes: ["table", "chart"], + prev_period: 961, + timeout: true + } + } +}; diff --git a/test/javascripts/fixtures/top_referred_topics.js.es6 b/test/javascripts/fixtures/top_referred_topics.js.es6 index a944c59fc78..231736a92d7 100644 --- a/test/javascripts/fixtures/top_referred_topics.js.es6 +++ b/test/javascripts/fixtures/top_referred_topics.js.es6 @@ -1,18 +1,7 @@ export default { "/admin/reports/top_referred_topics": { report: { - type: "top_referred_topics", - title: "Trending search", - xaxis: "", - yaxis: "", - data: null, - total: null, - start_date: "2018-03-26T00:00:00.000Z", - end_date: "2018-04-25T23:59:59.999Z", - category_id: null, - group_id: null, - prev30Days: null, - labels: ["Topic", "Visits"] + report_key: "top_referred_topics" } } }; diff --git a/test/javascripts/fixtures/topics.js.es6 b/test/javascripts/fixtures/topics.js.es6 index 7af8f8e008d..00257dc13bf 100644 --- a/test/javascripts/fixtures/topics.js.es6 +++ b/test/javascripts/fixtures/topics.js.es6 @@ -1,19 +1,7 @@ export default { "/admin/reports/topics": { report: { - type: "topics", - title: "Topics", - xaxis: "Day", - yaxis: "Number of new topics", - data: null, - total: null, - start_date: "2018-03-26T00:00:00.000Z", - end_date: "2018-04-25T23:59:59.999Z", - category_id: null, - group_id: null, - prev30Days: 0, - labels: null, - report_key: "" + report_key: "topics" } } }; diff --git a/test/javascripts/fixtures/trending-search.js.es6 b/test/javascripts/fixtures/trending-search.js.es6 index 8258466673b..906867eb7c9 100644 --- a/test/javascripts/fixtures/trending-search.js.es6 +++ b/test/javascripts/fixtures/trending-search.js.es6 @@ -1,19 +1,7 @@ export default { "/admin/reports/trending_search": { report: { - type: "trending_search", - title: "Trending search", - xaxis: "", - yaxis: "", - data: null, - total: null, - start_date: "2018-03-26T00:00:00.000Z", - end_date: "2018-04-25T23:59:59.999Z", - category_id: null, - group_id: null, - prev30Days: null, - labels: ["Term", "Searches", "Unique"], - report_key: "" + report_key: "trending_search" } } }; diff --git a/test/javascripts/fixtures/users-by-trust-level.js.es6 b/test/javascripts/fixtures/users-by-trust-level.js.es6 index 597f44a90a5..0d59c577c26 100644 --- a/test/javascripts/fixtures/users-by-trust-level.js.es6 +++ b/test/javascripts/fixtures/users-by-trust-level.js.es6 @@ -1,21 +1,7 @@ export default { "/admin/reports/users_by_trust_level": { report: { - type: "users_by_trust_level", - title: "Users per Trust Level", - xaxis: "Trust Level", - yaxis: "Number of Users", - description: - "translation missing: en.reports.users_by_trust_level.description", - data: null, - total: null, - start_date: "2018-03-30T00:00:00.000Z", - end_date: "2018-04-29T23:59:59.999Z", - category_id: null, - group_id: null, - prev30Days: null, - labels: null, - report_key: "" + report_key: "users_by_trust_level" } } }; diff --git a/test/javascripts/fixtures/users-by-type.js.es6 b/test/javascripts/fixtures/users-by-type.js.es6 index 1cc9dec287b..56d3e611834 100644 --- a/test/javascripts/fixtures/users-by-type.js.es6 +++ b/test/javascripts/fixtures/users-by-type.js.es6 @@ -1,20 +1,7 @@ export default { "/admin/reports/users_by_type": { report: { - type: "users_by_type", - title: "Users per Type", - xaxis: "Type", - yaxis: "Number of Users", - description: "translation missing: en.reports.users_by_type.description", - data: null, - total: null, - start_date: "2018-03-30T00:00:00.000Z", - end_date: "2018-04-29T23:59:59.999Z", - category_id: null, - group_id: null, - prev30Days: null, - labels: null, - report_key: "" + report_key: "users_by_type" } } }; diff --git a/test/javascripts/models/report-test.js.es6 b/test/javascripts/models/report-test.js.es6 index 96b05d94b76..8d12e8680eb 100644 --- a/test/javascripts/models/report-test.js.es6 +++ b/test/javascripts/models/report-test.js.es6 @@ -391,3 +391,74 @@ QUnit.test("average", assert => { report.set("average", false); assert.ok(report.get("lastSevenDaysCount") === 35); }); + +QUnit.test("computed labels", assert => { + const data = [ + { + username: "joffrey", + user_url: "/admin/users/1/joffrey", + flag_count: 1876, + time_read: 287362, + note: "This is a long note" + } + ]; + + const labels = [ + { + type: "link", + properties: ["username", "user_url"], + title: "Username" + }, + { properties: ["flag_count"], title: "Flag count" }, + { type: "seconds", properties: ["time_read"], title: "Time read" }, + { type: "text", properties: ["note"], title: "Note" } + ]; + + const report = Report.create({ + type: "topics", + labels, + data + }); + + const row = report.get("data.0"); + const computedLabels = report.get("computedLabels"); + + const usernameLabel = computedLabels[0]; + assert.equal(usernameLabel.property, "username"); + assert.equal(usernameLabel.sort_property, "username"); + assert.equal(usernameLabel.title, "Username"); + const computedUsernameLabel = usernameLabel.compute(row); + assert.equal( + computedUsernameLabel.formatedValue, + 'joffrey' + ); + assert.equal(computedUsernameLabel.type, "link"); + assert.equal(computedUsernameLabel.value, "joffrey"); + + const flagCountLabel = computedLabels[1]; + assert.equal(flagCountLabel.property, "flag_count"); + assert.equal(flagCountLabel.sort_property, "flag_count"); + assert.equal(flagCountLabel.title, "Flag count"); + const computedFlagCountLabel = flagCountLabel.compute(row); + assert.equal(computedFlagCountLabel.formatedValue, "1.9k"); + assert.equal(computedFlagCountLabel.type, "number"); + assert.equal(computedFlagCountLabel.value, 1876); + + const timeReadLabel = computedLabels[2]; + assert.equal(timeReadLabel.property, "time_read"); + assert.equal(timeReadLabel.sort_property, "time_read"); + assert.equal(timeReadLabel.title, "Time read"); + const computedTimeReadLabel = timeReadLabel.compute(row); + assert.equal(computedTimeReadLabel.formatedValue, "3d"); + assert.equal(computedTimeReadLabel.type, "seconds"); + assert.equal(computedTimeReadLabel.value, 287362); + + const noteLabel = computedLabels[3]; + assert.equal(noteLabel.property, "note"); + assert.equal(noteLabel.sort_property, "note"); + assert.equal(noteLabel.title, "Note"); + const computedNoteLabel = noteLabel.compute(row); + assert.equal(computedNoteLabel.formatedValue, "This is a long note"); + assert.equal(computedNoteLabel.type, "text"); + assert.equal(computedNoteLabel.value, "This is a long note"); +});