2023-01-02 18:42:57 +03:00
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
2021-11-23 12:39:09 +03:00
|
|
|
import { Link } from 'react-router-dom'
|
2023-01-02 18:42:57 +03:00
|
|
|
import FlipMove from 'react-flip-move';
|
|
|
|
|
2023-07-06 17:29:08 +03:00
|
|
|
import { displayMetricValue, metricLabelFor } from './metrics';
|
2021-11-23 12:39:09 +03:00
|
|
|
import FadeIn from '../../fade-in'
|
|
|
|
import MoreLink from '../more-link'
|
|
|
|
import Bar from '../bar'
|
2021-12-03 14:59:32 +03:00
|
|
|
import LazyLoader from '../../components/lazy-loader'
|
2023-07-21 07:35:41 +03:00
|
|
|
import classNames from 'classnames'
|
2023-08-02 14:45:35 +03:00
|
|
|
import { trimURL } from '../../util/url'
|
2023-07-06 17:29:08 +03:00
|
|
|
const MAX_ITEMS = 9
|
|
|
|
const MIN_HEIGHT = 380
|
|
|
|
const ROW_HEIGHT = 32
|
|
|
|
const ROW_GAP_HEIGHT = 4
|
|
|
|
const DATA_CONTAINER_HEIGHT = (ROW_HEIGHT + ROW_GAP_HEIGHT) * (MAX_ITEMS - 1) + ROW_HEIGHT
|
|
|
|
const COL_MIN_WIDTH = 70
|
|
|
|
|
2023-08-02 14:45:35 +03:00
|
|
|
function FilterLink({ filterQuery, onClick, children }) {
|
2023-07-21 07:35:41 +03:00
|
|
|
const className = classNames('max-w-max w-full flex md:overflow-hidden', {
|
|
|
|
'hover:underline': !!filterQuery
|
|
|
|
})
|
2023-08-02 14:45:35 +03:00
|
|
|
|
2023-07-21 07:35:41 +03:00
|
|
|
if (filterQuery) {
|
|
|
|
return (
|
|
|
|
<Link
|
2023-08-02 14:45:35 +03:00
|
|
|
to={{ search: filterQuery.toString() }}
|
2023-07-21 07:35:41 +03:00
|
|
|
onClick={onClick}
|
|
|
|
className={className}
|
2023-08-02 14:45:35 +03:00
|
|
|
>
|
|
|
|
{children}
|
2023-07-21 07:35:41 +03:00
|
|
|
</Link>
|
|
|
|
)
|
|
|
|
} else {
|
2023-08-02 14:45:35 +03:00
|
|
|
return <span className={className}>{children}</span>
|
2023-07-21 07:35:41 +03:00
|
|
|
}
|
|
|
|
}
|
2021-12-14 12:28:43 +03:00
|
|
|
|
2023-08-02 14:45:35 +03:00
|
|
|
function ExternalLink({ item, externalLinkDest }) {
|
2023-07-21 07:35:41 +03:00
|
|
|
const dest = externalLinkDest && externalLinkDest(item)
|
|
|
|
if (dest) {
|
2021-12-14 12:28:43 +03:00
|
|
|
return (
|
|
|
|
<a
|
|
|
|
target="_blank"
|
|
|
|
rel="noreferrer"
|
|
|
|
href={dest}
|
2023-07-06 17:29:08 +03:00
|
|
|
className="w-4 h-4 hidden group-hover:block"
|
2021-12-14 12:28:43 +03:00
|
|
|
>
|
2023-07-06 17:29:08 +03:00
|
|
|
<svg className="inline w-full h-full ml-1 -mt-1 text-gray-600 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path></svg>
|
2021-12-14 12:28:43 +03:00
|
|
|
</a>
|
|
|
|
)
|
2021-11-23 12:39:09 +03:00
|
|
|
}
|
|
|
|
|
2021-12-14 12:28:43 +03:00
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
2023-07-06 17:29:08 +03:00
|
|
|
// The main function component for rendering list reports and making them react to what
|
|
|
|
// is happening on the dashboard.
|
|
|
|
|
|
|
|
// A `fetchData` function must be passed through props. This function defines the format
|
|
|
|
// of the data, which is expected to be a list of objects. Think of these objects as rows
|
|
|
|
// with keys being columns. The number of columns is dynamic and should be configured
|
|
|
|
// via the `metrics` input list. For example:
|
|
|
|
|
|
|
|
// | keyLabel | METRIC_1.label | METRIC_2.label | ...
|
|
|
|
// |--------------------|---------------------------|---------------------------|-----
|
|
|
|
// | LISTITEM_1.name | LISTITEM_1[METRIC_1.name] | LISTITEM_1[METRIC_2.name] | ...
|
|
|
|
// | LISTITEM_2.name | LISTITEM_2[METRIC_1.name] | LISTITEM_2[METRIC_2.name] | ...
|
|
|
|
|
|
|
|
// Further configuration of the report is possible through optional props.
|
|
|
|
|
|
|
|
// REQUIRED PROPS:
|
|
|
|
|
|
|
|
// * `keyLabel` - What each entry in the list represents (for UI only).
|
|
|
|
|
|
|
|
// * `query` - The query object representing the current state of the dashboard.
|
|
|
|
|
|
|
|
// * `fetchData` - a function that returns an `api.get` promise that will resolve to the
|
|
|
|
// list of data.
|
|
|
|
|
|
|
|
// * `metrics` - a list of `metric` objects. Each `metric` object is required to have at
|
|
|
|
// least the `name` and the `label` keys. If the metric should have a different label
|
|
|
|
// in realtime or goal-filtered views, we'll use `realtimeLabel` and `GoalFilterLabel`.
|
|
|
|
|
|
|
|
// * `getFilterFor` - a function that takes a list item and returns the query link (with
|
2023-07-21 07:35:41 +03:00
|
|
|
// the filter) to navigate to when this list item is clicked. If a list item is not
|
|
|
|
// supposed to be clickable, this function should return `null` for that list item.
|
2023-07-06 17:29:08 +03:00
|
|
|
|
|
|
|
// OPTIONAL PROPS:
|
|
|
|
|
|
|
|
// * `onClick` - function with additional action to be taken when a list entry is clicked.
|
|
|
|
|
|
|
|
// * `detailsLink` - the pathname to the detailed view of this report. E.g.:
|
2023-08-03 21:12:38 +03:00
|
|
|
// `/dummy.site/pages`. If this is given as input to the ListReport, the Details button
|
|
|
|
// will always be rendered.
|
|
|
|
|
|
|
|
// * `maybeHideDetails` - set this to `true` if the details button should be hidden on
|
|
|
|
// the condition that there are less than MAX_ITEMS entries in the list. (i.e . nothing
|
|
|
|
// more to show)
|
2023-07-06 17:29:08 +03:00
|
|
|
|
|
|
|
// * `externalLinkDest` - a function that takes a list item and returns an external url
|
|
|
|
// to navigate to. If this prop is given, an additional icon is rendered upon hovering
|
|
|
|
// the entry.
|
|
|
|
|
2023-07-21 07:35:41 +03:00
|
|
|
// * `renderIcon` - a function that takes a list item and returns the
|
|
|
|
// HTML of an icon (such as a flag, favicon, or a screen size icon) for a listItem.
|
2023-07-06 17:29:08 +03:00
|
|
|
|
|
|
|
// * `color` - color of the comparison bars in light-mode
|
|
|
|
|
2021-12-14 12:28:43 +03:00
|
|
|
export default function ListReport(props) {
|
2023-08-02 14:45:35 +03:00
|
|
|
const [state, setState] = useState({ loading: true, list: null })
|
2023-01-02 18:42:57 +03:00
|
|
|
const [visible, setVisible] = useState(false)
|
2023-07-06 17:29:08 +03:00
|
|
|
const metrics = props.metrics
|
2023-07-21 11:19:07 +03:00
|
|
|
const colMinWidth = props.colMinWidth || COL_MIN_WIDTH
|
2023-07-06 17:29:08 +03:00
|
|
|
|
|
|
|
const isRealtime = props.query.period === 'realtime'
|
|
|
|
const goalFilterApplied = !!props.query.filters.goal
|
2021-12-14 12:28:43 +03:00
|
|
|
|
2023-01-02 18:42:57 +03:00
|
|
|
const fetchData = useCallback(() => {
|
2023-08-02 14:45:35 +03:00
|
|
|
if (!isRealtime) {
|
|
|
|
setState({ loading: true, list: null })
|
|
|
|
}
|
|
|
|
props.fetchData()
|
|
|
|
.then((res) => setState({ loading: false, list: res }))
|
|
|
|
}, [props.keyLabel, props.query])
|
2021-11-23 12:39:09 +03:00
|
|
|
|
2023-07-06 17:29:08 +03:00
|
|
|
const onVisible = () => { setVisible(true) }
|
2021-11-23 12:39:09 +03:00
|
|
|
|
2023-01-02 18:42:57 +03:00
|
|
|
useEffect(() => {
|
2023-07-06 17:29:08 +03:00
|
|
|
if (isRealtime) {
|
|
|
|
// When a goal filter is applied or removed, we always want the component to go into a
|
|
|
|
// loading state, even in realtime mode, because the metrics list will change. We can
|
|
|
|
// only read the new metrics once the new list is loaded.
|
2023-08-02 14:45:35 +03:00
|
|
|
setState({ loading: true, list: null })
|
2023-07-06 17:29:08 +03:00
|
|
|
}
|
|
|
|
}, [goalFilterApplied]);
|
2023-01-02 18:42:57 +03:00
|
|
|
|
|
|
|
useEffect(() => {
|
2023-07-06 17:29:08 +03:00
|
|
|
if (visible) {
|
|
|
|
if (isRealtime) { document.addEventListener('tick', fetchData) }
|
|
|
|
fetchData()
|
|
|
|
}
|
|
|
|
|
2023-01-02 18:42:57 +03:00
|
|
|
return () => { document.removeEventListener('tick', fetchData) }
|
2023-07-21 11:19:07 +03:00
|
|
|
}, [props.keyLabel, props.query, visible]);
|
2021-12-14 12:28:43 +03:00
|
|
|
|
2023-08-01 15:52:31 +03:00
|
|
|
// returns a filtered `metrics` list. Since currently, the backend can return different
|
|
|
|
// metrics based on filters and existing data, this function validates that the metrics
|
|
|
|
// we want to display are actually there in the API response.
|
|
|
|
function getAvailableMetrics() {
|
|
|
|
return metrics.filter((metric) => {
|
|
|
|
return state.list.some((listItem) => listItem[metric.name] != null)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
function hiddenOnMobileClass(metric) {
|
|
|
|
if (metric.hiddenOnMobile) {
|
|
|
|
return 'hidden md:block'
|
|
|
|
} else {
|
|
|
|
return ''
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-06 17:29:08 +03:00
|
|
|
function renderReport() {
|
|
|
|
if (state.list && state.list.length > 0) {
|
|
|
|
return (
|
|
|
|
<div className="h-full flex flex-col">
|
2023-08-02 14:45:35 +03:00
|
|
|
<div style={{ height: ROW_HEIGHT }}>
|
|
|
|
{renderReportHeader()}
|
2023-07-06 17:29:08 +03:00
|
|
|
</div>
|
|
|
|
|
2023-08-02 14:45:35 +03:00
|
|
|
<div style={{ minHeight: DATA_CONTAINER_HEIGHT }}>
|
|
|
|
{renderReportBody()}
|
2023-07-06 17:29:08 +03:00
|
|
|
</div>
|
2021-11-25 13:00:17 +03:00
|
|
|
|
2023-08-03 21:12:38 +03:00
|
|
|
{maybeRenderDetailsLink()}
|
2023-07-06 17:29:08 +03:00
|
|
|
</div>
|
|
|
|
)
|
2021-11-25 13:00:17 +03:00
|
|
|
}
|
2023-07-06 17:29:08 +03:00
|
|
|
return renderNoDataYet()
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderReportHeader() {
|
2023-08-01 15:52:31 +03:00
|
|
|
const metricLabels = getAvailableMetrics().map((metric) => {
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
key={metric.name}
|
|
|
|
className={`text-right ${hiddenOnMobileClass(metric)}`}
|
2023-08-02 14:45:35 +03:00
|
|
|
style={{ minWidth: colMinWidth }}
|
2023-08-01 15:52:31 +03:00
|
|
|
>
|
2023-08-02 14:45:35 +03:00
|
|
|
{metricLabelFor(metric, props.query)}
|
2023-08-01 15:52:31 +03:00
|
|
|
</div>
|
|
|
|
)
|
2023-07-06 17:29:08 +03:00
|
|
|
})
|
2023-08-02 14:45:35 +03:00
|
|
|
|
2023-07-06 17:29:08 +03:00
|
|
|
return (
|
|
|
|
<div className="pt-3 w-full text-xs font-bold tracking-wide text-gray-500 flex items-center dark:text-gray-400">
|
2023-08-02 14:45:35 +03:00
|
|
|
<span className="flex-grow truncate">{props.keyLabel}</span>
|
|
|
|
{metricLabels}
|
2023-07-06 17:29:08 +03:00
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
2021-11-25 13:00:17 +03:00
|
|
|
|
2023-07-06 17:29:08 +03:00
|
|
|
function renderReportBody() {
|
|
|
|
return (
|
|
|
|
<FlipMove className="flex-grow">
|
2023-07-28 21:44:56 +03:00
|
|
|
{state.list.slice(0, MAX_ITEMS).map(renderRow)}
|
2023-07-06 17:29:08 +03:00
|
|
|
</FlipMove>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderRow(listItem) {
|
|
|
|
return (
|
2023-08-02 14:45:35 +03:00
|
|
|
<div key={listItem.name} style={{ minHeight: ROW_HEIGHT }}>
|
|
|
|
<div className="flex w-full" style={{ marginTop: ROW_GAP_HEIGHT }}>
|
|
|
|
{renderBarFor(listItem)}
|
|
|
|
{renderMetricValuesFor(listItem)}
|
2023-07-06 17:29:08 +03:00
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)
|
2021-12-03 14:59:32 +03:00
|
|
|
}
|
|
|
|
|
2023-07-21 07:35:41 +03:00
|
|
|
function getFilterQuery(listItem) {
|
2023-07-06 17:29:08 +03:00
|
|
|
const filter = props.getFilterFor(listItem)
|
2023-07-21 07:35:41 +03:00
|
|
|
if (!filter) { return null }
|
2023-08-02 14:45:35 +03:00
|
|
|
|
2023-07-21 07:35:41 +03:00
|
|
|
const query = new URLSearchParams(window.location.search)
|
2023-07-06 17:29:08 +03:00
|
|
|
Object.entries(filter).forEach((([key, value]) => {
|
|
|
|
query.set(key, value)
|
2021-11-23 12:39:09 +03:00
|
|
|
}))
|
|
|
|
|
2023-07-21 07:35:41 +03:00
|
|
|
return query
|
|
|
|
}
|
|
|
|
|
2023-08-02 14:45:35 +03:00
|
|
|
function renderBarFor(listItem) {
|
2021-12-14 12:28:43 +03:00
|
|
|
const lightBackground = props.color || 'bg-green-50'
|
2023-08-02 14:45:35 +03:00
|
|
|
const noop = () => { }
|
2023-08-01 15:52:31 +03:00
|
|
|
const metricToPlot = metrics.find(m => m.plot).name
|
2021-11-25 13:00:17 +03:00
|
|
|
|
2021-11-23 12:39:09 +03:00
|
|
|
return (
|
2023-07-06 17:29:08 +03:00
|
|
|
<div className="flex-grow w-full overflow-hidden">
|
2021-11-23 12:39:09 +03:00
|
|
|
<Bar
|
2023-07-06 17:29:08 +03:00
|
|
|
count={listItem[metricToPlot]}
|
2021-12-14 12:28:43 +03:00
|
|
|
all={state.list}
|
2021-11-25 13:00:17 +03:00
|
|
|
bg={`${lightBackground} dark:bg-gray-500 dark:bg-opacity-15`}
|
2023-07-06 17:29:08 +03:00
|
|
|
plot={metricToPlot}
|
2021-11-23 12:39:09 +03:00
|
|
|
>
|
2023-07-06 17:29:08 +03:00
|
|
|
<div className="flex justify-start px-2 py-1.5 group text-sm dark:text-gray-300 relative z-9 break-all w-full">
|
2023-07-21 07:35:41 +03:00
|
|
|
<FilterLink filterQuery={getFilterQuery(listItem)} onClick={props.onClick || noop}>
|
2023-07-06 17:29:08 +03:00
|
|
|
{maybeRenderIconFor(listItem)}
|
|
|
|
|
|
|
|
<span className="w-full md:truncate">
|
2023-08-02 14:45:35 +03:00
|
|
|
{trimURL(listItem.name, colMinWidth)}
|
2023-07-06 17:29:08 +03:00
|
|
|
</span>
|
2023-07-21 07:35:41 +03:00
|
|
|
</FilterLink>
|
|
|
|
<ExternalLink item={listItem} externalLinkDest={props.externalLinkDest} />
|
2023-07-06 17:29:08 +03:00
|
|
|
</div>
|
2021-11-23 12:39:09 +03:00
|
|
|
</Bar>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2023-07-06 17:29:08 +03:00
|
|
|
function maybeRenderIconFor(listItem) {
|
|
|
|
if (props.renderIcon) {
|
2021-11-23 12:39:09 +03:00
|
|
|
return (
|
2023-07-06 17:29:08 +03:00
|
|
|
<span className="pr-1">
|
|
|
|
{props.renderIcon(listItem)}
|
|
|
|
</span>
|
2021-11-23 12:39:09 +03:00
|
|
|
)
|
|
|
|
}
|
2023-07-06 17:29:08 +03:00
|
|
|
}
|
2021-11-23 12:39:09 +03:00
|
|
|
|
2023-07-06 17:29:08 +03:00
|
|
|
function renderMetricValuesFor(listItem) {
|
2023-08-01 15:52:31 +03:00
|
|
|
return getAvailableMetrics().map((metric) => {
|
2023-07-06 17:29:08 +03:00
|
|
|
return (
|
2023-08-01 15:52:31 +03:00
|
|
|
<div
|
|
|
|
key={`${listItem.name}__${metric.name}`}
|
|
|
|
className={`text-right ${hiddenOnMobileClass(metric)}`}
|
2023-08-02 14:45:35 +03:00
|
|
|
style={{ width: colMinWidth, minWidth: colMinWidth }}
|
2023-08-01 15:52:31 +03:00
|
|
|
>
|
2023-07-06 17:29:08 +03:00
|
|
|
<span className="font-medium text-sm dark:text-gray-200 text-right">
|
2023-08-02 14:45:35 +03:00
|
|
|
{displayMetricValue(listItem[metric.name], metric)}
|
2023-07-06 17:29:08 +03:00
|
|
|
</span>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderLoading() {
|
|
|
|
return (
|
2023-08-02 14:45:35 +03:00
|
|
|
<div className="w-full flex flex-col justify-center" style={{ minHeight: `${MIN_HEIGHT}px` }}>
|
2023-07-06 17:29:08 +03:00
|
|
|
<div className="mx-auto loading"><div></div></div>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderNoDataYet() {
|
|
|
|
return (
|
2023-08-02 14:45:35 +03:00
|
|
|
<div className="w-full h-full flex flex-col justify-center" style={{ minHeight: `${MIN_HEIGHT}px` }}>
|
2023-07-06 17:29:08 +03:00
|
|
|
<div className="mx-auto font-medium text-gray-500 dark:text-gray-400">No data yet</div>
|
|
|
|
</div>
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2023-08-03 21:12:38 +03:00
|
|
|
function maybeRenderDetailsLink() {
|
|
|
|
const moreResultsAvailable = state.list.length >= MAX_ITEMS
|
|
|
|
const hideDetails = props.maybeHideDetails && !moreResultsAvailable
|
|
|
|
|
|
|
|
const showDetails = props.detailsLink && !state.loading && !hideDetails
|
|
|
|
return showDetails && <MoreLink className={'mt-2'} url={props.detailsLink} list={state.list} />
|
2021-11-23 12:39:09 +03:00
|
|
|
}
|
|
|
|
|
2021-12-14 12:28:43 +03:00
|
|
|
return (
|
2023-07-06 17:29:08 +03:00
|
|
|
<LazyLoader onVisible={onVisible} >
|
2023-08-02 14:45:35 +03:00
|
|
|
<div className="w-full" style={{ minHeight: `${MIN_HEIGHT}px` }}>
|
|
|
|
{state.loading && renderLoading()}
|
|
|
|
{!state.loading && <FadeIn show={!state.loading} className="h-full">
|
|
|
|
{renderReport()}
|
|
|
|
</FadeIn>}
|
2023-07-06 17:29:08 +03:00
|
|
|
</div>
|
2021-12-14 12:28:43 +03:00
|
|
|
</LazyLoader>
|
|
|
|
)
|
2021-11-23 12:39:09 +03:00
|
|
|
}
|