UX: support for multiple datasets in one chart

This commit is contained in:
Joffrey JAFFEUX 2018-05-15 20:12:03 +02:00 committed by GitHub
parent 51ee31b3eb
commit 9947c38e1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 302 additions and 258 deletions

View File

@ -1,21 +1,22 @@
import { ajax } from 'discourse/lib/ajax';
import { ajax } from "discourse/lib/ajax";
import Report from "admin/models/report";
import AsyncReport from "admin/mixins/async-report";
export default Ember.Component.extend(AsyncReport, {
classNames: ["dashboard-table", "dashboard-inline-table", "fixed"],
isLoading: true,
help: null,
helpPage: null,
title: null,
loadingTitle: null,
loadReport(report_json) {
this._setPropertiesFromReport(Report.create(report_json));
return Report.create(report_json);
},
fetchReport() {
this.set("isLoading", true);
this._super();
let payload = { data: { async: true } };
let payload = { data: { async: true, facets: ["total", "prev30Days"] } };
if (this.get("startDate")) {
payload.data.start_date = this.get("startDate").format("YYYY-MM-DD[T]HH:mm:ss.SSSZZ");
@ -29,14 +30,15 @@ export default Ember.Component.extend(AsyncReport, {
payload.data.limit = this.get("limit");
}
ajax(this.get("dataSource"), payload)
.then((response) => {
this.set('reportKey', response.report.report_key);
this.loadReport(response.report);
}).finally(() => {
if (!Ember.isEmpty(this.get("report.data"))) {
this.set("isLoading", false);
};
});
this.set("reports", Ember.Object.create());
this.set("reportKeys", []);
return Ember.RSVP.Promise.all(this.get("dataSources").map(dataSource => {
return ajax(dataSource, payload)
.then(response => {
this.set(`reports.${response.report.report_key}`, this.loadReport(response.report));
this.get("reportKeys").pushObject(response.report.report_key);
});
}));
}
});

View File

@ -1,5 +1,4 @@
import { ajax } from "discourse/lib/ajax";
import computed from "ember-addons/ember-computed-decorators";
import AsyncReport from "admin/mixins/async-report";
import Report from "admin/models/report";
import { number } from 'discourse/lib/formatter';
@ -26,41 +25,20 @@ function collapseWeekly(data, average) {
export default Ember.Component.extend(AsyncReport, {
classNames: ["dashboard-mini-chart"],
classNameBindings: ["trend", "oneDataPoint"],
isLoading: true,
trend: Ember.computed.alias("report.trend"),
oneDataPoint: false,
backgroundColor: "rgba(200,220,240,0.3)",
borderColor: "#08C",
average: false,
percent: false,
total: 0,
@computed("dataSourceName")
dataSource(dataSourceName) {
if (dataSourceName) {
return `/admin/reports/${dataSourceName}`;
}
init() {
this._super();
this._colorsPool = ["rgb(0,136,204)", "rgb(235,83,148)"];
},
@computed("trend")
trendIcon(trend) {
switch (trend) {
case "trending-up":
return "angle-up";
case "trending-down":
return "angle-down";
case "high-trending-up":
return "angle-double-up";
case "high-trending-down":
return "angle-double-down";
default:
return null;
}
pickColorAtIndex(index) {
return this._colorsPool[index] || this._colorsPool[0];
},
fetchReport() {
this.set("isLoading", true);
this._super();
let payload = {
data: { async: true, facets: ["prev_period"] }
@ -79,56 +57,56 @@ export default Ember.Component.extend(AsyncReport, {
this._chart = null;
}
this.set("report", null);
this.set("reports", Ember.Object.create());
this.set("reportKeys", []);
ajax(this.get("dataSource"), payload)
.then((response) => {
this.set('reportKey', response.report.report_key);
this.loadReport(response.report);
})
.finally(() => {
if (this.get("oneDataPoint")) {
this.set("isLoading", false);
return;
}
if (!Ember.isEmpty(this.get("report.data"))) {
this.set("isLoading", false);
this.renderReport();
}
});
return Ember.RSVP.Promise.all(this.get("dataSources").map(dataSource => {
return ajax(dataSource, payload)
.then(response => {
this.set(`reports.${response.report.report_key}`, this.loadReport(response.report));
this.get("reportKeys").pushObject(response.report.report_key);
});
}));
},
loadReport(report) {
if (_.isArray(report.data)) {
Report.fillMissingDates(report);
loadReport(report, previousReport) {
Report.fillMissingDates(report);
if (report.data && report.data.length > 40) {
report.data = collapseWeekly(report.data, this.get("average"));
}
const model = Report.create(report);
this._setPropertiesFromReport(model);
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() {
if (!this.element || this.isDestroying || this.isDestroyed) { return; }
if (this.get("oneDataPoint")) return;
this._super();
Ember.run.schedule("afterRender", () => {
const $chartCanvas = this.$(".chart-canvas");
if (!$chartCanvas.length) return;
const context = $chartCanvas[0].getContext("2d");
const reports = _.values(this.get("reports"));
const labels = Ember.makeArray(reports.get("firstObject.data")).map(d => d.x);
const data = {
labels: this.get("labels"),
datasets: [{
data: Ember.makeArray(this.get("values")),
backgroundColor: this.get("backgroundColor"),
borderColor: this.get("borderColor")
}]
labels,
datasets: reports.map(report => {
return {
data: Ember.makeArray(report.data).map(d => d.y),
backgroundColor: "rgba(200,220,240,0.3)",
borderColor: report.color
};
})
};
if (this._chart) {
@ -138,15 +116,6 @@ export default Ember.Component.extend(AsyncReport, {
});
},
_setPropertiesFromReport(report) {
const oneDataPoint = (this.get("startDate") && this.get("endDate")) &&
this.get("startDate").isSame(this.get("endDate"), "day");
report.set("average", this.get("average"));
report.set("percent", this.get("percent"));
this.setProperties({ oneDataPoint, report });
},
_buildChartConfig(data) {
return {
type: "line",
@ -171,6 +140,7 @@ export default Ember.Component.extend(AsyncReport, {
}],
xAxes: [{
display: true,
gridLines: { display: false },
type: "time",
time: {
parser: "YYYY-MM-DD"

View File

@ -1,77 +1,101 @@
import computed from 'ember-addons/ember-computed-decorators';
import computed from "ember-addons/ember-computed-decorators";
export default Ember.Mixin.create({
classNameBindings: ["isLoading"],
report: null,
reports: null,
reportKeys: null,
isLoading: false,
dataSourceNames: "",
init() {
this._super();
this._channel = this.get("dataSource");
this.set("reports", Ember.Object.create());
this.set("reportKeys", []);
this._channels = this.get("dataSources");
this._callback = (report) => {
if (report.report_key = this.get("reportKey")) {
if (this.get("reportKeys").includes(report.report_key)) {
Em.run.next(() => {
if (report.report_key = this.get("reportKey")) {
this.loadReport(report);
this.set("isLoading", false);
if (this.get("reportKeys").includes(report.report_key)) {
const previousReport = this.get(`reports.${report.report_key}`);
this.set(`reports.${report.report_key}`, this.loadReport(report, previousReport));
this.renderReport();
}
});
}
};
// in case we did not subscribe in time ensure we always grab the
// last thing on the channel
this.messageBus.subscribe(this._channel, this._callback, -2);
this.subscribe(-2);
},
subscribe(position) {
this._channels.forEach(channel => {
this.messageBus.subscribe(channel, this._callback, position);
});
},
unsubscribe() {
this._channels.forEach(channel => {
this.messageBus.unsubscribe(channel, this._callback);
});
},
@computed("dataSourceNames")
dataSources(dataSourceNames) {
return dataSourceNames.split(",").map(source => `/admin/reports/${source}`);
},
willDestroyElement() {
this._super();
this.messageBus.unsubscribe(this._channel, this._callback);
this.unsubscribe();
},
didInsertElement() {
this._super();
Ember.run.later(this, function() {
this.fetchReport();
this.fetchReport()
.finally(() => {
this.renderReport();
});
}, 500);
},
didUpdateAttrs() {
this._super();
this.fetchReport();
this.fetchReport()
.finally(() => {
this.renderReport();
});
},
renderReport() {},
renderReport() {
if (!this.element || this.isDestroying || this.isDestroyed) return;
const reports = _.values(this.get("reports"));
if (!reports.length) return;
const title = reports.map(report => report.title).join(", ");
if (reports.map(report => report.processing).includes(true)) {
const loading = I18n.t("conditional_loading_section.loading");
this.set("loadingTitle", `${loading}\n\n${title}`);
return;
}
this.setProperties({ title, isLoading: false});
},
loadReport() {},
fetchReport() {},
@computed("dataSourceName")
dataSource(dataSourceName) {
return `/admin/reports/${dataSourceName}`;
fetchReport() {
this.set("isLoading", true);
this.set("loadingTitle", I18n.t("conditional_loading_section.loading"));
},
@computed("report")
labels(report) {
if (!report) return;
if (report.labels) {
return Ember.makeArray(report.labels);
} else {
return Ember.makeArray(report.data).map(r => r.x);
}
},
@computed("report")
values(report) {
if (!report) return;
return Ember.makeArray(report.data).map(r => r.y);
},
_setPropertiesFromReport(report) {
if (!this.element || this.isDestroying || this.isDestroyed) { return; }
this.setProperties({ report });
}
});

View File

@ -5,6 +5,7 @@ import computed from 'ember-addons/ember-computed-decorators';
const Report = Discourse.Model.extend({
average: false,
percent: false,
@computed("type", "start_date", "end_date")
reportUrl(type, start_date, end_date) {
@ -101,7 +102,23 @@ const Report = Discourse.Model.extend({
@computed('data', 'currentTotal')
currentAverage(data, total) {
return data.length === 0 ? 0 : parseFloat((total / parseFloat(data.length)).toFixed(1));
return Ember.makeArray(data).length === 0 ? 0 : parseFloat((total / parseFloat(data.length)).toFixed(1));
},
@computed("trend")
trendIcon(trend) {
switch (trend) {
case "trending-up":
return "angle-up";
case "trending-down":
return "angle-down";
case "high-trending-up":
return "angle-double-up";
case "high-trending-down":
return "angle-double-down";
default:
return null;
}
},
@computed('prev_period', 'currentTotal', 'currentAverage')

View File

@ -1,32 +1,40 @@
{{#conditional-loading-section isLoading=isLoading title=report.title}}
{{#conditional-loading-section isLoading=isLoading title=loadingTitle}}
<div class="table-title">
<h3>{{report.title}}</h3>
<h3>{{title}}</h3>
{{#if help}}
<a href="{{helpPage}}">{{i18n help}}</a>
{{/if}}
</div>
<div class="table-container">
<table>
<thead>
<tr>
{{#each labels as |label|}}
<th>{{label}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#unless hasBlock}}
{{#each values as |value|}}
<tr>
<td>{{number value}}</td>
</tr>
{{/each}}
{{else}}
{{yield (hash report=report)}}
{{/unless}}
</tbody>
</table>
</div>
{{#each-in reports as |key report|}}
<div class="table-container">
<table>
<thead>
<tr>
{{#if report.labels}}
{{#each report.labels as |label|}}
<th>{{label}}</th>
{{/each}}
{{else}}
{{#each report.data as |data|}}
<th>{{data.x}}</th>
{{/each}}
{{/if}}
</tr>
</thead>
<tbody>
{{#unless hasBlock}}
{{#each report.data as |data|}}
<tr>
<td>{{number data.y}}</td>
</tr>
{{/each}}
{{else}}
{{yield (hash report=report)}}
{{/unless}}
</tbody>
</table>
</div>
{{/each-in}}
{{/conditional-loading-section}}

View File

@ -1,35 +1,33 @@
{{#conditional-loading-section isLoading=isLoading title=report.title}}
<div class="chart-title">
<h3 title={{report.description}}>
<a href="{{report.reportUrl}}">
{{report.title}}
</a>
</h3>
{{#conditional-loading-section isLoading=isLoading title=loadingTitle}}
<div class="dashboard-mini-statuses">
{{#each-in reports as |key report|}}
<div class="dashboard-mini-status" title="{{report.trendTitle}}">
<span class="indicator" style="background-color:{{report.color}}"></span>
<div class="legend">
<h4 class="title">
<a href="{{report.reportUrl}}">
{{report.title}}
</a>
</h4>
<div class="chart-trend {{trend}}">
{{#if average}}
<span title="{{report.trendTitle}}">
{{report.currentAverage}}{{if percent "%"}}
</span>
{{else}}
<span title="{{report.trendTitle}}">
{{number report.currentTotal noTitle="true"}}
</span>
{{/if}}
{{#if trendIcon}}
{{d-icon trendIcon}}
{{/if}}
</div>
<div class="trend">
<span class="value" title="{{report.trendTitle}}">
{{#if report.average}}
{{report.currentAverage}}
{{else}}
{{number report.currentTotal noTitle="true"}}
{{/if}}
</span>
{{#if report.trendIcon}}
{{d-icon report.trendIcon}}
{{/if}}
</div>
</div>
</div>
{{/each-in}}
</div>
<div class="chart-container">
{{#if oneDataPoint}}
<span class="data-point">
{{number values.lastObject.y}}
</span>
{{else}}
<canvas class="chart-canvas"></canvas>
{{/if}}
<div class="chart-canvas-container">
<canvas class="chart-canvas"></canvas>
</div>
{{/conditional-loading-section}}

View File

@ -21,38 +21,29 @@
<div class="section-body">
<div class="charts">
{{dashboard-mini-chart
dataSourceName="signups"
dataSourceNames="signups"
startDate=startDate
endDate=endDate}}
{{dashboard-mini-chart
dataSourceName="topics"
dataSourceNames="topics,posts"
startDate=startDate
endDate=endDate}}
{{dashboard-mini-chart
dataSourceName="posts"
dataSourceNames="dau_by_mau"
startDate=startDate
endDate=endDate}}
{{dashboard-mini-chart
dataSourceName="dau_by_mau"
average=true
percent=true
dataSourceNames="daily_engaged_users"
startDate=startDate
endDate=endDate}}
{{dashboard-mini-chart
dataSourceName="daily_engaged_users"
average=true
dataSourceNames="new_contributors"
startDate=startDate
endDate=endDate}}
{{dashboard-mini-chart
dataSourceName="new_contributors"
startDate=startDate
endDate=endDate}}
</div>
</div>
</div>
@ -86,27 +77,15 @@
{{/conditional-loading-section}}
</div>
{{#dashboard-inline-table dataSourceName="users_by_type" lastRefreshedAt=lastRefreshedAt as |context|}}
{{#dashboard-inline-table dataSourceNames="users_by_trust_level,users_by_type" lastRefreshedAt=lastRefreshedAt as |context|}}
<tr>
{{#each context.report.data as |data|}}
<td>
<a href="/admin/users/list/{{data.key}}">
{{number data.y}}
</a>
</td>
{{/each}}
</tr>
{{/dashboard-inline-table}}
{{#dashboard-inline-table dataSourceName="users_by_trust_level" lastRefreshedAt=lastRefreshedAt as |context|}}
<tr>
{{#each context.report.data as |data|}}
<td>
<a href="/admin/users/list/{{data.key}}">
{{number data.y}}
</a>
</td>
{{/each}}
{{#each context.report.data as |data|}}
<td>
<a href="/admin/users/list/{{data.key}}">
{{number data.y}}
</a>
</td>
{{/each}}
</tr>
{{/dashboard-inline-table}}
@ -115,7 +94,9 @@
<div class="durability">
{{#if currentUser.admin}}
<div class="backups">
<h3 class="durability-title"><a href="/admin/backups">{{i18n "admin.dashboard.backups"}}</a></h3>
<h3 class="durability-title">
<a href="/admin/backups">{{d-icon "archive"}} {{i18n "admin.dashboard.backups"}}</a>
</h3>
<p>
{{diskSpace.backups_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.backups_free}})
<br />
@ -125,7 +106,7 @@
{{/if}}
<div class="uploads">
<h3 class="durability-title">{{i18n "admin.dashboard.uploads"}}</h3>
<h3 class="durability-title">{{d-icon "upload"}} {{i18n "admin.dashboard.uploads"}}</h3>
<p>
{{diskSpace.uploads_used}} ({{i18n "admin.dashboard.space_free" size=diskSpace.uploads_free}})
</p>
@ -153,7 +134,7 @@
<div class="section-column">
{{#dashboard-inline-table
dataSourceName="top_referred_topics"
dataSourceNames="top_referred_topics"
lastRefreshedAt=lastRefreshedAt
limit=8
as |context|}}
@ -173,7 +154,7 @@
{{#dashboard-inline-table
limit=8
dataSourceName="trending_search"
dataSourceNames="trending_search"
isEnabled=logSearchQueriesEnabled
disabledLabel="admin.dashboard.reports.trending_search.disabled"
startDate=lastWeek
@ -194,5 +175,5 @@
{{{i18n "admin.dashboard.reports.trending_search.more"}}}
{{/dashboard-inline-table}}
</div>
</div>
</div>

View File

@ -1,14 +1,7 @@
import computed from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
classNames: ["conditional-loading-section"],
classNameBindings: ["isLoading"],
isLoading: false,
@computed("title")
computedTitle(title) {
return title || I18n.t("conditional_loading_section.loading");
}
isLoading: false
});

View File

@ -1,5 +1,5 @@
{{#if isLoading}}
<span class="title">{{computedTitle}}</span>
<span class="title">{{title}}</span>
<div class="spinner {{size}}"></div>
{{else}}
{{yield}}

View File

@ -155,14 +155,69 @@
.charts {
display: flex;
justify-content: space-between;
justify-content: flex-start;
flex-wrap: wrap;
.dashboard-mini-chart {
max-width: calc(100% * (1/3));
width: 100%;
.dashboard-mini-statuses {
margin-bottom: 1em;
display: inline-flex;
}
.dashboard-mini-status {
flex-direction: row;
margin-right: 1em;
display: flex;
.indicator {
margin-right: .5em;
width: .33em;
height: 35px;
}
.legend {
display: flex;
flex-direction: column;
.title {
a {color: black;}
font-size: $font-down-2;
font-weight: 700;
margin: 0;
}
.trend {
flex-direction: row;
.d-icon {
font-weight: 700;
&.d-icon-angle-down, &.d-icon-angle-double-down {
color: $danger;
}
&.d-icon-angle-up, &.d-icon-angle-double-up {
color: rgb(17, 141, 0);
}
}
}
}
}
.dashboard-mini-chart {
max-width: calc(100% * 1/3);
width: 100%;
flex-grow: 1;
flex-basis: 100%;
display: flex;
margin-bottom: 1em;
.conditional-loading-section {
display: flex;
flex-direction: column;
justify-content: space-between;
flex: 1;
}
@include small-width {
max-width: 100%;
@ -207,25 +262,6 @@
color: $danger;
}
}
&.one-data-point {
.chart-container {
min-height: 150px;
justify-content: center;
align-items: center;
display: flex;
}
.data-point {
width: 100%;
font-size: 6em;
font-weight: bold;
border-radius: 3px;
background: rgba(200,220,240,0.3);
text-align: center;
padding: .5em 0;
}
}
}
@include small-width {
@ -234,11 +270,6 @@
}
}
.chart-container {
position: relative;
padding: 0 1em 0 0;
}
.chart-trend {
font-size: $font-up-3;
display: flex;
@ -248,6 +279,11 @@
margin-right: 1em;
}
.chart-canvas-container {
position: relative;
padding: 0 1em 0 0;
}
.chart-canvas {
width: 100%;
height: 100%;

View File

@ -15,6 +15,9 @@ module Jobs
report.group_id = args['group_id'] if args['group_id']
report.facets = args['facets'].map(&:to_sym) if args['facets']
report.limit = args['limit'].to_i if args['limit']
report.processing = false
report.average = args[:average] || false
report.percent = args[:percent] || false
Report.send("report_#{type}", report)
json = report.as_json

View File

@ -4,7 +4,7 @@ class Report
attr_accessor :type, :data, :total, :prev30Days, :start_date,
:end_date, :category_id, :group_id, :labels, :async,
:prev_period, :facets, :limit
:prev_period, :facets, :limit, :processing, :average, :percent
def self.default_days
30
@ -51,7 +51,10 @@ class Report
group_id: group_id,
prev30Days: self.prev30Days,
report_key: Report.cache_key(self),
labels: labels
labels: labels,
processing: self.processing,
average: self.average,
percent: self.percent
}.tap do |json|
json[:total] = total if total
json[:prev_period] = prev_period if prev_period
@ -80,6 +83,9 @@ class Report
report.async = opts[:async] || false
report.facets = opts[:facets] || [:total, :prev30Days]
report.limit = opts[:limit] if opts[:limit]
report.processing = false
report.average = opts[:average] || false
report.percent = opts[:percent] || false
report_method = :"report_#{type}"
if respond_to?(report_method)
@ -89,6 +95,7 @@ class Report
return cached_report
else
Jobs.enqueue(:retrieve_report, opts.merge(report_type: type))
report.processing = true
end
else
send(report_method, report)
@ -176,6 +183,8 @@ class Report
end
def self.report_daily_engaged_users(report)
report.average = true
report.data = []
data = UserAction.count_daily_engaged_users(report.start_date, report.end_date)
@ -205,6 +214,9 @@ class Report
end
def self.report_dau_by_mau(report)
report.average = true
report.percent = true
data_points = UserVisit.count_by_active_users(report.start_date, report.end_date)
report.data = []