From ae38da2de14bfbd565ec946fc304f97122bee6ad Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Thu, 13 Mar 2025 10:46:03 -0600 Subject: [PATCH] create a hook for update charts --- .../api/buster_rest/metrics/queryRequests.ts | 47 ++++++-- .../context/Metrics/BusterMetricProvider.tsx | 2 - .../context/Metrics/useUpdateMetricChart.ts | 107 ++++++++++++++++++ web/src/hooks/useDebounce.ts | 90 +++++---------- web/src/hooks/useLatest.ts | 12 ++ 5 files changed, 182 insertions(+), 76 deletions(-) create mode 100644 web/src/context/Metrics/useUpdateMetricChart.ts create mode 100644 web/src/hooks/useLatest.ts diff --git a/web/src/api/buster_rest/metrics/queryRequests.ts b/web/src/api/buster_rest/metrics/queryRequests.ts index b324d5b50..2e535fb5f 100644 --- a/web/src/api/buster_rest/metrics/queryRequests.ts +++ b/web/src/api/buster_rest/metrics/queryRequests.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query'; -import { useMemoizedFn } from '@/hooks'; +import { useMemoizedFn, useDebounceFn } from '@/hooks'; import { deleteMetrics, getMetric, @@ -13,7 +13,7 @@ import { import type { GetMetricParams, ListMetricsParams, UpdateMetricParams } from './interfaces'; import { upgradeMetricToIMetric } from '@/lib/chat'; import { queryKeys } from '@/api/query_keys'; -import { useMemo } from 'react'; +import { useMemo, useTransition } from 'react'; import { useBusterAssetsContextSelector } from '@/context/Assets/BusterAssetsProvider'; import { resolveEmptyMetric } from '@/lib/metrics/resolve'; import { useGetUserFavorites } from '../users'; @@ -150,10 +150,13 @@ export const useSaveMetric = () => { * It will also strip out any values that are not changed from the DEFAULT_CHART_CONFIG. * It will also update the draft_session_id if it exists. */ -export const useUpdateMetric = () => { +export const useUpdateMetric = (params?: { wait?: number }) => { + const [isPending, startTransition] = useTransition(); const queryClient = useQueryClient(); const { mutateAsync: saveMetric } = useSaveMetric(); - const mutationFn = useMemoizedFn( + const waitTime = params?.wait || 0; + + const combineAndSaveMetric = useMemoizedFn( async (newMetricPartial: Partial & { id: string }) => { const metricId = newMetricPartial.id; const options = queryKeys.metricsGetMetric(metricId); @@ -163,17 +166,41 @@ export const useUpdateMetric = () => { }); if (prevMetric && newMetric) { - const changedValues = prepareMetricUpdateMetric(newMetric, prevMetric); - if (changedValues) { - return saveMetric(changedValues); - } + queryClient.setQueryData(options.queryKey, newMetric); + } + + return { newMetric, prevMetric }; + } + ); + + const mutationFn = useMemoizedFn( + async (newMetricPartial: Partial & { id: string }) => { + const { newMetric, prevMetric } = await combineAndSaveMetric(newMetricPartial); + if (newMetric && prevMetric) { + startTransition(() => { + const changedValues = prepareMetricUpdateMetric(newMetric, prevMetric); + if (changedValues) { + saveMetric(changedValues); + } + }); } return Promise.resolve(newMetric!); } ); - return useMutation({ - mutationFn + + const mutationRes = useMutation({ + mutationFn: mutationFn }); + + const { run: mutateDebounced } = useDebounceFn(mutationRes.mutateAsync, { wait: waitTime }); + + return useMemo( + () => ({ + ...mutationRes, + mutateDebounced + }), + [mutationRes, mutateDebounced] + ); }; export const useDeleteMetric = () => { diff --git a/web/src/context/Metrics/BusterMetricProvider.tsx b/web/src/context/Metrics/BusterMetricProvider.tsx index 3cac32784..ac9ccb590 100644 --- a/web/src/context/Metrics/BusterMetricProvider.tsx +++ b/web/src/context/Metrics/BusterMetricProvider.tsx @@ -16,12 +16,10 @@ import { useDebounceFn, useMemoizedFn } from '@/hooks'; import { prepareMetricUpdateMetric, resolveEmptyMetric } from '@/lib/metrics'; import { create } from 'mutative'; import { ShareRole } from '@/api/asset_interfaces/share'; -import { useUpdateMetric } from '@/api/buster_rest/metrics'; const useBusterMetrics = () => { const [isPending, startTransition] = useTransition(); const { metricId: selectedMetricId } = useParams<{ metricId: string }>(); - const { mutateAsync: updateMetricMutation } = useUpdateMetric(); const queryClient = useQueryClient(); const getMetricId = useMemoizedFn((metricId?: string): string => { diff --git a/web/src/context/Metrics/useUpdateMetricChart.ts b/web/src/context/Metrics/useUpdateMetricChart.ts new file mode 100644 index 000000000..c4ff53262 --- /dev/null +++ b/web/src/context/Metrics/useUpdateMetricChart.ts @@ -0,0 +1,107 @@ +import { + ColumnSettings, + DEFAULT_CHART_CONFIG, + IColumnLabelFormat, + type IBusterMetric, + type IBusterMetricChartConfig +} from '@/api/asset_interfaces/metric'; +import { useUpdateMetric } from '@/api/buster_rest/metrics'; +import { queryKeys } from '@/api/query_keys'; +import { useMemoizedFn } from '@/hooks'; +import { resolveEmptyMetric } from '@/lib/metrics/resolve'; +import { useQueryClient } from '@tanstack/react-query'; + +export const useUpdateMetricChart = ({ metricId }: { metricId: string }) => { + const queryClient = useQueryClient(); + const { mutateDebounced: onUpdateMetricDebounced } = useUpdateMetric({ wait: 600 }); + + const getMetricMemoized = useMemoizedFn((metricIdProp?: string): IBusterMetric => { + const options = queryKeys.metricsGetMetric(metricIdProp || metricId); + const data = queryClient.getQueryData(options.queryKey); + return resolveEmptyMetric(data, metricIdProp || metricId); + }); + + const onUpdateMetricChartConfig = useMemoizedFn( + ({ + chartConfig, + ignoreUndoRedo + }: { + chartConfig: Partial; + ignoreUndoRedo?: boolean; + }) => { + const currentMetric = getMetricMemoized(); + + if (!ignoreUndoRedo) { + // undoRedoParams.addToUndoStack({ + // metricId: editMetric.id, + // messageId: editMessage.id, + // chartConfig: editMessage.chart_config + // }); + } + + const newChartConfig: IBusterMetricChartConfig = { + ...DEFAULT_CHART_CONFIG, + ...currentMetric.chart_config, + ...chartConfig + }; + onUpdateMetricDebounced({ + id: metricId, + chart_config: newChartConfig + }); + } + ); + + const onUpdateColumnLabelFormat = useMemoizedFn( + ({ + columnId, + columnLabelFormat + }: { + columnId: string; + columnLabelFormat: Partial; + }) => { + const currentMetric = getMetricMemoized(); + const existingColumnLabelFormats = currentMetric.chart_config.columnLabelFormats; + const existingColumnLabelFormat = existingColumnLabelFormats[columnId]; + const newColumnLabelFormat = { + ...existingColumnLabelFormat, + ...columnLabelFormat + }; + const columnLabelFormats = { + ...existingColumnLabelFormats, + [columnId]: newColumnLabelFormat + }; + onUpdateMetricChartConfig({ + chartConfig: { + columnLabelFormats + } + }); + } + ); + + const onUpdateColumnSetting = useMemoizedFn( + ({ columnId, columnSetting }: { columnId: string; columnSetting: Partial }) => { + const currentMetric = getMetricMemoized(); + const existingColumnSettings = currentMetric.chart_config.columnSettings; + const existingColumnSetting = currentMetric.chart_config.columnSettings[columnId]; + const newColumnSetting: Required = { + ...existingColumnSetting, + ...columnSetting + }; + const newColumnSettings: Record> = { + ...existingColumnSettings, + [columnId]: newColumnSetting + }; + onUpdateMetricChartConfig({ + chartConfig: { + columnSettings: newColumnSettings + } + }); + } + ); + + return { + onUpdateMetricChartConfig, + onUpdateColumnLabelFormat, + onUpdateColumnSetting + }; +}; diff --git a/web/src/hooks/useDebounce.ts b/web/src/hooks/useDebounce.ts index 7de96427e..09138eeea 100644 --- a/web/src/hooks/useDebounce.ts +++ b/web/src/hooks/useDebounce.ts @@ -1,6 +1,9 @@ 'use client'; -import { useEffect, useState, useRef, useCallback } from 'react'; +import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; +import useLatest from './useLatest'; +import debounce from 'lodash/debounce'; +import { useUnmount } from './useUnmount'; interface DebounceOptions { wait?: number; @@ -8,79 +11,38 @@ interface DebounceOptions { leading?: boolean; } -export function useDebounceFn any>( - fn: T, - options: DebounceOptions = {} -) { - const { wait = 1000, maxWait, leading = false } = options; - const timeoutRef = useRef | undefined>(undefined); - const maxTimeoutRef = useRef | undefined>(undefined); - const fnRef = useRef(fn); - const argsRef = useRef | null>(null); - const lastCallTime = useRef(0); +type noop = (...args: any[]) => any; - // Update the function ref when fn changes - useEffect(() => { - fnRef.current = fn; - }, [fn]); +export function useDebounceFn(fn: T, options?: DebounceOptions) { + const fnRef = useLatest(fn); - const cancel = useCallback(() => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - if (maxTimeoutRef.current) { - clearTimeout(maxTimeoutRef.current); - } - }, []); + const wait = options?.wait ?? 1000; - const run = useCallback( - (...args: Parameters) => { - argsRef.current = args; - const now = Date.now(); - - if (leading && !timeoutRef.current) { - fnRef.current(...args); - lastCallTime.current = now; - } - - cancel(); - - timeoutRef.current = setTimeout(() => { - if (!leading && argsRef.current) { - fnRef.current(...argsRef.current); - } - lastCallTime.current = now; - }, wait); - - // Handle maxWait - if (maxWait && !maxTimeoutRef.current && !leading) { - const timeSinceLastCall = now - lastCallTime.current; - const maxWaitTimeRemaining = Math.max(0, maxWait - timeSinceLastCall); - - maxTimeoutRef.current = setTimeout(() => { - if (timeoutRef.current && argsRef.current) { - clearTimeout(timeoutRef.current); - fnRef.current(...argsRef.current); - lastCallTime.current = Date.now(); - } - maxTimeoutRef.current = undefined; - }, maxWaitTimeRemaining); - } - }, - [wait, maxWait, leading, cancel] + const debounced = useMemo( + () => + debounce( + (...args: Parameters): ReturnType => { + return fnRef.current(...args); + }, + wait, + options + ), + [] ); - // Clean up timeouts on unmount - useEffect(() => { - return () => cancel(); - }, [cancel]); + useUnmount(() => { + debounced.cancel(); + }); return { - run, - cancel + run: debounced, + cancel: debounced.cancel, + flush: debounced.flush }; } +export default useDebounceFn; + export function useDebounce(value: T, options: DebounceOptions = {}) { const { wait = 1000, maxWait } = options; const [debouncedValue, setDebouncedValue] = useState(value); diff --git a/web/src/hooks/useLatest.ts b/web/src/hooks/useLatest.ts new file mode 100644 index 000000000..afc341f19 --- /dev/null +++ b/web/src/hooks/useLatest.ts @@ -0,0 +1,12 @@ +'use client'; + +import { useRef } from 'react'; + +export function useLatest(value: T) { + const ref = useRef(value); + ref.current = value; + + return ref; +} + +export default useLatest;