From 2012ea5074e82fa6fccb90fd008c6533173efa46 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Tue, 13 May 2025 21:54:45 -0600 Subject: [PATCH 1/3] Create a better handler for clicking favorites --- .../features/sidebars/SidebarPrimary.tsx | 151 ++++++++-------- .../useFavoritesSidebarPanel.test.tsx | 161 ++++++++++++++++++ .../sidebars/useFavoritesSidebarPanel.tsx | 102 +++++++++++ web/src/components/ui/sidebar/Sidebar.tsx | 40 ++--- web/src/components/ui/sidebar/interfaces.ts | 1 - 5 files changed, 354 insertions(+), 101 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/useFavoritesSidebarPanel.test.tsx b/web/src/components/features/sidebars/useFavoritesSidebarPanel.test.tsx new file mode 100644 index 000000000..ba0ffed7c --- /dev/null +++ b/web/src/components/features/sidebars/useFavoritesSidebarPanel.test.tsx @@ -0,0 +1,161 @@ +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'; +import { useMemoizedFn } from '@/hooks'; + +// 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.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; } From 4ca368837d791f039c451bc83d6e20d69ceae79e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 14 May 2025 03:56:34 +0000 Subject: [PATCH 2/3] chore(versions): bump api to v0.1.9; bump web to v0.1.9; bump cli to v0.1.9 [skip ci] --- api/server/Cargo.toml | 2 +- cli/cli/Cargo.toml | 2 +- web/package-lock.json | 4 ++-- web/package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/server/Cargo.toml b/api/server/Cargo.toml index 534f5301a..46624a0c1 100644 --- a/api/server/Cargo.toml +++ b/api/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buster_server" -version = "0.1.8" +version = "0.1.9" edition = "2021" default-run = "buster_server" diff --git a/cli/cli/Cargo.toml b/cli/cli/Cargo.toml index 8919ea60e..20dbf8cf1 100644 --- a/cli/cli/Cargo.toml +++ b/cli/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "buster-cli" -version = "0.1.8" +version = "0.1.9" edition = "2021" build = "build.rs" diff --git a/web/package-lock.json b/web/package-lock.json index a4fbf30d1..e96b69247 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "web", - "version": "0.1.8", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "web", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", diff --git a/web/package.json b/web/package.json index 1bcfc96bc..6123eb662 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "0.1.8", + "version": "0.1.9", "private": true, "scripts": { "dev": "next dev --turbo", From c884cfecd11e5fee8f130aee07cb2b69283a1ad8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 14 May 2025 03:56:35 +0000 Subject: [PATCH 3/3] chore: update tag_info.json with potential release versions [skip ci] --- tag_info.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tag_info.json b/tag_info.json index 5f5a0b82e..51c3cec80 100644 --- a/tag_info.json +++ b/tag_info.json @@ -1,7 +1,7 @@ { - "api_tag": "api/v0.1.8", "api_version": "0.1.8" + "api_tag": "api/v0.1.9", "api_version": "0.1.9" , - "web_tag": "web/v0.1.8", "web_version": "0.1.8" + "web_tag": "web/v0.1.9", "web_version": "0.1.9" , - "cli_tag": "cli/v0.1.8", "cli_version": "0.1.8" + "cli_tag": "cli/v0.1.9", "cli_version": "0.1.9" }