import { useQueryClient } from '@tanstack/react-query';
import { t } from 'i18next';
import { Compass, Search, Loader2, Plus } from 'lucide-react';
import { useState, useMemo, useRef, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useDebounce } from 'use-debounce';
import { NewProjectDialog } from '@/app/routes/platform/projects/new-project-dialog';
import { useEmbedding } from '@/components/embed-provider';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarMenu,
SidebarGroupContent,
SidebarSeparator,
useSidebar,
SidebarGroupLabel,
} from '@/components/ui/sidebar-shadcn';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { platformHooks } from '@/hooks/platform-hooks';
import { projectHooks } from '@/hooks/project-hooks';
import { userHooks } from '@/hooks/user-hooks';
import { cn } from '@/lib/utils';
import {
isNil,
PlatformRole,
ProjectType,
TeamProjectsLimit,
} from '@activepieces/shared';
import { SidebarGeneralItemType } from '../ap-sidebar-group';
import { ApSidebarItem, SidebarItemType } from '../ap-sidebar-item';
import ProjectSideBarItem from '../project';
import { AppSidebarHeader } from '../sidebar-header';
import SidebarUsageLimits from '../sidebar-usage-limits';
import { SidebarUser } from '../sidebar-user';
export function ProjectDashboardSidebar() {
const {
data: projectPages,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch: refetchProjects,
} = projectHooks.useProjectsInfinite(20);
const { embedState } = useEmbedding();
const { state, setOpen } = useSidebar();
const location = useLocation();
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearchQuery] = useDebounce(searchQuery, 300);
const [searchOpen, setSearchOpen] = useState(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
const { setCurrentProject } = projectHooks.useCurrentProject();
const projectsScrollRef = useRef<HTMLDivElement>(null);
const { data: currentUser } = userHooks.useCurrentUser();
const { platform } = platformHooks.useCurrentPlatform();
const {
data: searchResults,
isLoading: isSearching,
refetch: refetchSearchResults,
} = projectHooks.useProjects({
displayName: debouncedSearchQuery,
limit: 100,
});
useEffect(() => {
if (!searchOpen) {
setSearchQuery('');
}
}, [searchOpen]);
const allProjects = useMemo(() => {
const projects = projectPages?.pages.flatMap((page) => page.data) ?? [];
const uniqueProjects = Array.from(
new Map(projects.map((project) => [project.id, project])).values(),
);
return uniqueProjects;
}, [projectPages]);
const shouldShowNewProjectButton = useMemo(() => {
if (platform.plan.teamProjectsLimit === TeamProjectsLimit.NONE) {
return false;
}
return currentUser?.platformRole === PlatformRole.ADMIN;
}, [platform.plan.teamProjectsLimit]);
const shouldShowSearchButton = useMemo(() => {
if (platform.plan.teamProjectsLimit === TeamProjectsLimit.NONE) {
return false;
}
return true;
}, [platform.plan.teamProjectsLimit]);
const shouldDisableNewProjectButton = useMemo(() => {
if (platform.plan.teamProjectsLimit === TeamProjectsLimit.ONE) {
const teamProjects = allProjects.filter(
(project) => project.type === ProjectType.TEAM,
);
return teamProjects.length >= 1;
}
return false;
}, [platform.plan.teamProjectsLimit, allProjects]);
const isSearchMode = debouncedSearchQuery.length > 0;
const displayProjects = useMemo(() => {
if (isSearchMode) {
return searchResults ?? [];
}
return allProjects;
}, [isSearchMode, searchResults, allProjects]);
useEffect(() => {
const scrollContainer = projectsScrollRef.current;
if (!scrollContainer || isSearchMode) return;
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollThreshold = 100;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
if (
distanceFromBottom < scrollThreshold &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
};
scrollContainer.addEventListener('scroll', handleScroll);
handleScroll();
return () => scrollContainer.removeEventListener('scroll', handleScroll);
}, [hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode]);
const permissionFilter = (link: SidebarGeneralItemType) => {
if (link.type === 'link') {
return isNil(link.hasPermission) || link.hasPermission;
}
return true;
};
const exploreLink: SidebarItemType = {
type: 'link',
to: '/explore',
label: t('Explore'),
show: true,
icon: Compass,
hasPermission: true,
isSubItem: false,
};
const items = [exploreLink].filter(permissionFilter);
const handleProjectSelect = async (projectId: string) => {
const project = displayProjects?.find((p) => p.id === projectId);
if (project) {
await setCurrentProject(queryClient, project);
navigate('/');
setSearchOpen(false);
}
};
return (
!embedState.hideSideNav && (
<Sidebar
variant="inset"
collapsible="icon"
onClick={() => setOpen(true)}
className={cn(
state === 'collapsed' ? 'cursor-nesw-resize' : '',
'group',
'p-1',
)}
>
<AppSidebarHeader />
{state === 'collapsed' && <div className="mt-1" />}
{state === 'expanded' && <div className="mt-2" />}
<SidebarContent
className={cn(
state === 'collapsed' ? 'gap-2' : 'gap-0',
'scrollbar-hover',
'cursor-default',
'flex',
'flex-col',
'overflow-hidden',
)}
>
<SidebarGroup className="cursor-default flex-shrink-0">
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<ApSidebarItem key={item.label} {...item} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator
className={cn(
state === 'collapsed' ? 'mb-3' : 'mb-5',
'flex-shrink-0',
)}
/>
<SidebarGroup className="flex-1 flex flex-col overflow-hidden">
{state === 'expanded' && (
<div className="flex items-center justify-between">
<SidebarGroupLabel>{t('Projects')}</SidebarGroupLabel>
<div className="flex items-center gap-1">
{shouldShowNewProjectButton && (
<>
{!shouldDisableNewProjectButton ? (
<NewProjectDialog
onCreate={() => {
refetchProjects();
refetchSearchResults();
}}
>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:bg-accent"
>
<Plus />
</Button>
</NewProjectDialog>
) : (
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
variant="ghost"
size="icon"
disabled
className="h-6 w-6"
>
<Plus />
</Button>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[250px]">
<p className="text-xs mb-1">
{t(
'Upgrade your plan to create additional team projects.',
)}{' '}
<button
className="text-xs text-primary underline hover:no-underline"
onClick={() =>
window.open(
'https://www.activepieces.com/pricing',
'_blank',
)
}
>
{t('View Plans')}
</button>
</p>
</TooltipContent>
</Tooltip>
)}
</>
)}
{shouldShowSearchButton && (
<Popover open={searchOpen} onOpenChange={setSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:bg-accent"
>
<Search />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[280px] p-3"
align="start"
side="right"
sideOffset={8}
>
<Input
placeholder={t('Search projects...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-9"
autoFocus
/>
</PopoverContent>
</Popover>
)}
</div>
</div>
)}
<div
ref={projectsScrollRef}
className={cn(
'flex-1 overflow-y-auto',
state === 'collapsed'
? 'flex flex-col items-center scrollbar-none'
: 'scrollbar-hover',
)}
onClick={(e) => e.stopPropagation()}
>
<SidebarMenu
className={cn(
state === 'collapsed'
? 'gap-2 flex flex-col items-center'
: '',
)}
>
{displayProjects.map((project) => (
<ProjectSideBarItem
key={project.id}
project={project}
isCurrentProject={location.pathname.includes(
`/projects/${project.id}`,
)}
handleProjectSelect={handleProjectSelect}
/>
))}
{(isFetchingNextPage || (isSearchMode && isSearching)) && (
<div className="flex items-center gap-2 px-2 py-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{state === 'expanded' && <span>{t('Loading...')}</span>}
</div>
)}
{isSearchMode &&
!isSearching &&
displayProjects.length === 0 && (
<div className="px-2 py-2 text-sm text-muted-foreground">
{state === 'expanded' && t('No projects found.')}
</div>
)}
</SidebarMenu>
</div>
</SidebarGroup>
</SidebarContent>
<SidebarFooter
onClick={(e) => e.stopPropagation()}
className="cursor-default"
>
{state === 'expanded' && (
<div className="mb-2">
<SidebarUsageLimits />
</div>
)}
<SidebarUser />
</SidebarFooter>
</Sidebar>
)
);
}