mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-06 12:19:50 +00:00
add empty state
This commit is contained in:
@@ -3573,6 +3573,11 @@
|
|||||||
"resourceLauncherViewAsAdmin": "View as Admin",
|
"resourceLauncherViewAsAdmin": "View as Admin",
|
||||||
"resourceLauncherUnlabeled": "Unlabeled",
|
"resourceLauncherUnlabeled": "Unlabeled",
|
||||||
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
||||||
|
"resourceLauncherEmptyStateTitle": "No Resources Available",
|
||||||
|
"resourceLauncherEmptyStateDescription": "You don't have access to any resources yet. Contact your administrator to request access.",
|
||||||
|
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
|
||||||
|
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
|
||||||
|
"resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.",
|
||||||
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
|
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
|
||||||
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
|
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
|
||||||
"resourceLauncherViewNamePlaceholder": "View name",
|
"resourceLauncherViewNamePlaceholder": "View name",
|
||||||
|
|||||||
118
src/components/resource-launcher/LauncherEmptyState.tsx
Normal file
118
src/components/resource-launcher/LauncherEmptyState.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { LayoutGrid, SearchX } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
type LauncherEmptyStateVariant = "empty" | "noResults";
|
||||||
|
|
||||||
|
type LauncherEmptyStateProps = {
|
||||||
|
variant: LauncherEmptyStateVariant;
|
||||||
|
layout: "grid" | "list";
|
||||||
|
query?: string;
|
||||||
|
onClearFilters?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function GhostResourceGrid() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex min-w-0 flex-col gap-2.5 rounded-xl border border-border/60 bg-muted/20 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<div className="size-10 shrink-0 rounded-lg bg-muted/60" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<div className="h-3.5 w-3/5 rounded bg-muted/60" />
|
||||||
|
<div className="h-3 w-2/5 rounded bg-muted/40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GhostResourceList() {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col" aria-hidden>
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-4 px-4 py-3",
|
||||||
|
index < 2 && "border-b border-border/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="size-8 shrink-0 rounded-lg bg-muted/60" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<div className="h-3.5 w-2/5 rounded bg-muted/60" />
|
||||||
|
<div className="h-3 w-1/4 rounded bg-muted/40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LauncherEmptyState({
|
||||||
|
variant,
|
||||||
|
layout,
|
||||||
|
query,
|
||||||
|
onClearFilters
|
||||||
|
}: LauncherEmptyStateProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const isNoResults = variant === "noResults";
|
||||||
|
const Icon = isNoResults ? SearchX : LayoutGrid;
|
||||||
|
const trimmedQuery = query?.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full overflow-hidden rounded-xl border border-dashed border-border">
|
||||||
|
<div className="pointer-events-none absolute inset-0 opacity-50">
|
||||||
|
{layout === "grid" ? (
|
||||||
|
<GhostResourceGrid />
|
||||||
|
) : (
|
||||||
|
<GhostResourceList />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative flex min-h-56 flex-col items-center justify-center gap-4 px-6 py-12 text-center">
|
||||||
|
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Icon className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md space-y-1.5">
|
||||||
|
<h3 className="text-base font-semibold text-foreground">
|
||||||
|
{isNoResults
|
||||||
|
? t("resourceLauncherEmptyStateNoResultsTitle")
|
||||||
|
: t("resourceLauncherEmptyStateTitle")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isNoResults
|
||||||
|
? trimmedQuery
|
||||||
|
? t(
|
||||||
|
"resourceLauncherEmptyStateNoResultsWithQuery",
|
||||||
|
{ query: trimmedQuery }
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"resourceLauncherEmptyStateNoResultsDescription"
|
||||||
|
)
|
||||||
|
: t("resourceLauncherEmptyStateDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isNoResults && onClearFilters ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClearFilters}
|
||||||
|
>
|
||||||
|
{t("clearAllFilters")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { LauncherEmptyState } from "./LauncherEmptyState";
|
||||||
import { LauncherGroupSection } from "./LauncherGroupSection";
|
import { LauncherGroupSection } from "./LauncherGroupSection";
|
||||||
|
|
||||||
type LauncherGroupListProps = {
|
type LauncherGroupListProps = {
|
||||||
@@ -23,15 +24,25 @@ type LauncherGroupListProps = {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
};
|
};
|
||||||
resourcesByGroupKey: Record<string, LauncherGroupResources>;
|
resourcesByGroupKey: Record<string, LauncherGroupResources>;
|
||||||
|
onClearFilters?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function hasActiveLauncherFilters(config: LauncherViewConfig): boolean {
|
||||||
|
return (
|
||||||
|
config.query.trim().length > 0 ||
|
||||||
|
config.siteIds.length > 0 ||
|
||||||
|
config.labelIds.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LauncherGroupList({
|
export function LauncherGroupList({
|
||||||
orgId,
|
orgId,
|
||||||
activeViewId,
|
activeViewId,
|
||||||
config,
|
config,
|
||||||
initialGroups,
|
initialGroups,
|
||||||
groupsPagination,
|
groupsPagination,
|
||||||
resourcesByGroupKey
|
resourcesByGroupKey,
|
||||||
|
onClearFilters
|
||||||
}: LauncherGroupListProps) {
|
}: LauncherGroupListProps) {
|
||||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -55,7 +66,7 @@ export function LauncherGroupList({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
...launcherQueries.groups(orgId, groupFilters),
|
...launcherQueries.groups(orgId, groupFilters),
|
||||||
initialData: {
|
initialData: {
|
||||||
@@ -91,6 +102,27 @@ export function LauncherGroupList({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
if (isFetching) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Loader2 className="size-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LauncherEmptyState
|
||||||
|
variant={
|
||||||
|
hasActiveLauncherFilters(config) ? "noResults" : "empty"
|
||||||
|
}
|
||||||
|
layout={config.layout}
|
||||||
|
query={config.query}
|
||||||
|
onClearFilters={onClearFilters}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
{groups.map((group) => {
|
{groups.map((group) => {
|
||||||
|
|||||||
@@ -306,6 +306,16 @@ export default function ResourceLauncher({
|
|||||||
[navigateToConfig]
|
[navigateToConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleClearFilters = useCallback(() => {
|
||||||
|
setSearchInput("");
|
||||||
|
navigateToConfig(activeViewIdRef.current, {
|
||||||
|
...configRef.current,
|
||||||
|
query: "",
|
||||||
|
siteIds: [],
|
||||||
|
labelIds: []
|
||||||
|
});
|
||||||
|
}, [navigateToConfig]);
|
||||||
|
|
||||||
const handleSaveToCurrent = () => {
|
const handleSaveToCurrent = () => {
|
||||||
if (isDefaultView) {
|
if (isDefaultView) {
|
||||||
return;
|
return;
|
||||||
@@ -448,6 +458,7 @@ export default function ResourceLauncher({
|
|||||||
initialGroups={groups}
|
initialGroups={groups}
|
||||||
groupsPagination={groupsPagination}
|
groupsPagination={groupsPagination}
|
||||||
resourcesByGroupKey={resourcesByGroupKey}
|
resourcesByGroupKey={resourcesByGroupKey}
|
||||||
|
onClearFilters={handleClearFilters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||||
|
|||||||
Reference in New Issue
Block a user