objects are not doing equality very well

This commit is contained in:
Nate Kelley 2025-04-08 12:45:20 -06:00
parent 1d272b36ec
commit 634e5f0460
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
18 changed files with 808 additions and 88 deletions

View File

@ -2,7 +2,8 @@ import { DashboardLayout } from '@/layouts/DashboardLayout';
export default async function Layout({
children,
params
params,
...rest
}: {
children: React.ReactNode;
params: Promise<{ dashboardId: string }>;

View File

@ -2,10 +2,12 @@ import { AppAssetCheckLayout } from '@/layouts/AppAssetCheckLayout';
export default async function MetricLayout({
children,
params
params,
searchParams
}: {
children: React.ReactNode;
params: Promise<{ metricId: string }>;
searchParams: Promise<{ metric_version_number?: number }>;
}) {
const { metricId } = await params;

View File

@ -0,0 +1,13 @@
import { dashboardQueryKeys } from '@/api/query_keys/dashboard';
import { useMemoizedFn } from '@/hooks';
import { useQueryClient } from '@tanstack/react-query';
export const useGetDashboardMemoized = () => {
const queryClient = useQueryClient();
const getDashboardMemoized = useMemoizedFn((dashboardId: string) => {
const options = dashboardQueryKeys.dashboardGetDashboard(dashboardId);
const data = queryClient.getQueryData(options.queryKey);
return data;
});
return getDashboardMemoized;
};

View File

@ -4,6 +4,7 @@ import { useMemoizedFn } from '@/hooks';
import { metricsQueryKeys } from '@/api/query_keys/metric';
import { useGetMetric } from '@/api/buster_rest/metrics';
import { compareObjectsByKeys } from '@/lib/objects';
import { useMemo } from 'react';
export const useIsMetricChanged = ({ metricId }: { metricId: string }) => {
const queryClient = useQueryClient();
@ -29,9 +30,8 @@ export const useIsMetricChanged = ({ metricId }: { metricId: string }) => {
refetchCurrentMetric();
});
return {
onResetMetricToOriginal,
isMetricChanged:
const isMetricChanged = useMemo(
() =>
!originalMetric ||
!currentMetric ||
!compareObjectsByKeys(originalMetric, currentMetric, [
@ -39,6 +39,12 @@ export const useIsMetricChanged = ({ metricId }: { metricId: string }) => {
'description',
'chart_config',
'file'
])
]),
[originalMetric, currentMetric]
);
return {
onResetMetricToOriginal,
isMetricChanged
};
};

View File

@ -12,6 +12,7 @@ import { useDashboardContentStore } from '@/context/Dashboards';
import { ScrollArea } from '@/components/ui/scroll-area';
import { canEdit } from '@/lib/share';
import { useChatLayoutContextSelector } from '@/layouts/ChatLayout';
import { StatusCard } from '@/components/ui/card/StatusCard';
export const DashboardViewDashboardController: React.FC<{
dashboardId: string;
@ -19,7 +20,12 @@ export const DashboardViewDashboardController: React.FC<{
readOnly?: boolean;
}> = ({ dashboardId, chatId, readOnly: readOnlyProp = false }) => {
const isVersionHistoryMode = useChatLayoutContextSelector((x) => x.isVersionHistoryMode);
const { data: dashboardResponse } = useGetDashboard({ id: dashboardId });
const {
data: dashboardResponse,
isFetched,
isError,
error
} = useGetDashboard({ id: dashboardId });
const { mutateAsync: onUpdateDashboard } = useUpdateDashboard();
const { mutateAsync: onUpdateDashboardConfig } = useUpdateDashboardConfig();
const onOpenAddContentModal = useDashboardContentStore((x) => x.onOpenAddContentModal);
@ -28,6 +34,18 @@ export const DashboardViewDashboardController: React.FC<{
const dashboard = dashboardResponse?.dashboard;
const readOnly = readOnlyProp || !canEdit(dashboardResponse?.permission) || isVersionHistoryMode;
if (isError) {
return (
<div className="p-10">
<StatusCard variant="danger" title="Error" message={error?.message || ''} />
</div>
);
}
if (!isFetched) {
return <></>;
}
return (
<ScrollArea className="h-full">
<div className="flex h-full flex-col space-y-3 p-10">

View File

@ -26,10 +26,10 @@ export const ChatResponseMessage_File: React.FC<ChatResponseMessageProps> = Reac
const { file_type, id } = responseMessage;
const { isSelectedFile, isLatestVersion } = useGetIsSelectedFile({ responseMessage });
const { isSelectedFile } = useGetIsSelectedFile({ responseMessage });
const onSetSelectedFile = useChatLayoutContextSelector((x) => x.onSetSelectedFile);
const href = useGetFileHref({ isLatestVersion, responseMessage, isSelectedFile, chatId });
const href = useGetFileHref({ responseMessage, isSelectedFile, chatId });
const onLinkClick = useMemoizedFn(() => {
if (isSelectedFile) {

View File

@ -7,12 +7,10 @@ import { useMemo } from 'react';
export const useGetFileHref = ({
responseMessage,
isSelectedFile,
isLatestVersion,
chatId
}: {
responseMessage: BusterChatResponseMessage_file;
isSelectedFile: boolean;
isLatestVersion: boolean;
chatId: string;
}) => {
const { file_type, id, version_number } = responseMessage;
@ -28,36 +26,20 @@ export const useGetFileHref = ({
}
if (file_type === 'metric') {
if (isLatestVersion) {
return createBusterRoute({
route: BusterRoutes.APP_CHAT_ID_METRIC_ID_CHART,
chatId,
metricId: id
});
}
return createBusterRoute({
route: BusterRoutes.APP_CHAT_ID_METRIC_ID_VERSION_NUMBER,
chatId,
metricId: id,
versionNumber: version_number.toString()
versionNumber: version_number
});
}
if (file_type === 'dashboard') {
if (isLatestVersion) {
return createBusterRoute({
route: BusterRoutes.APP_CHAT_ID_DASHBOARD_ID,
chatId,
dashboardId: id
});
}
return createBusterRoute({
route: BusterRoutes.APP_CHAT_ID_DASHBOARD_ID_VERSION_NUMBER,
chatId,
dashboardId: id,
versionNumber: version_number.toString()
versionNumber: version_number
});
}

View File

@ -1,5 +1,6 @@
import { BusterChatResponseMessage_file } from '@/api/asset_interfaces';
import { queryKeys } from '@/api/query_keys';
import { useChatLayoutContextSelector } from '@/layouts/ChatLayout';
import { useChatIndividualContextSelector } from '@/layouts/ChatLayout/ChatContext';
import { useQueryClient } from '@tanstack/react-query';
@ -9,36 +10,30 @@ export const useGetIsSelectedFile = ({
responseMessage: Pick<BusterChatResponseMessage_file, 'file_type' | 'id' | 'version_number'>;
}): {
isSelectedFile: boolean;
isLatestVersion: boolean;
} => {
const queryClient = useQueryClient();
const isSelectedFile = useChatIndividualContextSelector(
(x) => x.selectedFileId === responseMessage.id
);
const metricVersionNumber = useChatLayoutContextSelector((x) => x.metricVersionNumber);
const dashboardVersionNumber = useChatLayoutContextSelector((x) => x.dashboardVersionNumber);
const versionNumber = responseMessage.version_number;
switch (responseMessage.file_type) {
case 'metric': {
const options = queryKeys.metricsGetMetric(responseMessage.id);
const data = queryClient.getQueryData(options.queryKey);
const lastVersion = data?.versions[data.versions.length - 1];
const isLatestVersion = lastVersion?.version_number === versionNumber;
return { isSelectedFile: isSelectedFile && isLatestVersion, isLatestVersion };
const isSelectedVersion = versionNumber.toString() === metricVersionNumber;
return { isSelectedFile: isSelectedFile && isSelectedVersion };
}
case 'dashboard': {
const options = queryKeys.dashboardGetDashboard(responseMessage.id);
const versions = queryClient.getQueryData(options.queryKey)?.versions;
const lastVersion = versions?.[versions.length - 1];
const isLatestVersion = lastVersion?.version_number === versionNumber;
return { isSelectedFile: isSelectedFile && isLatestVersion, isLatestVersion };
const isSelectedVersion = versionNumber.toString() === dashboardVersionNumber;
return { isSelectedFile: isSelectedFile && isSelectedVersion };
}
case 'reasoning': {
return { isSelectedFile: false, isLatestVersion: false };
return { isSelectedFile: false };
}
default: {
const exhaustiveCheck: never = responseMessage.file_type;
return { isSelectedFile: false, isLatestVersion: false };
return { isSelectedFile: false };
}
}
};

View File

@ -6,6 +6,7 @@ import { useGetChat } from '@/api/buster_rest/chats';
import { useQueries } from '@tanstack/react-query';
import { queryKeys } from '@/api/query_keys';
import { IBusterChatMessage } from '@/api/asset_interfaces/chat';
import { useChatLayoutContextSelector } from '..';
const useChatIndividualContext = ({
chatId,
@ -84,30 +85,22 @@ const IndividualChatContext = createContext<ReturnType<typeof useChatIndividualC
{} as ReturnType<typeof useChatIndividualContext>
);
export const ChatContextProvider = React.memo(
({
export const ChatContextProvider = React.memo(({ children }: PropsWithChildren<{}>) => {
const chatId = useChatLayoutContextSelector((x) => x.chatId);
const selectedFile = useChatLayoutContextSelector((x) => x.selectedFile);
const onSetSelectedFile = useChatLayoutContextSelector((x) => x.onSetSelectedFile);
const useChatContextValue = useChatIndividualContext({
chatId,
selectedFile,
onSetSelectedFile,
children
}: PropsWithChildren<{
chatId: string | undefined;
selectedFile: SelectedFile | null;
onSetSelectedFile: (file: SelectedFile) => void;
}>) => {
const useChatContextValue = useChatIndividualContext({
chatId,
selectedFile,
onSetSelectedFile
});
onSetSelectedFile
});
return (
<IndividualChatContext.Provider value={useChatContextValue}>
{children}
</IndividualChatContext.Provider>
);
}
);
return (
<IndividualChatContext.Provider value={useChatContextValue}>
{children}
</IndividualChatContext.Provider>
);
});
ChatContextProvider.displayName = 'ChatContextProvider';

View File

@ -4,7 +4,11 @@ import { useGetChatMessageMemoized, useGetChatMessage } from '@/api/buster_rest/
import type { SelectedFile } from '../interfaces';
import { useEffect, useRef } from 'react';
import findLast from 'lodash/findLast';
import { BusterChatResponseMessage_file } from '@/api/asset_interfaces/chat';
import { BusterChatResponseMessage_file, FileType } from '@/api/asset_interfaces/chat';
import { useMemoizedFn } from '@/hooks';
import { useChatLayoutContextSelector } from '../ChatLayoutContext';
import { BusterRoutes, createBusterRoute } from '@/routes';
import { useAppLayoutContextSelector } from '@/context/BusterAppLayout';
export const useAutoChangeLayout = ({
lastMessageId,
@ -17,17 +21,21 @@ export const useAutoChangeLayout = ({
selectedFileId: string | undefined;
chatId: string | undefined;
}) => {
const onChangePage = useAppLayoutContextSelector((x) => x.onChangePage);
const previousLastMessageId = useRef<string | null>(null);
const reasoningMessagesLength = useGetChatMessage(
lastMessageId,
(x) => x?.reasoning_message_ids?.length || 0
);
const getChatMessageMemoized = useGetChatMessageMemoized();
const getFileHref = useGetFileHref({ chatId });
const isCompletedStream = useGetChatMessage(lastMessageId, (x) => x?.isCompletedStream);
const hasReasoning = !!reasoningMessagesLength;
//change the page to reasoning file if we get a reasoning message
//change the page to the file if we get a file
useEffect(() => {
if (
!isCompletedStream &&
@ -49,9 +57,76 @@ export const useAutoChangeLayout = ({
| BusterChatResponseMessage_file
| undefined;
if (lastFile && lastFileId && selectedFileId !== lastFileId) {
onSetSelectedFile({ id: lastFileId, type: lastFile.file_type });
if (lastFileId && lastFile) {
if (selectedFileId !== lastFileId) {
}
const href = getFileHref({
fileId: lastFileId,
fileType: lastFile.file_type,
currentFile: lastFile
});
console.log(href);
if (href) {
// onChangePage(href);
onSetSelectedFile({
id: lastFileId,
type: lastFile.file_type,
versionNumber: lastFile.version_number
});
}
}
}
}, [isCompletedStream, chatId, hasReasoning, lastMessageId]);
};
const useGetFileHref = ({ chatId }: { chatId: string | undefined }) => {
const metricVersionNumber = useChatLayoutContextSelector((x) => x.metricVersionNumber);
const dashboardVersionNumber = useChatLayoutContextSelector((x) => x.dashboardVersionNumber);
const getFileHref = useMemoizedFn(
({
fileId,
fileType,
currentFile
}: {
fileId: string;
fileType: FileType;
currentFile: BusterChatResponseMessage_file | undefined;
}) => {
if (!currentFile || !chatId || metricVersionNumber || dashboardVersionNumber) return false;
if (fileType === 'metric') {
if (metricVersionNumber) return false;
return createBusterRoute({
route: BusterRoutes.APP_CHAT_ID_METRIC_ID_VERSION_NUMBER,
chatId: chatId,
metricId: fileId,
versionNumber: currentFile.version_number
});
}
if (fileType === 'dashboard') {
if (dashboardVersionNumber) return false;
return createBusterRoute({
route: BusterRoutes.APP_CHAT_ID_DASHBOARD_ID_VERSION_NUMBER,
chatId: chatId,
dashboardId: fileId,
versionNumber: currentFile.version_number
});
}
if (fileType === 'reasoning') {
return false;
}
const exhaustiveCheck: never = fileType;
return false;
}
);
return getFileHref;
};

View File

@ -7,15 +7,12 @@ import { FileContainer } from '../FileContainer';
import { ChatLayoutContextProvider, useChatLayoutContext } from '../ChatLayoutContext';
import { ChatContextProvider } from '../ChatContext/ChatContext';
import { DEFAULT_CHAT_OPTION_SIDEBAR_SIZE } from '../ChatLayoutContext/config';
import { useParams } from 'next/navigation';
interface ChatSplitterProps {
children?: React.ReactNode;
}
export const ChatLayout: React.FC<ChatSplitterProps> = ({ children }) => {
const params = useParams();
const appSplitterRef = useRef<AppSplitterRef>(null);
const chatLayoutProps = useChatLayoutContext({ appSplitterRef });
const { selectedLayout, selectedFile, onSetSelectedFile, chatId } = chatLayoutProps;
@ -28,10 +25,7 @@ export const ChatLayout: React.FC<ChatSplitterProps> = ({ children }) => {
return (
<ChatLayoutContextProvider chatLayoutProps={chatLayoutProps}>
<ChatContextProvider
chatId={chatId}
selectedFile={selectedFile}
onSetSelectedFile={onSetSelectedFile}>
<ChatContextProvider>
<AppSplitter
ref={appSplitterRef}
leftChildren={useMemo(

View File

@ -15,7 +15,6 @@ export const useGetChatParams = () => {
};
const searchParams = useSearchParams();
const currentRoute = useAppLayoutContextSelector((state) => state.currentRoute);
const metricVersionNumber = searchParams.get('metric_version_number');
const dashboardVersionNumber = searchParams.get('dashboard_version_number');

View File

@ -7,7 +7,6 @@ import { createSelectedFile } from './createSelectedFile';
import type { useGetChatParams } from '../useGetChatParams';
import type { AppSplitterRef } from '@/components/ui/layouts/AppSplitter';
import { useAppLayoutContextSelector } from '@/context/BusterAppLayout';
import { BusterRoutes, createBusterRoute } from '@/routes';
import { createChatAssetRoute } from '../helpers';
export const useSelectedFile = ({
@ -19,7 +18,7 @@ export const useSelectedFile = ({
appSplitterRef: React.RefObject<AppSplitterRef | null>;
chatParams: ReturnType<typeof useGetChatParams>;
}) => {
const { metricVersionNumber, dashboardVersionNumber } = chatParams;
const { metricVersionNumber, dashboardVersionNumber, chatId } = chatParams;
const onChangePage = useAppLayoutContextSelector((x) => x.onChangePage);
const selectedFile: SelectedFile | null = useMemo(() => {
@ -27,10 +26,11 @@ export const useSelectedFile = ({
}, [chatParams]);
const isVersionHistoryMode = useMemo(() => {
if (chatId) return false; // we don't need to show the version history mode if we are in a chat
if (selectedFile?.type === 'metric') return !!metricVersionNumber;
if (selectedFile?.type === 'dashboard') return !!dashboardVersionNumber;
return false;
}, [selectedFile?.type, metricVersionNumber, dashboardVersionNumber]);
}, [selectedFile?.type, metricVersionNumber, dashboardVersionNumber, chatId]);
/**
* @description Opens the splitter if the file is not already open.

View File

@ -20,6 +20,7 @@ export const FileContainerHeader: React.FC = React.memo(() => {
const onCollapseFileClick = useChatLayoutContextSelector((state) => state.onCollapseFileClick);
const selectedLayout = useChatLayoutContextSelector((x) => x.selectedLayout);
const showCollapseButton = selectedLayout === 'both';
const chatId = useChatLayoutContextSelector((x) => x.chatId);
const SelectedFileSegment = React.useMemo(
() =>

View File

@ -4,10 +4,10 @@ import React from 'react';
import { AppAssetCheckLayout } from '../AppAssetCheckLayout';
import { DashboardLayoutContainer } from './DashboardLayoutContainer';
export const DashboardLayout: React.FC<{ dashboardId: string; children: React.ReactNode }> = ({
dashboardId,
children
}) => {
export const DashboardLayout: React.FC<{
dashboardId: string;
children: React.ReactNode;
}> = ({ dashboardId, children }) => {
return (
<AppAssetCheckLayout assetId={dashboardId} type="dashboard">
<DashboardLayoutContainer dashboardId={dashboardId}>{children}</DashboardLayoutContainer>

572
web/src/lib/objects.test.ts Normal file
View File

@ -0,0 +1,572 @@
import { compareObjectsByKeys } from './objects';
describe('compareObjectsByKeys', () => {
// Test basic equality
it('should return true when objects are equal for specified keys', () => {
const obj1 = { name: 'John', age: 30, city: 'New York' };
const obj2 = { name: 'John', age: 30, country: 'USA' };
expect(compareObjectsByKeys(obj1, obj2, ['name', 'age'])).toBe(true);
});
// Test inequality
it('should return false when objects are not equal for specified keys', () => {
const obj1 = { name: 'John', age: 30 };
const obj2 = { name: 'Jane', age: 25 };
expect(compareObjectsByKeys(obj1, obj2, ['name', 'age'])).toBe(false);
});
// Test with nested objects
it('should correctly compare nested objects', () => {
const obj1 = { user: { id: 1, name: 'John' }, status: 'active' };
const obj2 = { user: { id: 1, name: 'John' }, status: 'inactive' };
expect(compareObjectsByKeys(obj1, obj2, ['user'])).toBe(true);
expect(compareObjectsByKeys(obj1, obj2, ['status'])).toBe(false);
});
// Test with null values
it('should handle null values correctly', () => {
const obj1 = { name: 'John', age: null };
const obj2 = { name: 'John', age: null };
const obj3 = { name: 'John', age: 30 };
expect(compareObjectsByKeys(obj1, obj2, ['name', 'age'])).toBe(true);
expect(compareObjectsByKeys(obj1, obj3, ['name', 'age'])).toBe(false);
});
// Test with arrays
it('should compare arrays correctly', () => {
const obj1 = { tags: [1, 2, 3], name: 'John' };
const obj2 = { tags: [1, 2, 3], name: 'Jane' };
const obj3 = { tags: [1, 2, 4], name: 'John' };
expect(compareObjectsByKeys(obj1, obj2, ['tags'])).toBe(true);
expect(compareObjectsByKeys(obj1, obj3, ['tags'])).toBe(false);
});
// Test error cases
it('should throw error for null/undefined objects', () => {
const obj1 = { name: 'John' };
expect(() => compareObjectsByKeys(null as any, obj1, ['name'])).toThrow(
'Both objects must be defined'
);
expect(() => compareObjectsByKeys(obj1, undefined as any, ['name'])).toThrow(
'Both objects must be defined'
);
});
it('should throw error for empty keys array', () => {
const obj1 = { name: 'John' };
const obj2 = { name: 'John' };
expect(() => compareObjectsByKeys(obj1, obj2, [])).toThrow('Keys array must be non-empty');
});
// Test with different object shapes
it('should handle objects with different shapes', () => {
const obj1 = { name: 'John', age: 30 };
const obj2 = { name: 'John', title: 'Developer' };
expect(compareObjectsByKeys(obj1, obj2, ['name'])).toBe(true);
});
it('complex test with debug logging', () => {
const consoleSpy = jest.spyOn(console, 'log');
const object1 = {
colors: [
'#B399FD',
'#FC8497',
'#FBBC30',
'#279EFF',
'#E83562',
'#41F8FF',
'#F3864F',
'#C82184',
'#31FCB4',
'#E83562'
]
};
const object2 = {
colors: [
'#B399FD',
'#FC8497',
'#FBBC30',
'#279EFF',
'#E83562',
'#41F8FF',
'#F3864F',
'#C82184',
'#31FCB4',
'#E83562'
]
};
const result = compareObjectsByKeys(object1, object2, ['colors']);
// Log what was actually compared
if (!result) {
console.log('Comparison failed. Console output:', consoleSpy.mock.calls);
}
expect(result).toBe(true);
consoleSpy.mockRestore();
});
it('complex test 2 with debug logging', () => {
const object1 = {
id: '84cbc2f3-a4e5-52c5-a784-c9cb14b55eab',
type: 'metric',
name: 'Orders vs Revenue Trend',
version_number: 1,
description:
'Combines the monthly count of orders and sales revenue to show their trends side by side.',
file_name: 'Orders vs Revenue Trend',
time_frame: 'All time',
datasets: [
{
name: 'entity_sales_order',
id: '9fa460b4-1410-4e74-aa34-eb79027cd59c'
}
],
data_source_id: 'cc3ef3bc-44ec-4a43-8dc4-681cae5c996a',
error: null,
chart_config: {
colors: [
'#B399FD',
'#FC8497',
'#FBBC30',
'#279EFF',
'#E83562',
'#41F8FF',
'#F3864F',
'#C82184',
'#31FCB4',
'#E83562'
],
selectedChartType: 'combo',
yAxisShowAxisLabel: true,
yAxisShowAxisTitle: true,
yAxisAxisTitle: null,
yAxisStartAxisAtZero: null,
yAxisScaleType: 'linear',
y2AxisShowAxisLabel: true,
y2AxisAxisTitle: null,
y2AxisShowAxisTitle: true,
y2AxisStartAxisAtZero: true,
y2AxisScaleType: 'linear',
xAxisTimeInterval: null,
xAxisShowAxisLabel: true,
xAxisShowAxisTitle: true,
xAxisAxisTitle: null,
xAxisLabelRotation: 'auto',
xAxisDataZoom: false,
categoryAxisTitle: null,
showLegend: null,
gridLines: true,
goalLines: [],
trendlines: [],
showLegendHeadline: false,
disableTooltip: false,
barAndLineAxis: {
x: ['month'],
y: ['order_count'],
category: [],
tooltip: null
},
scatterAxis: {
x: ['order_count'],
y: ['total_revenue'],
size: [],
tooltip: null
},
comboChartAxis: {
x: ['month'],
y: ['order_count', 'total_revenue'],
y2: [],
tooltip: null
},
pieChartAxis: {
x: ['month'],
y: ['order_count'],
tooltip: null
},
lineGroupType: null,
scatterDotSize: [3, 15],
barSortBy: [],
barLayout: 'vertical',
barGroupType: 'group',
barShowTotalAtTop: false,
pieShowInnerLabel: true,
pieInnerLabelAggregate: 'sum',
pieInnerLabelTitle: 'Total',
pieLabelPosition: null,
pieDonutWidth: 40,
pieMinimumSlicePercentage: 0,
pieDisplayLabelAs: 'number',
metricColumnId: 'order_count',
metricValueAggregate: 'sum',
metricHeader: null,
metricSubHeader: null,
metricValueLabel: null,
tableColumnOrder: null,
tableColumnWidths: null,
tableHeaderBackgroundColor: null,
tableHeaderFontColor: null,
tableColumnFontColor: null,
columnSettings: {
month: {
showDataLabels: false,
columnVisualization: 'bar',
lineWidth: 2,
lineStyle: 'line',
lineType: 'normal',
lineSymbolSize: 0,
barRoundness: 8,
showDataLabelsAsPercentage: false
},
order_count: {
showDataLabels: false,
columnVisualization: 'bar',
lineWidth: 2,
lineStyle: 'line',
lineType: 'normal',
lineSymbolSize: 0,
barRoundness: 8,
showDataLabelsAsPercentage: false
},
total_revenue: {
showDataLabels: false,
columnVisualization: 'bar',
lineWidth: 2,
lineStyle: 'line',
lineType: 'normal',
lineSymbolSize: 0,
barRoundness: 8,
showDataLabelsAsPercentage: false
}
},
columnLabelFormats: {
month: {
style: 'date',
compactNumbers: false,
columnType: 'date',
displayName: '',
numberSeparatorStyle: ',',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
currency: 'USD',
convertNumberTo: null,
dateFormat: 'MMM YYYY',
useRelativeTime: false,
isUTC: false,
multiplier: 1,
prefix: '',
suffix: '',
replaceMissingDataWith: null,
makeLabelHumanReadable: true
},
order_count: {
style: 'number',
compactNumbers: false,
columnType: 'number',
displayName: '',
numberSeparatorStyle: ',',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
currency: 'USD',
convertNumberTo: null,
dateFormat: 'auto',
useRelativeTime: false,
isUTC: false,
multiplier: 1,
prefix: '',
suffix: '',
replaceMissingDataWith: 0,
makeLabelHumanReadable: true
},
total_revenue: {
style: 'currency',
compactNumbers: false,
columnType: 'number',
displayName: '',
numberSeparatorStyle: ',',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
currency: 'USD',
convertNumberTo: null,
dateFormat: 'auto',
useRelativeTime: false,
isUTC: false,
multiplier: 1,
prefix: '',
suffix: '',
replaceMissingDataWith: 0,
makeLabelHumanReadable: true
}
}
},
data_metadata: {
column_count: 3,
row_count: 15,
column_metadata: [
{
name: 'month',
min_value: '2022-02-01',
max_value: '2023-04-01',
unique_values: 15,
simple_type: 'date',
type: 'date'
},
{
name: 'order_count',
min_value: 368,
max_value: 3216,
unique_values: 15,
simple_type: 'number',
type: 'int8'
},
{
name: 'total_revenue',
min_value: 539906.14,
max_value: 4113794.05,
unique_values: 15,
simple_type: 'number',
type: 'float8'
}
]
},
status: 'notRequested',
evaluation_score: null,
evaluation_summary: '',
file: 'name: Orders vs Revenue Trend\ndescription: Combines the monthly count of orders and sales revenue to show their trends side by side.\ntimeFrame: All time\nsql: "SELECT \\n DATE_TRUNC(\'month\', order_date)::date AS month, \\n COUNT(sales_order_id) AS order_count, \\n SUM(line_total) AS total_revenue\\nFROM sem.entity_sales_order\\nGROUP BY 1\\nORDER BY 1\\n"\nchartConfig:\n selectedChartType: combo\n columnLabelFormats:\n month:\n columnType: date\n style: date\n dateFormat: MMM YYYY\n order_count:\n columnType: number\n style: number\n minimumFractionDigits: 0\n total_revenue:\n columnType: number\n style: currency\n minimumFractionDigits: 2\n currency: USD\n comboChartAxis:\n x:\n - month\n y:\n - order_count\n - total_revenue\ndatasetIds:\n- 9fa460b4-1410-4e74-aa34-eb79027cd59c\n',
created_at: '2025-04-08T16:31:43.755232Z',
updated_at: '2025-04-08T16:31:43.755237Z',
sent_by_id: 'c2dd64cd-f7f3-4884-bc91-d46ae431901e',
sent_by_name: '',
sent_by_avatar_url: null,
code: null,
dashboards: [
{
id: '2057b640-3e98-56e0-a9af-093875a94f17',
name: 'Sales Dashboard'
}
],
collections: [],
versions: [
{
version_number: 1,
updated_at: '2025-04-08T16:31:43.755253Z'
}
],
permission: 'owner',
sql: "SELECT \n DATE_TRUNC('month', order_date)::date AS month, \n COUNT(sales_order_id) AS order_count, \n SUM(line_total) AS total_revenue\nFROM sem.entity_sales_order\nGROUP BY 1\nORDER BY 1\n",
individual_permissions: [
{
email: 'chad@buster.so',
role: 'owner',
name: 'Chad 🇹🇩'
}
],
public_expiry_date: null,
public_enabled_by: null,
publicly_accessible: false,
public_password: null
} as const;
const object2 = {
name: 'Orders vs Revenue Trend',
description:
'Combines the monthly count of orders and sales revenue to show their trends side by side.',
chart_config: {
colors: [
'#B399FD',
'#FC8497',
'#FBBC30',
'#279EFF',
'#E83562',
'#41F8FF',
'#F3864F',
'#C82184',
'#31FCB4',
'#E83562'
],
selectedChartType: 'combo',
yAxisShowAxisLabel: true,
yAxisShowAxisTitle: true,
yAxisAxisTitle: null,
yAxisStartAxisAtZero: null,
yAxisScaleType: 'linear',
y2AxisShowAxisLabel: true,
y2AxisAxisTitle: null,
y2AxisShowAxisTitle: true,
y2AxisStartAxisAtZero: true,
y2AxisScaleType: 'linear',
xAxisTimeInterval: null,
xAxisShowAxisLabel: true,
xAxisShowAxisTitle: true,
xAxisAxisTitle: null,
xAxisLabelRotation: 'auto',
xAxisDataZoom: false,
categoryAxisTitle: null,
showLegend: null,
gridLines: true,
goalLines: [],
trendlines: [],
showLegendHeadline: false,
disableTooltip: false,
barAndLineAxis: {
x: ['month'],
y: ['order_count'],
category: [],
tooltip: null
},
scatterAxis: {
x: ['order_count'],
y: ['total_revenue'],
size: [],
tooltip: null
},
comboChartAxis: {
x: ['month'],
y: ['order_count', 'total_revenue'],
y2: [],
tooltip: null
},
pieChartAxis: {
x: ['month'],
y: ['order_count'],
tooltip: null
},
lineGroupType: null,
scatterDotSize: [3, 15],
barSortBy: [],
barLayout: 'vertical',
barGroupType: 'group',
barShowTotalAtTop: false,
pieShowInnerLabel: true,
pieInnerLabelAggregate: 'sum',
pieInnerLabelTitle: 'Total',
pieLabelPosition: null,
pieDonutWidth: 40,
pieMinimumSlicePercentage: 0,
pieDisplayLabelAs: 'number',
metricColumnId: 'order_count',
metricValueAggregate: 'sum',
metricHeader: null,
metricSubHeader: null,
metricValueLabel: null,
tableColumnOrder: null,
tableColumnWidths: null,
tableHeaderBackgroundColor: null,
tableHeaderFontColor: null,
tableColumnFontColor: null,
columnSettings: {
month: {
showDataLabels: false,
columnVisualization: 'bar',
lineWidth: 2,
lineStyle: 'line',
lineType: 'normal',
lineSymbolSize: 0,
barRoundness: 8,
showDataLabelsAsPercentage: false
},
order_count: {
showDataLabels: false,
columnVisualization: 'bar',
lineWidth: 2,
lineStyle: 'line',
lineType: 'normal',
lineSymbolSize: 0,
barRoundness: 8,
showDataLabelsAsPercentage: false
},
total_revenue: {
showDataLabels: false,
columnVisualization: 'bar',
lineWidth: 2,
lineStyle: 'line',
lineType: 'normal',
lineSymbolSize: 0,
barRoundness: 8,
showDataLabelsAsPercentage: false
}
},
columnLabelFormats: {
month: {
style: 'date',
compactNumbers: false,
columnType: 'date',
displayName: '',
numberSeparatorStyle: ',',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
currency: 'USD',
convertNumberTo: null,
dateFormat: 'MMM YYYY',
useRelativeTime: false,
isUTC: false,
multiplier: 1,
prefix: '',
suffix: '',
replaceMissingDataWith: null,
makeLabelHumanReadable: true
},
order_count: {
style: 'number',
compactNumbers: false,
columnType: 'number',
displayName: '',
numberSeparatorStyle: ',',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
currency: 'USD',
convertNumberTo: null,
dateFormat: 'auto',
useRelativeTime: false,
isUTC: false,
multiplier: 1,
prefix: '',
suffix: '',
replaceMissingDataWith: 0,
makeLabelHumanReadable: true
},
total_revenue: {
style: 'currency',
compactNumbers: false,
columnType: 'number',
displayName: '',
numberSeparatorStyle: ',',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
currency: 'USD',
convertNumberTo: null,
dateFormat: 'auto',
useRelativeTime: false,
isUTC: false,
multiplier: 1,
prefix: '',
suffix: '',
replaceMissingDataWith: 0,
makeLabelHumanReadable: true
}
}
},
file: 'name: Orders vs Revenue Trend\ndescription: Combines the monthly count of orders and sales revenue to show their trends side by side.\ntimeFrame: All time\nsql: "SELECT \\n DATE_TRUNC(\'month\', order_date)::date AS month, \\n COUNT(sales_order_id) AS order_count, \\n SUM(line_total) AS total_revenue\\nFROM sem.entity_sales_order\\nGROUP BY 1\\nORDER BY 1\\n"\nchartConfig:\n selectedChartType: combo\n columnLabelFormats:\n month:\n columnType: date\n style: date\n dateFormat: MMM YYYY\n order_count:\n columnType: number\n style: number\n minimumFractionDigits: 0\n total_revenue:\n columnType: number\n style: currency\n minimumFractionDigits: 2\n currency: USD\n comboChartAxis:\n x:\n - month\n y:\n - order_count\n - total_revenue\ndatasetIds:\n- 9fa460b4-1410-4e74-aa34-eb79027cd59c\n'
} as const;
const result = compareObjectsByKeys(object1, object2, [
'name',
'description',
'chart_config',
'file'
]);
expect(result).toBe(true);
});
});

View File

@ -5,8 +5,77 @@ import pickBy from 'lodash/pickBy';
type ObjectKeys<T> = keyof T;
type CommonKeys<T, U> = ObjectKeys<T> & ObjectKeys<U>;
export const compareObjectsByKeys = <T, U>(obj1: T, obj2: U, keys: CommonKeys<T, U>[]) => {
return isEqual(pick(obj1, keys), pick(obj2, keys));
/**
* Compares two objects based on specified keys
* @param obj1 First object to compare
* @param obj2 Second object to compare
* @param keys Array of keys to compare
* @returns boolean indicating if the objects are equal for the specified keys
* @throws Error if either object is null/undefined or keys array is empty
*/
export const compareObjectsByKeys = <K extends string>(
obj1: Record<K, unknown>,
obj2: Record<K, unknown>,
keys: K[]
): boolean => {
// Input validation
if (!obj1 || !obj2) {
throw new Error('Both objects must be defined');
}
if (!Array.isArray(keys) || keys.length === 0) {
throw new Error('Keys array must be non-empty');
}
// Compare values for each key
return keys.every((key) => {
const val1 = obj1[key];
const val2 = obj2[key];
// Handle special cases
if (val1 === val2) return true;
if (val1 === null || val2 === null) return val1 === val2;
// Handle arrays explicitly
if (Array.isArray(val1) && Array.isArray(val2)) {
const arrayEqual = isEqual(val1, val2);
if (!arrayEqual) {
console.log('Arrays not equal:', {
key,
array1: val1,
array2: val2,
length1: val1.length,
length2: val2.length
});
}
return arrayEqual;
}
// Handle other objects
if (typeof val1 === 'object' && typeof val2 === 'object') {
const isWasEqual = isEqual(JSON.stringify(val1), JSON.stringify(val2));
if (!isWasEqual) {
console.log('Objects not equal:', {
key,
object1: val1,
object2: val2
});
}
return isWasEqual;
}
const itWasEqual = isEqual(val1, val2);
if (!itWasEqual) {
console.log('Values not equal:', {
key,
value1: val1,
value2: val2,
type1: typeof val1,
type2: typeof val2
});
}
return itWasEqual;
});
};
export const isJsonParsed = (jsonString: string): boolean => {

View File

@ -59,7 +59,7 @@ export type BusterAppRoutesWithArgs = {
[BusterAppRoutes.APP_METRIC_ID_VERSION_NUMBER]: {
route: BusterAppRoutes.APP_METRIC_ID_VERSION_NUMBER;
metricId: string;
versionNumber: string;
versionNumber: number;
};
[BusterAppRoutes.APP_METRIC_ID_FILE]: {
route: BusterAppRoutes.APP_METRIC_ID_FILE;
@ -77,7 +77,7 @@ export type BusterAppRoutesWithArgs = {
[BusterAppRoutes.APP_DASHBOARD_ID_VERSION_NUMBER]: {
route: BusterAppRoutes.APP_DASHBOARD_ID_VERSION_NUMBER;
dashboardId: string;
versionNumber: string;
versionNumber: number;
};
[BusterAppRoutes.APP_DASHBOARD_ID_FILE]: {
route: BusterAppRoutes.APP_DASHBOARD_ID_FILE;
@ -134,7 +134,7 @@ export type BusterAppRoutesWithArgs = {
route: BusterAppRoutes.APP_CHAT_ID_METRIC_ID_VERSION_NUMBER;
chatId: string;
metricId: string;
versionNumber: string;
versionNumber: number;
};
[BusterAppRoutes.APP_CHAT_ID_METRIC_ID_FILE]: {
route: BusterAppRoutes.APP_CHAT_ID_METRIC_ID_FILE;
@ -160,7 +160,7 @@ export type BusterAppRoutesWithArgs = {
route: BusterAppRoutes.APP_CHAT_ID_DASHBOARD_ID_VERSION_NUMBER;
chatId: string;
dashboardId: string;
versionNumber: string;
versionNumber: number;
};
[BusterAppRoutes.APP_CHAT_ID_DASHBOARD_ID_FILE]: {
route: BusterAppRoutes.APP_CHAT_ID_DASHBOARD_ID_FILE;