Create a better handler for clicking favorites

This commit is contained in:
Nate Kelley 2025-05-13 21:54:45 -06:00
parent ba93fa9c39
commit 7afa3cf399
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
7 changed files with 382 additions and 127 deletions

View File

@ -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: <ASSET_ICONS.metrics />,
route: BusterRoutes.APP_METRIC,
id: BusterRoutes.APP_METRIC,
active: isActiveCheck(ShareAssetType.METRIC, BusterRoutes.APP_METRIC)
},
{
label: 'Dashboards',
icon: <ASSET_ICONS.dashboards />,
route: BusterRoutes.APP_DASHBOARDS,
id: BusterRoutes.APP_DASHBOARDS,
active: isActiveCheck(ShareAssetType.DASHBOARD, BusterRoutes.APP_DASHBOARDS)
},
{
label: 'Collections',
icon: <ASSET_ICONS.collections />,
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: <ASSET_ICONS.metrics />,
route: BusterRoutes.APP_METRIC,
id: BusterRoutes.APP_METRIC
},
{
label: 'Dashboards',
icon: <ASSET_ICONS.dashboards />,
route: BusterRoutes.APP_DASHBOARDS,
id: BusterRoutes.APP_DASHBOARDS
},
{
label: 'Collections',
icon: <ASSET_ICONS.collections />,
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 (
<>
<Sidebar
content={sidebarItems}
header={HeaderMemoized}
activeItem={currentParentRoute}
footer={FooterMemoized}
/>
<Sidebar content={sidebarItems} header={HeaderMemoized} footer={FooterMemoized} />
<GlobalModals onCloseSupportModal={onCloseSupportModal} />
</>
@ -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: <Icon />,
route,
id: favorite.id,
onRemove: () => deleteUserFavorite([favorite.id])
};
})
};
};

View File

@ -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 (
<Sidebar
@ -88,7 +95,6 @@ export const SidebarSettings: React.FC<{}> = React.memo(({}) => {
),
[]
)}
activeItem={currentParentRoute}
footer={useMemo(
() => (
<SidebarUserFooter />

View File

@ -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);
});
});

View File

@ -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: <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 };
};

View File

@ -72,8 +72,7 @@ export const Default: Story = {
content: mockGroupedContent,
footer: (
<div className="flex h-full items-center justify-center text-sm text-gray-500">Footer</div>
),
activeItem: '1'
)
}
};
@ -88,27 +87,25 @@ export const WithLongContent: Story = {
id: `item-${i}`,
label: `Menu Item ${i + 1}`,
icon: <Window width="1.25em" height="1.25em" />,
route: BusterRoutes.APP_HOME
route: BusterRoutes.APP_HOME,
active: i === 0
}))
}
],
footer: <div className="text-sm text-gray-500">Sticky Footer</div>,
activeItem: 'item-1'
footer: <div className="text-sm text-gray-500">Sticky Footer</div>
}
};
export const NoFooter: Story = {
args: {
header: <div className="text-xl font-semibold">My App</div>,
content: mockGroupedContent,
activeItem: '1'
content: mockGroupedContent
}
};
export const ScrollAndTruncationTest: Story = {
args: {
header: <div className="text-xl font-semibold">Scroll & Truncation Test</div>,
activeItem: 'long-4',
content: [
{
id: 'default-items',
@ -121,7 +118,8 @@ export const ScrollAndTruncationTest: Story = {
id: `short-${i}`,
label: `Item ${i + 1}`,
icon: <Window width="1.25em" height="1.25em" />,
route: BusterRoutes.APP_HOME
route: BusterRoutes.APP_HOME,
active: i === 4
}))
},
{
@ -175,7 +173,6 @@ export const WithRemovableItems: Story = {
items: mockItems
}
],
activeItem: '1',
footer: <div className="text-sm text-gray-500">Footer</div>
}
};

View File

@ -3,46 +3,42 @@ import { ISidebarGroup, ISidebarList, SidebarProps } from './interfaces';
import { SidebarCollapsible } from './SidebarCollapsible';
import { SidebarItem } from './SidebarItem';
export const Sidebar: React.FC<SidebarProps> = React.memo(
({ header, content, footer, activeItem }) => {
return (
<div className="flex h-full flex-col overflow-hidden px-3.5 pt-4.5">
<div className="flex flex-col space-y-4.5 overflow-hidden">
<div className="mb-5"> {header}</div>
<div className="flex flex-grow flex-col space-y-4.5 overflow-y-auto pb-3">
{content.map((item) => (
<ContentSelector key={item.id} content={item} activeItem={activeItem} />
))}
</div>
export const Sidebar: React.FC<SidebarProps> = React.memo(({ header, content, footer }) => {
return (
<div className="flex h-full flex-col overflow-hidden px-3.5 pt-4.5">
<div className="flex flex-col space-y-4.5 overflow-hidden">
<div className="mb-5"> {header}</div>
<div className="flex flex-grow flex-col space-y-4.5 overflow-y-auto pb-3">
{content.map((item) => (
<ContentSelector key={item.id} content={item} />
))}
</div>
{footer && <div className="mt-auto mb-2 overflow-hidden pt-5">{footer}</div>}
</div>
);
}
);
{footer && <div className="mt-auto mb-2 overflow-hidden pt-5">{footer}</div>}
</div>
);
});
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 <SidebarCollapsible {...content} activeItem={activeItem} />;
return <SidebarCollapsible {...content} />;
}
return <SidebarList items={content.items} activeItem={activeItem} />;
return <SidebarList items={content.items} />;
});
ContentSelector.displayName = 'ContentSelector';
const SidebarList: React.FC<{
items: ISidebarList['items'];
activeItem: SidebarProps['activeItem'];
}> = ({ items, activeItem }) => {
}> = ({ items }) => {
return (
<div className="flex flex-col space-y-0.5">
{items.map((item) => (
<SidebarItem key={item.id} {...item} active={activeItem === item.id || item.active} />
<SidebarItem key={item.id} {...item} />
))}
</div>
);

View File

@ -33,6 +33,5 @@ export interface SidebarProps {
header: React.ReactNode;
content: SidebarContent[];
footer?: React.ReactNode;
activeItem: string;
isSortable?: boolean;
}