analytics/assets/js/dashboard/stats/modals/breakdown-modal.tsx

266 lines
7.8 KiB
TypeScript

import React, { useState, ReactNode, useMemo } from 'react'
import { useQueryContext } from '../../query-context'
import { usePaginatedGetAPI } from '../../hooks/api-client'
import { rootRoute } from '../../router'
import {
getStoredOrderBy,
Order,
OrderBy,
useOrderBy,
useRememberOrderBy
} from '../../hooks/use-order-by'
import { Metric } from '../reports/metrics'
import * as metricsModule from '../reports/metrics'
import { BreakdownResultMeta, DashboardQuery } from '../../query'
import { ColumnConfiguraton } from '../../components/table'
import { BreakdownTable } from './breakdown-table'
import { useSiteContext } from '../../site-context'
import { DrilldownLink, FilterInfo } from '../../components/drilldown-link'
import { SharedReportProps } from '../reports/list'
import { hasConversionGoalFilter } from '../../util/filters'
export type ReportInfo = {
/** Title of the report to render on the top left. */
title: string
/** Full pathname of the API endpoint to query. @example `/api/stats/plausible.io/sources` */
endpoint: string
/** Used as the leftmost column header. */
dimensionLabel: string
/** What this report will be initially sorted by. @example ["visitors", "desc"] */
defaultOrder?: Order
}
type BreakdownModalProps = {
/** Dimension and title of the breakdown. */
reportInfo: ReportInfo
/** Function that must return a new query that contains appropriate search filter for searchValue param. */
addSearchFilter?: (q: DashboardQuery, searchValue: string) => DashboardQuery
searchEnabled?: boolean
/** When true, keep the percentage metric as a permanently visible, sortable column. */
showPercentageColumn?: boolean
}
/**
BreakdownModal is for rendering the "Details" reports on the dashboard,
i.e. a breakdown by a single (non-time) dimension, with a given set of metrics.
BreakdownModal is expected to be rendered inside a `<Modal>`, which has it's own
specific URL pathname (e.g. /plausible.io/sources). During the lifecycle of a
BreakdownModal, the `query` object is not expected to change.
### Search As You Type
@see BreakdownTable
### Filter Links
@see NameCell
### Pagination
@see usePaginatedGetAPI
*/
export default function BreakdownModal<TListItem extends { name: string }>({
reportInfo,
metrics,
renderIcon,
getExternalLinkUrl,
searchEnabled = true,
showPercentageColumn = false,
afterFetchData,
afterFetchNextPage,
addSearchFilter,
getFilterInfo
}: Omit<SharedReportProps<TListItem>, 'fetchData'> & BreakdownModalProps) {
const site = useSiteContext()
const { query } = useQueryContext()
const [meta, setMeta] = useState<BreakdownResultMeta | null>(null)
const breakdownMetrics = useMemo(() => {
const hasPercentage = metrics.some((m) => m.key === 'percentage')
if (!hasPercentage && !hasConversionGoalFilter(query)) {
return [...metrics, metricsModule.createPercentage()]
}
return metrics
}, [metrics, query])
const [search, setSearch] = useState('')
const defaultOrderBy = getStoredOrderBy({
domain: site.domain,
reportInfo,
metrics: breakdownMetrics,
fallbackValue: reportInfo.defaultOrder ? [reportInfo.defaultOrder] : []
})
const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({
metrics: breakdownMetrics,
defaultOrderBy
})
useRememberOrderBy({
effectiveOrderBy: orderBy,
metrics: breakdownMetrics,
reportInfo
})
const apiState = usePaginatedGetAPI<
{ results: Array<TListItem>; meta: BreakdownResultMeta },
[string, { query: DashboardQuery; search: string; orderBy: OrderBy }]
>({
key: [reportInfo.endpoint, { query, search, orderBy }],
getRequestParams: (key) => {
const [_endpoint, { query, search }] = key
let queryWithSearchFilter = { ...query }
if (
searchEnabled &&
typeof addSearchFilter === 'function' &&
search !== ''
) {
queryWithSearchFilter = addSearchFilter(query, search)
}
return [
queryWithSearchFilter,
{
detailed: true,
order_by: JSON.stringify(orderBy)
}
]
},
afterFetchData: (response) => {
setMeta(response.meta)
afterFetchData?.(response)
},
afterFetchNextPage
})
const columns: ColumnConfiguraton<TListItem>[] = useMemo(
() => [
{
label: reportInfo.dimensionLabel,
key: 'name',
width: 'w-40 md:w-48',
align: 'left',
renderItem: (item) => (
<NameCell
item={item}
getFilterInfo={getFilterInfo}
getExternalLinkUrl={getExternalLinkUrl}
renderIcon={renderIcon}
/>
)
},
...breakdownMetrics
.filter((m) => showPercentageColumn || m.key !== 'percentage')
.map(
(m): ColumnConfiguraton<TListItem> => ({
label: m.renderLabel(query),
key: m.key,
width: m.width,
align: 'right',
metricWarning: getMetricWarning(m, meta),
renderValue: (item, isRowHovered) =>
m.renderValue(
showPercentageColumn && m.key === 'visitors'
? { ...item, percentage: null }
: item,
meta,
{ detailedView: true, isRowHovered }
),
onSort: m.sortable ? () => toggleSortByMetric(m) : undefined,
sortDirection: orderByDictionary[m.key]
})
)
],
[
reportInfo.dimensionLabel,
breakdownMetrics,
getFilterInfo,
query,
orderByDictionary,
toggleSortByMetric,
renderIcon,
getExternalLinkUrl,
meta,
showPercentageColumn
]
)
return (
<BreakdownTable<TListItem>
title={reportInfo.title}
{...apiState}
onSearch={searchEnabled ? setSearch : undefined}
columns={columns}
/>
)
}
/**
* Most interactive cell in the breakdown table.
* May have an icon.
* If `getFilterInfo(item)` does not return null,
* drills down the dashboard to that particular item.
* May have a tiny icon button to navigate to the actual resource.
* */
const NameCell = <TListItem extends { name: string }>({
item,
getFilterInfo,
renderIcon,
getExternalLinkUrl
}: {
item: TListItem
getFilterInfo: (item: TListItem) => FilterInfo | null
renderIcon?: (item: TListItem) => ReactNode
getExternalLinkUrl?: (listItem: TListItem) => string
}) => (
<div className="max-w-full break-all flex items-center">
{typeof renderIcon === 'function' && renderIcon(item)}
<DrilldownLink
path={rootRoute.path}
filterInfo={getFilterInfo(item)}
onClick={undefined}
extraClass={undefined}
>
{item.name}
</DrilldownLink>
{typeof getExternalLinkUrl === 'function' && (
<ExternalLinkIcon url={getExternalLinkUrl(item)} />
)}
</div>
)
const ExternalLinkIcon = ({ url }: { url?: string }) =>
url ? (
<a
target="_blank"
href={url}
rel="noreferrer"
className="hidden group-hover:block"
>
<svg
className="inline h-4 w-4 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>
</a>
) : null
const getMetricWarning = (metric: Metric, meta: BreakdownResultMeta | null) => {
const warnings = meta?.metric_warnings
if (warnings && warnings[metric.key]) {
const { code, message } = warnings[metric.key]
if (metric.key == 'scroll_depth' && code == 'no_imported_scroll_depth') {
return 'Does not include imported data'
}
if (metric.key == 'time_on_page' && code) {
return message
}
}
}