diff --git a/src/app/(loading-group)/[organizationSlug]/page.tsx b/src/app/(loading-group)/[organizationSlug]/page.tsx index d9d6d7d6..226bf488 100644 --- a/src/app/(loading-group)/[organizationSlug]/page.tsx +++ b/src/app/(loading-group)/[organizationSlug]/page.tsx @@ -17,18 +17,21 @@ import { Button } from "@/components/ui/button"; import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { FunctionComponent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import Page from "../../../components/Page"; import { useActiveOrg } from "../../../hooks/useActiveOrg"; import { browserApiClient } from "../../../services/devGuardApi"; +import type { + Paged, + ProjectDTO, + SubGroupsAndAsset, +} from "../../../types/api/api"; import { UserRole } from "../../../types/api/api"; -import type { Paged, ProjectDTO } from "../../../types/api/api"; import type { CreateProjectReq } from "../../../types/api/req"; -import ListItem from "@/components/common/ListItem"; import Section from "@/components/common/Section"; import { Dialog, @@ -38,31 +41,28 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Form } from "@/components/ui/form"; import { useOrganizationMenu } from "@/hooks/useOrganizationMenu"; import { toast } from "sonner"; import CustomPagination from "@/components/common/CustomPagination"; import { ProjectForm } from "@/components/project/ProjectForm"; +import Sort from "@/components/Sort"; +import SubgroupsAndAssetsList, { + checkType, +} from "@/components/SubgroupsAndAssetsList"; import { Input } from "@/components/ui/input"; -import { useCurrentUserRole } from "@/hooks/useUserRole"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useUpdateOrganization } from "@/context/OrganizationContext"; import { useSession } from "@/context/SessionContext"; +import { useCurrentUserRole } from "@/hooks/useUserRole"; +import { buildFilterSearchParams } from "@/utils/url"; import { debounce } from "lodash"; import { Loader2 } from "lucide-react"; -import Link from "next/link"; import useSWR from "swr"; -import Avatar from "../../../components/Avatar"; +import EmptyParty from "../../../components/common/EmptyParty"; import ListRenderer from "../../../components/common/ListRenderer"; -import Markdown from "../../../components/common/Markdown"; -import { ProjectBadge } from "../../../components/common/ProjectTitle"; import { fetcher } from "../../../data-fetcher/fetcher"; -import EmptyParty from "../../../components/common/EmptyParty"; import useRouterQuery from "../../../hooks/useRouterQuery"; -import { useUpdateOrganization } from "@/context/OrganizationContext"; -import { Badge } from "@/components/ui/badge"; -import { buildFilterSearchParams } from "@/utils/url"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import Sort from "@/components/Sort"; const OrganizationHomePage: FunctionComponent = () => { const [viewedProject, setViewedProject] = useState<"all" | "inactive">("all"); @@ -81,6 +81,9 @@ const OrganizationHomePage: FunctionComponent = () => { mode: "onBlur", }); + const searchQuery = searchParams?.get("search") ?? ""; + const isSearchActive = searchQuery.length >= 3; + const queryWithState = useMemo(() => { const p = buildFilterSearchParams(searchParams); const state = searchParams?.get("state"); @@ -95,30 +98,38 @@ const OrganizationHomePage: FunctionComponent = () => { const stillOnPage = useRef(true); const pushQuery = useRouterQuery(); + + const swrUrl = isSearchActive + ? `/organizations/${decodeURIComponent(activeOrg.slug)}/projects/search?${queryWithState.toString()}` + : `/organizations/${decodeURIComponent(activeOrg.slug)}/projects/?${queryWithState.toString()}`; + const { isLoading, data: projects, error, mutate, - } = useSWR>( - activeOrg - ? `/organizations/${decodeURIComponent(activeOrg.slug)}/projects/?${queryWithState.toString()}` - : null, - async (url: string) => - fetcher>(url).then((res) => { - return res; - }), - ); + } = useSWR>(swrUrl, async (url: string) => { + const data = await fetcher>(url); + // we need to transform the data to add the resourceType field to each item, so that we can distinguish between projects and assets in the SubgroupsAndAssetsList component + return { + ...data, + data: data.data.map((item) => ({ + ...item, + resourceType: "project", + })), + } as Paged; + }); const debouncedHandleSearch = useCallback( debounce((e: React.ChangeEvent) => { - if (e.target.value === "") { - pushQuery({ search: undefined, page: "1" }); - } else if (e.target.value.length >= 3) { - pushQuery({ search: e.target.value, page: "1" }); + const value = e.target.value; + if (value === "") { + pushQuery({ search: undefined, page: 1 }); + } else if (value.length >= 3) { + pushQuery({ search: value, page: 1 }); } }, 500), - [], + [pushQuery], ); const handleSetTabValue = (value: string) => { @@ -172,6 +183,61 @@ const OrganizationHomePage: FunctionComponent = () => { mutate(); }; + const handleLazyDataFetching = useCallback( + async (projectSlug: string, projectId: string) => { + const base = `/organizations/${decodeURIComponent(activeOrg.slug)}/projects/${decodeURIComponent(projectSlug)}/resources?parentId=${projectId}`; + + const resp = await browserApiClient(base); + if (resp.ok) { + const data = await resp.json(); + const subGroupsAndAsset = data as Paged; + + mutate( + (prev) => { + if (!prev) return prev; + // traverse the whole tree, find the correct project and update it with the new data + const recursiveFn = ( + item: SubGroupsAndAsset, + ): SubGroupsAndAsset => { + const { asset, subgroup } = checkType(item); + if (asset != null) { + return asset; + } + + if (subgroup.id === projectId) { + return { + ...subgroup, + subGroupsAndAsset: subGroupsAndAsset.data, + }; + } + + return { + ...subgroup, + subGroupsAndAsset: + subgroup?.subGroupsAndAsset?.map(recursiveFn), + }; + }; + + return { + ...prev, + data: prev.data.map(recursiveFn) as Array< + ProjectDTO & { + resourceType: "project"; + } + >, + }; + }, + { revalidate: false }, + ); + } else { + toast.error( + "Failed to load subgroups and assets. Please try again later.", + ); + } + }, + [activeOrg.slug, mutate], + ); + useEffect(() => { // trigger a sync on page load - if the org has an external entity provider if (activeOrg.externalEntityProviderId) { @@ -269,17 +335,24 @@ const OrganizationHomePage: FunctionComponent = () => { forceVertical title="Groups" > - - - Groups - Inactive - - - +
+ + + Groups + Inactive + + + {isSearchActive && ( + + Filter and sorting options are disabled while searching + + )} +
{ className="h-11" onChange={debouncedHandleSearch} defaultValue={searchParams?.get("search") || ""} - placeholder="Search for projects" + placeholder="Search for projects (min. 3 characters)..." />
- } - renderItem={(project) => ( - - - - {project.name} - {project.state === "deleted" && ( - Pending deletion - )} +
+ } + renderItem={(project) => { + return ( +
+
+
- } - Description={ -
- - ) => ( - {props.children} - ), - }} - > - {project.description} - - - {project.type !== "default" && ( -
- -
- )} -
- } - /> - - )} - /> +
+ ); + }} + /> +
{projects && (
diff --git a/src/app/(loading-group)/[organizationSlug]/projects/[projectSlug]/page.tsx b/src/app/(loading-group)/[organizationSlug]/projects/[projectSlug]/page.tsx index ca5983f0..f713ade1 100644 --- a/src/app/(loading-group)/[organizationSlug]/projects/[projectSlug]/page.tsx +++ b/src/app/(loading-group)/[organizationSlug]/projects/[projectSlug]/page.tsx @@ -1,25 +1,23 @@ "use client"; -import Image from "next/image"; -import Link from "next/link"; +import CustomPagination from "@/components/common/CustomPagination"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import useRouterQuery from "@/hooks/useRouterQuery"; +import { buildFilterSearchParams } from "@/utils/url"; +import { debounce } from "lodash"; import { useRouter, useSearchParams } from "next/navigation"; import { useCallback, useMemo, useState } from "react"; -import { Form, FormProvider, useForm } from "react-hook-form"; -import Markdown from "react-markdown"; +import { FormProvider, useForm } from "react-hook-form"; import { toast } from "sonner"; import useSWR from "swr"; import AssetForm, { type AssetFormValues, } from "../../../../../components/asset/AssetForm"; -import AssetOverviewListItem from "../../../../../components/AssetOverviewListItem"; -import Avatar from "../../../../../components/Avatar"; -import EmptyParty from "../../../../../components/common/EmptyParty"; -import ListItem from "../../../../../components/common/ListItem"; import ProjectTitle from "../../../../../components/common/ProjectTitle"; import Section from "../../../../../components/common/Section"; import Page from "../../../../../components/Page"; import { ProjectForm } from "../../../../../components/project/ProjectForm"; -import { Badge } from "../../../../../components/ui/badge"; import { Button } from "../../../../../components/ui/button"; import { Dialog, @@ -34,44 +32,25 @@ import { useOrganization, } from "../../../../../context/OrganizationContext"; import { useProject } from "../../../../../context/ProjectContext"; -import { useActiveOrg } from "../../../../../hooks/useActiveOrg"; +import { useSession } from "../../../../../context/SessionContext"; import { fetcher } from "../../../../../data-fetcher/fetcher"; +import { useActiveOrg } from "../../../../../hooks/useActiveOrg"; import { useProjectMenu } from "../../../../../hooks/useProjectMenu"; import { useCurrentUserRole } from "../../../../../hooks/useUserRole"; -import { useSession } from "../../../../../context/SessionContext"; import { browserApiClient } from "../../../../../services/devGuardApi"; -import { RequirementsLevel, UserRole } from "../../../../../types/api/api"; import type { AssetDTO, EnvDTO, Paged, ProjectDTO, + SubGroupsAndAsset, } from "../../../../../types/api/api"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Input } from "@/components/ui/input"; -import { debounce } from "lodash"; -import useRouterQuery from "@/hooks/useRouterQuery"; -import { buildFilterSearchParams } from "@/utils/url"; -import CustomPagination from "@/components/common/CustomPagination"; -import ListRenderer from "@/components/common/ListRenderer"; +import { RequirementsLevel, UserRole } from "../../../../../types/api/api"; import Sort from "@/components/Sort"; - -function isProject(d: AssetDTO | ProjectDTO): d is ProjectDTO { - return "type" in d && (d as ProjectDTO).type === "project"; -} - -function checkType(data: SubGroupsAndAsset): { - asset: AssetDTO | null; - subgroup: ProjectDTO | null; -} { - return isProject(data) - ? { asset: null, subgroup: data } - : { asset: data, subgroup: null }; -} - -type SubGroupsAndAsset = AssetDTO | ProjectDTO; -type SubGroupsAndAssets = Array; +import SubgroupsAndAssetsList, { + checkType, +} from "@/components/SubgroupsAndAssetsList"; export default function RepositoriesPage() { const [viewedProject, setViewedProject] = useState<"active" | "inactive">( @@ -83,6 +62,9 @@ export default function RepositoriesPage() { const [showModal, setShowModal] = useState(false); const searchParams = useSearchParams(); + const searchQuery = searchParams?.get("search") ?? ""; + const isSearchActive = searchQuery.length >= 3; + const queryWithState = useMemo(() => { const p = buildFilterSearchParams(searchParams); const state = searchParams?.get("state"); @@ -95,18 +77,35 @@ export default function RepositoriesPage() { return p; }, [searchParams]); - const { - isLoading, - data: subgroupsWithAssets, - error, - } = useSWR>(() => { + const pushQuery = useRouterQuery(); + + const swrUrl = (() => { if (!isOrganization(organization.organization)) return null; - const base = `/organizations/${decodeURIComponent(organization.organization.slug)}/projects/${decodeURIComponent(project.slug)}/resources?parentId=${project?.id}`; + const orgSlug = decodeURIComponent(organization.organization.slug); + if (isSearchActive) { + return `/organizations/${orgSlug}/projects/search?parentId=${project?.id}&${queryWithState.toString()}`; + } + const base = `/organizations/${orgSlug}/projects/${decodeURIComponent(project.slug)}/resources?parentId=${project?.id}`; const query = queryWithState.toString(); return query ? `${base}&${query}` : base; - }, fetcher); + })(); - const pushQuery = useRouterQuery(); + const { + data: subgroupsWithAssets, + error, + mutate, + } = useSWR>(swrUrl, async (url: string) => { + if (isSearchActive) { + const raw = (await fetcher(url)) as Paged< + ProjectDTO & { subGroupsAndAsset: SubGroupsAndAsset[] | null } + >; + return { + ...raw, + data: raw.data.flatMap((item) => item.subGroupsAndAsset ?? []), + }; + } + return fetcher>(url); + }); const router = useRouter(); const activeOrg = useActiveOrg(); @@ -134,13 +133,14 @@ export default function RepositoriesPage() { const debouncedHandleSearch = useCallback( debounce((e: React.ChangeEvent) => { - if (e.target.value === "") { - pushQuery({ search: undefined, page: "1" }); - } else if (e.target.value.length >= 3) { - pushQuery({ search: e.target.value, page: "1" }); + const value = e.target.value; + if (value === "") { + pushQuery({ search: undefined, page: 1 }); + } else if (value.length >= 3) { + pushQuery({ search: value, page: 1 }); } }, 500), - [], + [pushQuery], ); const handleSetTabValue = (value: string) => { @@ -203,6 +203,57 @@ export default function RepositoriesPage() { } }; + const handleLazyDataFetching = useCallback( + async (projectSlug: string, projectId: string) => { + const base = `/organizations/${decodeURIComponent(activeOrg.slug)}/projects/${decodeURIComponent(projectSlug)}/resources?parentId=${projectId}`; + + const resp = await browserApiClient(base); + if (resp.ok) { + const data = await resp.json(); + const subGroupsAndAsset = data as Paged; + + mutate( + (prev) => { + if (!prev) return prev; + // traverse the whole tree, find the correct project and update it with the new data + const recursiveFn = ( + item: SubGroupsAndAsset, + ): SubGroupsAndAsset => { + const { asset, subgroup } = checkType(item); + if (asset != null) { + return asset; + } + + if (subgroup.id === projectId) { + return { + ...subgroup, + subGroupsAndAsset: subGroupsAndAsset.data, + }; + } + + return { + ...subgroup, + subGroupsAndAsset: + subgroup?.subGroupsAndAsset?.map(recursiveFn), + }; + }; + + return { + ...prev, + data: prev.data.map(recursiveFn) as SubGroupsAndAsset[], + }; + }, + { revalidate: false }, + ); + } else { + toast.error( + "Failed to load subgroups and assets. Please try again later.", + ); + } + }, + [activeOrg.slug, mutate], + ); + return ( <> - - - - {project.externalEntityProviderId - ? "Repositories" - : "Subgroups & Repositories"} - - Inactive - - - +
+ + + + {project.externalEntityProviderId + ? "Repositories" + : "Subgroups & Repositories"} + + Inactive + + + {isSearchActive && ( + + Filter and sorting options are disabled while searching + + )} +
- } - renderItem={(item) => { - const { asset, subgroup } = checkType(item); - if (subgroup) - return ( - - - - - {subgroup.name.replace(project.name + " /", "")} - - Subgroup - {subgroup.state === "deleted" && ( - - Pending deletion - - )} - {subgroup.type === "kubernetesNamespace" && ( - - Kubernetes logo - Kubernetes Namespace - - )} -
- } - Description={ - ) => ( - {props.children} - ), - }} - > - {subgroup.description} - - } - /> - - ); - if (asset) - return ; - }} + subgroupsWithAssets={subgroupsWithAssets?.data} + projectSlug={project.slug} + onFetchData={handleLazyDataFetching} />
diff --git a/src/components/AssetOverviewListItem.tsx b/src/components/AssetOverviewListItem.tsx deleted file mode 100644 index 8c3674d8..00000000 --- a/src/components/AssetOverviewListItem.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import Link from "next/link"; -import React, { type FunctionComponent, useMemo } from "react"; -import { useActiveOrg } from "../hooks/useActiveOrg"; -import { useActiveProject } from "../hooks/useActiveProject"; -import type { AssetDTO, PolicyEvaluation } from "../types/api/api"; -import Avatar from "./Avatar"; -import ListItem from "./common/ListItem"; -import Markdown from "./common/Markdown"; -import { Badge } from "./ui/badge"; - -interface Props { - asset: AssetDTO; -} -const AssetOverviewListItem: FunctionComponent = ({ asset }) => { - const activeOrg = useActiveOrg(); - const project = useActiveProject(); - - return ( - - - - ) => ( - {props.children} - ), - }} - > - {asset.description} - - -
- } - Title={ -
- - {asset.name} - {asset.state === "archived" && ( - Archived - )} - {asset.state === "deleted" && ( - Pending deletion - )} -
- } - /> - - ); -}; - -export default AssetOverviewListItem; diff --git a/src/components/SubgroupsAndAssetsList.tsx b/src/components/SubgroupsAndAssetsList.tsx new file mode 100644 index 00000000..4be341f7 --- /dev/null +++ b/src/components/SubgroupsAndAssetsList.tsx @@ -0,0 +1,44 @@ +"use client"; + +import type { ProjectDTO, SubGroupsAndAsset } from "../types/api/api"; +import NestedList from "./group-list/NestedList"; +import ProjectRow from "./group-list/ProjectRow"; + +export { isProject, checkType } from "./group-list/utils"; + +interface Props { + project?: ProjectDTO; + subgroupsWithAssets?: SubGroupsAndAsset[]; + onFetchData: (projectSlug: string, projectId: string) => any; + error?: Error; + projectSlug: string; +} + +export default function SubgroupsAndAssetsList({ + project, + subgroupsWithAssets, + onFetchData, + error, + projectSlug, +}: Props) { + if (project) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/components/common/ProjectTitle.tsx b/src/components/common/ProjectTitle.tsx index 7795e2e4..a6ae5c17 100644 --- a/src/components/common/ProjectTitle.tsx +++ b/src/components/common/ProjectTitle.tsx @@ -6,11 +6,21 @@ import { Badge } from "../ui/badge"; import type { ProjectDTO } from "../../types/api/api"; import Image from "next/image"; import { truncateMiddle } from "@/utils/common"; +import { cn } from "@/lib/utils"; -export const ProjectBadge = ({ type }: { type: ProjectDTO["type"] }) => { +export const ProjectBadge = ({ + type, + inHeader = false, +}: { + type: ProjectDTO["type"]; + inHeader?: boolean; +}) => { if (type === "kubernetesNamespace") { return ( - + { ); } else if (type === "kubernetesCluster") { return ( - + { ); } else { return ( - + {type === "default" ? "Group" : "Subgroup"} ); @@ -88,7 +104,7 @@ export const ProjectElement = ({ title={displayName} > {truncateMiddle(displayName)} - + {!isLast && /} diff --git a/src/components/group-list/AssetRow.tsx b/src/components/group-list/AssetRow.tsx new file mode 100644 index 00000000..6c973432 --- /dev/null +++ b/src/components/group-list/AssetRow.tsx @@ -0,0 +1,65 @@ +import { CodeBracketSquareIcon } from "@heroicons/react/24/outline"; +import Link from "next/link"; +import type { ComponentPropsWithoutRef, FunctionComponent } from "react"; +import { useActiveOrg } from "../../hooks/useActiveOrg"; +import type { AssetDTO } from "../../types/api/api"; +import Markdown from "../common/Markdown"; +import { Badge } from "../ui/badge"; + +interface Props { + asset: AssetDTO; + projectSlug: string; +} + +const AssetRow: FunctionComponent = ({ asset, projectSlug }) => { + const activeOrg = useActiveOrg(); + + return ( + +
+ {asset.avatar ? ( + {asset.name} + ) : ( +
+ +
+ )} +
+
+ + {asset.name} + + {asset.state === "archived" && ( + Archived + )} + {asset.state === "deleted" && ( + Pending deletion + )} +
+ {Boolean(asset.description) && ( +
+ ) => ( + {props.children} + ), + }} + > + {asset.description} + +
+ )} +
+
+ + ); +}; + +export default AssetRow; diff --git a/src/components/group-list/EmptyGroupState.tsx b/src/components/group-list/EmptyGroupState.tsx new file mode 100644 index 00000000..9cdfa11e --- /dev/null +++ b/src/components/group-list/EmptyGroupState.tsx @@ -0,0 +1,24 @@ +import { FolderOpenIcon } from "@heroicons/react/24/outline"; +import type { FunctionComponent } from "react"; + +interface Props { + variant?: "card" | "row"; +} + +const EmptyGroupState: FunctionComponent = ({ variant = "card" }) => { + const isCard = variant === "card"; + return ( +
+ + No subgroups or repositories yet +
+ ); +}; + +export default EmptyGroupState; diff --git a/src/components/group-list/NestedList.tsx b/src/components/group-list/NestedList.tsx new file mode 100644 index 00000000..c315f8e2 --- /dev/null +++ b/src/components/group-list/NestedList.tsx @@ -0,0 +1,89 @@ +import type { FunctionComponent } from "react"; +import type { SubGroupsAndAsset } from "../../types/api/api"; +import Err from "../common/Err"; +import EmptyParty from "../common/EmptyParty"; +import SkeletonListItems from "../common/SkeletonListItems"; +import { Skeleton } from "../ui/skeleton"; +import AssetRow from "./AssetRow"; +import ProjectRow from "./ProjectRow"; +import { checkType } from "./utils"; + +interface Props { + items?: SubGroupsAndAsset[]; + onFetchData: (projectSlug: string, projectId: string) => any; + error?: Error; + isLoading?: boolean; + parentProjectSlug: string; + compact?: boolean; +} + +const NestedRowSkeleton: FunctionComponent = () => ( +
+ +
+ + +
+
+); + +const NestedList: FunctionComponent = ({ + items, + onFetchData, + error, + isLoading = false, + parentProjectSlug, + compact = false, +}) => { + if (isLoading) { + if (compact) { + return ( + <> + + + + + ); + } + return ; + } + + if (error) { + return ; + } + + if (!items || items.length === 0) { + if (compact) { + return null; + } + return ; + } + + return ( + <> + {items.map((item) => { + const { asset, subgroup } = checkType(item); + if (asset) { + return ( + + ); + } + return ( + + ); + })} + + ); +}; + +export default NestedList; diff --git a/src/components/group-list/ProjectRow.tsx b/src/components/group-list/ProjectRow.tsx new file mode 100644 index 00000000..e45b3e91 --- /dev/null +++ b/src/components/group-list/ProjectRow.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { + ChevronDownIcon, + RectangleGroupIcon as RectangleGroupIconOutline, +} from "@heroicons/react/24/outline"; +import { RectangleGroupIcon as RectangleGroupIconSolid } from "@heroicons/react/24/solid"; +import Link from "next/link"; +import { + useCallback, + useEffect, + useRef, + useState, + type ComponentPropsWithoutRef, + type FunctionComponent, + type KeyboardEvent, +} from "react"; +import { useActiveOrg } from "../../hooks/useActiveOrg"; +import type { ProjectDTO, SubGroupsAndAsset } from "../../types/api/api"; +import { classNames } from "../../utils/common"; +import Markdown from "../common/Markdown"; +import { ProjectBadge } from "../common/ProjectTitle"; +import { Badge } from "../ui/badge"; +import { Collapsible, CollapsibleContent } from "../ui/collapsible"; +import EmptyGroupState from "./EmptyGroupState"; +import NestedList from "./NestedList"; + +interface Props { + project: ProjectDTO; + subgroupsWithAssets?: SubGroupsAndAsset[]; + onFetchData: (projectSlug: string, projectId: string) => any; + error?: Error; + depth: "root" | "nested"; +} + +const ProjectRow: FunctionComponent = ({ + project, + subgroupsWithAssets, + onFetchData, + error, + depth, +}) => { + const activeOrg = useActiveOrg(); + const isRoot = depth === "root"; + const isFetched = subgroupsWithAssets != null; + const hasContent = isFetched && (subgroupsWithAssets?.length ?? 0) > 0; + const [isOpen, setIsOpen] = useState(hasContent); + const [isLoading, setIsLoading] = useState(false); + const [fetchCompleted, setFetchCompleted] = useState(isFetched); + const inFlight = useRef(false); + + const fetchChildren = useCallback(async () => { + if (inFlight.current) return; + inFlight.current = true; + setIsLoading(true); + setFetchCompleted(false); + try { + await onFetchData(project.slug, project.id); + setFetchCompleted(true); + } finally { + setIsLoading(false); + inFlight.current = false; + } + }, [onFetchData, project.slug, project.id]); + + useEffect(() => { + if (hasContent) setIsOpen(true); + }, [hasContent]); + + useEffect(() => { + if (isOpen && !isFetched && !inFlight.current) { + fetchChildren(); + } + }, [isOpen, isFetched, fetchChildren]); + + const toggle = async () => { + const next = !isOpen; + setIsOpen(next); + if (next && !isFetched) { + await fetchChildren(); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggle(); + } + }; + + const Icon = isRoot ? RectangleGroupIconSolid : RectangleGroupIconOutline; + + const body = ( + +
+ {project.avatar ? ( + {project.name} + ) : ( +
+ +
+ )} +
+
+ e.stopPropagation()} + className={classNames( + "!text-foreground truncate hover:underline", + isRoot ? "font-semibold text-base" : "font-medium", + )} + > + {project.name} + + {project.type !== "default" && } + {project.state === "deleted" && ( + Pending deletion + )} +
+ {Boolean(project.description) && ( +
+ ) => ( + {props.children} + ), + }} + > + {project.description} + +
+ )} +
+ +
+ + {(isLoading || Boolean(error) || hasContent) && ( +
+
+
+ +
+
+ )} + {!isLoading && + !error && + (isFetched || fetchCompleted) && + !hasContent && } + + + ); + + if (isRoot) { + return
{body}
; + } + return body; +}; + +export default ProjectRow; diff --git a/src/components/group-list/utils.ts b/src/components/group-list/utils.ts new file mode 100644 index 00000000..2ad6de07 --- /dev/null +++ b/src/components/group-list/utils.ts @@ -0,0 +1,25 @@ +import type { + AssetDTO, + ProjectDTO, + SubGroupsAndAsset, +} from "../../types/api/api"; + +export function isProject( + d: SubGroupsAndAsset, +): d is ProjectDTO & { resourceType: "project" } { + return d.resourceType === "project"; +} + +export function checkType(data: SubGroupsAndAsset): + | { + asset: AssetDTO & { resourceType: "asset" }; + subgroup: null; + } + | { + asset: null; + subgroup: ProjectDTO & { resourceType: "project" }; + } { + return isProject(data) + ? { asset: null, subgroup: data } + : { asset: data, subgroup: null }; +} diff --git a/src/types/api/api.ts b/src/types/api/api.ts index 6110af71..45da0060 100644 --- a/src/types/api/api.ts +++ b/src/types/api/api.ts @@ -143,6 +143,10 @@ export interface PatWithPrivKey extends PersonalAccessTokenDTO { privKey: string; } +export type SubGroupsAndAsset = + | (AssetDTO & { resourceType: "asset" }) + | (ProjectDTO & { resourceType: "project" }); + export interface ProjectDTO { avatar?: string; name: string; @@ -173,6 +177,8 @@ export interface ProjectDTO { externalEntityProviderId?: string; state: "active" | "deleted"; + + subGroupsAndAsset?: Array; } export type ExpandedVulnDTOState = | DependencyVuln["state"]