diff --git a/assets/js/dashboard/components/sort-button.tsx b/assets/js/dashboard/components/sort-button.tsx index cb0f7751f..6ee94d7b5 100644 --- a/assets/js/dashboard/components/sort-button.tsx +++ b/assets/js/dashboard/components/sort-button.tsx @@ -1,25 +1,48 @@ /** @format */ import React, { ReactNode } from 'react' -import { getSortDirectionIndicator, SortDirection } from '../hooks/use-order-by' +import { SortDirection } from '../hooks/use-order-by' +import classNames from 'classnames' export const SortButton = ({ children, toggleSort, hint, - sortDirection + sortDirection, + nextSortDirection }: { children: ReactNode toggleSort: () => void hint: string sortDirection: SortDirection | null + nextSortDirection: SortDirection }) => { return ( - ) } diff --git a/assets/js/dashboard/components/table.tsx b/assets/js/dashboard/components/table.tsx new file mode 100644 index 000000000..da48dd78e --- /dev/null +++ b/assets/js/dashboard/components/table.tsx @@ -0,0 +1,77 @@ +/** @format */ + +import classNames from 'classnames' +import React, { ReactNode } from 'react' + +export const TableHeaderCell = ({ + children, + className +}: { + children: ReactNode + className: string +}) => { + return ( + + {children} + + ) +} + +export const TableCell = ({ + children, + className +}: { + children: ReactNode + className: string +}) => { + return ( + + {children} + + ) +} + +export const Table = >({ + data, + columns +}: { + columns: { accessor: keyof T; width: string; label: string }[] + data: T[] +}) => { + return ( +
+ + + + {columns.map((column, index) => ( + + {column.label} + + ))} + + + + {data.map((item, itemIndex) => ( + + {columns.map(({ accessor, width }, colIndex) => ( + + {item[accessor]} + + ))} + + ))} + +
+
+ ) +} diff --git a/assets/js/dashboard/extra/funnel.js b/assets/js/dashboard/extra/funnel.js index 477b110d4..95be9391b 100644 --- a/assets/js/dashboard/extra/funnel.js +++ b/assets/js/dashboard/extra/funnel.js @@ -319,7 +319,7 @@ export default function Funnel({ funnelName, tabs }) { diff --git a/assets/js/dashboard/hooks/api-client.js b/assets/js/dashboard/hooks/api-client.js deleted file mode 100644 index a44b78970..000000000 --- a/assets/js/dashboard/hooks/api-client.js +++ /dev/null @@ -1,84 +0,0 @@ -import { useEffect } from "react" -import { useQueryClient, useInfiniteQuery } from "@tanstack/react-query" -import * as api from "../api" - -const LIMIT = 10 // FOR DEBUGGING - -/** - * A wrapper for the React Query library. Constructs the necessary options - * (including pagination config) to pass into the `useInfiniteQuery` hook. - * - * ### Required props - * - * @param {Array} key - The key under which the global "query" instance will live. - * Should be passed as a list of two elements - `[endpoint, { query }]`. The object - * can also contain additional values (such as `search`) to be used by: - * 1) React Query, to determine the uniqueness of the query instance - * 2) the `getRequestParams` function to build the request params. - * - * @param {Function} getRequestParams - A function that takes the `key` prop as an - * argument, and returns `[query, params]` which will be used by `queryFn` that - * actually calls the API. - * - * ### Optional props - * - * @param {Function} [afterFetchData] - A function to call after data has been fetched. - * Receives the API response as an argument. - * - * @param {Function} [afterFetchNextPage] - A function to call after the next page has - * been fetched. Receives the API response as an argument. - */ - -export function useAPIClient(props) { - const {key, getRequestParams, afterFetchData, afterFetchNextPage} = props - const [endpoint] = key - const queryClient = useQueryClient() - - const queryFn = async ({ pageParam, queryKey }) => { - const [query, params] = getRequestParams(queryKey) - params.limit = LIMIT - params.page = pageParam - - const response = await api.get(endpoint, query, params) - - if (pageParam === 1 && typeof afterFetchData === 'function') { - afterFetchData(response) - } - - if (pageParam > 1 && typeof afterFetchNextPage === 'function') { - afterFetchNextPage(response) - } - - return response.results - } - - // During the cleanup phase, make sure only the first page of results - // is cached under any `queryKey` containing this endpoint. - useEffect(() => { - const key = [endpoint] - return () => { - queryClient.setQueriesData(key, (data) => { - if (data?.pages?.length) { - return { - pages: data.pages.slice(0, 1), - pageParams: data.pageParams.slice(0, 1), - } - } - }) - } - }, [queryClient, endpoint]) - - const getNextPageParam = (lastPageResults, _, lastPageIndex) => { - return lastPageResults.length === LIMIT ? lastPageIndex + 1 : null - } - const defaultInitialPageParam = 1 - const initialPageParam = props.initialPageParam === undefined ? defaultInitialPageParam : props.initialPageParam - - return useInfiniteQuery({ - queryKey: key, - queryFn, - getNextPageParam, - initialPageParam, - placeholderData: (previousData) => previousData, - }) -} \ No newline at end of file diff --git a/assets/js/dashboard/hooks/api-client.ts b/assets/js/dashboard/hooks/api-client.ts new file mode 100644 index 000000000..eb7a7ac98 --- /dev/null +++ b/assets/js/dashboard/hooks/api-client.ts @@ -0,0 +1,102 @@ +/** @format */ + +import { useEffect } from 'react' +import { + useQueryClient, + useInfiniteQuery, + QueryFilters, +} from '@tanstack/react-query' +import * as api from '../api' +import { DashboardQuery } from '../query'; + +const LIMIT = 10 // FOR DEBUGGING + +/** + * A wrapper for the React Query library. Constructs the necessary options + * (including pagination config) to pass into the `useInfiniteQuery` hook. + * + * ### Required props + * + * @param {Array} key - The key under which the global "query" instance will live. + * Should be passed as a list of two elements - `[endpoint, { query }]`. The object + * can also contain additional values (such as `search`) to be used by: + * 1) React Query, to determine the uniqueness of the query instance + * 2) the `getRequestParams` function to build the request params. + * + * @param {Function} getRequestParams - A function that takes the `key` prop as an + * argument, and returns `[query, params]` which will be used by `queryFn` that + * actually calls the API. + * + * ### Optional props + * + * @param {Function} [afterFetchData] - A function to call after data has been fetched. + * Receives the API response as an argument. + * + * @param {Function} [afterFetchNextPage] - A function to call after the next page has + * been fetched. Receives the API response as an argument. + */ + + +type Endpoint = string; + +type InfiniteQueryKey = [Endpoint, {query: DashboardQuery}] + +export function useAPIClient(props: { + initialPageParam?: number + key: TKey + getRequestParams: (key: TKey) => [Record, Record] + afterFetchData: (response: TResponse) => void + afterFetchNextPage: (response: TResponse) => void +}) { + const { key, getRequestParams, afterFetchData, afterFetchNextPage } = props + const [endpoint] = key + const queryClient = useQueryClient() + + // During the cleanup phase, make sure only the first page of results + // is cached under any `queryKey` containing this endpoint. + useEffect(() => { + const queryKeyToClean = [endpoint] as QueryFilters + return () => { + queryClient.setQueriesData<{pages: TResponse[], pageParams: unknown[]}>(queryKeyToClean, (data) => { + if (data?.pages?.length) { + return { + pages: data.pages.slice(0, 1), + pageParams: data.pageParams.slice(0, 1) + } + } + }) + } + }, [queryClient, endpoint]) + + const defaultInitialPageParam = 1 + const initialPageParam = + props.initialPageParam === undefined + ? defaultInitialPageParam + : props.initialPageParam + + return useInfiniteQuery({ + queryKey: key, + queryFn: async ({ pageParam, queryKey }) => { + const [query, params] = getRequestParams(queryKey) + params.limit = LIMIT + params.page = pageParam + + const response = await api.get(endpoint, query, params) + + if (pageParam === 1 && typeof afterFetchData === 'function') { + afterFetchData(response) + } + + if (pageParam > 1 && typeof afterFetchNextPage === 'function') { + afterFetchNextPage(response) + } + + return response.results + }, + getNextPageParam: (lastPageResults, _, lastPageIndex) => { + return lastPageResults.length === LIMIT ? lastPageIndex + 1 : null + }, + initialPageParam, + placeholderData: (previousData) => previousData + }) +} diff --git a/assets/js/dashboard/hooks/use-order-by.ts b/assets/js/dashboard/hooks/use-order-by.ts index 91287d048..984f13d03 100644 --- a/assets/js/dashboard/hooks/use-order-by.ts +++ b/assets/js/dashboard/hooks/use-order-by.ts @@ -8,26 +8,30 @@ export enum SortDirection { desc = 'desc' } -type Order = [Metric['key'], SortDirection] +export type Order = [Metric['key'], SortDirection] export type OrderBy = Order[] -export const getSortDirectionIndicator = ( - sortDirection: SortDirection -): string => - ({ [SortDirection.asc]: '↑', [SortDirection.desc]: '↓' })[sortDirection] - export const getSortDirectionLabel = (sortDirection: SortDirection): string => ({ [SortDirection.asc]: 'Sorted in ascending order', [SortDirection.desc]: 'Sorted in descending order' })[sortDirection] -export function useOrderBy({ metrics }: { metrics: Metric[] }) { +export function useOrderBy({ + metrics, + defaultOrderBy +}: { + metrics: Metric[] + defaultOrderBy: OrderBy +}) { const [orderBy, setOrderBy] = useState([]) const orderByDictionary = useMemo( - () => Object.fromEntries(orderBy), - [orderBy] + () => + orderBy.length + ? Object.fromEntries(orderBy) + : Object.fromEntries(defaultOrderBy), + [orderBy, defaultOrderBy] ) const toggleSortByMetric = useCallback( @@ -35,19 +39,24 @@ export function useOrderBy({ metrics }: { metrics: Metric[] }) { if (!metrics.find(({ key }) => key === metric.key)) { return } - setOrderBy((currentOrderBy) => rearrangeOrderBy(currentOrderBy, metric)) + setOrderBy((currentOrderBy) => rearrangeOrderBy(currentOrderBy.length ? currentOrderBy : defaultOrderBy, metric)) }, - [metrics] + [metrics, defaultOrderBy] ) - return { orderBy, orderByDictionary, toggleSortByMetric } + return { + orderBy: orderBy.length ? orderBy : defaultOrderBy, + orderByDictionary, + toggleSortByMetric + } } export function cycleSortDirection( currentSortDirection: SortDirection | null -): { direction: SortDirection | null; hint: string } { +): { direction: SortDirection; hint: string } { switch (currentSortDirection) { case null: + case SortDirection.asc: return { direction: SortDirection.desc, hint: 'Press to sort column in descending order' @@ -57,8 +66,8 @@ export function cycleSortDirection( direction: SortDirection.asc, hint: 'Press to sort column in ascending order' } - case SortDirection.asc: - return { direction: null, hint: 'Press to remove sorting from column' } + // case SortDirection.asc: + // return { direction: SortDirection, hint: 'Press to remove sorting from column' } } } diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.tsx similarity index 78% rename from assets/js/dashboard/stats/modals/breakdown-modal.js rename to assets/js/dashboard/stats/modals/breakdown-modal.tsx index 398a1629c..7b254ff66 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.tsx @@ -1,14 +1,22 @@ /** @format */ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect, useRef, ReactNode } from 'react' import { FilterLink } from '../reports/list' import { useQueryContext } from '../../query-context' import { useDebounce } from '../../custom-hooks' import { useAPIClient } from '../../hooks/api-client' import { rootRoute } from '../../router' -import { cycleSortDirection, useOrderBy } from '../../hooks/use-order-by' +import { + cycleSortDirection, + Order, + OrderBy, + useOrderBy +} from '../../hooks/use-order-by' import { SortButton } from '../../components/sort-button' +import { Metric } from '../reports/metrics' +import { DashboardQuery } from '../../query' +import classNames from 'classnames' export const MIN_HEIGHT_PX = 500 @@ -76,7 +84,7 @@ export const MIN_HEIGHT_PX = 500 // * `afterFetchNextPage` - a function with the same behaviour as `afterFetchData`, // but will be called after a successful next page load in `fetchNextPage`. -export default function BreakdownModal({ +export default function BreakdownModal({ reportInfo, metrics, renderIcon, @@ -86,13 +94,29 @@ export default function BreakdownModal({ afterFetchNextPage, addSearchFilter, getFilterInfo +}: { + reportInfo: { + title: string + endpoint: string + dimensionLabel: string + defaultOrderBy: Order + } + metrics: Metric[] + renderIcon: (listItem: TListItem) => ReactNode + getExternalLinkURL: (listItem: TListItem) => string + searchEnabled?: boolean + afterFetchData: () => void + afterFetchNextPage: () => void + addSearchFilter: (q: DashboardQuery, search: string) => Record + getFilterInfo: (listItem: TListItem) => void }) { - const searchBoxRef = useRef(null) + const searchBoxRef = useRef(null) const { query } = useQueryContext() const [search, setSearch] = useState('') const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({ - metrics + metrics, + defaultOrderBy: [reportInfo.defaultOrderBy] }) const { @@ -102,12 +126,15 @@ export default function BreakdownModal({ isFetchingNextPage, isFetching, isPending - } = useAPIClient({ + } = useAPIClient< + never, + [string, { query: DashboardQuery; search: string; orderBy: OrderBy }] + >({ key: [reportInfo.endpoint, { query, search, orderBy }], getRequestParams: (key) => { const [_endpoint, { query, search }] = key - let queryWithSearchFilter = { ...query } + let queryWithSearchFilter: Record = { ...query } if (searchEnabled && search !== '') { queryWithSearchFilter = addSearchFilter(query, search) @@ -115,7 +142,10 @@ export default function BreakdownModal({ return [ queryWithSearchFilter, - { detailed: true, order_by: JSON.stringify(orderBy) } + { + detailed: true, + order_by: JSON.stringify(orderBy) + } ] }, afterFetchData, @@ -123,33 +153,32 @@ export default function BreakdownModal({ }) useEffect(() => { - if (!searchEnabled) { + const searchBox = searchBoxRef.current + if (!searchEnabled && searchBox) { return } - const searchBox = searchBoxRef.current - - const handleKeyUp = (event) => { + const handleKeyUp = (event: KeyboardEvent) => { if (event.key === 'Escape') { - event.target.blur() + ;(event.target as HTMLElement | undefined)?.blur() event.stopPropagation() } } - searchBox.addEventListener('keyup', handleKeyUp) + searchBox?.addEventListener('keyup', handleKeyUp) return () => { - searchBox.removeEventListener('keyup', handleKeyUp) + searchBox?.removeEventListener('keyup', handleKeyUp) } }, [searchEnabled]) - function maybeRenderIcon(item) { + function maybeRenderIcon(item: TListItem) { if (typeof renderIcon === 'function') { return renderIcon(item) } } - function maybeRenderExternalLink(item) { + function maybeRenderExternalLink(item: TListItem) { if (typeof getExternalLinkURL === 'function') { const linkUrl = getExternalLinkURL(item) @@ -177,20 +206,20 @@ export default function BreakdownModal({ } } - function renderRow(item) { + function renderRow(item: TListItem) { return ( {maybeRenderIcon(item)} - + {item.name} {maybeRenderExternalLink(item)} {metrics.map((metric) => { return ( - - {metric.renderValue(item[metric.key])} + + {metric.renderValue(item[metric.key as keyof TListItem])} ) })} @@ -226,7 +255,7 @@ export default function BreakdownModal({ return (
{!isFetching && ( - )} @@ -235,8 +264,12 @@ export default function BreakdownModal({ ) } - function handleInputChange(e) { - setSearch(e.target.value) + function handleInputChange(e: Event) { + const element = e.target as HTMLInputElement | null; + if (!element) { + return + } + setSearch(element.value) } const debouncedHandleInputChange = useDebounce(handleInputChange) @@ -261,7 +294,7 @@ export default function BreakdownModal({ {reportInfo.dimensionLabel} @@ -271,12 +304,15 @@ export default function BreakdownModal({ return ( {metric.sortable ? ( toggleSortByMetric(metric)} hint={ cycleSortDirection( diff --git a/assets/js/dashboard/stats/modals/conversions.js b/assets/js/dashboard/stats/modals/conversions.js index fe9df2ec6..1ce10eca0 100644 --- a/assets/js/dashboard/stats/modals/conversions.js +++ b/assets/js/dashboard/stats/modals/conversions.js @@ -16,7 +16,8 @@ function ConversionsModal() { title: 'Goal Conversions', dimension: 'goal', endpoint: url.apiPath(site, '/conversions'), - dimensionLabel: "Goal" + dimensionLabel: "Goal", + defaultOrderBy: [] } const getFilterInfo = useCallback((listItem) => { diff --git a/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js b/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js index f9ba69946..d1ad9aa79 100644 --- a/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js +++ b/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js @@ -7,6 +7,7 @@ import { useQueryContext } from "../../../query-context"; import { useSiteContext } from "../../../site-context"; import { browserIconFor } from "../../devices"; import chooseMetrics from './choose-metrics'; +import { SortDirection } from "../../../hooks/use-order-by"; function BrowserVersionsModal() { const { query } = useQueryContext(); @@ -16,7 +17,8 @@ function BrowserVersionsModal() { title: 'Browser Versions', dimension: 'browser_version', endpoint: url.apiPath(site, '/browser-versions'), - dimensionLabel: 'Browser version' + dimensionLabel: 'Browser version', + defaultOrderBy: ["visitors", SortDirection.desc] } const getFilterInfo = useCallback((listItem) => { diff --git a/assets/js/dashboard/stats/modals/devices/browsers-modal.js b/assets/js/dashboard/stats/modals/devices/browsers-modal.js index dc0598baa..ff3103104 100644 --- a/assets/js/dashboard/stats/modals/devices/browsers-modal.js +++ b/assets/js/dashboard/stats/modals/devices/browsers-modal.js @@ -7,6 +7,7 @@ import { useQueryContext } from "../../../query-context"; import { useSiteContext } from "../../../site-context"; import { browserIconFor } from "../../devices"; import chooseMetrics from './choose-metrics'; +import { SortDirection } from "../../../hooks/use-order-by"; function BrowsersModal() { const { query } = useQueryContext(); @@ -16,7 +17,8 @@ function BrowsersModal() { title: 'Browsers', dimension: 'browser', endpoint: url.apiPath(site, '/browsers'), - dimensionLabel: 'Browser' + dimensionLabel: 'Browser', + defaultOrderBy: ["visitors", SortDirection.desc] } const getFilterInfo = useCallback((listItem) => { diff --git a/assets/js/dashboard/stats/modals/devices/choose-metrics.js b/assets/js/dashboard/stats/modals/devices/choose-metrics.js index a9f5cbb85..acde40634 100644 --- a/assets/js/dashboard/stats/modals/devices/choose-metrics.js +++ b/assets/js/dashboard/stats/modals/devices/choose-metrics.js @@ -5,14 +5,14 @@ export default function chooseMetrics(query) { if (hasGoalFilter(query)) { return [ metrics.createTotalVisitors(), - metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }), + metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }), metrics.createConversionRate() ] } if (isRealTimeDashboard(query)) { return [ - metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' }), + metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', width: 'w-36' }), metrics.createPercentage() ] } diff --git a/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js b/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js index 3e94ea293..a8a3cc539 100644 --- a/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js +++ b/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js @@ -7,6 +7,7 @@ import { useQueryContext } from "../../../query-context"; import { useSiteContext } from "../../../site-context"; import { osIconFor } from "../../devices"; import chooseMetrics from './choose-metrics'; +import { SortDirection } from "../../../hooks/use-order-by"; function OperatingSystemVersionsModal() { const { query } = useQueryContext(); @@ -16,7 +17,8 @@ function OperatingSystemVersionsModal() { title: 'Operating System Versions', dimension: 'os_version', endpoint: url.apiPath(site, '/operating-system-versions'), - dimensionLabel: 'Operating system version' + dimensionLabel: 'Operating system version', + defaultOrderBy: ["visitors", SortDirection.desc] } const getFilterInfo = useCallback((listItem) => { diff --git a/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js b/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js index 9ae5c4074..243e9d7eb 100644 --- a/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js +++ b/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js @@ -7,6 +7,7 @@ import { useQueryContext } from "../../../query-context"; import { useSiteContext } from "../../../site-context"; import { osIconFor } from "../../devices"; import chooseMetrics from './choose-metrics'; +import { SortDirection } from "../../../hooks/use-order-by"; function OperatingSystemsModal() { const { query } = useQueryContext(); @@ -16,7 +17,8 @@ function OperatingSystemsModal() { title: 'Operating Systems', dimension: 'os', endpoint: url.apiPath(site, '/operating-systems'), - dimensionLabel: 'Operating system' + dimensionLabel: 'Operating system', + defaultOrderBy: ["visitors", SortDirection.desc] } const getFilterInfo = useCallback((listItem) => { diff --git a/assets/js/dashboard/stats/modals/devices/screen-sizes.js b/assets/js/dashboard/stats/modals/devices/screen-sizes.js index dc6458946..4ccc28ac3 100644 --- a/assets/js/dashboard/stats/modals/devices/screen-sizes.js +++ b/assets/js/dashboard/stats/modals/devices/screen-sizes.js @@ -6,6 +6,7 @@ import { useQueryContext } from "../../../query-context"; import { useSiteContext } from "../../../site-context"; import { screenSizeIconFor } from "../../devices"; import chooseMetrics from './choose-metrics'; +import { SortDirection } from "../../../hooks/use-order-by"; function ScreenSizesModal() { const { query } = useQueryContext(); @@ -15,7 +16,8 @@ function ScreenSizesModal() { title: 'Screen Sizes', dimension: 'screen', endpoint: url.apiPath(site, '/screen-sizes'), - dimensionLabel: 'Screen size' + dimensionLabel: 'Screen size', + defaultOrderBy: ["visitors", SortDirection.desc] } const getFilterInfo = useCallback((listItem) => { diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js index f1d3068b3..668335775 100644 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ b/assets/js/dashboard/stats/modals/entry-pages.js @@ -7,6 +7,7 @@ import * as metrics from '../reports/metrics' import * as url from '../../util/url'; import { useQueryContext } from "../../query-context"; import { useSiteContext } from "../../site-context"; +import { SortDirection } from "../../hooks/use-order-by"; function EntryPagesModal() { const { query } = useQueryContext(); @@ -16,7 +17,8 @@ function EntryPagesModal() { title: 'Entry Pages', dimension: 'entry_page', endpoint: url.apiPath(site, '/entry-pages'), - dimensionLabel: 'Entry page' + dimensionLabel: 'Entry page', + defaultOrderBy: ["visitors", SortDirection.desc] } const getFilterInfo = useCallback((listItem) => { @@ -34,20 +36,20 @@ function EntryPagesModal() { if (hasGoalFilter(query)) { return [ metrics.createTotalVisitors(), - metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }), + metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }), metrics.createConversionRate() ] } if (isRealTimeDashboard(query)) { return [ - metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' }) + metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', width: 'w-36' }) ] } return [ metrics.createVisitors({ renderLabel: (_query) => "Visitors" }), - metrics.createVisits({ renderLabel: (_query) => "Total Entrances" }), + metrics.createVisits({ renderLabel: (_query) => "Total Entrances", width: 'w-36' }), metrics.createVisitDuration() ] } diff --git a/assets/js/dashboard/stats/modals/exit-pages.js b/assets/js/dashboard/stats/modals/exit-pages.js index 3769c8f1d..354a099d4 100644 --- a/assets/js/dashboard/stats/modals/exit-pages.js +++ b/assets/js/dashboard/stats/modals/exit-pages.js @@ -7,6 +7,7 @@ import * as metrics from '../reports/metrics' import * as url from '../../util/url'; import { useQueryContext } from "../../query-context"; import { useSiteContext } from "../../site-context"; +import { SortDirection } from "../../hooks/use-order-by"; function ExitPagesModal() { const { query } = useQueryContext(); @@ -16,7 +17,8 @@ function ExitPagesModal() { title: 'Exit Pages', dimension: 'exit_page', endpoint: url.apiPath(site, '/exit-pages'), - dimensionLabel: 'Page url' + dimensionLabel: 'Page url', + defaultOrderBy: [] } const getFilterInfo = useCallback((listItem) => { @@ -34,14 +36,14 @@ function ExitPagesModal() { if (hasGoalFilter(query)) { return [ metrics.createTotalVisitors(), - metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }), + metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }), metrics.createConversionRate() ] } if (query.period === 'realtime') { return [ - metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' }) + metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', width: 'w-36' }) ] } diff --git a/assets/js/dashboard/stats/modals/google-keywords.js b/assets/js/dashboard/stats/modals/google-keywords.js index 498489c14..43ef10c61 100644 --- a/assets/js/dashboard/stats/modals/google-keywords.js +++ b/assets/js/dashboard/stats/modals/google-keywords.js @@ -21,9 +21,9 @@ function GoogleKeywordsModal() { const metrics = [ createVisitors({renderLabel: (_query) => 'Visitors'}), - new Metric({key: 'impressions', renderLabel: (_query) => 'Impressions', renderValue: renderNumberWithTooltip}), - new Metric({key: 'ctr', renderLabel: (_query) => 'CTR', renderValue: percentageFormatter}), - new Metric({key: 'position', renderLabel: (_query) => 'Position', renderValue: numberFormatter}) + new Metric({width: 'w-28', key: 'impressions', renderLabel: (_query) => 'Impressions', renderValue: renderNumberWithTooltip, sortable: false}), + new Metric({width: 'w-16', key: 'ctr', renderLabel: (_query) => 'CTR', renderValue: percentageFormatter, sortable: false}), + new Metric({width: 'w-28', key: 'position', renderLabel: (_query) => 'Position', renderValue: numberFormatter, sortable: false}) ] const { diff --git a/assets/js/dashboard/stats/modals/locations-modal.js b/assets/js/dashboard/stats/modals/locations-modal.js index d607663f8..7e7c11cf8 100644 --- a/assets/js/dashboard/stats/modals/locations-modal.js +++ b/assets/js/dashboard/stats/modals/locations-modal.js @@ -8,11 +8,12 @@ import * as url from "../../util/url"; import { useQueryContext } from "../../query-context"; import { useSiteContext } from "../../site-context"; import { addFilter } from "../../query"; +import { SortDirection } from "../../hooks/use-order-by"; const VIEWS = { - countries: { title: 'Top Countries', dimension: 'country', endpoint: '/countries', dimensionLabel: 'Country' }, - regions: { title: 'Top Regions', dimension: 'region', endpoint: '/regions', dimensionLabel: 'Region' }, - cities: { title: 'Top Cities', dimension: 'city', endpoint: '/cities', dimensionLabel: 'City' }, + countries: { title: 'Top Countries', dimension: 'country', endpoint: '/countries', dimensionLabel: 'Country', defaultOrderBy: ["visitors", SortDirection.desc] }, + regions: { title: 'Top Regions', dimension: 'region', endpoint: '/regions', dimensionLabel: 'Region', defaultOrderBy: ["visitors", SortDirection.desc] }, + cities: { title: 'Top Cities', dimension: 'city', endpoint: '/cities', dimensionLabel: 'City', defaultOrderBy: ["visitors", SortDirection.desc] }, } function LocationsModal({ currentView }) { @@ -38,14 +39,14 @@ function LocationsModal({ currentView }) { if (hasGoalFilter(query)) { return [ metrics.createTotalVisitors(), - metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }), + metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }), metrics.createConversionRate() ] } if (query.period === 'realtime') { return [ - metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' }) + metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', width: 'w-36' }) ] } diff --git a/assets/js/dashboard/stats/modals/pages.js b/assets/js/dashboard/stats/modals/pages.js index 5d59e0d07..e85aaa8b9 100644 --- a/assets/js/dashboard/stats/modals/pages.js +++ b/assets/js/dashboard/stats/modals/pages.js @@ -7,6 +7,7 @@ import * as metrics from '../reports/metrics' import * as url from '../../util/url'; import { useQueryContext } from "../../query-context"; import { useSiteContext } from "../../site-context"; +import { SortDirection } from "../../hooks/use-order-by"; function PagesModal() { const { query } = useQueryContext(); @@ -16,7 +17,8 @@ function PagesModal() { title: 'Top Pages', dimension: 'page', endpoint: url.apiPath(site, '/pages'), - dimensionLabel: 'Page url' + dimensionLabel: 'Page url', + defaultOrderBy: ["visitors", SortDirection.desc] } const getFilterInfo = useCallback((listItem) => { @@ -34,14 +36,14 @@ function PagesModal() { if (hasGoalFilter(query)) { return [ metrics.createTotalVisitors(), - metrics.createVisitors({renderLabel: (_query) => 'Conversions'}), + metrics.createVisitors({renderLabel: (_query) => 'Conversions', width: 'w-32'}), metrics.createConversionRate() ] } if (isRealTimeDashboard(query)) { return [ - metrics.createVisitors({renderLabel: (_query) => 'Current visitors'}) + metrics.createVisitors({renderLabel: (_query) => 'Current visitors', width: 'w-36'}) ] } diff --git a/assets/js/dashboard/stats/modals/props.js b/assets/js/dashboard/stats/modals/props.js index 2e4a88530..71a1d6c9a 100644 --- a/assets/js/dashboard/stats/modals/props.js +++ b/assets/js/dashboard/stats/modals/props.js @@ -10,6 +10,7 @@ import * as metrics from "../reports/metrics"; import * as url from "../../util/url"; import { useQueryContext } from "../../query-context"; import { useSiteContext } from "../../site-context"; +import { SortDirection } from "../../hooks/use-order-by"; function PropsModal() { const { query } = useQueryContext(); @@ -23,7 +24,8 @@ function PropsModal() { title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'), dimension: propKey, endpoint: url.apiPath(site, `/custom-prop-values/${propKey}`), - dimensionLabel: propKey + dimensionLabel: propKey, + defaultOrderBy: ["visitors", SortDirection.desc] } const getFilterInfo = useCallback((listItem) => { diff --git a/assets/js/dashboard/stats/modals/referrer-drilldown.js b/assets/js/dashboard/stats/modals/referrer-drilldown.js index 5e008c694..c21b69abb 100644 --- a/assets/js/dashboard/stats/modals/referrer-drilldown.js +++ b/assets/js/dashboard/stats/modals/referrer-drilldown.js @@ -9,6 +9,7 @@ import * as url from "../../util/url"; import { addFilter } from "../../query"; import { useQueryContext } from "../../query-context"; import { useSiteContext } from "../../site-context"; +import { SortDirection } from '../../hooks/use-order-by'; function ReferrerDrilldownModal() { const { referrer } = useParams(); @@ -19,7 +20,8 @@ function ReferrerDrilldownModal() { title: "Referrer Drilldown", dimension: 'referrer', endpoint: url.apiPath(site, `/referrers/${referrer}`), - dimensionLabel: "Referrer" + dimensionLabel: "Referrer", + defaultOrderBy: ["visitors", SortDirection.desc] } const getFilterInfo = useCallback((listItem) => { @@ -37,14 +39,14 @@ function ReferrerDrilldownModal() { if (hasGoalFilter(query)) { return [ metrics.createTotalVisitors(), - metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }), + metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }), metrics.createConversionRate() ] } if (isRealTimeDashboard(query)) { return [ - metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' }) + metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', width: 'w-36' }) ] } diff --git a/assets/js/dashboard/stats/modals/sources.js b/assets/js/dashboard/stats/modals/sources.js index edf2d06d0..7d02b357f 100644 --- a/assets/js/dashboard/stats/modals/sources.js +++ b/assets/js/dashboard/stats/modals/sources.js @@ -7,6 +7,7 @@ import * as url from "../../util/url"; import { addFilter } from "../../query"; import { useQueryContext } from "../../query-context"; import { useSiteContext } from "../../site-context"; +import { SortDirection } from "../../hooks/use-order-by"; const VIEWS = { sources: { @@ -22,19 +23,19 @@ const VIEWS = { } }, utm_mediums: { - info: { title: 'Top UTM Mediums', dimension: 'utm_medium', endpoint: '/utm_mediums', dimensionLabel: 'UTM Medium' } + info: { title: 'Top UTM Mediums', dimension: 'utm_medium', endpoint: '/utm_mediums', dimensionLabel: 'UTM Medium', defaultOrderBy: ["visitors", SortDirection.desc] } }, utm_sources: { - info: { title: 'Top UTM Sources', dimension: 'utm_source', endpoint: '/utm_sources', dimensionLabel: 'UTM Source' } + info: { title: 'Top UTM Sources', dimension: 'utm_source', endpoint: '/utm_sources', dimensionLabel: 'UTM Source', defaultOrderBy: ["visitors", SortDirection.desc] } }, utm_campaigns: { - info: { title: 'Top UTM Campaigns', dimension: 'utm_campaign', endpoint: '/utm_campaigns', dimensionLabel: 'UTM Campaign' } + info: { title: 'Top UTM Campaigns', dimension: 'utm_campaign', endpoint: '/utm_campaigns', dimensionLabel: 'UTM Campaign', defaultOrderBy: ["visitors", SortDirection.desc] } }, utm_contents: { - info: { title: 'Top UTM Contents', dimension: 'utm_content', endpoint: '/utm_contents', dimensionLabel: 'UTM Content' } + info: { title: 'Top UTM Contents', dimension: 'utm_content', endpoint: '/utm_contents', dimensionLabel: 'UTM Content', defaultOrderBy: ["visitors", SortDirection.desc] } }, utm_terms: { - info: { title: 'Top UTM Terms', dimension: 'utm_term', endpoint: '/utm_terms', dimensionLabel: 'UTM Term' } + info: { title: 'Top UTM Terms', dimension: 'utm_term', endpoint: '/utm_terms', dimensionLabel: 'UTM Term', defaultOrderBy: ["visitors", SortDirection.desc] } }, } @@ -60,14 +61,14 @@ function SourcesModal({ currentView }) { if (hasGoalFilter(query)) { return [ metrics.createTotalVisitors(), - metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }), + metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }), metrics.createConversionRate() ] } if (isRealTimeDashboard(query)) { return [ - metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' }) + metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', width: 'w-36' }) ] } diff --git a/assets/js/dashboard/stats/pages/index.js b/assets/js/dashboard/stats/pages/index.js index 79394a427..3723087f6 100644 --- a/assets/js/dashboard/stats/pages/index.js +++ b/assets/js/dashboard/stats/pages/index.js @@ -31,7 +31,7 @@ function EntryPages({ afterFetchData }) { function chooseMetrics() { return [ - metrics.createVisitors({ defaultLabel: 'Unique Entrances', meta: { plot: true } }), + metrics.createVisitors({ defaultLabel: 'Unique Entrances', width: 'w-36', meta: { plot: true } }), hasGoalFilter(query) && metrics.createConversionRate(), ].filter(metric => !!metric) } @@ -70,7 +70,7 @@ function ExitPages({ afterFetchData }) { function chooseMetrics() { return [ - metrics.createVisitors({ defaultLabel: 'Unique Exits', meta: { plot: true } }), + metrics.createVisitors({ defaultLabel: 'Unique Exits', width: 'w-36', meta: { plot: true } }), hasGoalFilter(query) && metrics.createConversionRate(), ].filter(metric => !!metric) } diff --git a/assets/js/dashboard/stats/reports/list.js b/assets/js/dashboard/stats/reports/list.js index 38aacb599..e55113187 100644 --- a/assets/js/dashboard/stats/reports/list.js +++ b/assets/js/dashboard/stats/reports/list.js @@ -170,7 +170,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI function renderReport() { if (state.list && state.list.length > 0) { return ( -
+
{renderReportHeader()}
@@ -191,7 +191,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI return (
{metric.renderLabel(query)} @@ -200,7 +200,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI }) return ( -
+
{keyLabel} {metricLabels}
@@ -218,7 +218,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI function renderRow(listItem) { return (
-
+
{renderBarFor(listItem)} {renderMetricValuesFor(listItem)}
@@ -268,10 +268,10 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI return (
- + {metric.renderValue(listItem[metric.key])}
diff --git a/assets/js/dashboard/stats/reports/metrics.js b/assets/js/dashboard/stats/reports/metrics.js index 187483c09..dddd19d25 100644 --- a/assets/js/dashboard/stats/reports/metrics.js +++ b/assets/js/dashboard/stats/reports/metrics.js @@ -52,7 +52,8 @@ export class Metric { this.renderValue = props.renderValue this.renderLabel = props.renderLabel this.meta = props.meta || {} - this.sortable = props.sortable ?? true + this.sortable = props.sortable + this.width = props.width ?? 'w-24' } } @@ -88,77 +89,77 @@ export const createVisitors = (props) => { } } - return new Metric({...props, key: "visitors", renderValue, renderLabel}) + return new Metric({width: 'w-24', sortable: true, ...props, key: "visitors", renderValue, renderLabel}) } export const createConversionRate = (props) => { const renderValue = percentageFormatter const renderLabel = (_query) => "CR" - return new Metric({...props, key: "conversion_rate", renderLabel, renderValue, sortable: false}) + return new Metric({width: 'w-16', ...props, key: "conversion_rate", renderLabel, renderValue, sortable: false}) } export const createPercentage = (props) => { const renderValue = (value) => value const renderLabel = (_query) => "%" - return new Metric({...props, key: "percentage", renderLabel, renderValue}) + return new Metric({width: 'w-16', ...props, key: "percentage", renderLabel, renderValue, sortable: true}) } export const createEvents = (props) => { const renderValue = typeof props.renderValue === 'function' ? props.renderValue : renderNumberWithTooltip - return new Metric({...props, key: "events", renderValue: renderValue}) + return new Metric({width: 'w-24', ...props, key: "events", renderValue: renderValue, sortable: true}) } export const createTotalRevenue = (props) => { const renderValue = (value) => const renderLabel = (_query) => "Revenue" - return new Metric({...props, key: "total_revenue", renderValue, renderLabel}) + return new Metric({width: 'w-16', ...props, key: "total_revenue", renderValue, renderLabel, sortable: true}) } export const createAverageRevenue = (props) => { const renderValue = (value) => const renderLabel = (_query) => "Average" - return new Metric({...props, key: "average_revenue", renderValue, renderLabel}) + return new Metric({width: 'w-24', ...props, key: "average_revenue", renderValue, renderLabel, sortable: true}) } export const createTotalVisitors = (props) => { const renderValue = renderNumberWithTooltip const renderLabel = (_query) => "Total Visitors" - return new Metric({...props, key: "total_visitors", renderValue, renderLabel, sortable: false }) + return new Metric({width: 'w-32', ...props, key: "total_visitors", renderValue, renderLabel, sortable: false}) } export const createVisits = (props) => { const renderValue = renderNumberWithTooltip - return new Metric({...props, key: "visits", renderValue}) + return new Metric({width: 'w-24', sortable: true, ...props, key: "visits", renderValue }) } export const createVisitDuration = (props) => { const renderValue = durationFormatter const renderLabel = (_query) => "Visit Duration" - return new Metric({...props, key: "visit_duration", renderValue, renderLabel}) + return new Metric({width: 'w-36', ...props, key: "visit_duration", renderValue, renderLabel, sortable: true}) } export const createBounceRate = (props) => { const renderValue = (value) => `${value}%` const renderLabel = (_query) => "Bounce Rate" - return new Metric({...props, key: "bounce_rate", renderValue, renderLabel}) + return new Metric({width: 'w-36', ...props, key: "bounce_rate", renderValue, renderLabel, sortable: true}) } export const createPageviews = (props) => { const renderValue = renderNumberWithTooltip const renderLabel = (_query) => "Pageviews" - return new Metric({...props, key: "pageviews", renderValue, renderLabel}) + return new Metric({width: 'w-28', ...props, key: "pageviews", renderValue, renderLabel, sortable: true}) } export const createTimeOnPage = (props) => { const renderValue = durationFormatter const renderLabel = (_query) => "Time on Page" - return new Metric({...props, key: "time_on_page", renderValue, renderLabel, sortable: false}) + return new Metric({width: 'w-32', ...props, key: "time_on_page", renderValue, renderLabel, sortable: false}) } export const createExitRate = (props) => { const renderValue = percentageFormatter const renderLabel = (_query) => "Exit Rate" - return new Metric({...props, key: "exit_rate", renderValue, renderLabel, sortable: false}) + return new Metric({width: 'w-28', ...props, key: "exit_rate", renderValue, renderLabel, sortable: false}) } export function renderNumberWithTooltip(value) {