diff --git a/web/src/app/app/(primary_layout)/(chat_experience)/chats/[chatId]/dashboards/[dashboardId]/layout.tsx b/web/src/app/app/(primary_layout)/(chat_experience)/chats/[chatId]/dashboards/[dashboardId]/layout.tsx
index 69cae866c..192f57e14 100644
--- a/web/src/app/app/(primary_layout)/(chat_experience)/chats/[chatId]/dashboards/[dashboardId]/layout.tsx
+++ b/web/src/app/app/(primary_layout)/(chat_experience)/chats/[chatId]/dashboards/[dashboardId]/layout.tsx
@@ -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 }>;
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 bc1260c8e..9b715379a 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
@@ -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;
diff --git a/web/src/context/Dashboards/useGetDashboardMemoized.tsx b/web/src/context/Dashboards/useGetDashboardMemoized.tsx
new file mode 100644
index 000000000..4dffa5950
--- /dev/null
+++ b/web/src/context/Dashboards/useGetDashboardMemoized.tsx
@@ -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;
+};
diff --git a/web/src/context/Metrics/useIsMetricChanged.tsx b/web/src/context/Metrics/useIsMetricChanged.tsx
index 33f430462..a3217fdc2 100644
--- a/web/src/context/Metrics/useIsMetricChanged.tsx
+++ b/web/src/context/Metrics/useIsMetricChanged.tsx
@@ -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
};
};
diff --git a/web/src/controllers/DashboardController/DashboardViewDashboardController/DashboardViewDashboardController.tsx b/web/src/controllers/DashboardController/DashboardViewDashboardController/DashboardViewDashboardController.tsx
index bce697bd6..e601ab6bd 100644
--- a/web/src/controllers/DashboardController/DashboardViewDashboardController/DashboardViewDashboardController.tsx
+++ b/web/src/controllers/DashboardController/DashboardViewDashboardController/DashboardViewDashboardController.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (!isFetched) {
+ return <>>;
+ }
+
return (
diff --git a/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_File/ChatResponseMessage_File.tsx b/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_File/ChatResponseMessage_File.tsx
index 68398cc2c..f9192a11a 100644
--- a/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_File/ChatResponseMessage_File.tsx
+++ b/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_File/ChatResponseMessage_File.tsx
@@ -26,10 +26,10 @@ export const ChatResponseMessage_File: React.FC
= 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) {
diff --git a/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_File/useGetFileHref.tsx b/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_File/useGetFileHref.tsx
index d1d7107af..3003b3322 100644
--- a/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_File/useGetFileHref.tsx
+++ b/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_File/useGetFileHref.tsx
@@ -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
});
}
diff --git a/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_File/useGetIsSelectedFile.tsx b/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_File/useGetIsSelectedFile.tsx
index 38ca945d9..e9b57702d 100644
--- a/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_File/useGetIsSelectedFile.tsx
+++ b/web/src/layouts/ChatLayout/ChatContainer/ChatContent/ChatResponseMessages/ChatResponseMessage_File/useGetIsSelectedFile.tsx
@@ -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;
}): {
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 };
}
}
};
diff --git a/web/src/layouts/ChatLayout/ChatContext/ChatContext.tsx b/web/src/layouts/ChatLayout/ChatContext/ChatContext.tsx
index c541762b9..c62d97620 100644
--- a/web/src/layouts/ChatLayout/ChatContext/ChatContext.tsx
+++ b/web/src/layouts/ChatLayout/ChatContext/ChatContext.tsx
@@ -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
);
-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 (
-
- {children}
-
- );
- }
-);
+ return (
+
+ {children}
+
+ );
+});
ChatContextProvider.displayName = 'ChatContextProvider';
diff --git a/web/src/layouts/ChatLayout/ChatContext/useAutoChangeLayout.ts b/web/src/layouts/ChatLayout/ChatContext/useAutoChangeLayout.ts
index 2c731e801..c99062f3f 100644
--- a/web/src/layouts/ChatLayout/ChatContext/useAutoChangeLayout.ts
+++ b/web/src/layouts/ChatLayout/ChatContext/useAutoChangeLayout.ts
@@ -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(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;
+};
diff --git a/web/src/layouts/ChatLayout/ChatLayout/ChatLayout.tsx b/web/src/layouts/ChatLayout/ChatLayout/ChatLayout.tsx
index 23264cb08..df32b349a 100644
--- a/web/src/layouts/ChatLayout/ChatLayout/ChatLayout.tsx
+++ b/web/src/layouts/ChatLayout/ChatLayout/ChatLayout.tsx
@@ -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 = ({ children }) => {
- const params = useParams();
-
const appSplitterRef = useRef(null);
const chatLayoutProps = useChatLayoutContext({ appSplitterRef });
const { selectedLayout, selectedFile, onSetSelectedFile, chatId } = chatLayoutProps;
@@ -28,10 +25,7 @@ export const ChatLayout: React.FC = ({ children }) => {
return (
-
+
{
};
const searchParams = useSearchParams();
const currentRoute = useAppLayoutContextSelector((state) => state.currentRoute);
-
const metricVersionNumber = searchParams.get('metric_version_number');
const dashboardVersionNumber = searchParams.get('dashboard_version_number');
diff --git a/web/src/layouts/ChatLayout/ChatLayoutContext/useSelectedFile/useSelectedFile.ts b/web/src/layouts/ChatLayout/ChatLayoutContext/useSelectedFile/useSelectedFile.ts
index c32850f74..ab326e380 100644
--- a/web/src/layouts/ChatLayout/ChatLayoutContext/useSelectedFile/useSelectedFile.ts
+++ b/web/src/layouts/ChatLayout/ChatLayoutContext/useSelectedFile/useSelectedFile.ts
@@ -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;
chatParams: ReturnType;
}) => {
- 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.
diff --git a/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/FileContainerHeader.tsx b/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/FileContainerHeader.tsx
index 6b839da60..6089c148b 100644
--- a/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/FileContainerHeader.tsx
+++ b/web/src/layouts/ChatLayout/FileContainer/FileContainerHeader/FileContainerHeader.tsx
@@ -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(
() =>
diff --git a/web/src/layouts/DashboardLayout/DashboardLayout.tsx b/web/src/layouts/DashboardLayout/DashboardLayout.tsx
index 85bb0cd85..34483070d 100644
--- a/web/src/layouts/DashboardLayout/DashboardLayout.tsx
+++ b/web/src/layouts/DashboardLayout/DashboardLayout.tsx
@@ -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 (
{children}
diff --git a/web/src/lib/objects.test.ts b/web/src/lib/objects.test.ts
new file mode 100644
index 000000000..06d1039cc
--- /dev/null
+++ b/web/src/lib/objects.test.ts
@@ -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);
+ });
+});
diff --git a/web/src/lib/objects.ts b/web/src/lib/objects.ts
index 6cce46c0c..e7cb499b2 100644
--- a/web/src/lib/objects.ts
+++ b/web/src/lib/objects.ts
@@ -5,8 +5,77 @@ import pickBy from 'lodash/pickBy';
type ObjectKeys = keyof T;
type CommonKeys = ObjectKeys & ObjectKeys;
-export const compareObjectsByKeys = (obj1: T, obj2: U, keys: CommonKeys[]) => {
- 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 = (
+ obj1: Record,
+ obj2: Record,
+ 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 => {
diff --git a/web/src/routes/busterRoutes/busterAppRoutes.ts b/web/src/routes/busterRoutes/busterAppRoutes.ts
index 6d03b25fd..850ddb38d 100644
--- a/web/src/routes/busterRoutes/busterAppRoutes.ts
+++ b/web/src/routes/busterRoutes/busterAppRoutes.ts
@@ -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;