Martin Brennan 95a759092e
UX: Show full total numbers in admin reports (#31061)
This commit updates the display of totals and table
rows for reports in the admin interface. Currently
we show abbreviated numbers for totals e.g. 2.1M
which is not helpful when you need accurate data.
We are also not adding locale-specific number separators
so the row numbers are hard to read e.g. 246999 instead
of 246,999.

This commit fixes both issues to improve the UX of reports
without having to export them.

**Before (totals)**


![image](https://github.com/user-attachments/assets/6958252d-f778-495d-b799-7ebe4e9f1366)

**After (totals)**


![image](https://github.com/user-attachments/assets/2b51580a-cd09-42cd-b713-3c5018fa6727)

**Before (rows)**


![image](https://github.com/user-attachments/assets/38d36236-1382-45b2-a3dc-5b267b122e39)

**After (rows)**


![image](https://github.com/user-attachments/assets/607997b7-7a46-452a-ab6e-368671445a06)
2025-01-31 09:55:05 +10:00

187 lines
4.8 KiB
JavaScript

import Component from "@ember/component";
import { action } from "@ember/object";
import { alias } from "@ember/object/computed";
import { classNameBindings, classNames } from "@ember-decorators/component";
import discourseComputed from "discourse/lib/decorators";
import { makeArray } from "discourse/lib/helpers";
const PAGES_LIMIT = 8;
@classNameBindings("sortable", "twoColumns")
@classNames("admin-report-table")
export default class AdminReportTable extends Component {
sortable = false;
sortDirection = 1;
@alias("options.perPage") perPage;
page = 0;
@discourseComputed("model.computedLabels.length")
twoColumns(labelsLength) {
return labelsLength === 2;
}
@discourseComputed(
"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;
}
@discourseComputed("model.total", "options.total", "twoColumns")
showTotal(reportTotal, total, twoColumns) {
return reportTotal && total && twoColumns;
}
@discourseComputed(
"model.{average,data}",
"totalsForSample.1.value",
"twoColumns"
)
showAverage(model, sampleTotalValue, hasTwoColumns) {
return (
model.average &&
model.data.length > 0 &&
sampleTotalValue &&
hasTwoColumns
);
}
@discourseComputed("totalsForSample.1.value", "model.data.length")
averageForSample(totals, count) {
const averageLabel = this.model.computedLabels.at(-1);
return averageLabel.compute({ y: (totals / count).toFixed(0) })
.formattedValue;
}
@discourseComputed("model.data.length")
showSortingUI(dataLength) {
return dataLength >= 5;
}
@discourseComputed("totalsForSampleRow", "model.computedLabels")
totalsForSample(row, labels) {
return labels.map((label) => {
const computedLabel = label.compute(row);
computedLabel.type = label.type;
computedLabel.property = label.mainProperty;
return computedLabel;
});
}
@discourseComputed("model.total", "model.computedLabels")
formattedTotal(total, labels) {
const totalLabel = labels.at(-1);
return totalLabel.compute({ y: total }).formattedValue;
}
@discourseComputed("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 (!["seconds", "number", "percent"].includes(label.type)) {
return;
} else {
return sum + Math.round(value || 0);
}
};
const total = rows.reduce(reducer, 0);
totalsRow[label.mainProperty] =
label.type === "percent" ? Math.round(total / rows.length) : total;
});
return totalsRow;
}
@discourseComputed("sortLabel", "sortDirection", "model.data.[]")
sortedData(sortLabel, sortDirection, data) {
data = makeArray(data);
if (sortLabel) {
const compare = (label, direction) => {
return (a, b) => {
const aValue = label.compute(a, { useSortProperty: true }).value;
const bValue = label.compute(b, { useSortProperty: true }).value;
const result = aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
return result * direction;
};
};
return data.sort(compare(sortLabel, sortDirection));
}
return data;
}
@discourseComputed("sortedData.[]", "perPage", "page")
paginatedData(data, perPage, page) {
if (perPage < data.length) {
const start = perPage * page;
return data.slice(start, start + perPage);
}
return data;
}
@discourseComputed("model.data", "perPage", "page")
pages(data, perPage, page) {
if (!data || data.length <= perPage) {
return [];
}
const pagesIndexes = [];
for (let i = 0; i < Math.ceil(data.length / perPage); i++) {
pagesIndexes.push(i);
}
let pages = pagesIndexes.map((v) => {
return {
page: v + 1,
index: v,
class: v === page ? "is-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;
}
@action
changePage(page) {
this.set("page", page);
}
@action
sortByLabel(label) {
if (this.sortLabel === label) {
this.set("sortDirection", this.sortDirection === 1 ? -1 : 1);
} else {
this.set("sortLabel", label);
}
}
}