fix: refactor folders UI/UX (#1770)

- Add folder search
- Used correct HTML elements
- Added missing translations
- Removed automatic folder redirects
- Removed duplicate code
- Added folder loading skeletons and empty states
This commit is contained in:
Catalin Pit
2025-06-19 07:57:32 +03:00
committed by GitHub
parent 29a03d4ec7
commit 1be0e2842c
20 changed files with 946 additions and 2277 deletions

View File

@ -30,8 +30,12 @@ export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string })
);
useEffect(() => {
handleSearch(searchTerm);
}, [debouncedSearchTerm]);
const currentQueryParam = searchParams.get('query') || '';
if (debouncedSearchTerm !== currentQueryParam) {
handleSearch(debouncedSearchTerm);
}
}, [debouncedSearchTerm, searchParams]);
return (
<Input

View File

@ -1,19 +1,32 @@
import { FolderIcon, PinIcon } from 'lucide-react';
import { Plural, Trans } from '@lingui/react/macro';
import { FolderType } from '@prisma/client';
import {
ArrowRightIcon,
FolderIcon,
FolderPlusIcon,
MoreVerticalIcon,
PinIcon,
SettingsIcon,
TrashIcon,
} from 'lucide-react';
import { Link } from 'react-router';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatFolderCount } from '@documenso/lib/utils/format-folder-count';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useCurrentTeam } from '~/providers/team';
export type FolderCardProps = {
folder: TFolderWithSubfolders;
onNavigate: (folderId: string) => void;
onMove: (folder: TFolderWithSubfolders) => void;
onPin: (folderId: string) => void;
onUnpin: (folderId: string) => void;
@ -23,66 +36,132 @@ export type FolderCardProps = {
export const FolderCard = ({
folder,
onNavigate,
onMove,
onPin,
onUnpin,
onSettings,
onDelete,
}: FolderCardProps) => {
const team = useCurrentTeam();
const formatPath = () => {
const rootPath =
folder.type === FolderType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
return `${rootPath}/f/${folder.id}`;
};
return (
<div
key={folder.id}
className="border-border hover:border-muted-foreground/40 group relative flex flex-col rounded-lg border p-4 transition-all hover:shadow-sm"
>
<div className="flex items-start justify-between">
<button
className="flex items-center space-x-2 text-left"
onClick={() => onNavigate(folder.id)}
>
<FolderIcon className="text-documenso h-6 w-6" />
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium">{folder.name}</h3>
{folder.pinned && <PinIcon className="text-documenso h-3 w-3" />}
</div>
<div className="mt-1 flex space-x-2 text-xs text-gray-500">
<span>
{formatFolderCount(
folder.type === FolderType.TEMPLATE
? folder._count.templates
: folder._count.documents,
folder.type === FolderType.TEMPLATE ? 'template' : 'document',
folder.type === FolderType.TEMPLATE ? 'templates' : 'documents',
)}
</span>
<span></span>
<span>{formatFolderCount(folder._count.subfolders, 'folder', 'folders')}</span>
<Link to={formatPath()} key={folder.id}>
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
<CardContent className="p-4">
<div className="flex min-w-0 items-center gap-3">
<FolderIcon className="text-documenso h-6 w-6 flex-shrink-0" />
<div className="flex w-full min-w-0 items-center justify-between">
<div className="min-w-0 flex-1">
<h3 className="flex min-w-0 items-center gap-2 font-medium">
<span className="truncate">{folder.name}</span>
{folder.pinned && <PinIcon className="text-documenso h-3 w-3 flex-shrink-0" />}
</h3>
<div className="text-muted-foreground mt-1 flex space-x-2 truncate text-xs">
<span>
{folder.type === FolderType.TEMPLATE ? (
<Plural
value={folder._count.templates}
one={<Trans># template</Trans>}
other={<Trans># templates</Trans>}
/>
) : (
<Plural
value={folder._count.documents}
one={<Trans># document</Trans>}
other={<Trans># documents</Trans>}
/>
)}
</span>
<span></span>
<span>
<Plural
value={folder._count.subfolders}
one={<Trans># folder</Trans>}
other={<Trans># folders</Trans>}
/>
</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
data-testid="folder-card-more-button"
>
<MoreVerticalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent onClick={(e) => e.stopPropagation()} align="end">
<DropdownMenuItem onClick={() => onMove(folder)}>
<ArrowRightIcon className="mr-2 h-4 w-4" />
<Trans>Move</Trans>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => (folder.pinned ? onUnpin(folder.id) : onPin(folder.id))}
>
<PinIcon className="mr-2 h-4 w-4" />
{folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSettings(folder)}>
<SettingsIcon className="mr-2 h-4 w-4" />
<Trans>Settings</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(folder)}>
<TrashIcon className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100">
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onMove(folder)}>Move</DropdownMenuItem>
{folder.pinned ? (
<DropdownMenuItem onClick={() => onUnpin(folder.id)}>Unpin</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => onPin(folder.id)}>Pin</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onSettings(folder)}>Settings</DropdownMenuItem>
<DropdownMenuItem className="text-red-500" onClick={() => onDelete(folder)}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
</Link>
);
};
export const FolderCardEmpty = ({ type }: { type: FolderType }) => {
return (
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<FolderPlusIcon className="text-muted-foreground/60 h-6 w-6" />
<div>
<h3 className="text-muted-foreground flex items-center gap-2 font-medium">
<Trans>Create folder</Trans>
</h3>
<div className="text-muted-foreground/60 mt-1 flex space-x-2 truncate text-xs">
{type === FolderType.DOCUMENT ? (
<Trans>Organise your documents</Trans>
) : (
<Trans>Organise your templates</Trans>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
};

View File

@ -0,0 +1,249 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { FolderType } from '@prisma/client';
import { FolderIcon, HomeIcon } from 'lucide-react';
import { Link } from 'react-router';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
import { useCurrentTeam } from '~/providers/team';
export type FolderGridProps = {
type: FolderType;
parentId: string | null;
};
export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
const team = useCurrentTeam();
const [isMovingFolder, setIsMovingFolder] = useState(false);
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
const { data: foldersData, isPending } = trpc.folder.getFolders.useQuery({
type,
parentId,
});
const formatBreadCrumbPath = (folderId: string) => {
const rootPath =
type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
return `${rootPath}/f/${folderId}`;
};
const formatViewAllFoldersPath = () => {
const rootPath =
type === FolderType.DOCUMENT ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
return `${rootPath}/folders`;
};
const formatRootPath = () => {
return type === FolderType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
};
const pinnedFolders = foldersData?.folders.filter((folder) => folder.pinned) || [];
const unpinnedFolders = foldersData?.folders.filter((folder) => !folder.pinned) || [];
return (
<div>
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div
className="text-muted-foreground hover:text-muted-foreground/80 flex flex-1 items-center text-sm font-medium"
data-testid="folder-grid-breadcrumbs"
>
<Link to={formatRootPath()} className="flex items-center">
<HomeIcon className="mr-2 h-4 w-4" />
<Trans>Home</Trans>
</Link>
{isPending && parentId ? (
<div className="flex items-center">
<Skeleton className="mx-3 h-4 w-1 rotate-12" />
<Skeleton className="h-4 w-20" />
</div>
) : (
foldersData?.breadcrumbs.map((folder) => (
<div key={folder.id} className="flex items-center">
<span className="px-3">/</span>
<Link to={formatBreadCrumbPath(folder.id)} className="flex items-center">
<FolderIcon className="mr-2 h-4 w-4" />
<span>{folder.name}</span>
</Link>
</div>
))
)}
</div>
<div className="flex gap-4 sm:flex-row sm:justify-end">
{type === FolderType.DOCUMENT ? (
<DocumentUploadDropzone />
) : (
<TemplateCreateDialog folderId={parentId ?? undefined} />
)}
<FolderCreateDialog type={type} />
</div>
</div>
{isPending ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="border-border bg-card h-full rounded-lg border px-4 py-5">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded" />
<div className="flex w-full items-center justify-between">
<div className="flex-1">
<Skeleton className="mb-2 h-4 w-24" />
<div className="flex space-x-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-3" />
<Skeleton className="h-3 w-12" />
</div>
</div>
<Skeleton className="h-8 w-2 rounded" />
</div>
</div>
</div>
))}
</div>
) : foldersData && foldersData.folders.length === 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<FolderCreateDialog
type={type}
trigger={
<button>
<FolderCardEmpty type={type} />
</button>
}
/>
</div>
) : (
foldersData && (
<div key="content" className="space-y-4">
{pinnedFolders.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{pinnedFolders.map((folder) => (
<FolderCard
key={folder.id}
folder={folder}
onMove={(folder) => {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
}}
onDelete={(folder) => {
setFolderToDelete(folder);
setIsDeletingFolder(true);
}}
/>
))}
</div>
)}
{unpinnedFolders.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{unpinnedFolders.slice(0, 12).map((folder) => (
<FolderCard
key={folder.id}
folder={folder}
onMove={(folder) => {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
}}
onDelete={(folder) => {
setFolderToDelete(folder);
setIsDeletingFolder(true);
}}
/>
))}
</div>
)}
{foldersData.folders.length > 12 && (
<div className="mt-2 flex items-center justify-center">
<Link
className="text-muted-foreground hover:text-foreground text-sm font-medium"
to={formatViewAllFoldersPath()}
>
View all folders
</Link>
</div>
)}
</div>
)
)}
<FolderMoveDialog
foldersData={foldersData?.folders}
folder={folderToMove}
isOpen={isMovingFolder}
onOpenChange={(open) => {
setIsMovingFolder(open);
if (!open) {
setFolderToMove(null);
}
}}
/>
<FolderSettingsDialog
folder={folderToSettings}
isOpen={isSettingsFolderOpen}
onOpenChange={(open) => {
setIsSettingsFolderOpen(open);
if (!open) {
setFolderToSettings(null);
}
}}
/>
{folderToDelete && (
<FolderDeleteDialog
folder={folderToDelete}
isOpen={isDeletingFolder}
onOpenChange={(open) => {
setIsDeletingFolder(open);
if (!open) {
setFolderToDelete(null);
}
}}
/>
)}
</div>
);
};