mirror of https://github.com/buster-so/buster.git
Create a better handler for clicking favorites
This commit is contained in:
parent
ba93fa9c39
commit
2012ea5074
|
@ -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])
|
||||
};
|
||||
})
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -33,6 +33,5 @@ export interface SidebarProps {
|
|||
header: React.ReactNode;
|
||||
content: SidebarContent[];
|
||||
footer?: React.ReactNode;
|
||||
activeItem: string;
|
||||
isSortable?: boolean;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue