From 7afa3cf399e53690445cb1c127b164cc55d1d5b2 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Tue, 13 May 2025 21:54:45 -0600 Subject: [PATCH] Create a better handler for clicking favorites --- .../features/sidebars/SidebarPrimary.tsx | 151 ++++++++--------- .../features/sidebars/SidebarSettings.tsx | 38 +++-- .../useFavoritesSidebarPanel.test.tsx | 160 ++++++++++++++++++ .../sidebars/useFavoritesSidebarPanel.tsx | 102 +++++++++++ .../components/ui/sidebar/Sidebar.stories.tsx | 17 +- web/src/components/ui/sidebar/Sidebar.tsx | 40 ++--- web/src/components/ui/sidebar/interfaces.ts | 1 - 7 files changed, 382 insertions(+), 127 deletions(-) create mode 100644 web/src/components/features/sidebars/useFavoritesSidebarPanel.test.tsx create mode 100644 web/src/components/features/sidebars/useFavoritesSidebarPanel.tsx diff --git a/web/src/components/features/sidebars/SidebarPrimary.tsx b/web/src/components/features/sidebars/SidebarPrimary.tsx index 530b7bc2d..21450c483 100644 --- a/web/src/components/features/sidebars/SidebarPrimary.tsx +++ b/web/src/components/features/sidebars/SidebarPrimary.tsx @@ -28,8 +28,10 @@ import { } from '@/api/buster_rest'; import { useHotkeys } from 'react-hotkeys-hook'; import { useInviteModalStore } from '@/context/BusterAppLayout'; +import { useFavoriteSidebarPanel } from './useFavoritesSidebarPanel'; +import { ShareAssetType } from '@/api/asset_interfaces/share'; -const topItems: ISidebarList = { +const topItems = (currentParentRoute: BusterRoutes): ISidebarList => ({ id: 'top-items', items: [ { @@ -44,35 +46,52 @@ const topItems: ISidebarList = { route: BusterRoutes.APP_CHAT, id: BusterRoutes.APP_CHAT } - ] + ].map((x) => ({ + ...x, + active: x.route === currentParentRoute + })) +}); + +const yourStuff = ( + currentParentRoute: BusterRoutes, + favoritedPageType: ShareAssetType | null +): ISidebarGroup => { + const isActiveCheck = (type: ShareAssetType, route: BusterRoutes) => { + if (favoritedPageType === type) return false; + if (favoritedPageType === null) return currentParentRoute === route; + return false; + }; + + return { + label: 'Your stuff', + id: 'your-stuff', + items: [ + { + label: 'Metrics', + icon: , + route: BusterRoutes.APP_METRIC, + id: BusterRoutes.APP_METRIC, + active: isActiveCheck(ShareAssetType.METRIC, BusterRoutes.APP_METRIC) + }, + { + label: 'Dashboards', + icon: , + route: BusterRoutes.APP_DASHBOARDS, + id: BusterRoutes.APP_DASHBOARDS, + active: isActiveCheck(ShareAssetType.DASHBOARD, BusterRoutes.APP_DASHBOARDS) + }, + { + label: 'Collections', + icon: , + route: BusterRoutes.APP_COLLECTIONS, + id: BusterRoutes.APP_COLLECTIONS, + active: isActiveCheck(ShareAssetType.COLLECTION, BusterRoutes.APP_COLLECTIONS) + } + ] + }; }; -const yourStuff: ISidebarGroup = { - label: 'Your stuff', - id: 'your-stuff', - items: [ - { - label: 'Metrics', - icon: , - route: BusterRoutes.APP_METRIC, - id: BusterRoutes.APP_METRIC - }, - { - label: 'Dashboards', - icon: , - route: BusterRoutes.APP_DASHBOARDS, - id: BusterRoutes.APP_DASHBOARDS - }, - { - label: 'Collections', - icon: , - route: BusterRoutes.APP_COLLECTIONS, - id: BusterRoutes.APP_COLLECTIONS - } - ] -}; - -const adminTools: ISidebarGroup = { +const adminTools = (currentParentRoute: BusterRoutes): ISidebarGroup => ({ label: 'Admin tools', id: 'admin-tools', items: [ @@ -94,8 +113,11 @@ const adminTools: ISidebarGroup = { route: BusterRoutes.APP_DATASETS, id: BusterRoutes.APP_DATASETS } - ] -}; + ].map((x) => ({ + ...x, + active: x.route === currentParentRoute + })) +}); const tryGroup = ( onClickInvitePeople: () => void, @@ -126,36 +148,43 @@ const tryGroup = ( export const SidebarPrimary = React.memo(() => { const isAdmin = useUserConfigContextSelector((x) => x.isAdmin); const isUserRegistered = useUserConfigContextSelector((x) => x.isUserRegistered); - const { data: favorites } = useGetUserFavorites(); const currentParentRoute = useAppLayoutContextSelector((x) => x.currentParentRoute); const onToggleInviteModal = useInviteModalStore((s) => s.onToggleInviteModal); const onOpenContactSupportModal = useContactSupportModalStore((s) => s.onOpenContactSupportModal); - const { mutateAsync: updateUserFavorites } = useUpdateUserFavorites(); - const { mutateAsync: deleteUserFavorite } = useDeleteUserFavorite(); - const onFavoritesReorder = useMemoizedFn((itemIds: string[]) => { - updateUserFavorites(itemIds); - }); + const { favoritesDropdownItems, favoritedPageType } = useFavoriteSidebarPanel(); + + const topItemsItems = useMemo(() => topItems(currentParentRoute), [currentParentRoute]); + + const adminToolsItems = useMemo(() => { + if (!isAdmin) return null; + return adminTools(currentParentRoute); + }, [isAdmin, currentParentRoute]); + + const yourStuffItems = useMemo( + () => yourStuff(currentParentRoute, favoritedPageType), + [currentParentRoute, favoritedPageType] + ); const sidebarItems: SidebarProps['content'] = useMemo(() => { if (!isUserRegistered) return []; - const items = [topItems]; + const items = [topItemsItems]; - if (isAdmin) { - items.push(adminTools); + if (adminToolsItems) { + items.push(adminToolsItems); } - items.push(yourStuff); + items.push(yourStuffItems); - if (favorites && favorites.length > 0) { - items.push(favoritesDropdown(favorites, { deleteUserFavorite, onFavoritesReorder })); + if (favoritesDropdownItems) { + items.push(favoritesDropdownItems); } items.push(tryGroup(onToggleInviteModal, () => onOpenContactSupportModal('feedback'), isAdmin)); return items; - }, [isAdmin, isUserRegistered, favorites, currentParentRoute, onFavoritesReorder]); + }, [isUserRegistered, adminToolsItems, yourStuffItems, favoritesDropdownItems]); const onCloseSupportModal = useMemoizedFn(() => onOpenContactSupportModal(false)); @@ -167,12 +196,7 @@ export const SidebarPrimary = React.memo(() => { return ( <> - + @@ -233,32 +257,3 @@ const GlobalModals = ({ onCloseSupportModal }: { onCloseSupportModal: () => void ); }; GlobalModals.displayName = 'GlobalModals'; - -const favoritesDropdown = ( - favorites: BusterUserFavorite[], - { - onFavoritesReorder, - deleteUserFavorite - }: { - onFavoritesReorder: (itemIds: string[]) => void; - deleteUserFavorite: (itemIds: string[]) => void; - } -): ISidebarGroup => { - return { - label: 'Favorites', - id: 'favorites', - isSortable: true, - onItemsReorder: onFavoritesReorder, - items: favorites.map((favorite) => { - const Icon = assetTypeToIcon(favorite.asset_type); - const route = assetTypeToRoute(favorite.asset_type, favorite.id); - return { - label: favorite.name, - icon: , - route, - id: favorite.id, - onRemove: () => deleteUserFavorite([favorite.id]) - }; - }) - }; -}; diff --git a/web/src/components/features/sidebars/SidebarSettings.tsx b/web/src/components/features/sidebars/SidebarSettings.tsx index 9a8cbf1a0..d3ea603c3 100644 --- a/web/src/components/features/sidebars/SidebarSettings.tsx +++ b/web/src/components/features/sidebars/SidebarSettings.tsx @@ -9,7 +9,7 @@ import { useUserConfigContextSelector } from '@/context/Users'; import { useAppLayoutContextSelector } from '@/context/BusterAppLayout'; import { SidebarUserFooter } from './SidebarUserFooter/SidebarUserFooter'; -const accountItems: ISidebarGroup = { +const accountItems = (currentParentRoute: BusterRoutes): ISidebarGroup => ({ label: 'Account', variant: 'icon', id: 'account', @@ -18,12 +18,13 @@ const accountItems: ISidebarGroup = { { label: 'Profile', route: createBusterRoute({ route: BusterRoutes.SETTINGS_PROFILE }), - id: createBusterRoute({ route: BusterRoutes.SETTINGS_PROFILE }) + id: BusterRoutes.SETTINGS_PROFILE, + active: currentParentRoute === BusterRoutes.SETTINGS_PROFILE } ] -}; +}); -const workspaceItems: ISidebarGroup = { +const workspaceItems = (currentParentRoute: BusterRoutes): ISidebarGroup => ({ label: 'Workspace', variant: 'icon', id: 'workspace', @@ -32,17 +33,20 @@ const workspaceItems: ISidebarGroup = { { label: 'API Keys', route: createBusterRoute({ route: BusterRoutes.SETTINGS_API_KEYS }), - id: createBusterRoute({ route: BusterRoutes.SETTINGS_API_KEYS }) + id: BusterRoutes.SETTINGS_API_KEYS }, { label: 'Data Sources', route: createBusterRoute({ route: BusterRoutes.SETTINGS_DATASOURCES }), - id: createBusterRoute({ route: BusterRoutes.SETTINGS_DATASOURCES }) + id: BusterRoutes.SETTINGS_DATASOURCES } - ] -}; + ].map((item) => ({ + ...item, + active: currentParentRoute === item.id + })) +}); -const permissionAndSecurityItems: ISidebarGroup = { +const permissionAndSecurityItems = (currentParentRoute: BusterRoutes): ISidebarGroup => ({ label: 'Permission & Security', variant: 'icon', id: 'permission-and-security', @@ -63,21 +67,24 @@ const permissionAndSecurityItems: ISidebarGroup = { route: createBusterRoute({ route: BusterRoutes.SETTINGS_PERMISSION_GROUPS }), id: createBusterRoute({ route: BusterRoutes.SETTINGS_PERMISSION_GROUPS }) } - ] -}; + ].map((item) => ({ + ...item, + active: currentParentRoute === item.id + })) +}); export const SidebarSettings: React.FC<{}> = React.memo(({}) => { const isAdmin = useUserConfigContextSelector((x) => x.isAdmin); const currentParentRoute = useAppLayoutContextSelector((x) => x.currentParentRoute); const content = useMemo(() => { - const items = [accountItems]; + const items = [accountItems(currentParentRoute)]; if (isAdmin) { - items.push(workspaceItems); - items.push(permissionAndSecurityItems); + items.push(workspaceItems(currentParentRoute)); + items.push(permissionAndSecurityItems(currentParentRoute)); } return items; - }, [isAdmin]); + }, [isAdmin, currentParentRoute]); return ( = React.memo(({}) => { ), [] )} - activeItem={currentParentRoute} footer={useMemo( () => ( diff --git a/web/src/components/features/sidebars/useFavoritesSidebarPanel.test.tsx b/web/src/components/features/sidebars/useFavoritesSidebarPanel.test.tsx new file mode 100644 index 000000000..6d98a557b --- /dev/null +++ b/web/src/components/features/sidebars/useFavoritesSidebarPanel.test.tsx @@ -0,0 +1,160 @@ +import { renderHook, act } from '@testing-library/react'; +import { useFavoriteSidebarPanel } from './useFavoritesSidebarPanel'; +import { + useGetUserFavorites, + useUpdateUserFavorites, + useDeleteUserFavorite +} from '@/api/buster_rest/users'; +import { useParams } from 'next/navigation'; +import { ShareAssetType } from '@/api/asset_interfaces/share'; + +// Mock the hooks +jest.mock('@/api/buster_rest/users', () => ({ + useGetUserFavorites: jest.fn(), + useUpdateUserFavorites: jest.fn(), + useDeleteUserFavorite: jest.fn() +})); + +jest.mock('next/navigation', () => ({ + useParams: jest.fn() +})); + +// Do not mock useMemoizedFn to use the real implementation + +describe('useFavoriteSidebarPanel', () => { + const mockFavorites = [ + { id: 'metric1', name: 'Metric 1', asset_type: ShareAssetType.METRIC }, + { id: 'dashboard1', name: 'Dashboard 1', asset_type: ShareAssetType.DASHBOARD }, + { id: 'chat1', name: 'Chat 1', asset_type: ShareAssetType.CHAT }, + { id: 'collection1', name: 'Collection 1', asset_type: ShareAssetType.COLLECTION } + ]; + + const mockUpdateFavorites = jest.fn(); + const mockDeleteFavorite = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + (useGetUserFavorites as jest.Mock).mockReturnValue({ + data: mockFavorites + }); + + (useUpdateUserFavorites as jest.Mock).mockReturnValue({ + mutateAsync: mockUpdateFavorites + }); + + (useDeleteUserFavorite as jest.Mock).mockReturnValue({ + mutateAsync: mockDeleteFavorite + }); + + (useParams as jest.Mock).mockReturnValue({ + chatId: undefined, + metricId: undefined, + dashboardId: undefined, + collectionId: undefined + }); + }); + + test('should return correct initial structure', () => { + const { result } = renderHook(() => useFavoriteSidebarPanel()); + + expect(result.current).toHaveProperty('favoritesDropdownItems'); + expect(result.current).toHaveProperty('favoritedPageType'); + }); + + test('should call updateUserFavorites when onFavoritesReorder is called', () => { + const { result } = renderHook(() => useFavoriteSidebarPanel()); + const itemIds = ['metric1', 'dashboard1']; + + act(() => { + const onItemsReorder = result.current.favoritesDropdownItems?.onItemsReorder; + if (onItemsReorder) { + onItemsReorder(itemIds); + } + }); + + expect(mockUpdateFavorites).toHaveBeenCalledWith(itemIds); + }); + + test('should correctly identify active chat asset', () => { + (useParams as jest.Mock).mockReturnValue({ + chatId: 'chat1' + }); + + const { result } = renderHook(() => useFavoriteSidebarPanel()); + + const chatItem = result.current.favoritesDropdownItems?.items.find( + (item) => item.id === 'chat1' + ); + + expect(chatItem?.active).toBe(true); + }); + + test('should correctly identify active metric asset', () => { + (useParams as jest.Mock).mockReturnValue({ + metricId: 'metric1' + }); + + const { result } = renderHook(() => useFavoriteSidebarPanel()); + + const metricItem = result.current.favoritesDropdownItems?.items.find( + (item) => item.id === 'metric1' + ); + + expect(metricItem?.active).toBe(true); + }); + + test('should set favoritedPageType to METRIC when metricId is in favorites', () => { + (useParams as jest.Mock).mockReturnValue({ + metricId: 'metric1' + }); + + const { result } = renderHook(() => useFavoriteSidebarPanel()); + + expect(result.current.favoritedPageType).toBe(ShareAssetType.METRIC); + }); + + test('should set favoritedPageType to null when page is not favorited', () => { + (useParams as jest.Mock).mockReturnValue({ + metricId: 'nonexistent' + }); + + const { result } = renderHook(() => useFavoriteSidebarPanel()); + + expect(result.current.favoritedPageType).toBe(null); + }); + + test('should return null for favoritesDropdownItems when no favorites exist', () => { + (useGetUserFavorites as jest.Mock).mockReturnValue({ + data: [] + }); + + const { result } = renderHook(() => useFavoriteSidebarPanel()); + + expect(result.current.favoritesDropdownItems).toBe(null); + }); + + test('should call deleteUserFavorite when item removal is triggered', () => { + const { result } = renderHook(() => useFavoriteSidebarPanel()); + + act(() => { + const firstItem = result.current.favoritesDropdownItems?.items[0]; + if (firstItem?.onRemove) { + firstItem.onRemove(); + } + }); + + expect(mockDeleteFavorite).toHaveBeenCalledWith(['metric1']); + }); + + test('should set favoritedPageType to null when chatId and another param exist', () => { + (useParams as jest.Mock).mockReturnValue({ + chatId: 'chat1', + metricId: 'metric1' + }); + + const { result } = renderHook(() => useFavoriteSidebarPanel()); + + expect(result.current.favoritedPageType).toBe(null); + }); +}); diff --git a/web/src/components/features/sidebars/useFavoritesSidebarPanel.tsx b/web/src/components/features/sidebars/useFavoritesSidebarPanel.tsx new file mode 100644 index 000000000..bcc03a399 --- /dev/null +++ b/web/src/components/features/sidebars/useFavoritesSidebarPanel.tsx @@ -0,0 +1,102 @@ +import type { BusterUserFavorite } from '@/api/asset_interfaces/users'; +import { ISidebarGroup } from '@/components/ui/sidebar'; +import { assetTypeToIcon, assetTypeToRoute } from '../config/assetIcons'; +import { useMemoizedFn } from '@/hooks'; +import { + updateUserFavorites, + useDeleteUserFavorite, + useGetUserFavorites, + useUpdateUserFavorites +} from '@/api/buster_rest/users'; +import { useMemo } from 'react'; +import { useParams } from 'next/navigation'; +import { ShareAssetType } from '@/api/asset_interfaces/share'; + +export const useFavoriteSidebarPanel = () => { + const { data: favorites } = useGetUserFavorites(); + const { mutateAsync: updateUserFavorites } = useUpdateUserFavorites(); + const { mutateAsync: deleteUserFavorite } = useDeleteUserFavorite(); + + const { chatId, metricId, dashboardId, collectionId } = useParams() as { + chatId: string | undefined; + metricId: string | undefined; + dashboardId: string | undefined; + collectionId: string | undefined; + }; + + const onFavoritesReorder = useMemoizedFn((itemIds: string[]) => { + updateUserFavorites(itemIds); + }); + + const isAssetActive = useMemoizedFn((favorite: BusterUserFavorite) => { + const assetType = favorite.asset_type; + const id = favorite.id; + + switch (assetType) { + case ShareAssetType.CHAT: + return id === chatId; + case ShareAssetType.METRIC: + return id === metricId; + case ShareAssetType.DASHBOARD: + return id === dashboardId; + case ShareAssetType.COLLECTION: + return id === collectionId; + default: + return false; + } + }); + + const favoritedPageType: ShareAssetType | null = useMemo(() => { + if (chatId && (metricId || dashboardId || collectionId)) { + return null; + } + + if (metricId && favorites.some((f) => f.id === metricId)) { + return ShareAssetType.METRIC; + } + + if (dashboardId && favorites.some((f) => f.id === dashboardId)) { + return ShareAssetType.DASHBOARD; + } + + if (collectionId && favorites.some((f) => f.id === collectionId)) { + return ShareAssetType.COLLECTION; + } + + return null; + }, [favorites, chatId, metricId, dashboardId, collectionId]); + + const favoritesDropdownItems: ISidebarGroup | null = useMemo(() => { + if (!favorites || favorites.length === 0) return null; + + return { + label: 'Favorites', + id: 'favorites', + isSortable: true, + onItemsReorder: onFavoritesReorder, + items: favorites.map((favorite) => { + const Icon = assetTypeToIcon(favorite.asset_type); + const route = assetTypeToRoute(favorite.asset_type, favorite.id); + return { + label: favorite.name, + icon: , + route, + active: isAssetActive(favorite), + id: favorite.id, + onRemove: () => deleteUserFavorite([favorite.id]) + }; + }) + } satisfies ISidebarGroup; + }, [ + favorites, + deleteUserFavorite, + onFavoritesReorder, + isAssetActive, + chatId, + metricId, + dashboardId, + collectionId + ]); + + return { favoritesDropdownItems, favoritedPageType }; +}; diff --git a/web/src/components/ui/sidebar/Sidebar.stories.tsx b/web/src/components/ui/sidebar/Sidebar.stories.tsx index 8adec438e..6d1d44534 100644 --- a/web/src/components/ui/sidebar/Sidebar.stories.tsx +++ b/web/src/components/ui/sidebar/Sidebar.stories.tsx @@ -72,8 +72,7 @@ export const Default: Story = { content: mockGroupedContent, footer: (
Footer
- ), - activeItem: '1' + ) } }; @@ -88,27 +87,25 @@ export const WithLongContent: Story = { id: `item-${i}`, label: `Menu Item ${i + 1}`, icon: , - route: BusterRoutes.APP_HOME + route: BusterRoutes.APP_HOME, + active: i === 0 })) } ], - footer:
Sticky Footer
, - activeItem: 'item-1' + footer:
Sticky Footer
} }; export const NoFooter: Story = { args: { header:
My App
, - content: mockGroupedContent, - activeItem: '1' + content: mockGroupedContent } }; export const ScrollAndTruncationTest: Story = { args: { header:
Scroll & Truncation Test
, - activeItem: 'long-4', content: [ { id: 'default-items', @@ -121,7 +118,8 @@ export const ScrollAndTruncationTest: Story = { id: `short-${i}`, label: `Item ${i + 1}`, icon: , - route: BusterRoutes.APP_HOME + route: BusterRoutes.APP_HOME, + active: i === 4 })) }, { @@ -175,7 +173,6 @@ export const WithRemovableItems: Story = { items: mockItems } ], - activeItem: '1', footer:
Footer
} }; diff --git a/web/src/components/ui/sidebar/Sidebar.tsx b/web/src/components/ui/sidebar/Sidebar.tsx index ac3b2d1d8..6ca78b54e 100644 --- a/web/src/components/ui/sidebar/Sidebar.tsx +++ b/web/src/components/ui/sidebar/Sidebar.tsx @@ -3,46 +3,42 @@ import { ISidebarGroup, ISidebarList, SidebarProps } from './interfaces'; import { SidebarCollapsible } from './SidebarCollapsible'; import { SidebarItem } from './SidebarItem'; -export const Sidebar: React.FC = React.memo( - ({ header, content, footer, activeItem }) => { - return ( -
-
-
{header}
-
- {content.map((item) => ( - - ))} -
+export const Sidebar: React.FC = React.memo(({ header, content, footer }) => { + return ( +
+
+
{header}
+
+ {content.map((item) => ( + + ))}
- {footer &&
{footer}
}
- ); - } -); + {footer &&
{footer}
} +
+ ); +}); Sidebar.displayName = 'Sidebar'; const ContentSelector: React.FC<{ content: SidebarProps['content'][number]; - activeItem: SidebarProps['activeItem']; -}> = React.memo(({ content, activeItem }) => { +}> = React.memo(({ content }) => { if (isSidebarGroup(content)) { - return ; + return ; } - return ; + return ; }); ContentSelector.displayName = 'ContentSelector'; const SidebarList: React.FC<{ items: ISidebarList['items']; - activeItem: SidebarProps['activeItem']; -}> = ({ items, activeItem }) => { +}> = ({ items }) => { return (
{items.map((item) => ( - + ))}
); diff --git a/web/src/components/ui/sidebar/interfaces.ts b/web/src/components/ui/sidebar/interfaces.ts index 9fc096aae..4c7c18748 100644 --- a/web/src/components/ui/sidebar/interfaces.ts +++ b/web/src/components/ui/sidebar/interfaces.ts @@ -33,6 +33,5 @@ export interface SidebarProps { header: React.ReactNode; content: SidebarContent[]; footer?: React.ReactNode; - activeItem: string; isSortable?: boolean; }