From 634e5f0460f2c5bac5d12d861e282a6516285623 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Tue, 8 Apr 2025 12:45:20 -0600 Subject: [PATCH] objects are not doing equality very well --- .../dashboards/[dashboardId]/layout.tsx | 3 +- .../[chatId]/metrics/[metricId]/layout.tsx | 4 +- .../Dashboards/useGetDashboardMemoized.tsx | 13 + .../context/Metrics/useIsMetricChanged.tsx | 14 +- .../DashboardViewDashboardController.tsx | 20 +- .../ChatResponseMessage_File.tsx | 4 +- .../useGetFileHref.tsx | 22 +- .../useGetIsSelectedFile.tsx | 23 +- .../ChatLayout/ChatContext/ChatContext.tsx | 35 +- .../ChatContext/useAutoChangeLayout.ts | 81 ++- .../ChatLayout/ChatLayout/ChatLayout.tsx | 8 +- .../ChatLayoutContext/useGetChatParams.tsx | 1 - .../useSelectedFile/useSelectedFile.ts | 6 +- .../FileContainerHeader.tsx | 1 + .../DashboardLayout/DashboardLayout.tsx | 8 +- web/src/lib/objects.test.ts | 572 ++++++++++++++++++ web/src/lib/objects.ts | 73 ++- .../routes/busterRoutes/busterAppRoutes.ts | 8 +- 18 files changed, 808 insertions(+), 88 deletions(-) create mode 100644 web/src/context/Dashboards/useGetDashboardMemoized.tsx create mode 100644 web/src/lib/objects.test.ts 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;