discourse/lib/admin_dashboard/reports/source_provider.rb
chapoi 9d6605bf0d
UX: admin dashboard design fixes (#40476)
This branch is a round of design and interaction fixes for the
redesigned admin dashboard:

### Engagement

* Replaces the KpiTile-based tiles with a Metrics (value, label +
tooltip, and delta) so the headline reads as a row of metrics rather
than cards.
* Formats percentage KPIs (e.g. dau_mau) with a % suffix and shows a
neutral "stable" pill when there's no meaningful change.
* Swaps SearchAdvancedCategoryChooser for a plain CategoryChooser with
an "All categories" item, so the single-category filter no longer
renders as a removable multi-select chip with a clear "×".

### Activity by category

* Makes every column in the activity table fully sortable, confirm with
how it's displayed on underlying report page.
* Fixes the table overflowing on mobile by adding min-width: 0 to the
flex row-block and wrapping the table in a horizontally scrollable
container.

### Report cards

Depends also on https://github.com/discourse/discourse/pull/40404 !

- Turns each report card title into a link to its report.
- Renders the provider label as a per-source pill (with a --source
modifier). The standard/core provider now returns nil for its label so
its reports render without a pill — labels exist only to distinguish
plugin-contributed sources.
- Drops the inline remove "×" button and the show_labels plumbing.

### Highlights & misc

- Removes the "vs prior" comparison footer from the highlights section.
- Updates tooltip icons from circle-question to far-circle-question and
trims "in this period" wording from KPI tooltip copy.

### General

- Numerous responsive/mobile styling fixes across admin_dashboard.scss
(sticky header, metrics, row blocks)
- i18n updates for consistency and conciseness
- Remove floating of data/custom buttons on mobile: this isn't a
standard pattern so holding off on this
- Abstract stable and delta classes

### Looks like
| BC | AC |
|--------|--------|
| <img width="1769" height="2957" alt="image"
src="https://github.com/user-attachments/assets/d60a8eeb-e2ed-4929-9f29-f6c7062fc66f"
/> | <img width="1769" height="2957" alt="image"
src="https://github.com/user-attachments/assets/188403a7-dc31-4b12-bf26-0455cd2a272b"
/> |
| <img width="543" height="3120" alt="image"
src="https://github.com/user-attachments/assets/87035498-fa8a-496c-b721-b7cb7bc44a43"
/>| <img width="391" height="3336" alt="image"
src="https://github.com/user-attachments/assets/a0173425-e60e-4b21-bdcb-5e6c642796cd"
/> |

---------

Co-authored-by: Krzysztof Kotlarek <kotlarek.krzysztof@gmail.com>
2026-06-03 13:35:05 +02:00

101 lines
4.4 KiB
Ruby
Vendored

# frozen_string_literal: true
module AdminDashboard
module Reports
# Base class for anything that contributes mountable reports to the
# customisable Reports section on the new admin dashboard. Subclasses are
# registered with DiscoursePluginRegistry (or, for built-ins, listed in
# AdminDashboard::Reports::Registry::CORE_PROVIDERS) and dispatched to by
# source_name.
#
# Every method on the provider is batch-shaped to keep the dashboard
# render path bounded — never one-by-one resolution.
class SourceProvider
# @return [String] the value stored in admin_dashboard_reports.source for
# rows this provider owns. Examples: "core_report",
# "data_explorer_query".
def self.source_name
raise NotImplementedError
end
# @return [String, nil] a short, translated label rendered as a tag pill
# in the UI to distinguish this provider's reports from
# the standard ones. Return nil for the standard/default
# provider so its reports render without a pill.
def self.label
raise NotImplementedError
end
# Cheap metadata resolution. Called server-side on dashboard render and
# by the Manage Reports modal to populate its enabled list.
#
# @param identifiers [Array<String>]
# @param guardian [Guardian]
# @return [Hash{String => AdminDashboard::Reports::ResolvedReport}]
# identifiers that cannot be resolved (deleted, hidden, no
# permission) are simply absent from the hash.
def self.resolve_many(identifiers, guardian:)
raise NotImplementedError
end
# Expensive data fetch. Called by the bulk endpoint to load chart/table
# content. Providers own their own caching.
#
# @param identifiers [Array<String>]
# @param guardian [Guardian]
# @param filters [Hash] dashboard-level filter values (date range, etc).
# @return [Hash{String => Object}] identifier -> report data payload.
def self.fetch_many(identifiers, guardian:, filters: {})
raise NotImplementedError
end
# Universe of items of this source. Powers the Manage Reports modal's
# available list and search filter. Only invoked in admin-only contexts,
# so implementations should not perform per-user access filtering here.
#
# Implements keyset pagination so the controller can merge every
# provider into one globally title-sorted stream without loading the
# whole universe. Items must come back sorted by
# [title.downcase, key] (key being "source:identifier") and limited to
# those strictly after `after`.
#
# @param search [String, nil] optional name/description filter.
# @param after [Hash, nil] cursor of the last item from the previous
# page: { title:, key: }. nil for the first page.
# @param limit [Integer, nil] maximum number of items to return.
# @return [Array<AdminDashboard::Reports::ResolvedReport>]
def self.list_all(search: nil, after: nil, limit: nil)
raise NotImplementedError
end
# Sort + keyset-filter an in-memory set of resolved reports for
# `list_all`. Suitable for providers small enough to materialise their
# whole set (e.g. core reports); SQL-backed providers should push the
# keyset into the query instead.
def self.seek(reports, after:, limit:)
reports = reports.sort_by { |report| sort_key(report) }
if after
threshold = [after[:title].to_s.downcase, after[:key].to_s]
reports = reports.select { |report| (sort_key(report) <=> threshold) == 1 }
end
limit ? reports.first(limit) : reports
end
def self.sort_key(report)
[report.title.to_s.downcase, report.key]
end
# Identifiers from the input that the guardian is allowed to mount /
# interact with. Default implementation is a subset of `resolve_many`
# keys, which works for any provider whose access check is identical
# to its resolution check.
#
# @param identifiers [Array<String>]
# @param guardian [Guardian]
# @return [Set<String>]
def self.accessible_ids(identifiers, guardian:)
resolve_many(identifiers, guardian: guardian).keys.to_set
end
end
end
end