mirror of https://github.com/buster-so/buster.git
Create a better handler for clicking favorites
This commit is contained in:
parent
ba93fa9c39
commit
7afa3cf399
|
@ -28,8 +28,10 @@ import {
|
||||||
} from '@/api/buster_rest';
|
} from '@/api/buster_rest';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { useInviteModalStore } from '@/context/BusterAppLayout';
|
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',
|
id: 'top-items',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
|
@ -44,35 +46,52 @@ const topItems: ISidebarList = {
|
||||||
route: BusterRoutes.APP_CHAT,
|
route: BusterRoutes.APP_CHAT,
|
||||||
id: 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 = {
|
const adminTools = (currentParentRoute: BusterRoutes): 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 = {
|
|
||||||
label: 'Admin tools',
|
label: 'Admin tools',
|
||||||
id: 'admin-tools',
|
id: 'admin-tools',
|
||||||
items: [
|
items: [
|
||||||
|
@ -94,8 +113,11 @@ const adminTools: ISidebarGroup = {
|
||||||
route: BusterRoutes.APP_DATASETS,
|
route: BusterRoutes.APP_DATASETS,
|
||||||
id: BusterRoutes.APP_DATASETS
|
id: BusterRoutes.APP_DATASETS
|
||||||
}
|
}
|
||||||
]
|
].map((x) => ({
|
||||||
};
|
...x,
|
||||||
|
active: x.route === currentParentRoute
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
const tryGroup = (
|
const tryGroup = (
|
||||||
onClickInvitePeople: () => void,
|
onClickInvitePeople: () => void,
|
||||||
|
@ -126,36 +148,43 @@ const tryGroup = (
|
||||||
export const SidebarPrimary = React.memo(() => {
|
export const SidebarPrimary = React.memo(() => {
|
||||||
const isAdmin = useUserConfigContextSelector((x) => x.isAdmin);
|
const isAdmin = useUserConfigContextSelector((x) => x.isAdmin);
|
||||||
const isUserRegistered = useUserConfigContextSelector((x) => x.isUserRegistered);
|
const isUserRegistered = useUserConfigContextSelector((x) => x.isUserRegistered);
|
||||||
const { data: favorites } = useGetUserFavorites();
|
|
||||||
const currentParentRoute = useAppLayoutContextSelector((x) => x.currentParentRoute);
|
const currentParentRoute = useAppLayoutContextSelector((x) => x.currentParentRoute);
|
||||||
const onToggleInviteModal = useInviteModalStore((s) => s.onToggleInviteModal);
|
const onToggleInviteModal = useInviteModalStore((s) => s.onToggleInviteModal);
|
||||||
const onOpenContactSupportModal = useContactSupportModalStore((s) => s.onOpenContactSupportModal);
|
const onOpenContactSupportModal = useContactSupportModalStore((s) => s.onOpenContactSupportModal);
|
||||||
const { mutateAsync: updateUserFavorites } = useUpdateUserFavorites();
|
|
||||||
const { mutateAsync: deleteUserFavorite } = useDeleteUserFavorite();
|
|
||||||
|
|
||||||
const onFavoritesReorder = useMemoizedFn((itemIds: string[]) => {
|
const { favoritesDropdownItems, favoritedPageType } = useFavoriteSidebarPanel();
|
||||||
updateUserFavorites(itemIds);
|
|
||||||
});
|
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(() => {
|
const sidebarItems: SidebarProps['content'] = useMemo(() => {
|
||||||
if (!isUserRegistered) return [];
|
if (!isUserRegistered) return [];
|
||||||
|
|
||||||
const items = [topItems];
|
const items = [topItemsItems];
|
||||||
|
|
||||||
if (isAdmin) {
|
if (adminToolsItems) {
|
||||||
items.push(adminTools);
|
items.push(adminToolsItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push(yourStuff);
|
items.push(yourStuffItems);
|
||||||
|
|
||||||
if (favorites && favorites.length > 0) {
|
if (favoritesDropdownItems) {
|
||||||
items.push(favoritesDropdown(favorites, { deleteUserFavorite, onFavoritesReorder }));
|
items.push(favoritesDropdownItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push(tryGroup(onToggleInviteModal, () => onOpenContactSupportModal('feedback'), isAdmin));
|
items.push(tryGroup(onToggleInviteModal, () => onOpenContactSupportModal('feedback'), isAdmin));
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, [isAdmin, isUserRegistered, favorites, currentParentRoute, onFavoritesReorder]);
|
}, [isUserRegistered, adminToolsItems, yourStuffItems, favoritesDropdownItems]);
|
||||||
|
|
||||||
const onCloseSupportModal = useMemoizedFn(() => onOpenContactSupportModal(false));
|
const onCloseSupportModal = useMemoizedFn(() => onOpenContactSupportModal(false));
|
||||||
|
|
||||||
|
@ -167,12 +196,7 @@ export const SidebarPrimary = React.memo(() => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sidebar
|
<Sidebar content={sidebarItems} header={HeaderMemoized} footer={FooterMemoized} />
|
||||||
content={sidebarItems}
|
|
||||||
header={HeaderMemoized}
|
|
||||||
activeItem={currentParentRoute}
|
|
||||||
footer={FooterMemoized}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<GlobalModals onCloseSupportModal={onCloseSupportModal} />
|
<GlobalModals onCloseSupportModal={onCloseSupportModal} />
|
||||||
</>
|
</>
|
||||||
|
@ -233,32 +257,3 @@ const GlobalModals = ({ onCloseSupportModal }: { onCloseSupportModal: () => void
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
GlobalModals.displayName = 'GlobalModals';
|
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])
|
|
||||||
};
|
|
||||||
})
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import { useUserConfigContextSelector } from '@/context/Users';
|
||||||
import { useAppLayoutContextSelector } from '@/context/BusterAppLayout';
|
import { useAppLayoutContextSelector } from '@/context/BusterAppLayout';
|
||||||
import { SidebarUserFooter } from './SidebarUserFooter/SidebarUserFooter';
|
import { SidebarUserFooter } from './SidebarUserFooter/SidebarUserFooter';
|
||||||
|
|
||||||
const accountItems: ISidebarGroup = {
|
const accountItems = (currentParentRoute: BusterRoutes): ISidebarGroup => ({
|
||||||
label: 'Account',
|
label: 'Account',
|
||||||
variant: 'icon',
|
variant: 'icon',
|
||||||
id: 'account',
|
id: 'account',
|
||||||
|
@ -18,12 +18,13 @@ const accountItems: ISidebarGroup = {
|
||||||
{
|
{
|
||||||
label: 'Profile',
|
label: 'Profile',
|
||||||
route: createBusterRoute({ route: BusterRoutes.SETTINGS_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',
|
label: 'Workspace',
|
||||||
variant: 'icon',
|
variant: 'icon',
|
||||||
id: 'workspace',
|
id: 'workspace',
|
||||||
|
@ -32,17 +33,20 @@ const workspaceItems: ISidebarGroup = {
|
||||||
{
|
{
|
||||||
label: 'API Keys',
|
label: 'API Keys',
|
||||||
route: createBusterRoute({ route: BusterRoutes.SETTINGS_API_KEYS }),
|
route: createBusterRoute({ route: BusterRoutes.SETTINGS_API_KEYS }),
|
||||||
id: createBusterRoute({ route: BusterRoutes.SETTINGS_API_KEYS })
|
id: BusterRoutes.SETTINGS_API_KEYS
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Data Sources',
|
label: 'Data Sources',
|
||||||
route: createBusterRoute({ route: BusterRoutes.SETTINGS_DATASOURCES }),
|
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',
|
label: 'Permission & Security',
|
||||||
variant: 'icon',
|
variant: 'icon',
|
||||||
id: 'permission-and-security',
|
id: 'permission-and-security',
|
||||||
|
@ -63,21 +67,24 @@ const permissionAndSecurityItems: ISidebarGroup = {
|
||||||
route: createBusterRoute({ route: BusterRoutes.SETTINGS_PERMISSION_GROUPS }),
|
route: createBusterRoute({ route: BusterRoutes.SETTINGS_PERMISSION_GROUPS }),
|
||||||
id: 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(({}) => {
|
export const SidebarSettings: React.FC<{}> = React.memo(({}) => {
|
||||||
const isAdmin = useUserConfigContextSelector((x) => x.isAdmin);
|
const isAdmin = useUserConfigContextSelector((x) => x.isAdmin);
|
||||||
const currentParentRoute = useAppLayoutContextSelector((x) => x.currentParentRoute);
|
const currentParentRoute = useAppLayoutContextSelector((x) => x.currentParentRoute);
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
const items = [accountItems];
|
const items = [accountItems(currentParentRoute)];
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
items.push(workspaceItems);
|
items.push(workspaceItems(currentParentRoute));
|
||||||
items.push(permissionAndSecurityItems);
|
items.push(permissionAndSecurityItems(currentParentRoute));
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}, [isAdmin]);
|
}, [isAdmin, currentParentRoute]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
|
@ -88,7 +95,6 @@ export const SidebarSettings: React.FC<{}> = React.memo(({}) => {
|
||||||
),
|
),
|
||||||
[]
|
[]
|
||||||
)}
|
)}
|
||||||
activeItem={currentParentRoute}
|
|
||||||
footer={useMemo(
|
footer={useMemo(
|
||||||
() => (
|
() => (
|
||||||
<SidebarUserFooter />
|
<SidebarUserFooter />
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 };
|
||||||
|
};
|
|
@ -72,8 +72,7 @@ export const Default: Story = {
|
||||||
content: mockGroupedContent,
|
content: mockGroupedContent,
|
||||||
footer: (
|
footer: (
|
||||||
<div className="flex h-full items-center justify-center text-sm text-gray-500">Footer</div>
|
<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}`,
|
id: `item-${i}`,
|
||||||
label: `Menu Item ${i + 1}`,
|
label: `Menu Item ${i + 1}`,
|
||||||
icon: <Window width="1.25em" height="1.25em" />,
|
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>,
|
footer: <div className="text-sm text-gray-500">Sticky Footer</div>
|
||||||
activeItem: 'item-1'
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NoFooter: Story = {
|
export const NoFooter: Story = {
|
||||||
args: {
|
args: {
|
||||||
header: <div className="text-xl font-semibold">My App</div>,
|
header: <div className="text-xl font-semibold">My App</div>,
|
||||||
content: mockGroupedContent,
|
content: mockGroupedContent
|
||||||
activeItem: '1'
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ScrollAndTruncationTest: Story = {
|
export const ScrollAndTruncationTest: Story = {
|
||||||
args: {
|
args: {
|
||||||
header: <div className="text-xl font-semibold">Scroll & Truncation Test</div>,
|
header: <div className="text-xl font-semibold">Scroll & Truncation Test</div>,
|
||||||
activeItem: 'long-4',
|
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
id: 'default-items',
|
id: 'default-items',
|
||||||
|
@ -121,7 +118,8 @@ export const ScrollAndTruncationTest: Story = {
|
||||||
id: `short-${i}`,
|
id: `short-${i}`,
|
||||||
label: `Item ${i + 1}`,
|
label: `Item ${i + 1}`,
|
||||||
icon: <Window width="1.25em" height="1.25em" />,
|
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
|
items: mockItems
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
activeItem: '1',
|
|
||||||
footer: <div className="text-sm text-gray-500">Footer</div>
|
footer: <div className="text-sm text-gray-500">Footer</div>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,46 +3,42 @@ import { ISidebarGroup, ISidebarList, SidebarProps } from './interfaces';
|
||||||
import { SidebarCollapsible } from './SidebarCollapsible';
|
import { SidebarCollapsible } from './SidebarCollapsible';
|
||||||
import { SidebarItem } from './SidebarItem';
|
import { SidebarItem } from './SidebarItem';
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = React.memo(
|
export const Sidebar: React.FC<SidebarProps> = React.memo(({ header, content, footer }) => {
|
||||||
({ header, content, footer, activeItem }) => {
|
return (
|
||||||
return (
|
<div className="flex h-full flex-col overflow-hidden px-3.5 pt-4.5">
|
||||||
<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="flex flex-col space-y-4.5 overflow-hidden">
|
<div className="mb-5"> {header}</div>
|
||||||
<div className="mb-5"> {header}</div>
|
<div className="flex flex-grow flex-col space-y-4.5 overflow-y-auto pb-3">
|
||||||
<div className="flex flex-grow flex-col space-y-4.5 overflow-y-auto pb-3">
|
{content.map((item) => (
|
||||||
{content.map((item) => (
|
<ContentSelector key={item.id} content={item} />
|
||||||
<ContentSelector key={item.id} content={item} activeItem={activeItem} />
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{footer && <div className="mt-auto mb-2 overflow-hidden pt-5">{footer}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
{footer && <div className="mt-auto mb-2 overflow-hidden pt-5">{footer}</div>}
|
||||||
}
|
</div>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Sidebar.displayName = 'Sidebar';
|
Sidebar.displayName = 'Sidebar';
|
||||||
|
|
||||||
const ContentSelector: React.FC<{
|
const ContentSelector: React.FC<{
|
||||||
content: SidebarProps['content'][number];
|
content: SidebarProps['content'][number];
|
||||||
activeItem: SidebarProps['activeItem'];
|
}> = React.memo(({ content }) => {
|
||||||
}> = React.memo(({ content, activeItem }) => {
|
|
||||||
if (isSidebarGroup(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';
|
ContentSelector.displayName = 'ContentSelector';
|
||||||
|
|
||||||
const SidebarList: React.FC<{
|
const SidebarList: React.FC<{
|
||||||
items: ISidebarList['items'];
|
items: ISidebarList['items'];
|
||||||
activeItem: SidebarProps['activeItem'];
|
}> = ({ items }) => {
|
||||||
}> = ({ items, activeItem }) => {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-0.5">
|
<div className="flex flex-col space-y-0.5">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<SidebarItem key={item.id} {...item} active={activeItem === item.id || item.active} />
|
<SidebarItem key={item.id} {...item} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -33,6 +33,5 @@ export interface SidebarProps {
|
||||||
header: React.ReactNode;
|
header: React.ReactNode;
|
||||||
content: SidebarContent[];
|
content: SidebarContent[];
|
||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
activeItem: string;
|
|
||||||
isSortable?: boolean;
|
isSortable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue