Skip to content

Commit 82ebf32

Browse files
committed
feat: add frontend date selection option
1 parent c657138 commit 82ebf32

4 files changed

Lines changed: 296 additions & 22 deletions

File tree

extensions/statistics/js/src/admin/components/StatisticsWidget.tsx

Lines changed: 154 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ import app from 'flarum/admin/app';
33
import SelectDropdown from 'flarum/common/components/SelectDropdown';
44
import Button from 'flarum/common/components/Button';
55
import abbreviateNumber from 'flarum/common/utils/abbreviateNumber';
6+
import extractText from 'flarum/common/utils/extractText';
67
import LoadingIndicator from 'flarum/common/components/LoadingIndicator';
8+
import Placeholder from 'flarum/common/components/Placeholder';
79
import icon from 'flarum/common/helpers/icon';
810

911
import DashboardWidget, { IDashboardWidgetAttrs } from 'flarum/admin/components/DashboardWidget';
1012

13+
import StatisticsWidgetDateSelectionModal, { IDateSelection, IStatisticsWidgetDateSelectionModalAttrs } from './StatisticsWidgetDateSelectionModal';
14+
1115
import type Mithril from 'mithril';
1216

1317
// @ts-expect-error No typings available
@@ -25,14 +29,23 @@ export default class StatisticsWidget extends DashboardWidget {
2529

2630
chart: any;
2731

32+
customPeriod: IDateSelection | null = null;
33+
2834
timedData: Record<string, undefined | any> = {};
2935
lifetimeData: any;
36+
customPeriodData: Record<string, undefined | any> = {};
37+
38+
noData: boolean = false;
3039

3140
loadingLifetime = true;
3241
loadingTimed: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'> = this.entities.reduce((acc, curr) => {
3342
acc[curr] = 'unloaded';
3443
return acc;
3544
}, {} as Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>);
45+
loadingCustom: Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'> = this.entities.reduce((acc, curr) => {
46+
acc[curr] = 'unloaded';
47+
return acc;
48+
}, {} as Record<string, 'unloaded' | 'loading' | 'loaded' | 'fail'>);
3649

3750
selectedEntity = 'users';
3851
selectedPeriod: undefined | string;
@@ -105,17 +118,74 @@ export default class StatisticsWidget extends DashboardWidget {
105118
m.redraw();
106119
}
107120

121+
async loadCustomRangeData(model: string): Promise<void> {
122+
this.loadingCustom[model] = 'loading';
123+
m.redraw();
124+
125+
// We clone so we can check that the same period is still selected
126+
// once the HTTP request is complete and the data is to be displayed
127+
const range = { ...this.customPeriod };
128+
try {
129+
const data = await app.request({
130+
method: 'GET',
131+
url: app.forum.attribute('apiUrl') + '/statistics',
132+
params: {
133+
period: 'custom',
134+
model,
135+
dateRange: {
136+
start: range.start,
137+
end: range.end,
138+
},
139+
},
140+
});
141+
142+
if (JSON.stringify(range) !== JSON.stringify(this.customPeriod)) {
143+
// The range this method was called with is no longer the selected.
144+
// Bail out here.
145+
return;
146+
}
147+
148+
this.customPeriodData[model] = data;
149+
this.loadingCustom[model] = 'loaded';
150+
151+
m.redraw();
152+
} catch (e) {
153+
if (JSON.stringify(range) !== JSON.stringify(this.customPeriod)) {
154+
// The range this method was called with is no longer the selected.
155+
// Bail out here.
156+
return;
157+
}
158+
159+
console.error(e);
160+
this.loadingCustom[model] = 'fail';
161+
}
162+
}
163+
108164
className() {
109165
return 'StatisticsWidget';
110166
}
111167

112168
content() {
113-
const loadingSelectedEntity = this.loadingTimed[this.selectedEntity] !== 'loaded';
114-
115-
const thisPeriod = loadingSelectedEntity ? null : this.periods![this.selectedPeriod!];
169+
const loadingSelectedEntity = (this.selectedPeriod === 'custom' ? this.loadingCustom : this.loadingTimed)[this.selectedEntity] !== 'loaded';
170+
171+
const thisPeriod = loadingSelectedEntity
172+
? null
173+
: this.selectedPeriod === 'custom'
174+
? {
175+
start: this.customPeriod?.end!,
176+
end: this.customPeriod?.end!,
177+
step: 86400,
178+
}
179+
: this.periods![this.selectedPeriod!];
116180

117-
if (!this.timedData[this.selectedEntity] && this.loadingTimed[this.selectedEntity] === 'unloaded') {
118-
this.loadTimedData(this.selectedEntity);
181+
if (this.selectedPeriod === 'custom') {
182+
if (!this.customPeriodData[this.selectedEntity] && this.loadingCustom[this.selectedEntity] === 'unloaded') {
183+
this.loadCustomRangeData(this.selectedEntity);
184+
}
185+
} else {
186+
if (!this.timedData[this.selectedEntity] && this.loadingTimed[this.selectedEntity] === 'unloaded') {
187+
this.loadTimedData(this.selectedEntity);
188+
}
119189
}
120190

121191
return (
@@ -128,16 +198,56 @@ export default class StatisticsWidget extends DashboardWidget {
128198
<LoadingIndicator size="small" display="inline" />
129199
) : (
130200
<SelectDropdown disabled={loadingSelectedEntity} buttonClassName="Button Button--text" caretIcon="fas fa-caret-down">
131-
{Object.keys(this.periods!).map((period) => (
132-
<Button
133-
key={period}
134-
active={period === this.selectedPeriod}
135-
onclick={this.changePeriod.bind(this, period)}
136-
icon={period === this.selectedPeriod ? 'fas fa-check' : true}
137-
>
138-
{app.translator.trans(`flarum-statistics.admin.statistics.${period}_label`)}
139-
</Button>
140-
))}
201+
{Object.keys(this.periods!)
202+
.map((period) => (
203+
<Button
204+
key={period}
205+
active={period === this.selectedPeriod}
206+
onclick={this.changePeriod.bind(this, period)}
207+
icon={period === this.selectedPeriod ? 'fas fa-check' : true}
208+
>
209+
{app.translator.trans(`flarum-statistics.admin.statistics.${period}_label`)}
210+
</Button>
211+
))
212+
.concat([
213+
<Button
214+
key="custom"
215+
active={this.selectedPeriod === 'custom'}
216+
onclick={() => {
217+
const attrs: IStatisticsWidgetDateSelectionModalAttrs = {
218+
onModalSubmit: (dates: IDateSelection) => {
219+
if (JSON.stringify(dates) === JSON.stringify(this.customPeriod)) {
220+
// If same period is selected, don't reload data
221+
return;
222+
}
223+
224+
this.customPeriodData = {};
225+
Object.keys(this.loadingCustom).forEach((k) => (this.loadingCustom[k] = 'unloaded'));
226+
this.customPeriod = dates;
227+
this.changePeriod('custom');
228+
},
229+
} as any;
230+
231+
// If we have a custom period set already,
232+
// let's prefill the modal with it
233+
if (this.customPeriod) {
234+
attrs.value = this.customPeriod;
235+
}
236+
237+
app.modal.show(StatisticsWidgetDateSelectionModal as any, attrs as any);
238+
}}
239+
icon={this.selectedPeriod === 'custom' ? 'fas fa-check' : true}
240+
>
241+
{this.selectedPeriod === 'custom'
242+
? extractText(
243+
app.translator.trans(`flarum-statistics.admin.statistics.custom_label_specified`, {
244+
fromDate: dayjs(this.customPeriod!.start! * 1000).format('DD MMM YYYY'),
245+
toDate: dayjs(this.customPeriod!.end! * 1000).format('DD MMM YYYY'),
246+
})
247+
)
248+
: app.translator.trans(`flarum-statistics.admin.statistics.custom_label`)}
249+
</Button>,
250+
])}
141251
</SelectDropdown>
142252
)}
143253
</div>
@@ -148,11 +258,14 @@ export default class StatisticsWidget extends DashboardWidget {
148258
const thisPeriodCount = loadingSelectedEntity
149259
? app.translator.trans('flarum-statistics.admin.statistics.loading')
150260
: this.getPeriodCount(entity, thisPeriod!);
151-
const lastPeriodCount = loadingSelectedEntity
152-
? app.translator.trans('flarum-statistics.admin.statistics.loading')
153-
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
261+
const lastPeriodCount =
262+
this.selectedPeriod === 'custom'
263+
? null
264+
: loadingSelectedEntity
265+
? app.translator.trans('flarum-statistics.admin.statistics.loading')
266+
: this.getPeriodCount(entity, this.getLastPeriod(thisPeriod!));
154267
const periodChange =
155-
loadingSelectedEntity || lastPeriodCount === 0
268+
loadingSelectedEntity || lastPeriodCount === 0 || lastPeriodCount === null
156269
? 0
157270
: (((thisPeriodCount as number) - (lastPeriodCount as number)) / (lastPeriodCount as number)) * 100;
158271

@@ -197,6 +310,8 @@ export default class StatisticsWidget extends DashboardWidget {
197310
/>
198311
)}
199312
</>
313+
314+
{this.noData && <Placeholder text={app.translator.trans(`flarum-statistics.admin.statistics.no_data`)} />}
200315
</div>
201316
);
202317
}
@@ -206,7 +321,16 @@ export default class StatisticsWidget extends DashboardWidget {
206321
return;
207322
}
208323

209-
const period = this.periods![this.selectedPeriod!];
324+
debugger;
325+
326+
const period =
327+
this.selectedPeriod === 'custom'
328+
? {
329+
start: this.customPeriod?.start!,
330+
end: this.customPeriod?.end!,
331+
step: 86400,
332+
}
333+
: this.periods![this.selectedPeriod!];
210334
const periodLength = period.end - period.start;
211335
const labels = [];
212336
const thisPeriod = [];
@@ -231,6 +355,15 @@ export default class StatisticsWidget extends DashboardWidget {
231355
lastPeriod.push(this.getPeriodCount(this.selectedEntity, { start: i - periodLength, end: i - periodLength + period.step }));
232356
}
233357

358+
if (thisPeriod.length === 0) {
359+
this.noData = true;
360+
m.redraw();
361+
return;
362+
} else {
363+
this.noData = false;
364+
m.redraw();
365+
}
366+
234367
const datasets = [{ values: lastPeriod }, { values: thisPeriod }];
235368
const data = {
236369
labels,
@@ -275,7 +408,7 @@ export default class StatisticsWidget extends DashboardWidget {
275408
}
276409

277410
getPeriodCount(entity: string, period: { start: number; end: number }) {
278-
const timed: Record<string, number> = this.timedData[entity];
411+
const timed: Record<string, number> = (this.selectedPeriod === 'custom' ? this.customPeriodData : this.timedData)[entity];
279412
let count = 0;
280413

281414
for (const t in timed) {
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import app from 'flarum/admin/app';
2+
import ItemList from 'flarum/common/utils/ItemList';
3+
import generateElementId from 'flarum/admin/utils/generateElementId';
4+
import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal';
5+
6+
import Mithril from 'mithril';
7+
import Button from 'flarum/common/components/Button';
8+
9+
export interface IDateSelection {
10+
/**
11+
* Timestamp (seconds, not ms) for start date
12+
*/
13+
start: number;
14+
/**
15+
* Timestamp (seconds, not ms) for end date
16+
*/
17+
end: number;
18+
}
19+
20+
export interface IStatisticsWidgetDateSelectionModalAttrs extends IInternalModalAttrs {
21+
onModalSubmit: (dates: IDateSelection) => void;
22+
value?: IDateSelection;
23+
}
24+
25+
interface IStatisticsWidgetDateSelectionModalState {
26+
inputs: {
27+
startDateVal: string;
28+
endDateVal: string;
29+
};
30+
ids: {
31+
startDate: string;
32+
endDate: string;
33+
};
34+
}
35+
36+
export default class StatisticsWidgetDateSelectionModal extends Modal<IStatisticsWidgetDateSelectionModalAttrs> {
37+
/* @ts-expect-error core typings don't allow us to set the type of the state attr :( */
38+
state: IStatisticsWidgetDateSelectionModalState = {
39+
inputs: {
40+
startDateVal: dayjs().format('YYYY-MM-DD'),
41+
endDateVal: dayjs().format('YYYY-MM-DD'),
42+
},
43+
ids: {
44+
startDate: generateElementId(),
45+
endDate: generateElementId(),
46+
},
47+
};
48+
49+
oninit(vnode) {
50+
super.oninit(vnode);
51+
52+
if (this.attrs.value) {
53+
this.state.inputs = {
54+
startDateVal: dayjs(this.attrs.value.start * 1000).format('YYYY-MM-DD'),
55+
endDateVal: dayjs(this.attrs.value.end * 1000).format('YYYY-MM-DD'),
56+
};
57+
}
58+
}
59+
60+
className(): string {
61+
return 'StatisticsWidgetDateSelectionModal';
62+
}
63+
64+
title(): Mithril.Children {
65+
return app.translator.trans('flarum-statistics.admin.date_selection_modal.title');
66+
}
67+
68+
content(): Mithril.Children {
69+
return <div class="Modal-body">{this.items().toArray()}</div>;
70+
}
71+
72+
items(): ItemList<Mithril.Children> {
73+
const items = new ItemList<Mithril.Children>();
74+
75+
items.add('intro', <p>{app.translator.trans('flarum-statistics.admin.date_selection_modal.description')}</p>, 100);
76+
77+
items.add(
78+
'date_start',
79+
<div class="Form-group">
80+
<label htmlFor={this.state.ids.startDate}>{app.translator.trans('flarum-statistics.admin.date_selection_modal.start_date')}</label>
81+
<input type="date" id={this.state.ids.startDate} value={this.state.inputs.startDateVal} onchange={this.updateState('startDateVal')} />
82+
</div>,
83+
90
84+
);
85+
86+
items.add(
87+
'date_end',
88+
<div class="Form-group">
89+
<label htmlFor={this.state.ids.endDate}>{app.translator.trans('flarum-statistics.admin.date_selection_modal.start_date')}</label>
90+
<input type="date" id={this.state.ids.endDate} value={this.state.inputs.endDateVal} onchange={this.updateState('endDateVal')} />
91+
</div>,
92+
80
93+
);
94+
95+
items.add(
96+
'submit',
97+
<Button class="Button Button--primary" type="submit">
98+
{app.translator.trans('flarum-statistics.admin.date_selection_modal.submit_button')}
99+
</Button>,
100+
0
101+
);
102+
103+
return items;
104+
}
105+
106+
updateState(field: keyof IStatisticsWidgetDateSelectionModalState['inputs']): (e: InputEvent) => void {
107+
return (e: InputEvent) => {
108+
this.state.inputs[field] = (e.currentTarget as HTMLInputElement).value;
109+
};
110+
}
111+
112+
submitData(): IDateSelection {
113+
// We force 'zulu' time (UTC)
114+
return {
115+
start: Math.floor(new Date(this.state.inputs.startDateVal + 'Z').getTime() / 1000),
116+
end: Math.floor(new Date(this.state.inputs.endDateVal + 'Z').getTime() / 1000),
117+
};
118+
}
119+
120+
onsubmit(e: SubmitEvent): void {
121+
e.preventDefault();
122+
123+
this.attrs.onModalSubmit(this.submitData());
124+
this.hide();
125+
}
126+
}

extensions/statistics/less/admin.less

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@
109109
padding: 12px 16px;
110110
text-align: center;
111111
}
112+
113+
.Placeholder {
114+
padding-bottom: 32px;
115+
}
112116
}
113117

114118
/*!

0 commit comments

Comments
 (0)