diff --git a/assets/js/dashboard/hooks/api-client.js b/assets/js/dashboard/hooks/api-client.js new file mode 100644 index 000000000..9e3c0fd08 --- /dev/null +++ b/assets/js/dashboard/hooks/api-client.js @@ -0,0 +1,82 @@ +import { useEffect } from "react" +import { useQueryClient, useInfiniteQuery } from "@tanstack/react-query" +import * as api from "../api" + +const LIMIT = 100 + +/** + * 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 initialPageParam = 1 + + return useInfiniteQuery({ + queryKey: key, + queryFn, + getNextPageParam, + initialPageParam + }) +} \ No newline at end of file diff --git a/assets/js/dashboard/router.js b/assets/js/dashboard/router.js index d7d5fbb58..336e4525b 100644 --- a/assets/js/dashboard/router.js +++ b/assets/js/dashboard/router.js @@ -15,6 +15,19 @@ import FilterModal from './stats/modals/filter-modal' import QueryContextProvider from './query-context'; import { useSiteContext } from './site-context'; +import { + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false + } + } +}) + function ScrollToTop() { const location = useLocation(); @@ -30,45 +43,47 @@ function ScrollToTop() { export default function Router() { const site = useSiteContext() return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.js b/assets/js/dashboard/stats/modals/breakdown-modal.js index 138c1a9a3..a938e0e14 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.js +++ b/assets/js/dashboard/stats/modals/breakdown-modal.js @@ -1,13 +1,11 @@ -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useEffect, useRef } from "react"; -import * as api from '../../api' -import { useMountedEffect, useDebounce } from '../../custom-hooks' import { trimURL } from '../../util/url' import { FilterLink } from "../reports/list"; import { useQueryContext } from "../../query-context"; import { useSiteContext } from "../../site-context"; - -const LIMIT = 100 +import { useDebounce } from "../../custom-hooks"; +import { useAPIClient } from "../../hooks/api-client"; const MIN_HEIGHT_PX = 500 // The main function component for rendering the "Details" reports on the dashboard, @@ -39,7 +37,8 @@ const MIN_HEIGHT_PX = 500 // * `title` - the title of the report to render on the top left -// * `endpoint` - the last part of the endpoint (e.g. "/sources") to query +// * `endpoint` - the full pathname of the API endpoint to query. E.g. +// `api/stats/plausible.io/sources` // * `dimensionLabel` - a string to render as the dimension column header. @@ -84,24 +83,36 @@ export default function BreakdownModal({ addSearchFilter, getFilterInfo }) { - const {query} = useQueryContext(); + const searchBoxRef = useRef(null) + const { query } = useQueryContext(); const site = useSiteContext(); - const endpoint = `/api/stats/${encodeURIComponent(site.domain)}${reportInfo.endpoint}` - - const [initialLoading, setInitialLoading] = useState(true) - const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') - const [results, setResults] = useState([]) - const [page, setPage] = useState(1) - const [moreResultsAvailable, setMoreResultsAvailable] = useState(false) - const searchBoxRef = useRef(null) - useEffect(() => { fetchData() }, []) + const { + data, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + isFetching, + isPending + } = useAPIClient({ + key: [reportInfo.endpoint, {query, search}], + getRequestParams: (key) => { + const [_endpoint, {query, search}] = key + + let queryWithSearchFilter = {...query} - useMountedEffect(() => { debouncedFetchData() }, [search]) + if (searchEnabled && search !== '') { + queryWithSearchFilter = addSearchFilter(query, search) + } + + return [queryWithSearchFilter, {detailed: true}] + }, + afterFetchData, + afterFetchNextPage + }) - useMountedEffect(() => { fetchNextPage() }, [page]) useEffect(() => { if (!searchEnabled) { return } @@ -120,54 +131,7 @@ export default function BreakdownModal({ return () => { searchBox.removeEventListener('keyup', handleKeyUp); } - }, []) - - const fetchData = useCallback(() => { - setLoading(true) - - api.get(endpoint, withSearch(query), { limit: LIMIT, page: 1, detailed: true }) - .then((response) => { - if (typeof afterFetchData === 'function') { - afterFetchData(response) - } - setInitialLoading(false) - setLoading(false) - setPage(1) - setResults(response.results) - setMoreResultsAvailable(response.results.length === LIMIT) - }) - }, [search]) - - const debouncedFetchData = useDebounce(fetchData) - - function fetchNextPage() { - setLoading(true) - - if (page > 1) { - api.get(endpoint, withSearch(query), { limit: LIMIT, page, detailed: true }) - .then((response) => { - if (typeof afterFetchNextPage === 'function') { - afterFetchNextPage(response) - } - setLoading(false) - setResults(results.concat(response.results)) - setMoreResultsAvailable(response.results.length === LIMIT) - }) - } - } - - function withSearch(query) { - if (searchEnabled && search !== '') { - return addSearchFilter(query, search) - } - - return query - } - - function loadNextPage() { - setLoading(true) - setPage(page + 1) - } + }, [searchEnabled]) function maybeRenderIcon(item) { if (typeof renderIcon === 'function') { @@ -178,8 +142,8 @@ export default function BreakdownModal({ function maybeRenderExternalLink(item) { if (typeof getExternalLinkURL === 'function') { const linkUrl = getExternalLinkURL(item) - - if (!linkUrl) { return null} + + if (!linkUrl) { return null } return ( @@ -193,14 +157,14 @@ export default function BreakdownModal({ return ( - { maybeRenderIcon(item) } + {maybeRenderIcon(item)} {trimURL(item.name, 40)} - { maybeRenderExternalLink(item) } + {maybeRenderExternalLink(item)} {metrics.map((metric) => { return ( @@ -215,7 +179,7 @@ export default function BreakdownModal({ function renderInitialLoadingSpinner() { return ( -
+
) @@ -228,11 +192,13 @@ export default function BreakdownModal({ } function renderLoadMoreButton() { + if (isPending) return null + if (!isFetching && !hasNextPage) return null + return ( -
- +
+ {!isFetching && } + {isFetchingNextPage && renderSmallLoadingSpinner()}
) } @@ -241,6 +207,8 @@ export default function BreakdownModal({ setSearch(e.target.value) } + const debouncedHandleInputChange = useDebounce(handleInputChange) + function renderSearchInput() { return ( ) } function renderModalBody() { - if (results) { + if (data?.pages?.length) { return (
@@ -277,7 +245,7 @@ export default function BreakdownModal({ - { results.map(renderRow) } + {data.pages.map((p) => p.map(renderRow))}
@@ -289,16 +257,16 @@ export default function BreakdownModal({
-

{ reportInfo.title }

- { !initialLoading && loading && renderSmallLoadingSpinner() } +

{reportInfo.title}

+ {!isPending && isFetching && renderSmallLoadingSpinner()}
- { searchEnabled && renderSearchInput()} + {searchEnabled && renderSearchInput()}
-
- { initialLoading && renderInitialLoadingSpinner() } - { !initialLoading && renderModalBody() } - { !loading && moreResultsAvailable && renderLoadMoreButton() } +
+ {isPending && renderInitialLoadingSpinner()} + {!isPending && renderModalBody()} + {renderLoadMoreButton()}
) diff --git a/assets/js/dashboard/stats/modals/conversions.js b/assets/js/dashboard/stats/modals/conversions.js index 45e62bd0c..92aa5cd9d 100644 --- a/assets/js/dashboard/stats/modals/conversions.js +++ b/assets/js/dashboard/stats/modals/conversions.js @@ -3,15 +3,18 @@ import React, { useCallback, useState } from "react"; import Modal from './modal' import BreakdownModal from "./breakdown-modal"; import * as metrics from "../reports/metrics"; +import * as url from '../../util/url'; +import { useSiteContext } from "../../site-context"; /*global BUILD_EXTRA*/ function ConversionsModal() { const [showRevenue, setShowRevenue] = useState(false) + const site = useSiteContext(); const reportInfo = { title: 'Goal Conversions', dimension: 'goal', - endpoint: '/conversions', + endpoint: url.apiPath(site, '/conversions'), dimensionLabel: "Goal" } @@ -20,7 +23,7 @@ function ConversionsModal() { prefix: reportInfo.dimension, filter: ["is", reportInfo.dimension, [listItem.name]] } - }, []) + }, [reportInfo.dimension]) function chooseMetrics() { return [ @@ -37,7 +40,7 @@ function ConversionsModal() { // whether revenue metrics are passed into BreakdownModal in `metrics`. const afterFetchData = useCallback((res) => { setShowRevenue(revenueInResponse(res)) - }, [showRevenue]) + }, []) // After fetching the next page, we never want to set `showRevenue` to // `false` as revenue metrics might exist in previously loaded data. diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js index 5c6df5a48..f1d3068b3 100644 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ b/assets/js/dashboard/stats/modals/entry-pages.js @@ -4,15 +4,18 @@ import { hasGoalFilter, isRealTimeDashboard } from "../../util/filters"; import { addFilter } from '../../query' import BreakdownModal from "./breakdown-modal"; import * as metrics from '../reports/metrics' +import * as url from '../../util/url'; import { useQueryContext } from "../../query-context"; +import { useSiteContext } from "../../site-context"; function EntryPagesModal() { const { query } = useQueryContext(); + const site = useSiteContext(); const reportInfo = { title: 'Entry Pages', dimension: 'entry_page', - endpoint: '/entry-pages', + endpoint: url.apiPath(site, '/entry-pages'), dimensionLabel: 'Entry page' } @@ -21,11 +24,11 @@ function EntryPagesModal() { prefix: reportInfo.dimension, filter: ["is", reportInfo.dimension, [listItem.name]] } - }, []) + }, [reportInfo.dimension]) const addSearchFilter = useCallback((query, searchString) => { return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) - }, []) + }, [reportInfo.dimension]) function chooseMetrics() { if (hasGoalFilter(query)) { diff --git a/assets/js/dashboard/stats/modals/exit-pages.js b/assets/js/dashboard/stats/modals/exit-pages.js index 1a4b97ca0..bd4bd2e5b 100644 --- a/assets/js/dashboard/stats/modals/exit-pages.js +++ b/assets/js/dashboard/stats/modals/exit-pages.js @@ -4,15 +4,18 @@ import { hasGoalFilter } from "../../util/filters"; import { addFilter } from '../../query' import BreakdownModal from "./breakdown-modal"; import * as metrics from '../reports/metrics' +import * as url from '../../util/url'; import { useQueryContext } from "../../query-context"; +import { useSiteContext } from "../../site-context"; function ExitPagesModal() { const { query } = useQueryContext(); + const site = useSiteContext(); const reportInfo = { title: 'Exit Pages', dimension: 'exit_page', - endpoint: '/exit-pages', + endpoint: url.apiPath(site, '/exit-pages'), dimensionLabel: 'Page url' } @@ -21,11 +24,11 @@ function ExitPagesModal() { prefix: reportInfo.dimension, filter: ["is", reportInfo.dimension, [listItem.name]] } - }, []) + }, [reportInfo.dimension]) const addSearchFilter = useCallback((query, searchString) => { return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) - }, []) + }, [reportInfo.dimension]) function chooseMetrics() { if (hasGoalFilter(query)) { diff --git a/assets/js/dashboard/stats/modals/locations-modal.js b/assets/js/dashboard/stats/modals/locations-modal.js index d13e17f4a..75373924c 100644 --- a/assets/js/dashboard/stats/modals/locations-modal.js +++ b/assets/js/dashboard/stats/modals/locations-modal.js @@ -5,7 +5,9 @@ import Modal from './modal' import { hasGoalFilter } from "../../util/filters"; import BreakdownModal from "./breakdown-modal"; import * as metrics from "../reports/metrics"; +import * as url from '../../util/url'; import { useQueryContext } from "../../query-context"; +import { useSiteContext } from "../../site-context"; const VIEWS = { countries: { title: 'Top Countries', dimension: 'country', endpoint: '/countries', dimensionLabel: 'Country' }, @@ -15,18 +17,20 @@ const VIEWS = { function LocationsModal({ location }) { const { query } = useQueryContext(); + const site = useSiteContext(); const urlParts = location.pathname.split('/') const currentView = urlParts[urlParts.length - 1] - const reportInfo = VIEWS[currentView] + let reportInfo = VIEWS[currentView] + reportInfo.endpoint = url.apiPath(site, reportInfo.endpoint) const getFilterInfo = useCallback((listItem) => { return { prefix: reportInfo.dimension, filter: ["is", reportInfo.dimension, [listItem.code]] } - }, []) + }, [reportInfo.dimension]) function chooseMetrics() { if (hasGoalFilter(query)) { diff --git a/assets/js/dashboard/stats/modals/pages.js b/assets/js/dashboard/stats/modals/pages.js index 9b62d0664..5d59e0d07 100644 --- a/assets/js/dashboard/stats/modals/pages.js +++ b/assets/js/dashboard/stats/modals/pages.js @@ -4,15 +4,18 @@ import { hasGoalFilter, isRealTimeDashboard } from "../../util/filters"; import { addFilter } from '../../query' import BreakdownModal from "./breakdown-modal"; import * as metrics from '../reports/metrics' +import * as url from '../../util/url'; import { useQueryContext } from "../../query-context"; +import { useSiteContext } from "../../site-context"; function PagesModal() { const { query } = useQueryContext(); + const site = useSiteContext(); const reportInfo = { title: 'Top Pages', dimension: 'page', - endpoint: '/pages', + endpoint: url.apiPath(site, '/pages'), dimensionLabel: 'Page url' } @@ -21,11 +24,11 @@ function PagesModal() { prefix: reportInfo.dimension, filter: ["is", reportInfo.dimension, [listItem.name]] } - }, []) + }, [reportInfo.dimension]) const addSearchFilter = useCallback((query, searchString) => { return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) - }, []) + }, [reportInfo.dimension]) function chooseMetrics() { if (hasGoalFilter(query)) { diff --git a/assets/js/dashboard/stats/modals/props.js b/assets/js/dashboard/stats/modals/props.js index 339d5faeb..cafa9e200 100644 --- a/assets/js/dashboard/stats/modals/props.js +++ b/assets/js/dashboard/stats/modals/props.js @@ -7,6 +7,7 @@ import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions"; import { EVENT_PROPS_PREFIX, hasGoalFilter } from "../../util/filters" import BreakdownModal from "./breakdown-modal"; import * as metrics from "../reports/metrics"; +import * as url from "../../util/url"; import { revenueAvailable } from "../../query"; import { useQueryContext } from "../../query-context"; import { useSiteContext } from "../../site-context"; @@ -22,7 +23,7 @@ function PropsModal({ location }) { const reportInfo = { title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'), dimension: propKey, - endpoint: `/custom-prop-values/${propKey}`, + endpoint: url.apiPath(site, `/custom-prop-values/${propKey}`), dimensionLabel: propKey } @@ -31,11 +32,11 @@ function PropsModal({ location }) { prefix: `${EVENT_PROPS_PREFIX}${propKey}`, filter: ["is", `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]] } - }, []) + }, [propKey]) const addSearchFilter = useCallback((query, searchString) => { return addFilter(query, ['contains', `${EVENT_PROPS_PREFIX}${propKey}`, [searchString]]) - }, []) + }, [propKey]) function chooseMetrics() { return [ diff --git a/assets/js/dashboard/stats/modals/referrer-drilldown.js b/assets/js/dashboard/stats/modals/referrer-drilldown.js index 977ca7fab..9bfb52fd9 100644 --- a/assets/js/dashboard/stats/modals/referrer-drilldown.js +++ b/assets/js/dashboard/stats/modals/referrer-drilldown.js @@ -5,16 +5,19 @@ import Modal from './modal' import { hasGoalFilter, isRealTimeDashboard } from "../../util/filters"; import BreakdownModal from "./breakdown-modal"; import * as metrics from "../reports/metrics"; +import * as url from "../../util/url"; import { addFilter } from "../../query"; import { useQueryContext } from "../../query-context"; +import { useSiteContext } from "../../site-context"; function ReferrerDrilldownModal({ match }) { const { query } = useQueryContext(); + const site = useSiteContext(); const reportInfo = { title: "Referrer Drilldown", dimension: 'referrer', - endpoint: `/referrers/${match.params.referrer}`, + endpoint: url.apiPath(site, `/referrers/${match.params.referrer}`), dimensionLabel: "Referrer" } @@ -23,11 +26,11 @@ function ReferrerDrilldownModal({ match }) { prefix: reportInfo.dimension, filter: ['is', reportInfo.dimension, [listItem.name]] } - }, []) + }, [reportInfo.dimension]) const addSearchFilter = useCallback((query, searchString) => { return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) - }, []) + }, [reportInfo.dimension]) function chooseMetrics() { if (hasGoalFilter(query)) { diff --git a/assets/js/dashboard/stats/modals/sources.js b/assets/js/dashboard/stats/modals/sources.js index ee6fdf173..85b4f1bce 100644 --- a/assets/js/dashboard/stats/modals/sources.js +++ b/assets/js/dashboard/stats/modals/sources.js @@ -5,8 +5,10 @@ import Modal from './modal' import { hasGoalFilter, isRealTimeDashboard } from "../../util/filters"; import BreakdownModal from "./breakdown-modal"; import * as metrics from "../reports/metrics"; +import * as url from "../../util/url"; import { addFilter } from "../../query"; import { useQueryContext } from "../../query-context"; +import { useSiteContext } from "../../site-context"; const VIEWS = { sources: { @@ -39,22 +41,24 @@ const VIEWS = { function SourcesModal({ location }) { const { query } = useQueryContext(); + const site = useSiteContext(); const urlParts = location.pathname.split('/') const currentView = urlParts[urlParts.length - 1] - const reportInfo = VIEWS[currentView].info + let reportInfo = VIEWS[currentView].info + reportInfo.endpoint = url.apiPath(site, reportInfo.endpoint) const getFilterInfo = useCallback((listItem) => { return { prefix: reportInfo.dimension, filter: ["is", reportInfo.dimension, [listItem.name]] } - }, []) + }, [reportInfo.dimension]) const addSearchFilter = useCallback((query, searchString) => { return addFilter(query, ['contains', reportInfo.dimension, [searchString]]) - }, []) + }, [reportInfo.dimension]) function chooseMetrics() { if (hasGoalFilter(query)) { diff --git a/assets/package-lock.json b/assets/package-lock.json index 6eebcc2e5..70f8dea1f 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -17,6 +17,7 @@ "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.6", "@tailwindcss/typography": "^0.4.1", + "@tanstack/react-query": "^5.51.1", "abortcontroller-polyfill": "^1.7.3", "alpinejs": "^3.13.1", "chart.js": "^3.3.2", @@ -446,6 +447,30 @@ "tailwindcss": ">=2.0.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.51.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.1.tgz", + "integrity": "sha512-fJBMQMpo8/KSsWW5ratJR5+IFr7YNJ3K2kfP9l5XObYHsgfVy1w3FJUWU4FT2fj7+JMaEg33zOcNDBo0LMwHnw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.51.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.51.1.tgz", + "integrity": "sha512-s47HKFnQ4HOJAHoIiXcpna/roMMPZJPy6fJ6p4ZNVn8+/onlLBEDd1+xc8OnDuwgvecqkZD7Z2mnSRbcWefrKw==", + "dependencies": { + "@tanstack/query-core": "5.51.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/@types/d3": { "version": "3.5.38", "license": "MIT" diff --git a/assets/package.json b/assets/package.json index 536561284..b770e4592 100644 --- a/assets/package.json +++ b/assets/package.json @@ -17,6 +17,7 @@ "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.6", "@tailwindcss/typography": "^0.4.1", + "@tanstack/react-query": "^5.51.1", "abortcontroller-polyfill": "^1.7.3", "alpinejs": "^3.13.1", "chart.js": "^3.3.2",