Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 31 additions & 12 deletions assets/js/dashboard/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Metric } from '../types/query-api'
import { Metric } from './stats/metrics'
import { DashboardState } from './dashboard-state'
import { PlausibleSite } from './site-context'
import { StatsQuery } from './stats-query'
Expand All @@ -9,18 +9,37 @@ import * as url from './util/url'
let abortController = new AbortController()
let SHARED_LINK_AUTH: null | string = null

export type RevenueMetricValue = {
short: string
value: number
long: string
currency: string
}

export type MetricValue = null | number | RevenueMetricValue

export type QueryResultQuery = {
metrics: Metric[]
date_range: [string, string]
comparison_date_range?: [string, string] | null
}

export type QueryResultMeta = {
metric_warnings?: Record<string, Record<string, string>>
imports_included?: boolean
imports_skip_reason?: string
}

export type QueryResultRow = {
metrics: Array<MetricValue>
dimensions: Array<string>
comparison?: { metrics: Array<number>; change: Array<number> }
}

export type QueryApiResponse = {
query: {
metrics: Metric[]
date_range: [string, string]
comparison_date_range: [string, string]
}
meta: Record<string, unknown>
results: {
metrics: Array<number>
dimensions: Array<string>
comparison: { metrics: Array<number>; change: Array<number> }
}[]
query: QueryResultQuery
meta: QueryResultMeta
results: QueryResultRow[]
}

export class ApiError extends Error {
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/components/sort-button.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { ReactNode } from 'react'
import { cycleSortDirection, SortDirection } from '../hooks/use-order-by'
import { cycleSortDirection, SortDirection } from '../hooks/use-order-by-legacy'
import classNames from 'classnames'

export const SortButton = ({
Expand Down
216 changes: 216 additions & 0 deletions assets/js/dashboard/components/table-legacy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import classNames from 'classnames'
import React, { ReactNode } from 'react'
import { SortDirection } from '../hooks/use-order-by-legacy'
import { SortButton } from './sort-button'
import { Tooltip } from '../util/tooltip'

export type ColumnConfiguraton<T extends Record<string, unknown>> = {
/** Unique column ID, used for sorting purposes and to get the value of the cell using rowItem[key] */
key: keyof T
/** Column title */
label: string
/** If defined, the column is considered sortable. @see SortButton */
onSort?: () => void
sortDirection?: SortDirection
/** CSS class string. @example "w-24 md:w-32" */
width: string
/** Aligns column content. */
align?: 'left' | 'right'
/** A warning to be rendered as a tooltip for the column header */
metricWarning?: string
/**
* Function used to transform the value found at item[key] for the cell. Superseded by renderItem if present. @example 1120 => "1.1k"
*/
renderValue?: (item: T, isRowHovered?: boolean) => ReactNode
/** Function used to create richer cells */
renderItem?: (item: T) => ReactNode
}

export const TableHeaderCell = ({
children,
className,
align
}: {
children: ReactNode
className: string
align?: 'left' | 'right'
}) => {
return (
<th
data-testid="report-header"
className={classNames(
'p-2 text-xs font-semibold text-gray-500 dark:text-gray-400',
className
)}
align={align}
>
{children}
</th>
)
}

export const TableCell = ({
children,
className,
align
}: {
children: ReactNode
className: string
align?: 'left' | 'right'
}) => {
return (
<td
className={classNames(
'p-2 font-medium first:rounded-s-sm last:rounded-e-sm',
className
)}
align={align}
>
{children}
</td>
)
}

export const ItemRow = <T extends Record<string, string | number | ReactNode>>({
rowIndex,
pageIndex,
item,
columns,
tappedRowName,
onRowTap
}: {
rowIndex: number
pageIndex?: number
item: T
columns: ColumnConfiguraton<T>[]
tappedRowName?: string | null
onRowTap?: (rowName: string | null) => void
}) => {
const [isHovered, setIsHovered] = React.useState(false)

const rowName = (item as unknown as { name: string }).name
const isTapped = tappedRowName === rowName
const isRowActive = isHovered || isTapped

const handleRowClick = (e: React.MouseEvent) => {
if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) {
if (onRowTap) {
if (isTapped) {
onRowTap(null)
} else {
onRowTap(rowName)
}
}
}
}

return (
<tr
data-testid="report-row"
className="group text-sm dark:text-gray-200 md:cursor-default cursor-pointer"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={handleRowClick}
>
{columns.map(({ key, width, align, renderValue, renderItem }) => (
<TableCell
key={`${(pageIndex ?? null) === null ? '' : `page_${pageIndex}_`}row_${rowIndex}_${String(key)}`}
className={width}
align={align}
>
{renderItem
? renderItem(item)
: renderValue
? renderValue(item, isRowActive)
: (item[key] ?? '')}
</TableCell>
))}
</tr>
)
}

export const Table = <T extends Record<string, string | number | ReactNode>>({
data,
columns
}: {
columns: ColumnConfiguraton<T>[]
data: T[] | { pages: T[][] }
}) => {
const [tappedRowName, setTappedRowName] = React.useState<string | null>(null)

const renderColumnLabel = (column: ColumnConfiguraton<T>) => {
if (column.metricWarning) {
return (
<Tooltip
info={warningSpan(column.metricWarning)}
className="inline-block"
>
{column.label + ' *'}
</Tooltip>
)
} else {
return column.label
}
}

const warningSpan = (warning: string) => {
return (
<span className="text-xs font-normal whitespace-nowrap">
{'* ' + warning}
</span>
)
}

return (
<table className="border-collapse table-striped table-fixed w-max min-w-full">
<thead className="sticky top-0 bg-white dark:bg-gray-900 z-10">
<tr className="text-xs font-semibold text-gray-500 dark:text-gray-400">
{columns.map((column) => (
<TableHeaderCell
key={`header_${String(column.key)}`}
className={classNames('p-2', column.width)}
align={column.align}
>
{column.onSort ? (
<SortButton
toggleSort={column.onSort}
sortDirection={column.sortDirection ?? null}
>
{renderColumnLabel(column)}
</SortButton>
) : (
renderColumnLabel(column)
)}
</TableHeaderCell>
))}
</tr>
</thead>
<tbody>
{Array.isArray(data)
? data.map((item, rowIndex) => (
<ItemRow
item={item}
columns={columns}
rowIndex={rowIndex}
key={rowIndex}
tappedRowName={tappedRowName}
onRowTap={setTappedRowName}
/>
))
: data.pages.map((page, pageIndex) =>
page.map((item, rowIndex) => (
<ItemRow
item={item}
columns={columns}
rowIndex={rowIndex}
pageIndex={pageIndex}
key={`page_${pageIndex}_row_${rowIndex}`}
tappedRowName={tappedRowName}
onRowTap={setTappedRowName}
/>
))
)}
</tbody>
</table>
)
}
Loading
Loading