add empty state

This commit is contained in:
miloschwartz
2026-06-30 21:18:46 -04:00
parent fed4ec42c4
commit 9f68be2a9b
4 changed files with 168 additions and 2 deletions

View File

@@ -3573,6 +3573,11 @@
"resourceLauncherViewAsAdmin": "View as Admin",
"resourceLauncherUnlabeled": "Unlabeled",
"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",
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
"resourceLauncherViewNamePlaceholder": "View name",

View 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>
);
}

View File

@@ -10,6 +10,7 @@ import type {
import { useInfiniteQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { LauncherEmptyState } from "./LauncherEmptyState";
import { LauncherGroupSection } from "./LauncherGroupSection";
type LauncherGroupListProps = {
@@ -23,15 +24,25 @@ type LauncherGroupListProps = {
pageSize: number;
};
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({
orgId,
activeViewId,
config,
initialGroups,
groupsPagination,
resourcesByGroupKey
resourcesByGroupKey,
onClearFilters
}: LauncherGroupListProps) {
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({
...launcherQueries.groups(orgId, groupFilters),
initialData: {
@@ -91,6 +102,27 @@ export function LauncherGroupList({
return () => observer.disconnect();
}, [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 (
<div className="flex flex-col gap-2.5">
{groups.map((group) => {

View File

@@ -306,6 +306,16 @@ export default function ResourceLauncher({
[navigateToConfig]
);
const handleClearFilters = useCallback(() => {
setSearchInput("");
navigateToConfig(activeViewIdRef.current, {
...configRef.current,
query: "",
siteIds: [],
labelIds: []
});
}, [navigateToConfig]);
const handleSaveToCurrent = () => {
if (isDefaultView) {
return;
@@ -448,6 +458,7 @@ export default function ResourceLauncher({
initialGroups={groups}
groupsPagination={groupsPagination}
resourcesByGroupKey={resourcesByGroupKey}
onClearFilters={handleClearFilters}
/>
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>