From 15838aa756722f0b84f78eb005f9690d1998401b Mon Sep 17 00:00:00 2001 From: Martin Brennan Date: Wed, 29 Jan 2025 10:33:43 +1000 Subject: [PATCH] DEV: Convert `AdminReport` component to gjs (#31011) This commit converts the `AdminReport` component, which is quite high complexity, to gjs. After this initial round, ideally this component would be broken up into smaller components because it is getting quite big now. Also in this commit: * Add an option to display the report description in a tooltip, which was the main way the description was shown until recently. We want to use this on the dashboard view mostly. * Move admin report "mode" definitions to the server-side Report model, inside a `Report::MODES` constant, collecting the modes defined in various places in the UI into one place * Refactor report code to refer to mode definitions * Add a `REPORT_MODES` constant in JS via javascript.rake and refactor JS to refer to the modes * Delete old admin report components that are no longer used (trust-level-counts, counts, per-day-counts) which were replaced by admin-report-counters a while ago * Add a new `registerReportModeComponent` plugin API, some plugins introduce their own modes (like AI's `emotion`) and components and we need a way to render them --- .../addon/components/admin-report-counts.hbs | 36 - .../addon/components/admin-report-counts.js | 12 - .../admin-report-per-day-counts.hbs | 8 - .../components/admin-report-per-day-counts.js | 5 - .../admin-report-trust-level-counts.hbs | 26 - .../admin-report-trust-level-counts.js | 5 - .../admin/addon/components/admin-report.gjs | 784 ++++++++++++++++++ .../admin/addon/components/admin-report.hbs | 209 ----- .../admin/addon/components/admin-report.js | 427 ---------- .../controllers/admin-dashboard-general.js | 14 +- .../addon/controllers/admin-dashboard-tab.js | 34 +- .../addon/templates/dashboard_general.hbs | 25 +- .../admin/addon/templates/reports-show.hbs | 1 + .../app/lib/admin-report-additional-modes.js | 10 + .../discourse/app/lib/constants.js | 11 + .../discourse/app/lib/plugin-api.gjs | 16 +- .../discourse/tests/helpers/qunit-helpers.js | 2 + .../components/admin-report-test.js | 4 +- .../common/admin/admin_report.scss | 1 - .../stylesheets/common/admin/dashboard.scss | 15 +- app/jobs/regular/export_csv_file.rb | 2 +- .../reports/consolidated_api_requests.rb | 2 +- .../reports/consolidated_page_views.rb | 2 +- app/models/concerns/reports/flags_status.rb | 2 +- .../concerns/reports/moderators_activity.rb | 2 +- app/models/concerns/reports/post_edits.rb | 2 +- app/models/concerns/reports/posts.rb | 2 - app/models/concerns/reports/site_traffic.rb | 2 +- app/models/concerns/reports/staff_logins.rb | 2 +- .../concerns/reports/suspicious_logins.rb | 2 +- .../concerns/reports/top_ignored_users.rb | 2 +- .../concerns/reports/top_referred_topics.rb | 2 +- app/models/concerns/reports/top_referrers.rb | 2 +- .../concerns/reports/top_traffic_sources.rb | 2 +- app/models/concerns/reports/top_uploads.rb | 2 +- .../reports/top_users_by_likes_received.rb | 2 +- ...likes_received_from_a_variety_of_people.rb | 2 +- ...ikes_received_from_inferior_trust_level.rb | 2 +- .../concerns/reports/topic_view_stats.rb | 2 +- .../concerns/reports/trending_search.rb | 2 +- .../concerns/reports/trust_level_growth.rb | 2 +- .../concerns/reports/user_flagging_ratio.rb | 2 +- .../concerns/reports/users_by_trust_level.rb | 2 +- app/models/concerns/reports/users_by_type.rb | 2 +- app/models/concerns/reports/web_crawlers.rb | 2 +- app/models/report.rb | 15 +- docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md | 4 + lib/tasks/javascript.rake | 2 + 48 files changed, 924 insertions(+), 792 deletions(-) delete mode 100644 app/assets/javascripts/admin/addon/components/admin-report-counts.hbs delete mode 100644 app/assets/javascripts/admin/addon/components/admin-report-counts.js delete mode 100644 app/assets/javascripts/admin/addon/components/admin-report-per-day-counts.hbs delete mode 100644 app/assets/javascripts/admin/addon/components/admin-report-per-day-counts.js delete mode 100644 app/assets/javascripts/admin/addon/components/admin-report-trust-level-counts.hbs delete mode 100644 app/assets/javascripts/admin/addon/components/admin-report-trust-level-counts.js create mode 100644 app/assets/javascripts/admin/addon/components/admin-report.gjs delete mode 100644 app/assets/javascripts/admin/addon/components/admin-report.hbs delete mode 100644 app/assets/javascripts/admin/addon/components/admin-report.js create mode 100644 app/assets/javascripts/discourse/app/lib/admin-report-additional-modes.js diff --git a/app/assets/javascripts/admin/addon/components/admin-report-counts.hbs b/app/assets/javascripts/admin/addon/components/admin-report-counts.hbs deleted file mode 100644 index e0a77431745..00000000000 --- a/app/assets/javascripts/admin/addon/components/admin-report-counts.hbs +++ /dev/null @@ -1,36 +0,0 @@ - - {{#if this.report.icon}} - {{d-icon this.report.icon}} - {{/if}} - {{this.report.title}} - - -{{number this.report.todayCount}} - - - {{number this.report.yesterdayCount}} - {{d-icon this.report.yesterdayTrendIcon}} - - - - {{number this.report.lastSevenDaysCount}} - {{d-icon this.report.sevenDaysTrendIcon}} - - - - {{number this.report.lastThirtyDaysCount}} - {{d-icon this.report.thirtyDaysTrendIcon}} - - -{{#if this.allTime}} - {{number this.report.total}} -{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/components/admin-report-counts.js b/app/assets/javascripts/admin/addon/components/admin-report-counts.js deleted file mode 100644 index e6ef645aa0e..00000000000 --- a/app/assets/javascripts/admin/addon/components/admin-report-counts.js +++ /dev/null @@ -1,12 +0,0 @@ -import Component from "@ember/component"; -import { match } from "@ember/object/computed"; -import { classNameBindings, tagName } from "@ember-decorators/component"; - -@tagName("tr") -@classNameBindings("reverseColors") -export default class AdminReportCounts extends Component { - allTime = true; - - @match("report.type", /^(time_to_first_response|topics_with_no_response)$/) - reverseColors; -} diff --git a/app/assets/javascripts/admin/addon/components/admin-report-per-day-counts.hbs b/app/assets/javascripts/admin/addon/components/admin-report-per-day-counts.hbs deleted file mode 100644 index 9c9e3ebb764..00000000000 --- a/app/assets/javascripts/admin/addon/components/admin-report-per-day-counts.hbs +++ /dev/null @@ -1,8 +0,0 @@ -{{this.report.title}} -{{this.report.todayCount}} -{{this.report.yesterdayCount}} -{{this.report.sevenDaysAgoCount}} -{{this.report.thirtyDaysAgoCount}} - \ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/components/admin-report-per-day-counts.js b/app/assets/javascripts/admin/addon/components/admin-report-per-day-counts.js deleted file mode 100644 index b5d5984f988..00000000000 --- a/app/assets/javascripts/admin/addon/components/admin-report-per-day-counts.js +++ /dev/null @@ -1,5 +0,0 @@ -import Component from "@ember/component"; -import { tagName } from "@ember-decorators/component"; - -@tagName("tr") -export default class AdminReportPerDayCounts extends Component {} diff --git a/app/assets/javascripts/admin/addon/components/admin-report-trust-level-counts.hbs b/app/assets/javascripts/admin/addon/components/admin-report-trust-level-counts.hbs deleted file mode 100644 index 81fce07818f..00000000000 --- a/app/assets/javascripts/admin/addon/components/admin-report-trust-level-counts.hbs +++ /dev/null @@ -1,26 +0,0 @@ -{{this.report.title}} - - - {{number (value-at-tl this.report.data level="0")}} - - - - - {{number (value-at-tl this.report.data level="1")}} - - - - - {{number (value-at-tl this.report.data level="2")}} - - - - - {{number (value-at-tl this.report.data level="3")}} - - - - - {{number (value-at-tl this.report.data level="4")}} - - \ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/components/admin-report-trust-level-counts.js b/app/assets/javascripts/admin/addon/components/admin-report-trust-level-counts.js deleted file mode 100644 index 0c41a0f6167..00000000000 --- a/app/assets/javascripts/admin/addon/components/admin-report-trust-level-counts.js +++ /dev/null @@ -1,5 +0,0 @@ -import Component from "@ember/component"; -import { tagName } from "@ember-decorators/component"; - -@tagName("tr") -export default class AdminReportTrustLevelCounts extends Component {} diff --git a/app/assets/javascripts/admin/addon/components/admin-report.gjs b/app/assets/javascripts/admin/addon/components/admin-report.gjs new file mode 100644 index 00000000000..1b61ede6be6 --- /dev/null +++ b/app/assets/javascripts/admin/addon/components/admin-report.gjs @@ -0,0 +1,784 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { concat, fn } from "@ember/helper"; +import EmberObject, { action } from "@ember/object"; +import didUpdate from "@ember/render-modifiers/modifiers/did-update"; +import { next } from "@ember/runloop"; +import { service } from "@ember/service"; +import { htmlSafe } from "@ember/template"; +import { isPresent } from "@ember/utils"; +import ConditionalLoadingSection from "discourse/components/conditional-loading-section"; +import DButton from "discourse/components/d-button"; +import DPageSubheader from "discourse/components/d-page-subheader"; +import DateTimeInputRange from "discourse/components/date-time-input-range"; +import concatClass from "discourse/helpers/concat-class"; +import dIcon from "discourse/helpers/d-icon"; +import number from "discourse/helpers/number"; +import { reportModeComponent } from "discourse/lib/admin-report-additional-modes"; +import { REPORT_MODES } from "discourse/lib/constants"; +import { bind } from "discourse/lib/decorators"; +import { isTesting } from "discourse/lib/environment"; +import { exportEntity } from "discourse/lib/export-csv"; +import { outputExportResult } from "discourse/lib/export-result"; +import { makeArray } from "discourse/lib/helpers"; +import ReportLoader from "discourse/lib/reports-loader"; +import { i18n } from "discourse-i18n"; +import AdminReportChart from "admin/components/admin-report-chart"; +import AdminReportCounters from "admin/components/admin-report-counters"; +import AdminReportInlineTable from "admin/components/admin-report-inline-table"; +import AdminReportRadar from "admin/components/admin-report-radar"; +import AdminReportStackedChart from "admin/components/admin-report-stacked-chart"; +import AdminReportStackedLineChart from "admin/components/admin-report-stacked-line-chart"; +import AdminReportStorageStats from "admin/components/admin-report-storage-stats"; +import AdminReportTable from "admin/components/admin-report-table"; +import ReportFilterBoolComponent from "admin/components/report-filters/bool"; +import ReportFilterCategoryComponent from "admin/components/report-filters/category"; +import ReportFilterGroupComponent from "admin/components/report-filters/group"; +import ReportFilterListComponent from "admin/components/report-filters/list"; +import Report, { DAILY_LIMIT_DAYS, SCHEMA_VERSION } from "admin/models/report"; +import DTooltip from "float-kit/components/d-tooltip"; + +const TABLE_OPTIONS = { + perPage: 8, + total: true, + limit: 20, + formatNumbers: true, +}; + +const CHART_OPTIONS = {}; + +export default class AdminReport extends Component { + @service siteSettings; + + @tracked isEnabled = true; + @tracked isLoading = false; + @tracked rateLimitationString = null; + @tracked report = null; + @tracked model = null; + @tracked showTitle = true; + @tracked currentMode = this.args.filters?.mode; + @tracked options = null; + @tracked dateRangeFrom = null; + @tracked dateRangeTo = null; + + showHeader = this.args.showHeader ?? true; + showFilteringUI = this.args.showFilteringUI ?? false; + showDescriptionInTooltip = this.args.showDescriptionInTooltip ?? true; + _reports = []; + + constructor() { + super(...arguments); + this.fetchOrRender(); + } + + get startDate() { + if (this.dateRangeFrom) { + return moment(this.dateRangeFrom); + } + + let startDate = moment(); + if (this.args.filters && isPresent(this.args.filters.startDate)) { + startDate = moment(this.args.filters.startDate, "YYYY-MM-DD"); + } + + return startDate; + } + + get endDate() { + if (this.dateRangeTo) { + return moment(this.dateRangeTo); + } + + let endDate = moment(); + if (this.args.filters && isPresent(this.args.filters.endDate)) { + endDate = moment(this.args.filters.endDate, "YYYY-MM-DD"); + } + + return endDate; + } + + get reportClasses() { + const builtReportClasses = []; + + if (this.isHidden) { + builtReportClasses.push("hidden"); + } + + if (!this.isHidden) { + builtReportClasses.push("is-visible"); + } + + if (this.isEnabled) { + builtReportClasses.push("is-enabled"); + } + + if (this.isLoading) { + builtReportClasses.push("is-loading"); + } + + if (this.showDescriptionInTooltip) { + builtReportClasses.push("description-in-tooltip"); + } + + builtReportClasses.push(this.dasherizedDataSourceName); + + return builtReportClasses.join(" "); + } + + get showDatesOptions() { + return this.model?.dates_filtering; + } + + get showRefresh() { + return this.showDatesOptions || this.model?.available_filters.length > 0; + } + + get shouldDisplayTrend() { + return this.args.showTrend && this.model?.prev_period; + } + + get showError() { + return ( + this.showTimeoutError || this.showExceptionError || this.showNotFoundError + ); + } + + get showNotFoundError() { + return this.model?.error === "not_found"; + } + + get showTimeoutError() { + return this.model?.error === "timeout"; + } + + get showExceptionError() { + return this.model?.error === "exception"; + } + + get hasData() { + return this.model?.data?.length > 0; + } + + get disabledLabel() { + return this.args.disabledLabel || i18n("admin.dashboard.disabled"); + } + + get isHidden() { + return (this.siteSettings.dashboard_hidden_reports || "") + .split("|") + .filter(Boolean) + .includes(this.args.dataSourceName); + } + + get dasherizedDataSourceName() { + return (this.args.dataSourceName || this.model.type || "undefined").replace( + /_/g, + "-" + ); + } + + get dataSource() { + let dataSourceName = this.args.dataSourceName || this.model.type; + return `/admin/reports/${dataSourceName}`; + } + + get showModes() { + return this.displayedModes.length > 1; + } + + get isChartMode() { + return this.currentMode === REPORT_MODES.chart; + } + + @action + changeGrouping(grouping) { + this.refreshReport({ chartGrouping: grouping }); + } + + get displayedModes() { + const modes = this.args.forcedModes + ? this.args.forcedModes.split(",") + : this.model?.modes; + + return makeArray(modes).map((mode) => { + const base = `btn-default mode-btn ${mode}`; + const cssClass = this.currentMode === mode ? `${base} btn-primary` : base; + + return { + mode, + cssClass, + icon: mode === REPORT_MODES.table ? "table" : "signal", + }; + }); + } + + reportFilterComponent(filter) { + switch (filter.type) { + case "bool": + return ReportFilterBoolComponent; + case "category": + return ReportFilterCategoryComponent; + case "group": + return ReportFilterGroupComponent; + case "list": + return ReportFilterListComponent; + } + } + + get modeComponent() { + const reportMode = this.currentMode.replace(/-/g, "_"); + switch (reportMode) { + case REPORT_MODES.table: + return AdminReportTable; + case REPORT_MODES.inline_table: + return AdminReportInlineTable; + case REPORT_MODES.chart: + return AdminReportChart; + case REPORT_MODES.stacked_chart: + return AdminReportStackedChart; + case REPORT_MODES.stacked_line_chart: + return AdminReportStackedLineChart; + case REPORT_MODES.counters: + return AdminReportCounters; + case REPORT_MODES.radar: + return AdminReportRadar; + case REPORT_MODES.storage_stats: + return AdminReportStorageStats; + default: + if (reportModeComponent(reportMode)) { + return reportModeComponent(reportMode); + } + + return null; + } + } + + get reportKey() { + if (!this.args.dataSourceName || !this.startDate || !this.endDate) { + return null; + } + + const formattedStartDate = this.startDate.toISOString(true).split("T")[0]; + const formattedEndDate = this.endDate.toISOString(true).split("T")[0]; + + let reportKey = "reports:"; + reportKey += [ + this.args.dataSourceName, + isTesting() ? "start" : formattedStartDate.replace(/-/g, ""), + isTesting() ? "end" : formattedEndDate.replace(/-/g, ""), + "[:prev_period]", + this.args.reportOptions?.table?.limit, + // Convert all filter values to strings to ensure unique serialization + this.args.filters?.customFilters + ? JSON.stringify(this.args.filters?.customFilters, (k, v) => + k ? `${v}` : v + ) + : null, + SCHEMA_VERSION, + ] + .filter((x) => x) + .map((x) => x.toString()) + .join(":"); + + return reportKey; + } + + get chartGroupings() { + const chartGrouping = this.options?.chartGrouping; + const options = ["daily", "weekly", "monthly"]; + + return options.map((id) => { + return { + id, + disabled: + id === "daily" && this.model.chartData.length >= DAILY_LIMIT_DAYS, + label: `admin.dashboard.reports.${id}`, + class: `chart-grouping ${chartGrouping === id ? "active" : "inactive"}`, + }; + }); + } + + @action + onChangeDateRange(range) { + this.dateRangeFrom = range.from; + this.dateRangeTo = range.to; + } + + @action + applyFilter(id, value) { + let customFilters = this.args.filters?.customFilters || {}; + + if (typeof value === "undefined") { + delete customFilters[id]; + } else { + customFilters[id] = value; + } + + this.refreshReport({ filters: customFilters }); + } + + @action + refreshReport(options = {}) { + if (!this.args.onRefresh) { + return; + } + + this.args.onRefresh({ + type: this.model.type, + mode: this.currentMode, + chartGrouping: options.chartGrouping, + startDate: + typeof options.startDate === "undefined" + ? this.startDate + : options.startDate, + endDate: + typeof options.endDate === "undefined" ? this.endDate : options.endDate, + filters: + typeof options.filters === "undefined" + ? this.args.filters?.customFilters + : options.filters, + }); + } + + @action + exportCsv() { + const args = { + name: this.model.type, + start_date: this.startDate.toISOString(true).split("T")[0], + end_date: this.endDate.toISOString(true).split("T")[0], + }; + + const customFilters = this.args.filters?.customFilters; + if (customFilters) { + Object.assign(args, customFilters); + } + + exportEntity("report", args).then(outputExportResult); + } + + @action + onChangeMode(mode) { + this.currentMode = mode; + this.refreshReport({ chartGrouping: null }); + } + + @bind + fetchOrRender() { + if (this.report) { + this._renderReport(this.report); + } else if (this.args.dataSourceName) { + this._fetchReport(); + } + } + + @bind + _computeReport() { + 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 foundReport; + + const sort = (report) => { + if (report.length > 1) { + return report.findBy("type", this.args.dataSourceName); + } else { + return report; + } + }; + + if (!this.startDate || !this.endDate) { + foundReport = sort(filteredReports)[0]; + } else { + const reportKey = this.reportKey; + foundReport = sort( + filteredReports.filter((report) => + report.report_key.includes(reportKey) + ) + )[0]; + + if (!foundReport) { + return; + } + } + + if (foundReport.error === "not_found") { + this.showFilteringUI = false; + } + + this._renderReport(foundReport); + } + + @bind + _renderReport(report) { + const modes = this.args.forcedModes?.split(",") || report.modes; + const currentMode = this.currentMode || modes?.[0]; + + this.model = report; + this.currentMode = currentMode; + this.options = this._buildOptions(currentMode, report); + } + + @bind + _fetchReport() { + this.isLoading = true; + this.rateLimitationString = null; + + next(() => { + let payload = this._buildPayload(["prev_period"]); + + const callback = (response) => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.isLoading = false; + + if (response === 429) { + this.rateLimitationString = i18n("admin.dashboard.too_many_requests"); + } else if (response === 500) { + this.model?.set("error", "exception"); + } else if (response) { + this._reports.push(this._loadReport(response)); + this._computeReport(); + } + }; + + ReportLoader.enqueue(this.args.dataSourceName, payload.data, callback); + }); + } + + _buildPayload(facets) { + let payload = { data: { facets } }; + + if (this.startDate) { + payload.data.start_date = moment(this.startDate) + .toISOString(true) + .split("T")[0]; + } + + if (this.endDate) { + payload.data.end_date = moment(this.endDate) + .toISOString(true) + .split("T")[0]; + } + + if (this.args.reportOptions?.table?.limit) { + payload.data.limit = this.args.reportOptions?.table?.limit; + } + + if (this.args.filters?.customFilters) { + payload.data.filters = this.args.filters?.customFilters; + } + + return payload; + } + + _buildOptions(mode, report) { + if (mode === REPORT_MODES.table) { + const tableOptions = JSON.parse(JSON.stringify(TABLE_OPTIONS)); + return EmberObject.create( + Object.assign(tableOptions, this.args.reportOptions?.table || {}) + ); + } else if (mode === REPORT_MODES.chart) { + const chartOptions = JSON.parse(JSON.stringify(CHART_OPTIONS)); + return EmberObject.create( + Object.assign(chartOptions, this.args.reportOptions?.chart || {}, { + chartGrouping: + this.args.reportOptions?.chartGrouping || + Report.groupingForDatapoints(report.chartData.length), + }) + ); + } else if (mode === REPORT_MODES.stacked_chart) { + return this.args.reportOptions?.stackedChart || {}; + } + } + + _loadReport(jsonReport) { + Report.fillMissingDates(jsonReport, { filledField: "chartData" }); + + if ( + jsonReport.chartData && + jsonReport.modes[0] === REPORT_MODES.stacked_chart + ) { + jsonReport.chartData = jsonReport.chartData.map((chartData) => { + if (chartData.length > 40) { + return { + data: chartData.data, + req: chartData.req, + label: chartData.label, + color: chartData.color, + }; + } else { + return chartData; + } + }); + } + + if (jsonReport.prev_data) { + Report.fillMissingDates(jsonReport, { + filledField: "prevChartData", + dataField: "prev_data", + starDate: jsonReport.prev_startDate, + endDate: jsonReport.prev_endDate, + }); + } + + return Report.create(jsonReport); + } + + +} diff --git a/app/assets/javascripts/admin/addon/components/admin-report.hbs b/app/assets/javascripts/admin/addon/components/admin-report.hbs deleted file mode 100644 index 0e45208054a..00000000000 --- a/app/assets/javascripts/admin/addon/components/admin-report.hbs +++ /dev/null @@ -1,209 +0,0 @@ -{{#unless this.isHidden}} - {{#if this.isEnabled}} - - {{#if this.showHeader}} -
- {{#if this.showTitle}} - {{#unless this.showNotFoundError}} - - {{/unless}} - {{/if}} - - {{#if this.shouldDisplayTrend}} -
- - {{#if this.model.average}} - {{number this.model.currentAverage}}{{#if - this.model.percent - }}%{{/if}} - {{else}} - {{number this.model.currentTotal noTitle="true"}}{{#if - this.model.percent - }}%{{/if}} - {{/if}} - - {{#if this.model.trendIcon}} - {{d-icon this.model.trendIcon class="icon"}} - {{/if}} - -
- {{/if}} -
- {{/if}} - -
-
- {{#if this.showError}} - {{#if this.showTimeoutError}} -
- {{d-icon "triangle-exclamation"}} - {{i18n "admin.dashboard.timeout_error"}} -
- {{/if}} - - {{#if this.showExceptionError}} -
- {{d-icon "triangle-exclamation"}} - {{i18n "admin.dashboard.exception_error"}} -
- {{/if}} - - {{#if this.showNotFoundError}} -
- {{d-icon "triangle-exclamation"}} - {{i18n "admin.dashboard.not_found_error"}} -
- {{/if}} - {{else}} - {{#if this.hasData}} - {{#if this.currentMode}} - {{component - this.modeComponent - model=this.model - options=this.options - }} - - {{#if this.model.relatedReport}} - - {{/if}} - {{/if}} - {{else}} - {{#if this.rateLimitationString}} -
- {{d-icon "temperature-three-quarters"}} - {{this.rateLimitationString}} -
- {{else}} -
- {{d-icon "chart-pie"}} - {{#if this.model.reportUrl}} - - - {{#if this.model.title}} - {{this.model.title}} - — - {{/if}} - {{i18n "admin.dashboard.reports.no_data"}} - - - {{else}} - {{i18n "admin.dashboard.reports.no_data"}} - {{/if}} -
- {{/if}} - {{/if}} - {{/if}} -
- - {{#if this.showFilteringUI}} -
- {{#if this.showModes}} -
- {{#each this.displayedModes as |displayedMode|}} - - {{/each}} -
- {{/if}} - - {{#if this.isChartMode}} - {{#if this.model.average}} - - {{i18n "admin.dashboard.reports.average_chart_label"}} - - {{/if}} -
- {{#each this.chartGroupings as |chartGrouping|}} - - {{/each}} -
- {{/if}} - - {{#if this.showDatesOptions}} -
- - {{i18n "admin.dashboard.reports.dates"}} - - -
- -
-
- {{/if}} - - {{#each this.model.available_filters as |filter|}} -
- - {{i18n - (concat - "admin.dashboard.reports.filters." filter.id ".label" - ) - }} - - -
- {{component - (concat "report-filters/" filter.type) - model=this.model - filter=filter - applyFilter=this.applyFilter - }} -
-
- {{/each}} - -
-
- -
-
- - {{#if this.showRefresh}} -
-
- -
-
- {{/if}} -
- {{/if}} -
-
- {{else}} -
- {{html-safe this.disabledLabel}} -
- {{/if}} -{{/unless}} \ No newline at end of file diff --git a/app/assets/javascripts/admin/addon/components/admin-report.js b/app/assets/javascripts/admin/addon/components/admin-report.js deleted file mode 100644 index e9012f3fd94..00000000000 --- a/app/assets/javascripts/admin/addon/components/admin-report.js +++ /dev/null @@ -1,427 +0,0 @@ -import Component from "@ember/component"; -import EmberObject, { action, computed } from "@ember/object"; -import { alias, and, equal, notEmpty, or } from "@ember/object/computed"; -import { next } from "@ember/runloop"; -import { isPresent } from "@ember/utils"; -import { classNameBindings, classNames } from "@ember-decorators/component"; -import discourseComputed from "discourse/lib/decorators"; -import { isTesting } from "discourse/lib/environment"; -import { exportEntity } from "discourse/lib/export-csv"; -import { outputExportResult } from "discourse/lib/export-result"; -import { makeArray } from "discourse/lib/helpers"; -import ReportLoader from "discourse/lib/reports-loader"; -import { i18n } from "discourse-i18n"; -import Report, { DAILY_LIMIT_DAYS, SCHEMA_VERSION } from "admin/models/report"; - -const TABLE_OPTIONS = { - perPage: 8, - total: true, - limit: 20, - formatNumbers: true, -}; - -const CHART_OPTIONS = {}; - -@classNameBindings( - "isHidden:hidden", - "isHidden::is-visible", - "isEnabled", - "isLoading", - "dasherizedDataSourceName" -) -@classNames("admin-report") -export default class AdminReport extends Component { - isEnabled = true; - disabledLabel = i18n("admin.dashboard.disabled"); - isLoading = false; - rateLimitationString = null; - dataSourceName = null; - report = null; - model = null; - reportOptions = null; - forcedModes = null; - filters = null; - showTrend = false; - showHeader = true; - showTitle = true; - showFilteringUI = false; - - @alias("model.dates_filtering") showDatesOptions; - - @or("showDatesOptions", "model.available_filters.length") showRefresh; - - @and("showTrend", "model.prev_period") shouldDisplayTrend; - - endDate = null; - startDate = null; - - @or("showTimeoutError", "showExceptionError", "showNotFoundError") showError; - @equal("model.error", "not_found") showNotFoundError; - @equal("model.error", "timeout") showTimeoutError; - @equal("model.error", "exception") showExceptionError; - @notEmpty("model.data") hasData; - - _reports = []; - - @computed("siteSettings.dashboard_hidden_reports") - get isHidden() { - return (this.siteSettings.dashboard_hidden_reports || "") - .split("|") - .filter(Boolean) - .includes(this.dataSourceName); - } - - didReceiveAttrs() { - super.didReceiveAttrs(...arguments); - - let startDate = moment(); - if (this.filters && isPresent(this.filters.startDate)) { - startDate = moment(this.filters.startDate, "YYYY-MM-DD"); - } - this.set("startDate", startDate); - - let endDate = moment(); - if (this.filters && isPresent(this.filters.endDate)) { - endDate = moment(this.filters.endDate, "YYYY-MM-DD"); - } - this.set("endDate", endDate); - - if (this.filters) { - this.set("currentMode", this.filters.mode); - } - - if (this.report) { - this._renderReport(this.report, this.forcedModes, this.currentMode); - } else if (this.dataSourceName) { - this._fetchReport(); - } - } - - @discourseComputed("dataSourceName", "model.type") - dasherizedDataSourceName(dataSourceName, type) { - return (dataSourceName || type || "undefined").replace(/_/g, "-"); - } - - @discourseComputed("dataSourceName", "model.type") - dataSource(dataSourceName, type) { - dataSourceName = dataSourceName || type; - return `/admin/reports/${dataSourceName}`; - } - - @discourseComputed("displayedModes.length") - showModes(displayedModesLength) { - return displayedModesLength > 1; - } - - @discourseComputed("currentMode") - isChartMode(currentMode) { - return currentMode === "chart"; - } - - @action - changeGrouping(grouping) { - this.send("refreshReport", { - chartGrouping: grouping, - }); - } - - @discourseComputed("currentMode", "model.modes", "forcedModes") - displayedModes(currentMode, reportModes, forcedModes) { - const modes = forcedModes ? forcedModes.split(",") : reportModes; - - return makeArray(modes).map((mode) => { - const base = `btn-default mode-btn ${mode}`; - const cssClass = currentMode === mode ? `${base} btn-primary` : base; - - return { - mode, - cssClass, - icon: mode === "table" ? "table" : "signal", - }; - }); - } - - @discourseComputed("currentMode") - modeComponent(currentMode) { - return `admin-report-${currentMode.replace(/_/g, "-")}`; - } - - @discourseComputed( - "dataSourceName", - "startDate", - "endDate", - "filters.customFilters" - ) - reportKey(dataSourceName, startDate, endDate, customFilters) { - if (!dataSourceName || !startDate || !endDate) { - return null; - } - - startDate = startDate.toISOString(true).split("T")[0]; - endDate = endDate.toISOString(true).split("T")[0]; - - let reportKey = "reports:"; - reportKey += [ - dataSourceName, - isTesting() ? "start" : startDate.replace(/-/g, ""), - isTesting() ? "end" : endDate.replace(/-/g, ""), - "[:prev_period]", - this.get("reportOptions.table.limit"), - // Convert all filter values to strings to ensure unique serialization - customFilters - ? JSON.stringify(customFilters, (k, v) => (k ? `${v}` : v)) - : null, - SCHEMA_VERSION, - ] - .filter((x) => x) - .map((x) => x.toString()) - .join(":"); - - return reportKey; - } - - @discourseComputed("options.chartGrouping", "model.chartData.length") - chartGroupings(grouping, count) { - const options = ["daily", "weekly", "monthly"]; - - return options.map((id) => { - return { - id, - disabled: id === "daily" && count >= DAILY_LIMIT_DAYS, - label: `admin.dashboard.reports.${id}`, - class: `chart-grouping ${grouping === id ? "active" : "inactive"}`, - }; - }); - } - - @action - onChangeDateRange(range) { - this.setProperties({ - startDate: range.from, - endDate: range.to, - }); - } - - @action - applyFilter(id, value) { - let customFilters = this.get("filters.customFilters") || {}; - - if (typeof value === "undefined") { - delete customFilters[id]; - } else { - customFilters[id] = value; - } - - this.send("refreshReport", { - filters: customFilters, - }); - } - - @action - refreshReport(options = {}) { - if (!this.onRefresh) { - return; - } - - this.onRefresh({ - type: this.get("model.type"), - mode: this.currentMode, - chartGrouping: options.chartGrouping, - startDate: - typeof options.startDate === "undefined" - ? this.startDate - : options.startDate, - endDate: - typeof options.endDate === "undefined" ? this.endDate : options.endDate, - filters: - typeof options.filters === "undefined" - ? this.get("filters.customFilters") - : options.filters, - }); - } - - @action - exportCsv() { - const args = { - name: this.get("model.type"), - start_date: this.startDate.toISOString(true).split("T")[0], - end_date: this.endDate.toISOString(true).split("T")[0], - }; - - const customFilters = this.get("filters.customFilters"); - if (customFilters) { - Object.assign(args, customFilters); - } - - exportEntity("report", args).then(outputExportResult); - } - - @action - onChangeMode(mode) { - this.set("currentMode", mode); - - this.send("refreshReport", { - chartGrouping: null, - }); - } - - _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.dataSourceName); - } else { - return r; - } - }; - - if (!this.startDate || !this.endDate) { - report = sort(filteredReports)[0]; - } else { - report = sort( - filteredReports.filter((r) => r.report_key.includes(this.reportKey)) - )[0]; - - if (!report) { - return; - } - } - - if (report.error === "not_found") { - this.set("showFilteringUI", false); - } - - this._renderReport(report, this.forcedModes, this.currentMode); - } - - _renderReport(report, forcedModes, currentMode) { - const modes = forcedModes ? forcedModes.split(",") : report.modes; - currentMode = currentMode || (modes ? modes[0] : null); - - this.setProperties({ - model: report, - currentMode, - options: this._buildOptions(currentMode, report), - }); - } - - _fetchReport() { - this.setProperties({ isLoading: true, rateLimitationString: null }); - - next(() => { - let payload = this._buildPayload(["prev_period"]); - - const callback = (response) => { - if (!this.element || this.isDestroying || this.isDestroyed) { - return; - } - - this.set("isLoading", false); - - if (response === 429) { - this.set( - "rateLimitationString", - i18n("admin.dashboard.too_many_requests") - ); - } else if (response === 500) { - this.set("model.error", "exception"); - } else if (response) { - this._reports.push(this._loadReport(response)); - this._computeReport(); - } - }; - - ReportLoader.enqueue(this.dataSourceName, payload.data, callback); - }); - } - - _buildPayload(facets) { - let payload = { data: { facets } }; - - if (this.startDate) { - payload.data.start_date = moment(this.startDate) - .toISOString(true) - .split("T")[0]; - } - - if (this.endDate) { - payload.data.end_date = moment(this.endDate) - .toISOString(true) - .split("T")[0]; - } - - if (this.get("reportOptions.table.limit")) { - payload.data.limit = this.get("reportOptions.table.limit"); - } - - if (this.get("filters.customFilters")) { - payload.data.filters = this.get("filters.customFilters"); - } - - return payload; - } - - _buildOptions(mode, report) { - if (mode === "table") { - const tableOptions = JSON.parse(JSON.stringify(TABLE_OPTIONS)); - return EmberObject.create( - Object.assign(tableOptions, this.get("reportOptions.table") || {}) - ); - } else if (mode === "chart") { - const chartOptions = JSON.parse(JSON.stringify(CHART_OPTIONS)); - return EmberObject.create( - Object.assign(chartOptions, this.get("reportOptions.chart") || {}, { - chartGrouping: - this.get("reportOptions.chartGrouping") || - Report.groupingForDatapoints(report.chartData.length), - }) - ); - } else if (mode === "stacked-chart" || mode === "stacked_chart") { - return this.get("reportOptions.stackedChart") || {}; - } - } - - _loadReport(jsonReport) { - Report.fillMissingDates(jsonReport, { filledField: "chartData" }); - - if (jsonReport.chartData && jsonReport.modes[0] === "stacked_chart") { - jsonReport.chartData = jsonReport.chartData.map((chartData) => { - if (chartData.length > 40) { - return { - data: chartData.data, - req: chartData.req, - label: chartData.label, - color: chartData.color, - }; - } else { - return chartData; - } - }); - } - - if (jsonReport.prev_data) { - Report.fillMissingDates(jsonReport, { - filledField: "prevChartData", - dataField: "prev_data", - starDate: jsonReport.prev_startDate, - endDate: jsonReport.prev_endDate, - }); - } - - return Report.create(jsonReport); - } -} diff --git a/app/assets/javascripts/admin/addon/controllers/admin-dashboard-general.js b/app/assets/javascripts/admin/addon/controllers/admin-dashboard-general.js index b46b9991045..3877d929f40 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-dashboard-general.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-dashboard-general.js @@ -2,6 +2,7 @@ import { inject as controller } from "@ember/controller"; import { computed } from "@ember/object"; import { service } from "@ember/service"; import { setting } from "discourse/lib/computed"; +import { REPORT_MODES } from "discourse/lib/constants"; import discourseComputed from "discourse/lib/decorators"; import getURL from "discourse/lib/get-url"; import { makeArray } from "discourse/lib/helpers"; @@ -30,6 +31,10 @@ export default class AdminDashboardGeneralController extends AdminDashboardTabCo @staticReport("users_by_trust_level") usersByTrustLevelReport; @staticReport("storage_report") storageReport; + get reportModes() { + return REPORT_MODES; + } + @discourseComputed("siteSettings.dashboard_general_tab_activity_metrics") activityMetrics(metrics) { return (metrics || "").split("|").filter(Boolean); @@ -53,7 +58,7 @@ export default class AdminDashboardGeneralController extends AdminDashboardTabCo @computed("hiddenReports") get isSearchReportsVisible() { return ["top_referred_topics", "trending_search"].some( - (x) => !this.hiddenReports.includes(x) + (report) => !this.hiddenReports.includes(report) ); } @@ -68,7 +73,7 @@ export default class AdminDashboardGeneralController extends AdminDashboardTabCo "dau_by_mau", "daily_engaged_users", "new_contributors", - ].some((x) => !this.hiddenReports.includes(x)); + ].some((report) => !this.hiddenReports.includes(report)); } @discourseComputed @@ -76,11 +81,6 @@ export default class AdminDashboardGeneralController extends AdminDashboardTabCo return moment().locale("en").utc().endOf("day"); } - @computed("startDate", "endDate") - get filters() { - return { startDate: this.startDate, endDate: this.endDate }; - } - @discourseComputed activityMetricsFilters() { const lastMonth = moment() diff --git a/app/assets/javascripts/admin/addon/controllers/admin-dashboard-tab.js b/app/assets/javascripts/admin/addon/controllers/admin-dashboard-tab.js index b5cc96a4a43..dd1ccf4d342 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-dashboard-tab.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-dashboard-tab.js @@ -1,23 +1,25 @@ +import { tracked } from "@glimmer/tracking"; import Controller from "@ember/controller"; -import { action, computed } from "@ember/object"; +import { action } from "@ember/object"; import { service } from "@ember/service"; +import { TrackedObject } from "@ember-compat/tracked-built-ins"; import CustomDateRangeModal from "../components/modal/custom-date-range"; export default class AdminDashboardTabController extends Controller { @service modal; + @tracked endDate = moment().locale("en").utc().endOf("day"); + @tracked startDate = this.calculateStartDate(); + @tracked + filters = new TrackedObject({ + startDate: this.startDate, + endDate: this.endDate, + }); + queryParams = ["period"]; period = "monthly"; - endDate = moment().locale("en").utc().endOf("day"); - _startDate; - - @computed("_startDate", "period") - get startDate() { - if (this._startDate) { - return this._startDate; - } - + calculateStartDate() { const fullDay = moment().locale("en").utc().endOf("day"); switch (this.period) { @@ -35,13 +37,19 @@ export default class AdminDashboardTabController extends Controller { } @action - setCustomDateRange(_startDate, endDate) { - this.setProperties({ _startDate, endDate }); + setCustomDateRange(startDate, endDate) { + this.startDate = startDate; + this.endDate = endDate; + this.filters.startDate = this.startDate; + this.filters.endDate = this.endDate; } @action setPeriod(period) { - this.setProperties({ period, _startDate: null }); + this.set("period", period); + this.startDate = this.calculateStartDate(); + this.filters.startDate = this.startDate; + this.filters.endDate = this.endDate; } @action diff --git a/app/assets/javascripts/admin/addon/templates/dashboard_general.hbs b/app/assets/javascripts/admin/addon/templates/dashboard_general.hbs index c132400371d..d6b2fdbc779 100644 --- a/app/assets/javascripts/admin/addon/templates/dashboard_general.hbs +++ b/app/assets/javascripts/admin/addon/templates/dashboard_general.hbs @@ -17,6 +17,7 @@ @startDate={{this.startDate}} @endDate={{this.endDate}} @setCustomDateRange={{this.setCustomDateRange}} + @onDateChange={{this.onDateChange}} /> @@ -25,13 +26,13 @@ {{#if this.siteSettings.use_legacy_pageviews}} {{else}} @@ -40,42 +41,42 @@ @@ -120,7 +121,7 @@ {{/each}} @@ -133,12 +134,12 @@
@@ -146,7 +147,7 @@
diff --git a/app/assets/javascripts/admin/addon/templates/reports-show.hbs b/app/assets/javascripts/admin/addon/templates/reports-show.hbs index c7ac2ba9255..4ac65d8b479 100644 --- a/app/assets/javascripts/admin/addon/templates/reports-show.hbs +++ b/app/assets/javascripts/admin/addon/templates/reports-show.hbs @@ -6,6 +6,7 @@ @filters={{this.model}} @reportOptions={{this.reportOptions}} @showFilteringUI={{true}} + @showDescriptionInTooltip={{false}} @onRefresh={{route-action "onParamsChange"}} />
diff --git a/app/assets/javascripts/discourse/app/lib/admin-report-additional-modes.js b/app/assets/javascripts/discourse/app/lib/admin-report-additional-modes.js new file mode 100644 index 00000000000..950285f1773 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/admin-report-additional-modes.js @@ -0,0 +1,10 @@ +let additionalReportModes = new Map(); +export function registerReportModeComponent(mode, componentClass) { + additionalReportModes.set(mode, componentClass); +} +export function resetAdditionalReportModes() { + additionalReportModes.clear(); +} +export function reportModeComponent(mode) { + return additionalReportModes.get(mode); +} diff --git a/app/assets/javascripts/discourse/app/lib/constants.js b/app/assets/javascripts/discourse/app/lib/constants.js index e08fcd52947..0aff2a48b0c 100644 --- a/app/assets/javascripts/discourse/app/lib/constants.js +++ b/app/assets/javascripts/discourse/app/lib/constants.js @@ -106,3 +106,14 @@ export const USER_FIELD_FLAGS = [ "show_on_user_card", "searchable", ]; + +export const REPORT_MODES = { + table: "table", + chart: "chart", + stacked_chart: "stacked_chart", + stacked_line_chart: "stacked_line_chart", + radar: "radar", + counters: "counters", + inline_table: "inline_table", + storage_stats: "storage_stats", +}; diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.gjs b/app/assets/javascripts/discourse/app/lib/plugin-api.gjs index faa2f236c77..1fee4b3ff48 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.gjs +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.gjs @@ -3,7 +3,7 @@ // docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version // using the format described at https://keepachangelog.com/en/1.0.0/. -export const PLUGIN_API_VERSION = "2.0.0"; +export const PLUGIN_API_VERSION = "2.0.1"; import $ from "jquery"; import { h } from "virtual-dom"; @@ -61,6 +61,7 @@ import { registerAdminPluginConfigNav, } from "discourse/lib/admin-plugin-config-nav"; import { registerPluginHeaderActionComponent } from "discourse/lib/admin-plugin-header-actions"; +import { registerReportModeComponent } from "discourse/lib/admin-report-additional-modes"; import classPrepend, { withPrependsRolledBack, } from "discourse/lib/class-prepend"; @@ -3375,6 +3376,19 @@ class PluginApi { registeredTabs.push(tab); } + /** + * Registers a report mode and an associated component, which will be rendered + * by the AdminReport component. A mode is a different way of displaying the + * report data, core modes are things like "table" and "chart". For all core modes + * see Admin::Report::MODES. + * + * @param {String} mode - The identifier of the mode to register + * @param {Class} componentClass - The class of the component to render + */ + registerReportModeComponent(mode, componentClass) { + registerReportModeComponent(mode, componentClass); + } + #deprecatedWidgetOverride(widgetName, override) { // insert here the code to handle widget deprecations, e.g. for the header widgets we used: // if (DEPRECATED_HEADER_WIDGETS.includes(widgetName)) { diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index 4bedff6e52a..7a01a298e2c 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -36,6 +36,7 @@ import { resetUsernameDecorators } from "discourse/helpers/decorate-username-sel import { resetBeforeAuthCompleteCallbacks } from "discourse/instance-initializers/auth-complete"; import { resetAdminPluginConfigNav } from "discourse/lib/admin-plugin-config-nav"; import { clearPluginHeaderActionComponents } from "discourse/lib/admin-plugin-header-actions"; +import { resetAdditionalReportModes } from "discourse/lib/admin-report-additional-modes"; import { rollbackAllPrepends } from "discourse/lib/class-prepend"; import { clearPopupMenuOptions } from "discourse/lib/composer/custom-popup-menu-options"; import deprecated from "discourse/lib/deprecated"; @@ -201,6 +202,7 @@ export function testCleanup(container, app) { User.resetCurrent(); resetMobile(); + resetAdditionalReportModes(); resetExtraClasses(); clearOutletCache(); clearHTMLCache(); diff --git a/app/assets/javascripts/discourse/tests/integration/components/admin-report-test.js b/app/assets/javascripts/discourse/tests/integration/components/admin-report-test.js index ce0b783861c..6f1d35664ee 100644 --- a/app/assets/javascripts/discourse/tests/integration/components/admin-report-test.js +++ b/app/assets/javascripts/discourse/tests/integration/components/admin-report-test.js @@ -8,7 +8,9 @@ module("Integration | Component | admin-report", function (hooks) { setupRenderingTest(hooks); test("default", async function (assert) { - await render(hbs``); + await render( + hbs`` + ); assert.dom(".admin-report.signups").exists(); assert.dom(".admin-report-table").exists("defaults to table mode"); diff --git a/app/assets/stylesheets/common/admin/admin_report.scss b/app/assets/stylesheets/common/admin/admin_report.scss index c7e61b59e9b..9c331a7688e 100644 --- a/app/assets/stylesheets/common/admin/admin_report.scss +++ b/app/assets/stylesheets/common/admin/admin_report.scss @@ -80,7 +80,6 @@ .body { display: flex; flex-direction: row; - margin-top: var(--space-3); } .main { diff --git a/app/assets/stylesheets/common/admin/dashboard.scss b/app/assets/stylesheets/common/admin/dashboard.scss index c48f4e56ad4..859c9d227a6 100644 --- a/app/assets/stylesheets/common/admin/dashboard.scss +++ b/app/assets/stylesheets/common/admin/dashboard.scss @@ -137,6 +137,19 @@ margin-bottom: 1em; } + .admin-report.description-in-tooltip .header { + .d-page-subheader { + .d-page-subheader__title-row { + margin-bottom: 0; + } + } + + .fk-d-tooltip__trigger { + margin-left: 0.5em; + max-width: 20px; + } + } + .charts { display: grid; grid-template-columns: repeat(12, 1fr); @@ -153,7 +166,7 @@ .header { display: grid; - grid-template-areas: "title trend" "description description"; + grid-template-areas: "title tooltip trend" "description description description"; grid-template-columns: auto 1fr; .d-page-subheader { diff --git a/app/jobs/regular/export_csv_file.rb b/app/jobs/regular/export_csv_file.rb index fb28fabf239..06ef5fd1024 100644 --- a/app/jobs/regular/export_csv_file.rb +++ b/app/jobs/regular/export_csv_file.rb @@ -227,7 +227,7 @@ module Jobs end end - if report.modes == [:stacked_chart] + if report.modes == [Report::MODES[:stacked_chart]] header = [:x] data = {} diff --git a/app/models/concerns/reports/consolidated_api_requests.rb b/app/models/concerns/reports/consolidated_api_requests.rb index e50d240c56c..b7e094a7bea 100644 --- a/app/models/concerns/reports/consolidated_api_requests.rb +++ b/app/models/concerns/reports/consolidated_api_requests.rb @@ -7,7 +7,7 @@ module Reports::ConsolidatedApiRequests def report_consolidated_api_requests(report) filters = %w[api user_api] - report.modes = [:stacked_chart] + report.modes = [Report::MODES[:stacked_chart]] requests = filters.map do |filter| diff --git a/app/models/concerns/reports/consolidated_page_views.rb b/app/models/concerns/reports/consolidated_page_views.rb index 4d48f18415b..1815ccd9b63 100644 --- a/app/models/concerns/reports/consolidated_page_views.rb +++ b/app/models/concerns/reports/consolidated_page_views.rb @@ -11,7 +11,7 @@ module Reports::ConsolidatedPageViews def report_consolidated_page_views(report) filters = %w[page_view_logged_in page_view_anon page_view_crawler] - report.modes = [:stacked_chart] + report.modes = [Report::MODES[:stacked_chart]] requests = filters.map do |filter| diff --git a/app/models/concerns/reports/flags_status.rb b/app/models/concerns/reports/flags_status.rb index 572185ebc53..0219ba90598 100644 --- a/app/models/concerns/reports/flags_status.rb +++ b/app/models/concerns/reports/flags_status.rb @@ -5,7 +5,7 @@ module Reports::FlagsStatus class_methods do def report_flags_status(report) - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.labels = [ { diff --git a/app/models/concerns/reports/moderators_activity.rb b/app/models/concerns/reports/moderators_activity.rb index 6b733e93661..5ab3156a85b 100644 --- a/app/models/concerns/reports/moderators_activity.rb +++ b/app/models/concerns/reports/moderators_activity.rb @@ -47,7 +47,7 @@ module Reports::ModeratorsActivity }, ] - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.data = [] query = <<~SQL diff --git a/app/models/concerns/reports/post_edits.rb b/app/models/concerns/reports/post_edits.rb index af97ddc19a8..c096e1ffa95 100644 --- a/app/models/concerns/reports/post_edits.rb +++ b/app/models/concerns/reports/post_edits.rb @@ -8,7 +8,7 @@ module Reports::PostEdits category_id, include_subcategories = report.add_category_filter editor_username = report.filters["editor"] - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.labels = [ { diff --git a/app/models/concerns/reports/posts.rb b/app/models/concerns/reports/posts.rb index b2ac5199099..338d962c29c 100644 --- a/app/models/concerns/reports/posts.rb +++ b/app/models/concerns/reports/posts.rb @@ -5,8 +5,6 @@ module Reports::Posts class_methods do def report_posts(report) - report.modes = %i[table chart] - category_id, include_subcategories = report.add_category_filter basic_report_about report, diff --git a/app/models/concerns/reports/site_traffic.rb b/app/models/concerns/reports/site_traffic.rb index b77c977f0a0..b3b3c658d9c 100644 --- a/app/models/concerns/reports/site_traffic.rb +++ b/app/models/concerns/reports/site_traffic.rb @@ -5,7 +5,7 @@ module Reports::SiteTraffic class_methods do def report_site_traffic(report) - report.modes = [:stacked_chart] + report.modes = [Report::MODES[:stacked_chart]] first_browser_pageview_date = DB.query_single( diff --git a/app/models/concerns/reports/staff_logins.rb b/app/models/concerns/reports/staff_logins.rb index d65c2e2df05..bba58aeee24 100644 --- a/app/models/concerns/reports/staff_logins.rb +++ b/app/models/concerns/reports/staff_logins.rb @@ -5,7 +5,7 @@ module Reports::StaffLogins class_methods do def report_staff_logins(report) - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.data = [] diff --git a/app/models/concerns/reports/suspicious_logins.rb b/app/models/concerns/reports/suspicious_logins.rb index cd07d427d11..64d7ac65361 100644 --- a/app/models/concerns/reports/suspicious_logins.rb +++ b/app/models/concerns/reports/suspicious_logins.rb @@ -5,7 +5,7 @@ module Reports::SuspiciousLogins class_methods do def report_suspicious_logins(report) - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.labels = [ { diff --git a/app/models/concerns/reports/top_ignored_users.rb b/app/models/concerns/reports/top_ignored_users.rb index 3403de992e7..ebb572d9fdf 100644 --- a/app/models/concerns/reports/top_ignored_users.rb +++ b/app/models/concerns/reports/top_ignored_users.rb @@ -5,7 +5,7 @@ module Reports::TopIgnoredUsers class_methods do def report_top_ignored_users(report) - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.labels = [ { diff --git a/app/models/concerns/reports/top_referred_topics.rb b/app/models/concerns/reports/top_referred_topics.rb index c8cacb79297..c57480dc698 100644 --- a/app/models/concerns/reports/top_referred_topics.rb +++ b/app/models/concerns/reports/top_referred_topics.rb @@ -7,7 +7,7 @@ module Reports::TopReferredTopics def report_top_referred_topics(report) category_id, include_subcategories = report.add_category_filter - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.labels = [ { diff --git a/app/models/concerns/reports/top_referrers.rb b/app/models/concerns/reports/top_referrers.rb index 3454f17632b..e3f3eecc8af 100644 --- a/app/models/concerns/reports/top_referrers.rb +++ b/app/models/concerns/reports/top_referrers.rb @@ -5,7 +5,7 @@ module Reports::TopReferrers class_methods do def report_top_referrers(report) - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.labels = [ { diff --git a/app/models/concerns/reports/top_traffic_sources.rb b/app/models/concerns/reports/top_traffic_sources.rb index a1b313f01f9..d3068c13bff 100644 --- a/app/models/concerns/reports/top_traffic_sources.rb +++ b/app/models/concerns/reports/top_traffic_sources.rb @@ -7,7 +7,7 @@ module Reports::TopTrafficSources def report_top_traffic_sources(report) category_id, include_subcategories = report.add_category_filter - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.labels = [ { property: :domain, title: I18n.t("reports.top_traffic_sources.labels.domain") }, diff --git a/app/models/concerns/reports/top_uploads.rb b/app/models/concerns/reports/top_uploads.rb index b9caaa63fab..1aff15b4873 100644 --- a/app/models/concerns/reports/top_uploads.rb +++ b/app/models/concerns/reports/top_uploads.rb @@ -5,7 +5,7 @@ module Reports::TopUploads class_methods do def report_top_uploads(report) - report.modes = [:table] + report.modes = [Report::MODES[:table]] extension_filter = report.filters.dig(:file_extension) report.add_filter( diff --git a/app/models/concerns/reports/top_users_by_likes_received.rb b/app/models/concerns/reports/top_users_by_likes_received.rb index 2861cbefc51..e0a78a7aefb 100644 --- a/app/models/concerns/reports/top_users_by_likes_received.rb +++ b/app/models/concerns/reports/top_users_by_likes_received.rb @@ -8,7 +8,7 @@ module Reports::TopUsersByLikesReceived report.icon = "heart" report.data = [] - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.dates_filtering = true diff --git a/app/models/concerns/reports/top_users_by_likes_received_from_a_variety_of_people.rb b/app/models/concerns/reports/top_users_by_likes_received_from_a_variety_of_people.rb index a6a42c72181..2445b244015 100644 --- a/app/models/concerns/reports/top_users_by_likes_received_from_a_variety_of_people.rb +++ b/app/models/concerns/reports/top_users_by_likes_received_from_a_variety_of_people.rb @@ -8,7 +8,7 @@ module Reports::TopUsersByLikesReceivedFromAVarietyOfPeople report.icon = "heart" report.data = [] - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.dates_filtering = true diff --git a/app/models/concerns/reports/top_users_by_likes_received_from_inferior_trust_level.rb b/app/models/concerns/reports/top_users_by_likes_received_from_inferior_trust_level.rb index 0daad06783f..927a468bf15 100644 --- a/app/models/concerns/reports/top_users_by_likes_received_from_inferior_trust_level.rb +++ b/app/models/concerns/reports/top_users_by_likes_received_from_inferior_trust_level.rb @@ -8,7 +8,7 @@ module Reports::TopUsersByLikesReceivedFromInferiorTrustLevel report.icon = "heart" report.data = [] - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.dates_filtering = true diff --git a/app/models/concerns/reports/topic_view_stats.rb b/app/models/concerns/reports/topic_view_stats.rb index ce33f30d3ab..d939c2715ab 100644 --- a/app/models/concerns/reports/topic_view_stats.rb +++ b/app/models/concerns/reports/topic_view_stats.rb @@ -5,7 +5,7 @@ module Reports::TopicViewStats class_methods do def report_topic_view_stats(report) - report.modes = [:table] + report.modes = [Report::MODES[:table]] category_id, include_subcategories = report.add_category_filter diff --git a/app/models/concerns/reports/trending_search.rb b/app/models/concerns/reports/trending_search.rb index 8d4f5ff38a3..9b447a30d44 100644 --- a/app/models/concerns/reports/trending_search.rb +++ b/app/models/concerns/reports/trending_search.rb @@ -21,7 +21,7 @@ module Reports::TrendingSearch report.data = [] - report.modes = [:table] + report.modes = [Report::MODES[:table]] trends = SearchLog.trending_from(report.start_date, end_date: report.end_date, limit: report.limit) diff --git a/app/models/concerns/reports/trust_level_growth.rb b/app/models/concerns/reports/trust_level_growth.rb index 3b393426c08..0e0c45850ac 100644 --- a/app/models/concerns/reports/trust_level_growth.rb +++ b/app/models/concerns/reports/trust_level_growth.rb @@ -5,7 +5,7 @@ module Reports::TrustLevelGrowth class_methods do def report_trust_level_growth(report) - report.modes = [:stacked_chart] + report.modes = [Report::MODES[:stacked_chart]] filters = %w[tl1_reached tl2_reached tl3_reached tl4_reached] diff --git a/app/models/concerns/reports/user_flagging_ratio.rb b/app/models/concerns/reports/user_flagging_ratio.rb index 0147792a8d8..c72474dd4d7 100644 --- a/app/models/concerns/reports/user_flagging_ratio.rb +++ b/app/models/concerns/reports/user_flagging_ratio.rb @@ -7,7 +7,7 @@ module Reports::UserFlaggingRatio def report_user_flagging_ratio(report) report.data = [] - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.dates_filtering = true diff --git a/app/models/concerns/reports/users_by_trust_level.rb b/app/models/concerns/reports/users_by_trust_level.rb index f0226a90c8a..688574a50ce 100644 --- a/app/models/concerns/reports/users_by_trust_level.rb +++ b/app/models/concerns/reports/users_by_trust_level.rb @@ -7,7 +7,7 @@ module Reports::UsersByTrustLevel def report_users_by_trust_level(report) report.data = [] - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.dates_filtering = false diff --git a/app/models/concerns/reports/users_by_type.rb b/app/models/concerns/reports/users_by_type.rb index a7c77cc7eb4..6dcf279b8cc 100644 --- a/app/models/concerns/reports/users_by_type.rb +++ b/app/models/concerns/reports/users_by_type.rb @@ -7,7 +7,7 @@ module Reports::UsersByType def report_users_by_type(report) report.data = [] - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.dates_filtering = false diff --git a/app/models/concerns/reports/web_crawlers.rb b/app/models/concerns/reports/web_crawlers.rb index cdb731eeab5..298ecd1a179 100644 --- a/app/models/concerns/reports/web_crawlers.rb +++ b/app/models/concerns/reports/web_crawlers.rb @@ -18,7 +18,7 @@ module Reports::WebCrawlers }, ] - report.modes = [:table] + report.modes = [Report::MODES[:table]] report.data = WebCrawlerRequest diff --git a/app/models/report.rb b/app/models/report.rb index 032094cc9af..b52162f7cdc 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -16,6 +16,17 @@ class Report include_subcategories ] + MODES = { + table: :table, + chart: :chart, + stacked_chart: :stacked_chart, + stacked_line_chart: :stacked_line_chart, + radar: :radar, + counters: :counters, + inline_table: :inline_table, + storage_stats: :storage_stats, + } + include Reports::Bookmarks include Reports::ConsolidatedApiRequests include Reports::ConsolidatedPageViews @@ -106,7 +117,7 @@ class Report @average = false @percent = false @higher_is_better = true - @modes = %i[table chart] + @modes = [MODES[:chart], MODES[:table]] @prev_data = nil @dates_filtering = true @available_filters = {} @@ -374,7 +385,7 @@ class Report end def self.add_prev_data(report, subject_class, report_method, *args) - if report.modes.include?(:chart) && report.facets.include?(:prev_period) + if report.modes.include?(Report::MODES[:chart]) && report.facets.include?(:prev_period) prev_data = subject_class.public_send(report_method, *args) report.prev_data = prev_data.map { |k, v| { x: k, y: v } } end diff --git a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md index ce597732f99..b007e2fdbc4 100644 --- a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md +++ b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md @@ -7,6 +7,10 @@ in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.1] - 2025-01-29 + +- Added `registerReportModeComponent`. This allows plugins to register different report display modes in addition to the built-in core ones like `chart`, `table`, and so on defined in `Report::MODES`. + ## [2.0.0] - 2025-01-07 - Removed `decorateTopicTitle`. This has been deprecated for more than a year, and we are not aware of any remaining uses in the ecosystem. diff --git a/lib/tasks/javascript.rake b/lib/tasks/javascript.rake index 08ae71850b4..180cea69d61 100644 --- a/lib/tasks/javascript.rake +++ b/lib/tasks/javascript.rake @@ -167,6 +167,8 @@ task "javascript:update_constants" => :environment do export const MAX_UNOPTIMIZED_CATEGORIES = #{CategoryList::MAX_UNOPTIMIZED_CATEGORIES}; export const USER_FIELD_FLAGS = #{UserField::FLAG_ATTRIBUTES}; + + export const REPORT_MODES = #{Report::MODES.to_json}; JS pretty_notifications = Notification.types.map { |n| " #{n[0]}: #{n[1]}," }.join("\n")