diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/flat-nav-item.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/flat-nav-item.tsx index 1c7466102e..42c1453204 100644 --- a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/flat-nav-item.tsx +++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/flat-nav-item.tsx @@ -2,18 +2,12 @@ import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar"; import { useDelayLoader } from "@/hooks/use-delay-loader"; import { useRouter } from "next/navigation"; import { useTransition } from "react"; -import type { NavItem } from "../../../workspace-navigations"; +import type { NavProps } from "."; import { NavLink } from "../nav-link"; import { AnimatedLoadingSpinner } from "./animated-loading-spinner"; import { getButtonStyles } from "./utils"; -export const FlatNavItem = ({ - item, - onLoadMore, -}: { - item: NavItem & { loadMoreAction?: boolean }; - onLoadMore?: () => void; -}) => { +export const FlatNavItem = ({ item, onLoadMore }: NavProps) => { const [isPending, startTransition] = useTransition(); const showLoader = useDelayLoader(isPending); const router = useRouter(); @@ -23,7 +17,7 @@ export const FlatNavItem = ({ const handleClick = () => { if (isLoadMoreButton && onLoadMore) { - onLoadMore(); + onLoadMore(item); return; } diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/index.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/index.tsx index 71bffed86f..a016385fe9 100644 --- a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/index.tsx +++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/index.tsx @@ -2,16 +2,14 @@ import type { NavItem } from "../../../workspace-navigations"; import { FlatNavItem } from "./flat-nav-item"; import { NestedNavItem } from "./nested-nav-item"; -export const NavItems = ({ - item, - onLoadMore, -}: { +export type NavProps = { item: NavItem & { items?: (NavItem & { loadMoreAction?: boolean })[]; loadMoreAction?: boolean; }; - onLoadMore?: () => void; -}) => { + onLoadMore?: (item: NavItem) => void; +}; +export const NavItems = ({ item, onLoadMore }: NavProps) => { if (!item.items || item.items.length === 0) { return ; } diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx index d9759795cd..5d38dddf10 100644 --- a/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx +++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/components/nav-items/nested-nav-item.tsx @@ -12,6 +12,7 @@ import { CaretRight } from "@unkey/icons"; import { usePathname, useRouter } from "next/navigation"; import { useLayoutEffect, useState, useTransition } from "react"; import slugify from "slugify"; +import type { NavProps } from "."; import type { NavItem } from "../../../workspace-navigations"; import { NavLink } from "../nav-link"; import { AnimatedLoadingSpinner } from "./animated-loading-spinner"; @@ -23,9 +24,7 @@ export const NestedNavItem = ({ depth = 0, maxDepth = 1, isSubItem = false, -}: { - item: NavItem; - onLoadMore?: () => void; +}: NavProps & { depth?: number; maxDepth?: number; isSubItem?: boolean; @@ -54,13 +53,15 @@ export const NestedNavItem = ({ setIsChildrenOpen(!!hasMatchingChild); - const itemPath = `/${slugify(item.label, { - lower: true, - replacement: "-", - })}`; + if (typeof item.label === "string") { + const itemPath = `/${slugify(item.label, { + lower: true, + replacement: "-", + })}`; - if (pathname.startsWith(itemPath)) { - setIsOpen(true); + if (pathname.startsWith(itemPath)) { + setIsOpen(true); + } } }, [pathname, item.items, item.label, hasChildren]); @@ -97,7 +98,7 @@ export const NestedNavItem = ({ // If this subitem has children and is not at max depth, render it as another NestedNavItem if (hasChildren && depth < maxDepth) { return ( - + { if (isLoadMoreButton && onLoadMore) { - onLoadMore(); + onLoadMore(subItem); return; } @@ -140,7 +141,7 @@ export const NestedNavItem = ({ }; return ( - + { if (hasNextPage) { apisItem.items?.push({ icon: () => null, - href: "#load-more", - //@ts-expect-error will fix that later + href: "#load-more-apis", label:
More
, active: false, loadMoreAction: true, diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx new file mode 100644 index 0000000000..d3c18044a0 --- /dev/null +++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-projects-navigation.tsx @@ -0,0 +1,74 @@ +"use client"; +import type { NavItem } from "@/components/navigation/sidebar/workspace-navigations"; +import { trpc } from "@/lib/trpc/client"; +import { useSelectedLayoutSegments } from "next/navigation"; +import { useMemo } from "react"; + +export const useProjectNavigation = (baseNavItems: NavItem[]) => { + const segments = useSelectedLayoutSegments() ?? []; + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + trpc.deploy.project.list.useInfiniteQuery( + { query: [] }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + const projectNavItems = useMemo(() => { + if (!data?.pages) { + return []; + } + return data.pages.flatMap((page) => + page.projects.map((project) => { + const currentProjectActive = segments.at(0) === "projects" && segments.at(1) === project.id; + + const projectNavItem: NavItem = { + href: `/projects/${project.id}`, + icon: null, + label: project.name, + active: currentProjectActive, + showSubItems: true, + }; + + return projectNavItem; + }), + ); + }, [data?.pages, segments]); + + const enhancedNavItems = useMemo(() => { + const items = [...baseNavItems]; + const projectsItemIndex = items.findIndex((item) => item.href === "/projects"); + + if (projectsItemIndex !== -1) { + const projectsItem = { ...items[projectsItemIndex] }; + projectsItem.showSubItems = true; + projectsItem.items = [...(projectsItem.items || []), ...projectNavItems]; + + if (hasNextPage) { + projectsItem.items?.push({ + icon: () => null, + href: "#load-more-projects", + label:
More
, + active: false, + loadMoreAction: true, + }); + } + + items[projectsItemIndex] = projectsItem; + } + + return items; + }, [baseNavItems, projectNavItems, hasNextPage]); + + const loadMore = () => { + if (!isFetchingNextPage && hasNextPage) { + fetchNextPage(); + } + }; + + return { + enhancedNavItems, + isLoading, + loadMore, + }; +}; diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx index 99b5be86da..a97931b756 100644 --- a/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx +++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/hooks/use-ratelimit-navigation.tsx @@ -94,7 +94,7 @@ export const useRatelimitNavigation = (baseNavItems: NavItem[]) => { ratelimitsItem.items?.push({ icon: () => null, href: "#load-more-ratelimits", - label: "More", + label:
More
, active: false, loadMoreAction: true, }); diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx index 9b392558e1..9d941bfd9e 100644 --- a/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx +++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx @@ -24,6 +24,7 @@ import { UsageBanner } from "../usage-banner"; import { NavItems } from "./components/nav-items"; import { ToggleSidebarButton } from "./components/nav-items/toggle-sidebar-button"; import { useApiNavigation } from "./hooks/use-api-navigation"; +import { useProjectNavigation } from "./hooks/use-projects-navigation"; import { useRatelimitNavigation } from "./hooks/use-ratelimit-navigation"; export function AppSidebar({ @@ -55,19 +56,23 @@ export function AppSidebar({ const { enhancedNavItems: ratelimitAddedNavItems, loadMore: loadMoreRatelimits } = useRatelimitNavigation(apiAddedNavItems); + const { enhancedNavItems: projectAddedNavItems, loadMore: loadMoreProjects } = + useProjectNavigation(ratelimitAddedNavItems); + const handleLoadMore = useCallback( (item: NavItem & { loadMoreAction?: boolean }) => { - // If this is the ratelimit "load more" item - if (item.href === "#load-more-ratelimits") { + if (item.href === "#load-more-projects") { + loadMoreProjects(); + } else if (item.href === "#load-more-ratelimits") { loadMoreRatelimits(); - } else { - // Default to API loading (existing behavior) + } else if (item.href === "#load-more-apis") { loadMoreApis(); + } else { + console.error(`Unknown item.href: ${item.href}`); } }, - [loadMoreApis, loadMoreRatelimits], + [loadMoreApis, loadMoreRatelimits, loadMoreProjects], ); - const toggleNavItem: NavItem = useMemo( () => ({ label: "Toggle Sidebar", @@ -111,12 +116,8 @@ export function AppSidebar({ {state === "collapsed" && ( )} - {ratelimitAddedNavItems.map((item) => ( - handleLoadMore(item)} - /> + {projectAddedNavItems.map((item) => ( + ))} diff --git a/apps/dashboard/components/navigation/sidebar/workspace-navigations.tsx b/apps/dashboard/components/navigation/sidebar/workspace-navigations.tsx index f24bceb8e4..e622001878 100644 --- a/apps/dashboard/components/navigation/sidebar/workspace-navigations.tsx +++ b/apps/dashboard/components/navigation/sidebar/workspace-navigations.tsx @@ -19,7 +19,7 @@ export type NavItem = { icon: React.ElementType | null; href: string; external?: boolean; - label: string; + label: string | React.ReactNode; active?: boolean; tag?: React.ReactNode; hidden?: boolean; diff --git a/apps/dashboard/lib/trpc/routers/deploy/project/list.ts b/apps/dashboard/lib/trpc/routers/deploy/project/list.ts index 5436b0f5cd..ab99f7479b 100644 --- a/apps/dashboard/lib/trpc/routers/deploy/project/list.ts +++ b/apps/dashboard/lib/trpc/routers/deploy/project/list.ts @@ -1,5 +1,19 @@ import { projectsQueryPayload as projectsInputSchema } from "@/app/(app)/projects/_components/list/projects-list.schema"; -import { and, count, db, desc, eq, exists, inArray, like, lt, or, schema } from "@/lib/db"; +import { + and, + count, + db, + desc, + eq, + exists, + inArray, + isNotNull, + isNull, + like, + lt, + or, + schema, +} from "@/lib/db"; import { ratelimit, requireUser, requireWorkspace, t, withRatelimit } from "@/lib/trpc/trpc"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -45,9 +59,23 @@ export const queryProjects = t.procedure // Add cursor condition for pagination if (input.cursor && typeof input.cursor === "number") { - baseConditions.push(lt(schema.projects.updatedAt, input.cursor)); - } + const cursorDate = input.cursor; + const sql = or( + // updatedAt exists and is less than cursor + and(isNotNull(schema.projects.updatedAt), lt(schema.projects.updatedAt, cursorDate)), + // updatedAt is null, use createdAt instead + and(isNull(schema.projects.updatedAt), lt(schema.projects.createdAt, cursorDate)), + ); + if (!sql) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid cursor: Failed to create pagination condition", + }); + } + + baseConditions.push(sql); + } const filterConditions = []; // Single query field that searches across name, branch, and hostnames @@ -148,7 +176,7 @@ export const queryProjects = t.procedure projectId: true, hostname: true, }, - orderBy: [desc(schema.routes.createdAt)], + orderBy: [desc(schema.projects.updatedAt), desc(schema.projects.createdAt)], }) : []; @@ -183,7 +211,10 @@ export const queryProjects = t.procedure projects, hasMore, total: totalResult[0]?.count ?? 0, - nextCursor: projects.length > 0 ? projects[projects.length - 1].updatedAt : null, + nextCursor: + hasMore && projects.length > 0 + ? (projects[projects.length - 1].updatedAt ?? projects[projects.length - 1].createdAt) + : null, }; return response;