Skip to content

Commit

Permalink
Remember user sort preferences for the detailed report
Browse files Browse the repository at this point in the history
  • Loading branch information
apata committed Sep 6, 2024
1 parent 9a550a0 commit 7b3be9f
Show file tree
Hide file tree
Showing 8 changed files with 260 additions and 36 deletions.
4 changes: 2 additions & 2 deletions assets/js/dashboard/components/sort-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import classNames from 'classnames'
export const SortButton = ({
children,
toggleSort,
sortDirection,
sortDirection
}: {
children: ReactNode
toggleSort: () => void
sortDirection: SortDirection | null
}) => {
const next = cycleSortDirection(sortDirection);
const next = cycleSortDirection(sortDirection)
return (
<button
onClick={toggleSort}
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/components/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export type ColumnConfiguraton<T extends Record<string, unknown>> = {
width: string
/** Aligns column content. */
align?: 'left' | 'right'
/**
/**
* Function used to transform the value found at item[accessor] for the cell. Superseded by renderItem if present. @example 1120 => "1.1k"
*/
renderValue?: (value: unknown) => ReactNode
Expand Down
8 changes: 7 additions & 1 deletion assets/js/dashboard/datepicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import {
import classNames from 'classnames'
import { useQueryContext } from './query-context'
import { useSiteContext } from './site-context'
import { isModifierPressed, isTyping, Keybind, KeybindHint, NavigateKeybind } from './keybinding'
import {
isModifierPressed,
isTyping,
Keybind,
KeybindHint,
NavigateKeybind
} from './keybinding'
import {
AppNavigationLink,
useAppNavigate
Expand Down
20 changes: 13 additions & 7 deletions assets/js/dashboard/hooks/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ type GetRequestParams<TKey extends PaginatedQueryKeyBase> = (
k: TKey
) => [Record<string, unknown>, Record<string, unknown>]

/**
* Hook that fetches the first page from the defined GET endpoint on mount,
* then subsequent pages when component calls fetchNextPage.
* Stores fetched pages locally, but only the first page of the results.
/**
* Hook that fetches the first page from the defined GET endpoint on mount,
* then subsequent pages when component calls fetchNextPage.
* Stores fetched pages locally, but only the first page of the results.
*/
export function usePaginatedGetAPI<
TResponse extends { results: unknown[] },
Expand All @@ -33,7 +33,7 @@ export function usePaginatedGetAPI<
getRequestParams,
afterFetchData,
afterFetchNextPage,
initialPageParam = 1,
initialPageParam = 1
}: {
key: TKey
getRequestParams: GetRequestParams<TKey>
Expand Down Expand Up @@ -63,11 +63,17 @@ export function usePaginatedGetAPI<
page: pageParam
})

if (pageParam === initialPageParam && typeof afterFetchData === 'function') {
if (
pageParam === initialPageParam &&
typeof afterFetchData === 'function'
) {
afterFetchData(response)
}

if (pageParam > initialPageParam && typeof afterFetchNextPage === 'function') {
if (
pageParam > initialPageParam &&
typeof afterFetchNextPage === 'function'
) {
afterFetchNextPage(response)
}

Expand Down
83 changes: 82 additions & 1 deletion assets/js/dashboard/hooks/use-order-by.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
SortDirection,
cycleSortDirection,
findOrderIndex,
rearrangeOrderBy
getOrderByStorageKey,
getStoredOrderBy,
maybeStoreOrderBy,
rearrangeOrderBy,
validateOrderBy
} from './use-order-by'

describe(`${findOrderIndex.name}`, () => {
Expand Down Expand Up @@ -82,3 +86,80 @@ describe(`${rearrangeOrderBy.name}`, () => {
}
)
})

describe(`${validateOrderBy.name}`, () => {
test.each([
[false, '', []],
[false, [], []],
[false, [['a']], [{ key: 'a' }]],
[false, [['a', 'b']], [{ key: 'a' }]],
[
false,
[
['a', 'desc'],
['a', 'asc']
],
[{ key: 'a' }]
],
[true, [['a', 'desc']], [{ key: 'a' }]]
])(
'[%#] returns %p given input %p and sortable metrics %p',
(expected, input, sortableMetrics) => {
expect(validateOrderBy(input, sortableMetrics)).toBe(expected)
}
)
})

describe(`storing detailed report preferred order`, () => {
const domain = 'any-domain'
const reportInfo = { dimensionLabel: 'Goal' }

it('does not store invalid value', () => {
maybeStoreOrderBy({
orderBy: [['foo', SortDirection.desc]],
domain,
reportInfo,
metrics: [{ key: 'foo', sortable: false }]
})
expect(localStorage.getItem(getOrderByStorageKey(domain, reportInfo))).toBe(
null
)
})

it('falls back to fallbackValue if metric has become unsortable between storing and retrieving', () => {
maybeStoreOrderBy({
orderBy: [['c', SortDirection.desc]],
domain,
reportInfo,
metrics: [{ key: 'c', sortable: true }]
})
const inStorage = localStorage.getItem(
getOrderByStorageKey(domain, reportInfo)
)
expect(inStorage).toBe('[["c","desc"]]')
expect(
getStoredOrderBy({
domain,
reportInfo,
metrics: [{ key: 'c', sortable: false }],
fallbackValue: [['visitors', SortDirection.desc]]
})
).toEqual([['visitors', SortDirection.desc]])
})

it('retrieves stored value correctly', () => {
const input = [['any-column', SortDirection.asc]]
localStorage.setItem(
getOrderByStorageKey(domain, reportInfo),
JSON.stringify(input)
)
expect(
getStoredOrderBy({
domain,
reportInfo,
metrics: [{ key: 'any-column', sortable: true }],
fallbackValue: [['visitors', SortDirection.desc]]
})
).toEqual(input)
})
})
110 changes: 109 additions & 1 deletion assets/js/dashboard/hooks/use-order-by.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/** @format */

import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Metric } from '../stats/reports/metrics'
import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage'
import { useSiteContext } from '../site-context'
import { ReportInfo } from '../stats/modals/breakdown-modal'

export enum SortDirection {
asc = 'asc',
Expand Down Expand Up @@ -94,3 +97,108 @@ export function rearrangeOrderBy(
}
return [[metric.key, sortDirection]]
}

export function getOrderByStorageKey(
domain: string,
reportInfo: Pick<ReportInfo, 'dimensionLabel'>
) {
const storageKey = getDomainScopedStorageKey(
`order_${reportInfo.dimensionLabel}_by`,
domain
)
return storageKey
}

export function validateOrderBy(
orderBy: unknown,
metrics: Pick<Metric, 'key'>[]
): orderBy is OrderBy {
if (!Array.isArray(orderBy)) {
return false
}
if (orderBy.length !== 1) {
return false
}
if (!Array.isArray(orderBy[0])) {
return false
}
if (
orderBy[0].length === 2 &&
metrics.findIndex((m) => m.key === orderBy[0][0]) > -1 &&
[SortDirection.asc, SortDirection.desc].includes(orderBy[0][1])
) {
return true
}
return false
}

export function getStoredOrderBy({
domain,
reportInfo,
metrics,
fallbackValue
}: {
domain: string
reportInfo: Pick<ReportInfo, 'dimensionLabel'>
metrics: Pick<Metric, 'key' | 'sortable'>[]
fallbackValue: OrderBy
}): OrderBy {
try {
const storedItem = getItem(getOrderByStorageKey(domain, reportInfo))
const parsed = JSON.parse(storedItem)
if (
validateOrderBy(
parsed,
metrics.filter((m) => m.sortable)
)
) {
return parsed
} else {
throw new Error('Invalid stored order_by value')
}
} catch (_e) {
return fallbackValue
}
}

export function maybeStoreOrderBy({
domain,
reportInfo,
metrics,
orderBy
}: {
domain: string
reportInfo: Pick<ReportInfo, 'dimensionLabel'>
metrics: Pick<Metric, 'key' | 'sortable'>[]
orderBy: OrderBy
}) {
if (
validateOrderBy(
orderBy,
metrics.filter((m) => m.sortable)
)
) {
setItem(getOrderByStorageKey(domain, reportInfo), JSON.stringify(orderBy))
}
}

export function useRememberOrderBy({
effectiveOrderBy,
metrics,
reportInfo
}: {
effectiveOrderBy: OrderBy
metrics: Pick<Metric, 'key' | 'sortable'>[]
reportInfo: Pick<ReportInfo, 'dimensionLabel'>
}) {
const site = useSiteContext()

useEffect(() => {
maybeStoreOrderBy({
domain: site.domain,
metrics,
reportInfo,
orderBy: effectiveOrderBy
})
}, [site, reportInfo, effectiveOrderBy, metrics])
}
32 changes: 25 additions & 7 deletions assets/js/dashboard/stats/modals/breakdown-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@ import { FilterLink } from '../reports/list'
import { useQueryContext } from '../../query-context'
import { usePaginatedGetAPI } from '../../hooks/api-client'
import { rootRoute } from '../../router'
import { Order, OrderBy, useOrderBy } from '../../hooks/use-order-by'
import {
getStoredOrderBy,
Order,
OrderBy,
useOrderBy,
useRememberOrderBy
} from '../../hooks/use-order-by'
import { Metric } from '../reports/metrics'
import { DashboardQuery } from '../../query'
import { ColumnConfiguraton } from '../../components/table'
import { BreakdownTable } from './breakdown-table'
import { useSiteContext } from '../../site-context'

export type ReportInfo = {
/** Title of the report to render on the top left. */
Expand Down Expand Up @@ -70,14 +77,25 @@ export default function BreakdownModal<TListItem extends { name: string }>({
addSearchFilter?: (q: DashboardQuery, searchValue: string) => DashboardQuery
searchEnabled?: boolean
}) {
const site = useSiteContext()
const { query } = useQueryContext()

const [search, setSearch] = useState('')
const defaultOrderBy = getStoredOrderBy({
domain: site.domain,
reportInfo,
metrics,
fallbackValue: reportInfo.defaultOrder ? [reportInfo.defaultOrder] : []
})
const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({
metrics,
defaultOrderBy: reportInfo.defaultOrder ? [reportInfo.defaultOrder] : []
defaultOrderBy
})
useRememberOrderBy({
effectiveOrderBy: orderBy,
metrics,
reportInfo
})

const apiState = usePaginatedGetAPI<
{ results: Array<TListItem> },
[string, { query: DashboardQuery; search: string; orderBy: OrderBy }]
Expand Down Expand Up @@ -160,12 +178,12 @@ export default function BreakdownModal<TListItem extends { name: string }>({
)
}

/**
* Most interactive cell in the breakdown table.
* May have an icon.
/**
* 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.
* May have a tiny icon button to navigate to the actual resource.
* */
const NameCell = <TListItem extends { name: string }>({
item,
Expand Down
Loading

0 comments on commit 7b3be9f

Please sign in to comment.