import React, { useState, useEffect, useCallback } from 'react'; import { Link } from 'react-router-dom' import FlipMove from 'react-flip-move'; import { displayMetricValue, metricLabelFor } from './metrics'; import FadeIn from '../../fade-in' import MoreLink from '../more-link' import Bar from '../bar' import LazyLoader from '../../components/lazy-loader' import classNames from 'classnames' 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 function FilterLink({filterQuery, onClick, children}) { const className = classNames('max-w-max w-full flex md:overflow-hidden', { 'hover:underline': !!filterQuery }) if (filterQuery) { return ( { children } ) } else { return { children } } } function ExternalLink({item, externalLinkDest}) { const dest = externalLinkDest && externalLinkDest(item) if (dest) { return ( ) } return null } // 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 // 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. // 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.: // `/dummy.site/pages` // * `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. // * `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. // * `color` - color of the comparison bars in light-mode export default function ListReport(props) { const [state, setState] = useState({loading: true, list: null}) const [visible, setVisible] = useState(false) const metrics = props.metrics const colMinWidth = props.colMinWidth || COL_MIN_WIDTH const isRealtime = props.query.period === 'realtime' const goalFilterApplied = !!props.query.filters.goal const fetchData = useCallback(() => { if (!isRealtime) { setState({loading: true, list: null}) } props.fetchData() .then((res) => setState({loading: false, list: res})) }, [props.keyLabel, props.query]) const onVisible = () => { setVisible(true) } useEffect(() => { 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. setState({loading: true, list: null}) } }, [goalFilterApplied]); useEffect(() => { if (visible) { if (isRealtime) { document.addEventListener('tick', fetchData) } fetchData() } return () => { document.removeEventListener('tick', fetchData) } }, [props.keyLabel, props.query, visible]); // 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 '' } } function renderReport() { if (state.list && state.list.length > 0) { return (
{ renderReportHeader() }
{ renderReportBody() }
{ maybeRenderMoreLink() }
) } return renderNoDataYet() } function renderReportHeader() { const metricLabels = getAvailableMetrics().map((metric) => { return (
{ metricLabelFor(metric, props.query) }
) }) return (
{ props.keyLabel } { metricLabels }
) } function renderReportBody() { return ( {state.list.slice(0, MAX_ITEMS).map(renderRow)} ) } function renderRow(listItem) { return (
{ renderBarFor(listItem) } { renderMetricValuesFor(listItem) }
) } function getFilterQuery(listItem) { const filter = props.getFilterFor(listItem) if (!filter) { return null } const query = new URLSearchParams(window.location.search) Object.entries(filter).forEach((([key, value]) => { query.set(key, value) })) return query } function renderBarFor(listItem) { const lightBackground = props.color || 'bg-green-50' const noop = () => {} const metricToPlot = metrics.find(m => m.plot).name return (
{maybeRenderIconFor(listItem)} {listItem.name}
) } function maybeRenderIconFor(listItem) { if (props.renderIcon) { return ( {props.renderIcon(listItem)} ) } } function renderMetricValuesFor(listItem) { return getAvailableMetrics().map((metric) => { return (
{ displayMetricValue(listItem[metric.name], metric) }
) }) } function renderLoading() { return (
) } function renderNoDataYet() { return (
No data yet
) } function maybeRenderMoreLink() { return props.detailsLink && !state.loading && } return (
{ state.loading && renderLoading() } { !state.loading && { renderReport() } }
) }