diff --git a/web/package-lock.json b/web/package-lock.json index 24ca3a743..7859086c4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -35,6 +35,7 @@ "@tanstack/react-query": "^5.71.5", "@tanstack/react-query-devtools": "^5.71.5", "@tanstack/react-query-persist-client": "^5.71.5", + "@tanstack/react-table": "^8.21.2", "@types/jest": "^29.5.14", "@types/prettier": "^2.7.3", "@types/react-color": "^3.0.13", @@ -6960,6 +6961,26 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.2.tgz", + "integrity": "sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/@tanstack/store": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.0.tgz", @@ -6970,6 +6991,19 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/table-core": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz", + "integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", diff --git a/web/package.json b/web/package.json index 5d32769e4..4c0910467 100644 --- a/web/package.json +++ b/web/package.json @@ -43,6 +43,7 @@ "@tanstack/react-query": "^5.71.5", "@tanstack/react-query-devtools": "^5.71.5", "@tanstack/react-query-persist-client": "^5.71.5", + "@tanstack/react-table": "^8.21.2", "@types/jest": "^29.5.14", "@types/prettier": "^2.7.3", "@types/react-color": "^3.0.13", diff --git a/web/src/api/buster_rest/metrics/queryRequests.ts b/web/src/api/buster_rest/metrics/queryRequests.ts index b351d39e1..91284dbbc 100644 --- a/web/src/api/buster_rest/metrics/queryRequests.ts +++ b/web/src/api/buster_rest/metrics/queryRequests.ts @@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query'; -import { useDebounceFn, useMemoizedFn } from '@/hooks'; +import { useDebounceFn, useMemoizedFn, useWhyDidYouUpdate } from '@/hooks'; import { deleteMetrics, duplicateMetric, @@ -17,7 +17,7 @@ import { import { prepareMetricUpdateMetric, upgradeMetricToIMetric } from '@/lib/metrics'; import { metricsQueryKeys } from '@/api/query_keys/metric'; import { collectionQueryKeys } from '@/api/query_keys/collection'; -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useBusterAssetsContextSelector } from '@/context/Assets/BusterAssetsProvider'; import { useGetUserFavorites } from '../users'; import type { IBusterMetric } from '@/api/asset_interfaces/metric'; @@ -42,7 +42,8 @@ export const useGetMetric = ( }, select?: (data: IBusterMetric) => TData ) => { - const { setOriginalMetric } = useOriginalMetricStore(); + const setOriginalMetric = useOriginalMetricStore((x) => x.setOriginalMetric); + const getOriginalMetric = useOriginalMetricStore((x) => x.getOriginalMetric); const searchParams = useSearchParams(); const queryVersionNumber = searchParams.get('metric_version_number'); const getAssetPassword = useBusterAssetsContextSelector((x) => x.getAssetPassword); @@ -68,7 +69,7 @@ export const useGetMetric = ( return updatedMetric; }); - return useQuery({ + const result = useQuery({ ...options, queryFn, select, @@ -80,6 +81,8 @@ export const useGetMetric = ( return false; } }); + + return result; }; export const useGetMetricsList = ( diff --git a/web/src/app/app/(primary_layout)/(chat_experience)/chats/[chatId]/metrics/[metricId]/layout.tsx b/web/src/app/app/(primary_layout)/(chat_experience)/chats/[chatId]/metrics/[metricId]/layout.tsx index e339d9bc1..4b27b991f 100644 --- a/web/src/app/app/(primary_layout)/(chat_experience)/chats/[chatId]/metrics/[metricId]/layout.tsx +++ b/web/src/app/app/(primary_layout)/(chat_experience)/chats/[chatId]/metrics/[metricId]/layout.tsx @@ -1,4 +1,6 @@ import { prefetchGetMetric } from '@/api/buster_rest/metrics'; +import { metricsQueryKeys } from '@/api/query_keys/metric'; +import { HydrationBoundaryMetricStore } from '@/context/Metrics/useOriginalMetricStore'; import { AppAssetCheckLayout } from '@/layouts/AppAssetCheckLayout'; import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; @@ -12,13 +14,16 @@ export default async function MetricLayout({ const { metricId } = await params; const queryClient = await prefetchGetMetric({ id: metricId }); + const metric = queryClient.getQueryData(metricsQueryKeys.metricsGetMetric(metricId).queryKey); // const state = queryClient.getQueryState(queryKeys.metricsGetMetric(metricId).queryKey); return ( - - {children} - + + + {children} + + ); } diff --git a/web/src/components/ui/charts/commonHelpers/useAxisTitles.test.ts b/web/src/components/ui/charts/commonHelpers/useAxisTitles.test.ts new file mode 100644 index 000000000..92b8b6a9f --- /dev/null +++ b/web/src/components/ui/charts/commonHelpers/useAxisTitles.test.ts @@ -0,0 +1,207 @@ +import { renderHook } from '@testing-library/react'; +import { useAxisTitles } from './useAxisTitles'; +import { useXAxisTitle } from './useXAxisTitle'; +import { useYAxisTitle } from './useYAxisTitle'; +import { useY2AxisTitle } from './useY2AxisTitle'; +import { ChartType } from '@/api/asset_interfaces/metric/charts'; +import type { ChartEncodes, ComboChartAxis } from '@/api/asset_interfaces/metric/charts'; +import type { IColumnLabelFormat } from '@/api/asset_interfaces/metric/charts'; +import type { SimplifiedColumnType } from '@/api/asset_interfaces/metric'; + +// Mock the dependency hooks +jest.mock('./useXAxisTitle', () => ({ + useXAxisTitle: jest.fn() +})); + +jest.mock('./useYAxisTitle', () => ({ + useYAxisTitle: jest.fn() +})); + +jest.mock('./useY2AxisTitle', () => ({ + useY2AxisTitle: jest.fn() +})); + +describe('useAxisTitles', () => { + const defaultProps = { + selectedAxis: { + x: ['date'], + y: ['value'], + y2: ['secondValue'] + } as ComboChartAxis, + columnLabelFormats: { + date: { + columnType: 'date' as SimplifiedColumnType, + style: 'date' as const + }, + value: { + columnType: 'number' as SimplifiedColumnType, + style: 'number' as const + }, + secondValue: { + columnType: 'number' as SimplifiedColumnType, + style: 'number' as const + } + } as Record, + yAxisShowAxisTitle: true, + yAxisAxisTitle: 'Value Axis', + xAxisShowAxisTitle: true, + xAxisAxisTitle: 'Date Axis', + selectedChartType: ChartType.Line, + y2AxisShowAxisTitle: true, + y2AxisAxisTitle: 'Second Value Axis', + barLayout: 'vertical' as const + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup default mock returns + (useXAxisTitle as jest.Mock).mockReturnValue('X Axis Title'); + (useYAxisTitle as jest.Mock).mockReturnValue('Y Axis Title'); + (useY2AxisTitle as jest.Mock).mockReturnValue('Y2 Axis Title'); + }); + + it('should return the expected axis titles for vertical layout', () => { + const { result } = renderHook(() => useAxisTitles(defaultProps)); + + expect(result.current).toEqual({ + xAxisTitle: 'X Axis Title', + yAxisTitle: 'Y Axis Title', + y2AxisTitle: 'Y2 Axis Title' + }); + + // Verify the hook calls + expect(useXAxisTitle).toHaveBeenCalledWith({ + xAxis: defaultProps.selectedAxis.x, + columnLabelFormats: defaultProps.columnLabelFormats, + isSupportedChartForAxisTitles: true, + xAxisAxisTitle: defaultProps.xAxisAxisTitle, + xAxisShowAxisTitle: defaultProps.xAxisShowAxisTitle, + selectedAxis: defaultProps.selectedAxis + }); + + expect(useYAxisTitle).toHaveBeenCalledWith({ + yAxis: defaultProps.selectedAxis.y, + columnLabelFormats: defaultProps.columnLabelFormats, + isSupportedChartForAxisTitles: true, + yAxisAxisTitle: defaultProps.yAxisAxisTitle, + yAxisShowAxisTitle: defaultProps.yAxisShowAxisTitle, + selectedAxis: defaultProps.selectedAxis + }); + + expect(useY2AxisTitle).toHaveBeenCalledWith({ + y2Axis: defaultProps.selectedAxis.y2, + columnLabelFormats: defaultProps.columnLabelFormats, + isSupportedChartForAxisTitles: true, + y2AxisAxisTitle: defaultProps.y2AxisAxisTitle, + y2AxisShowAxisTitle: defaultProps.y2AxisShowAxisTitle + }); + }); + + it('should swap X and Y axis titles for horizontal bar layout', () => { + const horizontalProps = { + ...defaultProps, + barLayout: 'horizontal' as const, + selectedChartType: ChartType.Bar + }; + + const { result } = renderHook(() => useAxisTitles(horizontalProps)); + + // When horizontal, the titles should be swapped + expect(result.current).toEqual({ + xAxisTitle: 'Y Axis Title', // Swapped + yAxisTitle: 'X Axis Title', // Swapped + y2AxisTitle: 'Y2 Axis Title' + }); + }); + + it('should not swap axis titles for non-bar charts, even with horizontal layout', () => { + const nonBarProps = { + ...defaultProps, + barLayout: 'horizontal' as const, + selectedChartType: ChartType.Line // Line chart, not Bar + }; + + const { result } = renderHook(() => useAxisTitles(nonBarProps)); + + // Should not swap for non-bar charts + expect(result.current).toEqual({ + xAxisTitle: 'X Axis Title', + yAxisTitle: 'Y Axis Title', + y2AxisTitle: 'Y2 Axis Title' + }); + }); + + it('should handle empty y2 axis properly', () => { + const noY2AxisProps = { + ...defaultProps, + selectedAxis: { + x: ['date'], + y: ['value'] + } as ChartEncodes + }; + + const { result } = renderHook(() => useAxisTitles(noY2AxisProps)); + + expect(useY2AxisTitle).toHaveBeenCalledWith({ + y2Axis: [], + columnLabelFormats: defaultProps.columnLabelFormats, + isSupportedChartForAxisTitles: true, + y2AxisAxisTitle: defaultProps.y2AxisAxisTitle, + y2AxisShowAxisTitle: defaultProps.y2AxisShowAxisTitle + }); + }); + + it('should pass isSupportedChartForAxisTitles as false for unsupported chart types', () => { + const unsupportedChartProps = { + ...defaultProps, + selectedChartType: ChartType.Pie // Pie is not in the supported types list + }; + + renderHook(() => useAxisTitles(unsupportedChartProps)); + + // All hooks should be called with isSupportedChartForAxisTitles = false + expect(useXAxisTitle).toHaveBeenCalledWith( + expect.objectContaining({ + isSupportedChartForAxisTitles: false + }) + ); + + expect(useYAxisTitle).toHaveBeenCalledWith( + expect.objectContaining({ + isSupportedChartForAxisTitles: false + }) + ); + + expect(useY2AxisTitle).toHaveBeenCalledWith( + expect.objectContaining({ + isSupportedChartForAxisTitles: false + }) + ); + }); + + it('should test all supported chart types for axis titles', () => { + // Test each supported chart type + const supportedTypes = [ChartType.Bar, ChartType.Line, ChartType.Scatter, ChartType.Combo]; + + supportedTypes.forEach((chartType) => { + (useXAxisTitle as jest.Mock).mockClear(); + (useYAxisTitle as jest.Mock).mockClear(); + (useY2AxisTitle as jest.Mock).mockClear(); + + const chartTypeProps = { + ...defaultProps, + selectedChartType: chartType + }; + + renderHook(() => useAxisTitles(chartTypeProps)); + + // For each supported type, isSupportedChartForAxisTitles should be true + expect(useXAxisTitle).toHaveBeenCalledWith( + expect.objectContaining({ + isSupportedChartForAxisTitles: true + }) + ); + }); + }); +}); diff --git a/web/src/components/ui/charts/commonHelpers/useXAxisTitle.test.ts b/web/src/components/ui/charts/commonHelpers/useXAxisTitle.test.ts new file mode 100644 index 000000000..10ed0c9a5 --- /dev/null +++ b/web/src/components/ui/charts/commonHelpers/useXAxisTitle.test.ts @@ -0,0 +1,193 @@ +import { renderHook } from '@testing-library/react'; +import { useXAxisTitle } from './useXAxisTitle'; +import { AXIS_TITLE_SEPARATOR } from './axisHelper'; +import { formatLabel } from '@/lib/columnFormatter'; +import { truncateWithEllipsis } from './titleHelpers'; +import { ChartType } from '@/api/asset_interfaces/metric/charts'; +import type { ChartEncodes, IColumnLabelFormat } from '@/api/asset_interfaces/metric/charts'; +import type { SimplifiedColumnType } from '@/api/asset_interfaces/metric'; + +// Mock the dependencies +jest.mock('@/lib/columnFormatter', () => ({ + formatLabel: jest.fn() +})); + +jest.mock('./titleHelpers', () => ({ + truncateWithEllipsis: jest.fn() +})); + +describe('useXAxisTitle', () => { + const defaultProps = { + xAxis: ['date', 'category'], + columnLabelFormats: { + date: { + columnType: 'date' as SimplifiedColumnType, + style: 'date' as const + }, + category: { + columnType: 'string' as SimplifiedColumnType, + style: 'string' as const + }, + value: { + columnType: 'number' as SimplifiedColumnType, + style: 'number' as const + } + } as Record, + isSupportedChartForAxisTitles: true, + xAxisShowAxisTitle: true, + xAxisAxisTitle: '', + selectedAxis: { + x: ['date', 'category'], + y: ['value'] + } as ChartEncodes + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Default mock implementations + (formatLabel as jest.Mock).mockImplementation((value) => `formatted_${value}`); + (truncateWithEllipsis as jest.Mock).mockImplementation((text) => text); + }); + + it('should return empty string when chart type is not supported', () => { + const props = { + ...defaultProps, + isSupportedChartForAxisTitles: false + }; + + const { result } = renderHook(() => useXAxisTitle(props)); + expect(result.current).toBe(''); + expect(formatLabel).not.toHaveBeenCalled(); + expect(truncateWithEllipsis).not.toHaveBeenCalled(); + }); + + it('should return empty string when xAxisShowAxisTitle is false', () => { + const props = { + ...defaultProps, + xAxisShowAxisTitle: false + }; + + const { result } = renderHook(() => useXAxisTitle(props)); + expect(result.current).toBe(''); + expect(formatLabel).not.toHaveBeenCalled(); + expect(truncateWithEllipsis).not.toHaveBeenCalled(); + }); + + it('should return empty string when xAxisAxisTitle is empty string', () => { + // This is actually testing the default behavior since xAxisAxisTitle is + // already an empty string in defaultProps, but keeping for clarity + const { result } = renderHook(() => useXAxisTitle(defaultProps)); + + // When xAxisAxisTitle is empty, the hook should return empty string without calling formatLabel + expect(result.current).toBe(''); + expect(formatLabel).not.toHaveBeenCalled(); + expect(truncateWithEllipsis).not.toHaveBeenCalled(); + }); + + it('should use the provided xAxisAxisTitle when available', () => { + const customTitle = 'Custom X-Axis Title'; + const props = { + ...defaultProps, + xAxisAxisTitle: customTitle + }; + + (truncateWithEllipsis as jest.Mock).mockReturnValue('Truncated Custom Title'); + + const { result } = renderHook(() => useXAxisTitle(props)); + + expect(truncateWithEllipsis).toHaveBeenCalledWith(customTitle); + expect(result.current).toBe('Truncated Custom Title'); + expect(formatLabel).not.toHaveBeenCalled(); // Should not format labels when custom title is provided + }); + + it('should generate title from x-axis columns when no custom title is provided', () => { + (formatLabel as jest.Mock) + .mockReturnValueOnce('Formatted Date') + .mockReturnValueOnce('Formatted Category'); + + (truncateWithEllipsis as jest.Mock).mockReturnValue('Formatted Date | Formatted Category'); + + // Modified props to ensure formatLabel is called + const modifiedProps = { + ...defaultProps, + xAxisAxisTitle: null, // Set to null instead of empty string to ensure formatLabel is called + isSupportedChartForAxisTitles: true, + xAxisShowAxisTitle: true + }; + + const { result } = renderHook(() => useXAxisTitle(modifiedProps)); + + // Should format each x-axis column + expect(formatLabel).toHaveBeenCalledWith('date', defaultProps.columnLabelFormats['date'], true); + expect(formatLabel).toHaveBeenCalledWith( + 'category', + defaultProps.columnLabelFormats['category'], + true + ); + + // Should generate title with separator + expect(truncateWithEllipsis).toHaveBeenCalledWith('Formatted Date | Formatted Category'); + expect(result.current).toBe('Formatted Date | Formatted Category'); + }); + + it('should handle single x-axis column correctly', () => { + const singleAxisProps = { + ...defaultProps, + xAxis: ['date'], + selectedAxis: { + x: ['date'], + y: ['value'] + } as ChartEncodes, + xAxisAxisTitle: null // Set to null instead of empty string to ensure formatLabel is called + }; + + (formatLabel as jest.Mock).mockReturnValue('Formatted Date'); + (truncateWithEllipsis as jest.Mock).mockReturnValue('Formatted Date'); + + const { result } = renderHook(() => useXAxisTitle(singleAxisProps)); + + expect(formatLabel).toHaveBeenCalledWith('date', defaultProps.columnLabelFormats['date'], true); + expect(truncateWithEllipsis).toHaveBeenCalledWith('Formatted Date'); + expect(result.current).toBe('Formatted Date'); + }); + + it('should correctly memoize the result', () => { + // Modified props to ensure formatLabel is called + const modifiedProps = { + ...defaultProps, + xAxisAxisTitle: null // Set to null instead of empty string to ensure formatLabel is called + }; + + const { result, rerender } = renderHook(() => useXAxisTitle(modifiedProps)); + const initialResult = result.current; + + // Rerender with the same props + rerender(); + + // Result should be the same instance (memoized) + expect(result.current).toBe(initialResult); + + // formatLabel and truncateWithEllipsis should only be called once + expect(formatLabel).toHaveBeenCalledTimes(2); // Once for each x-axis column + }); + + it('should update when dependencies change', () => { + const { result, rerender } = renderHook((props) => useXAxisTitle(props), { + initialProps: defaultProps + }); + + // Change a dependency + const newProps = { + ...defaultProps, + xAxisAxisTitle: 'New Title' + }; + + (truncateWithEllipsis as jest.Mock).mockReturnValue('Truncated New Title'); + + rerender(newProps); + + // Result should update + expect(result.current).toBe('Truncated New Title'); + expect(truncateWithEllipsis).toHaveBeenCalledWith('New Title'); + }); +}); diff --git a/web/src/components/ui/charts/commonHelpers/useY2AxisTitle.test.ts b/web/src/components/ui/charts/commonHelpers/useY2AxisTitle.test.ts new file mode 100644 index 000000000..6219367a5 --- /dev/null +++ b/web/src/components/ui/charts/commonHelpers/useY2AxisTitle.test.ts @@ -0,0 +1,197 @@ +import { renderHook } from '@testing-library/react'; +import { useY2AxisTitle } from './useY2AxisTitle'; +import { AXIS_TITLE_SEPARATOR } from './axisHelper'; +import { formatLabel } from '@/lib/columnFormatter'; +import { truncateWithEllipsis } from './titleHelpers'; +import type { IColumnLabelFormat } from '@/api/asset_interfaces/metric/charts'; +import type { SimplifiedColumnType } from '@/api/asset_interfaces/metric'; + +// Mock the dependencies +jest.mock('@/lib/columnFormatter', () => ({ + formatLabel: jest.fn() +})); + +jest.mock('./titleHelpers', () => ({ + truncateWithEllipsis: jest.fn() +})); + +describe('useY2AxisTitle', () => { + const defaultProps = { + y2Axis: ['revenue', 'profit'], + columnLabelFormats: { + revenue: { + columnType: 'number' as SimplifiedColumnType, + style: 'currency' as const, + currency: 'USD' + }, + profit: { + columnType: 'number' as SimplifiedColumnType, + style: 'currency' as const, + currency: 'USD' + }, + count: { + columnType: 'number' as SimplifiedColumnType, + style: 'number' as const + } + } as Record, + isSupportedChartForAxisTitles: true, + y2AxisShowAxisTitle: true, + y2AxisAxisTitle: '' + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Default mock implementations + (formatLabel as jest.Mock).mockImplementation((value) => `formatted_${value}`); + (truncateWithEllipsis as jest.Mock).mockImplementation((text) => text); + }); + + it('should return empty string when chart type is not supported', () => { + const props = { + ...defaultProps, + isSupportedChartForAxisTitles: false + }; + + const { result } = renderHook(() => useY2AxisTitle(props)); + expect(result.current).toBe(''); + expect(formatLabel).not.toHaveBeenCalled(); + expect(truncateWithEllipsis).not.toHaveBeenCalled(); + }); + + it('should return empty string when y2AxisShowAxisTitle is false', () => { + const props = { + ...defaultProps, + y2AxisShowAxisTitle: false + }; + + const { result } = renderHook(() => useY2AxisTitle(props)); + expect(result.current).toBe(''); + expect(formatLabel).not.toHaveBeenCalled(); + expect(truncateWithEllipsis).not.toHaveBeenCalled(); + }); + + it('should use the provided y2AxisAxisTitle when available', () => { + const customTitle = 'Custom Y2-Axis Title'; + const props = { + ...defaultProps, + y2AxisAxisTitle: customTitle + }; + + (truncateWithEllipsis as jest.Mock).mockReturnValue('Truncated Custom Title'); + + const { result } = renderHook(() => useY2AxisTitle(props)); + + expect(truncateWithEllipsis).toHaveBeenCalledWith(customTitle); + expect(result.current).toBe('Truncated Custom Title'); + expect(formatLabel).not.toHaveBeenCalled(); // Should not format labels when custom title is provided + }); + + it('should generate title from y2-axis columns when no custom title is provided', () => { + (formatLabel as jest.Mock) + .mockReturnValueOnce('Formatted Revenue') + .mockReturnValueOnce('Formatted Profit'); + + (truncateWithEllipsis as jest.Mock).mockReturnValue('Formatted Revenue | Formatted Profit'); + + // Set y2AxisAxisTitle to null to test the fallback behavior + const props = { + ...defaultProps, + y2AxisAxisTitle: null + }; + + const { result } = renderHook(() => useY2AxisTitle(props)); + + // Should format each y2-axis column + expect(formatLabel).toHaveBeenCalledWith( + 'revenue', + defaultProps.columnLabelFormats['revenue'], + true + ); + expect(formatLabel).toHaveBeenCalledWith( + 'profit', + defaultProps.columnLabelFormats['profit'], + true + ); + + // Should generate title with separator + expect(truncateWithEllipsis).toHaveBeenCalledWith('Formatted Revenue | Formatted Profit'); + expect(result.current).toBe('Formatted Revenue | Formatted Profit'); + }); + + it('should handle single y2-axis column correctly', () => { + const singleAxisProps = { + ...defaultProps, + y2Axis: ['revenue'], + y2AxisAxisTitle: null + }; + + (formatLabel as jest.Mock).mockReturnValue('Formatted Revenue'); + (truncateWithEllipsis as jest.Mock).mockReturnValue('Formatted Revenue'); + + const { result } = renderHook(() => useY2AxisTitle(singleAxisProps)); + + expect(formatLabel).toHaveBeenCalledWith( + 'revenue', + defaultProps.columnLabelFormats['revenue'], + true + ); + expect(truncateWithEllipsis).toHaveBeenCalledWith('Formatted Revenue'); + expect(result.current).toBe('Formatted Revenue'); + }); + + it('should correctly memoize the result', () => { + // Set y2AxisAxisTitle to null to test the fallback behavior + const props = { + ...defaultProps, + y2AxisAxisTitle: null + }; + + const { result, rerender } = renderHook(() => useY2AxisTitle(props)); + const initialResult = result.current; + + // Rerender with the same props + rerender(); + + // Result should be the same instance (memoized) + expect(result.current).toBe(initialResult); + + // formatLabel and truncateWithEllipsis should only be called once per render + expect(formatLabel).toHaveBeenCalledTimes(2); // Once for each y2-axis column + }); + + it('should update when dependencies change', () => { + const { result, rerender } = renderHook((props) => useY2AxisTitle(props), { + initialProps: defaultProps + }); + + // Change a dependency + const newProps = { + ...defaultProps, + y2AxisAxisTitle: 'New Title' + }; + + (truncateWithEllipsis as jest.Mock).mockReturnValue('Truncated New Title'); + + rerender(newProps); + + // Result should update + expect(result.current).toBe('Truncated New Title'); + expect(truncateWithEllipsis).toHaveBeenCalledWith('New Title'); + }); + + it('should handle empty y2Axis array', () => { + const emptyAxisProps = { + ...defaultProps, + y2Axis: [], + y2AxisAxisTitle: null + }; + + (truncateWithEllipsis as jest.Mock).mockReturnValue(''); + + const { result } = renderHook(() => useY2AxisTitle(emptyAxisProps)); + + expect(formatLabel).not.toHaveBeenCalled(); + expect(truncateWithEllipsis).toHaveBeenCalledWith(''); + expect(result.current).toBe(''); + }); +}); diff --git a/web/src/components/ui/charts/commonHelpers/useYAxisTitle.test.ts b/web/src/components/ui/charts/commonHelpers/useYAxisTitle.test.ts new file mode 100644 index 000000000..506b5c220 --- /dev/null +++ b/web/src/components/ui/charts/commonHelpers/useYAxisTitle.test.ts @@ -0,0 +1,246 @@ +import { renderHook } from '@testing-library/react'; +import { useYAxisTitle } from './useYAxisTitle'; +import { AXIS_TITLE_SEPARATOR } from './axisHelper'; +import { formatLabel } from '@/lib/columnFormatter'; +import { truncateWithEllipsis } from './titleHelpers'; +import type { ChartEncodes, IColumnLabelFormat } from '@/api/asset_interfaces/metric/charts'; +import type { SimplifiedColumnType } from '@/api/asset_interfaces/metric'; + +// Mock the dependencies +jest.mock('@/lib/columnFormatter', () => ({ + formatLabel: jest.fn() +})); + +jest.mock('./titleHelpers', () => ({ + truncateWithEllipsis: jest.fn() +})); + +describe('useYAxisTitle', () => { + const defaultProps = { + yAxis: ['value', 'count'], + columnLabelFormats: { + date: { + columnType: 'date' as SimplifiedColumnType, + style: 'date' as const + }, + category: { + columnType: 'string' as SimplifiedColumnType, + style: 'string' as const + }, + value: { + columnType: 'number' as SimplifiedColumnType, + style: 'number' as const + }, + count: { + columnType: 'number' as SimplifiedColumnType, + style: 'number' as const + } + } as Record, + isSupportedChartForAxisTitles: true, + yAxisShowAxisTitle: true, + yAxisAxisTitle: '', + selectedAxis: { + x: ['date', 'category'], + y: ['value', 'count'] + } as ChartEncodes + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Default mock implementations + (formatLabel as jest.Mock).mockImplementation((value) => `formatted_${value}`); + (truncateWithEllipsis as jest.Mock).mockImplementation((text) => text); + }); + + it('should return empty string when chart type is not supported', () => { + const props = { + ...defaultProps, + isSupportedChartForAxisTitles: false + }; + + const { result } = renderHook(() => useYAxisTitle(props)); + expect(result.current).toBe(''); + expect(formatLabel).not.toHaveBeenCalled(); + expect(truncateWithEllipsis).not.toHaveBeenCalled(); + }); + + it('should return empty string when yAxisShowAxisTitle is false', () => { + const props = { + ...defaultProps, + yAxisShowAxisTitle: false + }; + + const { result } = renderHook(() => useYAxisTitle(props)); + expect(result.current).toBe(''); + expect(formatLabel).not.toHaveBeenCalled(); + expect(truncateWithEllipsis).not.toHaveBeenCalled(); + }); + + it('should use the provided yAxisAxisTitle when available', () => { + const customTitle = 'Custom Y-Axis Title'; + const props = { + ...defaultProps, + yAxisAxisTitle: customTitle + }; + + (truncateWithEllipsis as jest.Mock).mockReturnValue('Truncated Custom Title'); + + const { result } = renderHook(() => useYAxisTitle(props)); + + expect(truncateWithEllipsis).toHaveBeenCalledWith(customTitle); + expect(result.current).toBe('Truncated Custom Title'); + expect(formatLabel).not.toHaveBeenCalled(); // Should not format labels when custom title is provided + }); + + it('should generate title from y-axis columns when no custom title is provided', () => { + (formatLabel as jest.Mock) + .mockReturnValueOnce('Formatted Value') + .mockReturnValueOnce('Formatted Count'); + + (truncateWithEllipsis as jest.Mock).mockReturnValue('Formatted Value | Formatted Count'); + + // Modified props to ensure formatLabel is called + const modifiedProps = { + ...defaultProps, + yAxisAxisTitle: null // Set to null instead of empty string to ensure formatLabel is called + }; + + const { result } = renderHook(() => useYAxisTitle(modifiedProps)); + + // Should format each y-axis column + expect(formatLabel).toHaveBeenCalledWith( + 'value', + defaultProps.columnLabelFormats['value'], + true + ); + expect(formatLabel).toHaveBeenCalledWith( + 'count', + defaultProps.columnLabelFormats['count'], + true + ); + + // Should generate title with separator + expect(truncateWithEllipsis).toHaveBeenCalledWith('Formatted Value | Formatted Count'); + expect(result.current).toBe('Formatted Value | Formatted Count'); + }); + + it('should handle single y-axis column correctly', () => { + const singleAxisProps = { + ...defaultProps, + yAxis: ['value'], + selectedAxis: { + x: ['date', 'category'], + y: ['value'] + } as ChartEncodes, + yAxisAxisTitle: null // Set to null instead of empty string to ensure formatLabel is called + }; + + (formatLabel as jest.Mock).mockReturnValue('Formatted Value'); + (truncateWithEllipsis as jest.Mock).mockReturnValue('Formatted Value'); + + const { result } = renderHook(() => useYAxisTitle(singleAxisProps)); + + expect(formatLabel).toHaveBeenCalledWith( + 'value', + defaultProps.columnLabelFormats['value'], + true + ); + expect(truncateWithEllipsis).toHaveBeenCalledWith('Formatted Value'); + expect(result.current).toBe('Formatted Value'); + }); + + it('should correctly memoize the result', () => { + // Modified props to ensure formatLabel is called + const modifiedProps = { + ...defaultProps, + yAxisAxisTitle: null // Set to null instead of empty string to ensure formatLabel is called + }; + + const { result, rerender } = renderHook(() => useYAxisTitle(modifiedProps)); + const initialResult = result.current; + + // Rerender with the same props + rerender(); + + // Result should be the same instance (memoized) + expect(result.current).toBe(initialResult); + + // formatLabel and truncateWithEllipsis should only be called once for each y-axis item + expect(formatLabel).toHaveBeenCalledTimes(2); + }); + + it('should update when dependencies change', () => { + const { result, rerender } = renderHook((props) => useYAxisTitle(props), { + initialProps: defaultProps + }); + + // Change a dependency + const newProps = { + ...defaultProps, + yAxisAxisTitle: 'New Title' + }; + + (truncateWithEllipsis as jest.Mock).mockReturnValue('Truncated New Title'); + + rerender(newProps); + + // Result should update + expect(result.current).toBe('Truncated New Title'); + expect(truncateWithEllipsis).toHaveBeenCalledWith('New Title'); + }); + + it('should handle empty yAxis array', () => { + const emptyAxisProps = { + ...defaultProps, + yAxis: [], + selectedAxis: { + x: ['date', 'category'], + y: [] + } as ChartEncodes, + yAxisAxisTitle: null + }; + + (truncateWithEllipsis as jest.Mock).mockReturnValue(''); + + const { result } = renderHook(() => useYAxisTitle(emptyAxisProps)); + + expect(formatLabel).not.toHaveBeenCalled(); + expect(truncateWithEllipsis).toHaveBeenCalledWith(''); + expect(result.current).toBe(''); + }); + + it('should correctly use selectedAxis.y property for formatting', () => { + // Test case where yAxis array and selectedAxis.y are different + const differentAxisProps = { + ...defaultProps, + yAxis: ['count'], // This is different from selectedAxis.y + selectedAxis: { + x: ['date'], + y: ['value', 'count'] // This should be used for formatting + } as ChartEncodes, + yAxisAxisTitle: null + }; + + (formatLabel as jest.Mock) + .mockReturnValueOnce('Formatted Value') + .mockReturnValueOnce('Formatted Count'); + + (truncateWithEllipsis as jest.Mock).mockReturnValue('Formatted Value | Formatted Count'); + + const { result } = renderHook(() => useYAxisTitle(differentAxisProps)); + + // Should use selectedAxis.y for formatting, not yAxis + expect(formatLabel).toHaveBeenCalledWith( + 'value', + defaultProps.columnLabelFormats['value'], + true + ); + expect(formatLabel).toHaveBeenCalledWith( + 'count', + defaultProps.columnLabelFormats['count'], + true + ); + expect(truncateWithEllipsis).toHaveBeenCalledWith('Formatted Value | Formatted Count'); + expect(result.current).toBe('Formatted Value | Formatted Count'); + }); +}); diff --git a/web/src/components/ui/streaming/StreamingMessage_File.tsx b/web/src/components/ui/streaming/StreamingMessage_File.tsx index 2b1d8c0b0..2c51b4ca1 100644 --- a/web/src/components/ui/streaming/StreamingMessage_File.tsx +++ b/web/src/components/ui/streaming/StreamingMessage_File.tsx @@ -9,7 +9,7 @@ import { itemAnimationConfig } from './animationConfig'; import { StatusIndicator } from '@/components/ui/indicators'; import { FileCard } from '../card/FileCard'; import { TextAndVersionPill } from '../typography/TextAndVersionPill'; - +import { cn } from '@/lib/utils'; export const StreamingMessage_File: React.FC<{ isSelectedFile: boolean; responseMessage: BusterChatResponseMessage_file; @@ -26,6 +26,7 @@ export const StreamingMessage_File: React.FC<{ }> diff --git a/web/src/components/ui/typography/TextAndVersionPill.tsx b/web/src/components/ui/typography/TextAndVersionPill.tsx index 4f9fbb1b9..43619f899 100644 --- a/web/src/components/ui/typography/TextAndVersionPill.tsx +++ b/web/src/components/ui/typography/TextAndVersionPill.tsx @@ -5,8 +5,8 @@ import { Text } from './Text'; export const TextAndVersionPill = React.memo( ({ fileName, versionNumber }: { fileName: string; versionNumber: number }) => { return ( -
- {fileName} +
+ {fileName} {versionNumber !== undefined && }
); diff --git a/web/src/context/Chats/useChatStreamMessage.ts b/web/src/context/Chats/useChatStreamMessage.ts index 6c9453438..82ab18b4b 100644 --- a/web/src/context/Chats/useChatStreamMessage.ts +++ b/web/src/context/Chats/useChatStreamMessage.ts @@ -23,6 +23,7 @@ import { } from './chatStreamMessageHelper'; import { useChatUpdate } from './useChatUpdate'; import { prefetchGetMetricDataClient } from '@/api/buster_rest/metrics'; +import { useOriginalMetricStore } from '@/context/Metrics/useOriginalMetricStore'; export const useChatStreamMessage = () => { const queryClient = useQueryClient(); diff --git a/web/src/context/Metrics/useOriginalMetricStore.ts b/web/src/context/Metrics/useOriginalMetricStore.tsx similarity index 71% rename from web/src/context/Metrics/useOriginalMetricStore.ts rename to web/src/context/Metrics/useOriginalMetricStore.tsx index d302af755..1d9ce7f1f 100644 --- a/web/src/context/Metrics/useOriginalMetricStore.ts +++ b/web/src/context/Metrics/useOriginalMetricStore.tsx @@ -1,18 +1,30 @@ +'use client'; + import type { IBusterMetric } from '@/api/asset_interfaces/metric'; import { create } from 'zustand'; import { compareObjectsByKeys } from '@/lib/objects'; import { useGetMetric } from '@/api/buster_rest/metrics/queryRequests'; -import { useMemoizedFn } from '@/hooks'; +import { useMemoizedFn, useMount } from '@/hooks'; import { useQueryClient } from '@tanstack/react-query'; import { metricsQueryKeys } from '@/api/query_keys/metric'; -export const useOriginalMetricStore = create<{ +type OriginalMetricStore = { originalMetrics: Record; + bulkAddOriginalMetrics: (metrics: Record) => void; setOriginalMetric: (metric: IBusterMetric) => void; getOriginalMetric: (metricId: string) => IBusterMetric | undefined; removeOriginalMetric: (metricId: string) => void; -}>((set, get) => ({ +}; + +export const useOriginalMetricStore = create((set, get) => ({ originalMetrics: {}, + bulkAddOriginalMetrics: (metrics: Record) => + set((prev) => ({ + originalMetrics: { + ...prev.originalMetrics, + ...metrics + } + })), setOriginalMetric: (metric: IBusterMetric) => set((state) => ({ originalMetrics: { @@ -63,3 +75,16 @@ export const useIsMetricChanged = ({ metricId }: { metricId: string }) => { ]) }; }; + +export const HydrationBoundaryMetricStore: React.FC<{ + children: React.ReactNode; + metric?: OriginalMetricStore['originalMetrics'][string]; +}> = ({ children, metric }) => { + const setOriginalMetrics = useOriginalMetricStore((x) => x.setOriginalMetric); + + useMount(() => { + if (metric) setOriginalMetrics(metric); + }); + + return <>{children}; +}; diff --git a/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx b/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx index ff5de8bac..adf57769e 100644 --- a/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx +++ b/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/MetricContainerHeaderButtons/MetricThreeDotMenu.tsx @@ -50,7 +50,6 @@ import { ShareMenuContent } from '@/components/features/ShareMenu/ShareMenuConte import { canEdit, getIsEffectiveOwner, getIsOwner } from '@/lib/share'; import { getShareAssetConfig } from '@/components/features/ShareMenu/helpers'; import { useAppLayoutContextSelector } from '@/context/BusterAppLayout'; -import { BusterRoutes, createBusterRoute } from '@/routes'; import { assetParamsToRoute } from '@/layouts/ChatLayout/ChatLayoutContext/helpers'; export const ThreeDotMenuButton = React.memo(({ metricId }: { metricId: string }) => { diff --git a/web/src/lib/columnFormatter.ts b/web/src/lib/columnFormatter.ts index a335bb196..bb1798196 100644 --- a/web/src/lib/columnFormatter.ts +++ b/web/src/lib/columnFormatter.ts @@ -14,10 +14,7 @@ const DEFAULT_DATE_FORMAT = 'll'; export const formatLabel = ( textProp: string | number | Date | null | undefined, - props: ColumnLabelFormat = { - columnType: 'text', - style: 'string' - }, + props: ColumnLabelFormat = DEFAULT_COLUMN_LABEL_FORMAT, useKeyFormatter: boolean = false ): string => { const {