Install react-query (#4361)

* WIP

* Fix issue with more than one page cached

* Make sure we don't access properties of undefined objects

* slight cleanup

* reset limit to 100

* add back the bottom loading spinner when fetching next page

* disable refetchOnWindowFocus in the global queryClient

* render bottom loading spinner only when fetching next page

* create a wrapper for react-query

* fix exhaustive deps warnings in modals

* always pass the full api path to BreakdownModal

* improve function doc

---------

Co-authored-by: Robert Joonas <robertjoonas16@gmail.com>
Co-authored-by: RobertJoonas <56999674+RobertJoonas@users.noreply.github.com>
This commit is contained in:
Artur Pata 2024-07-22 11:09:45 +03:00 committed by GitHub
parent 0310cecef8
commit 1acbbf292f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 263 additions and 148 deletions

View File

@ -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
})
}

View File

@ -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 (
<BrowserRouter basename={site.shared ? `/share/${encodeURIComponent(site.domain)}` : encodeURIComponent(site.domain)}>
<QueryContextProvider>
<Route path="/">
<ScrollToTop />
<Dashboard />
<Switch>
<Route exact path={["/sources", "/utm_mediums", "/utm_sources", "/utm_campaigns", "/utm_contents", "/utm_terms"]}>
<SourcesModal />
</Route>
<Route exact path="/referrers/Google">
<GoogleKeywordsModal site={site} />
</Route>
<Route exact path="/referrers/:referrer">
<ReferrersDrilldownModal />
</Route>
<Route path="/pages">
<PagesModal />
</Route>
<Route path="/entry-pages">
<EntryPagesModal />
</Route>
<Route path="/exit-pages">
<ExitPagesModal />
</Route>
<Route exact path={["/countries", "/regions", "/cities"]}>
<LocationsModal />
</Route>
<Route path="/custom-prop-values/:prop_key">
<PropsModal />
</Route>
<Route path="/conversions">
<ConversionsModal />
</Route>
<Route path={["/filter/:field"]}>
<FilterModal site={site} />
</Route>
</Switch>
</Route>
</QueryContextProvider>
</BrowserRouter>
<QueryClientProvider client={queryClient}>
<BrowserRouter basename={site.shared ? `/share/${encodeURIComponent(site.domain)}` : encodeURIComponent(site.domain)}>
<QueryContextProvider>
<Route path="/">
<ScrollToTop />
<Dashboard />
<Switch>
<Route exact path={["/sources", "/utm_mediums", "/utm_sources", "/utm_campaigns", "/utm_contents", "/utm_terms"]}>
<SourcesModal />
</Route>
<Route exact path="/referrers/Google">
<GoogleKeywordsModal site={site} />
</Route>
<Route exact path="/referrers/:referrer">
<ReferrersDrilldownModal />
</Route>
<Route path="/pages">
<PagesModal />
</Route>
<Route path="/entry-pages">
<EntryPagesModal />
</Route>
<Route path="/exit-pages">
<ExitPagesModal />
</Route>
<Route exact path={["/countries", "/regions", "/cities"]}>
<LocationsModal />
</Route>
<Route path="/custom-prop-values/:prop_key">
<PropsModal />
</Route>
<Route path="/conversions">
<ConversionsModal />
</Route>
<Route path={["/filter/:field"]}>
<FilterModal site={site} />
</Route>
</Switch>
</Route>
</QueryContextProvider>
</BrowserRouter>
</QueryClientProvider>
);
}

View File

@ -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 (
<a target="_blank" href={linkUrl} rel="noreferrer" className="hidden group-hover:block">
@ -193,14 +157,14 @@ export default function BreakdownModal({
return (
<tr className="text-sm dark:text-gray-200" key={item.name}>
<td className="p-2 truncate flex items-center group">
{ maybeRenderIcon(item) }
{maybeRenderIcon(item)}
<FilterLink
pathname={`/${encodeURIComponent(site.domain)}`}
filterInfo={getFilterInfo(item)}
>
{trimURL(item.name, 40)}
</FilterLink>
{ maybeRenderExternalLink(item) }
{maybeRenderExternalLink(item)}
</td>
{metrics.map((metric) => {
return (
@ -215,7 +179,7 @@ export default function BreakdownModal({
function renderInitialLoadingSpinner() {
return (
<div className="w-full h-full flex flex-col justify-center" style={{minHeight: `${MIN_HEIGHT_PX}px`}}>
<div className="w-full h-full flex flex-col justify-center" style={{ minHeight: `${MIN_HEIGHT_PX}px` }}>
<div className="mx-auto loading"><div></div></div>
</div>
)
@ -228,11 +192,13 @@ export default function BreakdownModal({
}
function renderLoadMoreButton() {
if (isPending) return null
if (!isFetching && !hasNextPage) return null
return (
<div className="w-full text-center my-4">
<button onClick={loadNextPage} type="button" className="button">
Load more
</button>
<div className="flex flex-col w-full my-4 items-center justify-center h-10">
{!isFetching && <button onClick={fetchNextPage} type="button" className="button">Load more</button>}
{isFetchingNextPage && renderSmallLoadingSpinner()}
</div>
)
}
@ -241,6 +207,8 @@ export default function BreakdownModal({
setSearch(e.target.value)
}
const debouncedHandleInputChange = useDebounce(handleInputChange)
function renderSearchInput() {
return (
<input
@ -248,13 +216,13 @@ export default function BreakdownModal({
type="text"
placeholder="Search"
className="shadow-sm dark:bg-gray-900 dark:text-gray-100 focus:ring-indigo-500 focus:border-indigo-500 block sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800 w-48"
onChange={handleInputChange}
onChange={debouncedHandleInputChange}
/>
)
}
function renderModalBody() {
if (results) {
if (data?.pages?.length) {
return (
<main className="modal__content">
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
@ -277,7 +245,7 @@ export default function BreakdownModal({
</tr>
</thead>
<tbody>
{ results.map(renderRow) }
{data.pages.map((p) => p.map(renderRow))}
</tbody>
</table>
</main>
@ -289,16 +257,16 @@ export default function BreakdownModal({
<div className="w-full h-full">
<div className="flex justify-between items-center">
<div className="flex items-center gap-x-2">
<h1 className="text-xl font-bold dark:text-gray-100">{ reportInfo.title }</h1>
{ !initialLoading && loading && renderSmallLoadingSpinner() }
<h1 className="text-xl font-bold dark:text-gray-100">{reportInfo.title}</h1>
{!isPending && isFetching && renderSmallLoadingSpinner()}
</div>
{ searchEnabled && renderSearchInput()}
{searchEnabled && renderSearchInput()}
</div>
<div className="my-4 border-b border-gray-300"></div>
<div style={{minHeight: `${MIN_HEIGHT_PX}px`}}>
{ initialLoading && renderInitialLoadingSpinner() }
{ !initialLoading && renderModalBody() }
{ !loading && moreResultsAvailable && renderLoadMoreButton() }
<div style={{ minHeight: `${MIN_HEIGHT_PX}px` }}>
{isPending && renderInitialLoadingSpinner()}
{!isPending && renderModalBody()}
{renderLoadMoreButton()}
</div>
</div>
)

View File

@ -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.

View File

@ -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)) {

View File

@ -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)) {

View File

@ -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)) {

View File

@ -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)) {

View File

@ -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 [

View File

@ -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)) {

View File

@ -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)) {

View File

@ -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"

View File

@ -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",