FEATURE: part 2 of dashboard improvements

- moderation tab
- sorting/pagination
- improved third party reports support
- trending charts
- better perf
- many fixes
- refactoring
- new reports

Co-Authored-By: Simon Cossar <scossar@users.noreply.github.com>
This commit is contained in:
Joffrey JAFFEUX
2018-07-19 14:33:11 -04:00
committed by GitHub
parent 4e09206061
commit 1a78e12f4e
76 changed files with 3177 additions and 1484 deletions
@@ -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;
}
}
});
@@ -0,0 +1,3 @@
export default Ember.Component.extend({
classNames: ["admin-report-inline-table"]
});
@@ -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";
}
});
@@ -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);
});
}
});
@@ -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);
}
}
}
});
@@ -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);
}
});
@@ -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);
}
});
@@ -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));
});
})
);
}
});
@@ -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"
}
}
]
}
}
};
}
});
@@ -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));
});
})
);
}
});