set up hydration boundary for metric

This commit is contained in:
Nate Kelley 2025-04-04 11:03:41 -06:00
parent a810e6261a
commit 8609748338
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
14 changed files with 927 additions and 18 deletions

34
web/package-lock.json generated
View File

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

View File

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

View File

@ -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 = <TData = IBusterMetric>(
},
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 = <TData = IBusterMetric>(
return updatedMetric;
});
return useQuery({
const result = useQuery({
...options,
queryFn,
select,
@ -80,6 +81,8 @@ export const useGetMetric = <TData = IBusterMetric>(
return false;
}
});
return result;
};
export const useGetMetricsList = (

View File

@ -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 (
<HydrationBoundary state={dehydrate(queryClient)}>
<AppAssetCheckLayout assetId={metricId} type="metric">
{children}
</AppAssetCheckLayout>
<HydrationBoundaryMetricStore metric={metric}>
<AppAssetCheckLayout assetId={metricId} type="metric">
{children}
</AppAssetCheckLayout>
</HydrationBoundaryMetricStore>
</HydrationBoundary>
);
}

View File

@ -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<string, IColumnLabelFormat>,
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
})
);
});
});
});

View File

@ -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<string, IColumnLabelFormat>,
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');
});
});

View File

@ -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<string, IColumnLabelFormat>,
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('');
});
});

View File

@ -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<string, IColumnLabelFormat>,
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');
});
});

View File

@ -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<{
<AnimatePresence initial={!isCompletedStream}>
<motion.div id={id} {...itemAnimationConfig}>
<FileCard
className={cn(isSelectedFile && 'border-foreground shadow-md')}
fileName={<TextAndVersionPill fileName={file_name} versionNumber={version_number} />}>
<StreamingMessageBody metadata={metadata} />
</FileCard>

View File

@ -5,8 +5,8 @@ import { Text } from './Text';
export const TextAndVersionPill = React.memo(
({ fileName, versionNumber }: { fileName: string; versionNumber: number }) => {
return (
<div className="flex items-center gap-1.5">
<Text>{fileName}</Text>
<div className="flex w-full items-center gap-1.5">
<Text truncate>{fileName}</Text>
{versionNumber !== undefined && <VersionPill version_number={versionNumber} />}
</div>
);

View File

@ -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();

View File

@ -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<string, IBusterMetric>;
bulkAddOriginalMetrics: (metrics: Record<string, IBusterMetric>) => void;
setOriginalMetric: (metric: IBusterMetric) => void;
getOriginalMetric: (metricId: string) => IBusterMetric | undefined;
removeOriginalMetric: (metricId: string) => void;
}>((set, get) => ({
};
export const useOriginalMetricStore = create<OriginalMetricStore>((set, get) => ({
originalMetrics: {},
bulkAddOriginalMetrics: (metrics: Record<string, IBusterMetric>) =>
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}</>;
};

View File

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

View File

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