mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 02:15:05 +10:00
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:
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
@@ -61,6 +62,8 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
const form = useForm<TMoveDocumentFormSchema>({
|
const form = useForm<TMoveDocumentFormSchema>({
|
||||||
resolver: zodResolver(ZMoveDocumentFormSchema),
|
resolver: zodResolver(ZMoveDocumentFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -83,6 +86,7 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setSearchTerm('');
|
||||||
} else {
|
} else {
|
||||||
form.reset({ folderId: currentFolderId });
|
form.reset({ folderId: currentFolderId });
|
||||||
}
|
}
|
||||||
@@ -131,6 +135,10 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredFolders = folders?.data.filter((folder) =>
|
||||||
|
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -144,8 +152,18 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
|
||||||
|
<Input
|
||||||
|
placeholder={_(msg`Search folders...`)}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="folderId"
|
name="folderId"
|
||||||
@@ -154,8 +172,9 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Folder</Trans>
|
<Trans>Folder</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="space-y-2">
|
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||||
{isFoldersLoading ? (
|
{isFoldersLoading ? (
|
||||||
<div className="flex h-10 items-center justify-center">
|
<div className="flex h-10 items-center justify-center">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -170,10 +189,10 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
disabled={currentFolderId === null}
|
disabled={currentFolderId === null}
|
||||||
>
|
>
|
||||||
<HomeIcon className="mr-2 h-4 w-4" />
|
<HomeIcon className="mr-2 h-4 w-4" />
|
||||||
<Trans>Root (No Folder)</Trans>
|
<Trans>Home (No Folder)</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{folders?.data.map((folder) => (
|
{filteredFolders?.map((folder) => (
|
||||||
<Button
|
<Button
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -186,6 +205,12 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
{folder.name}
|
{folder.name}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{searchTerm && filteredFolders?.length === 0 && (
|
||||||
|
<div className="text-muted-foreground px-2 py-2 text-center text-sm">
|
||||||
|
<Trans>No folders found</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import type { FolderType } from '@prisma/client';
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { FolderPlusIcon } from 'lucide-react';
|
import { FolderPlusIcon } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -34,26 +31,22 @@ import {
|
|||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
const ZCreateFolderFormSchema = z.object({
|
const ZCreateFolderFormSchema = z.object({
|
||||||
name: z.string().min(1, { message: 'Folder name is required' }),
|
name: z.string().min(1, { message: 'Folder name is required' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
||||||
|
|
||||||
export type CreateFolderDialogProps = {
|
export type FolderCreateDialogProps = {
|
||||||
|
type: FolderType;
|
||||||
trigger?: React.ReactNode;
|
trigger?: React.ReactNode;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProps) => {
|
export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { t } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { folderId } = useParams();
|
const { folderId } = useParams();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
|
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
|
||||||
@@ -67,37 +60,21 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp
|
|||||||
|
|
||||||
const onSubmit = async (data: TCreateFolderFormSchema) => {
|
const onSubmit = async (data: TCreateFolderFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const newFolder = await createFolder({
|
await createFolder({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
parentId: folderId,
|
parentId: folderId,
|
||||||
type: FolderType.DOCUMENT,
|
type,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsCreateFolderOpen(false);
|
setIsCreateFolderOpen(false);
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
|
||||||
|
|
||||||
await navigate(`${documentsPath}/f/${newFolder.id}`);
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
description: 'Folder created successfully',
|
description: t`Folder created successfully`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
|
||||||
toast({
|
|
||||||
title: 'Failed to create folder',
|
|
||||||
description: _(msg`This folder name is already taken.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to create folder',
|
title: t`Failed to create folder`,
|
||||||
description: _(msg`An unknown error occurred while creating the folder.`),
|
description: t`An unknown error occurred while creating the folder.`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -113,48 +90,60 @@ export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProp
|
|||||||
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="outline" className="flex items-center space-x-2">
|
<Button
|
||||||
<FolderPlusIcon className="h-4 w-4" />
|
variant="outline"
|
||||||
<span>Create Folder</span>
|
className="flex items-center"
|
||||||
|
data-testid="folder-create-button"
|
||||||
|
>
|
||||||
|
<FolderPlusIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Create Folder</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create New Folder</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Create New Folder</Trans>
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Enter a name for your new folder. Folders help you organise your documents.
|
<Trans>Enter a name for your new folder. Folders help you organise your items.</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<FormField
|
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||||
control={form.control}
|
<FormField
|
||||||
name="name"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="name"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>Folder Name</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>
|
||||||
<Input placeholder="My Folder" {...field} />
|
<Trans>Folder Name</Trans>
|
||||||
</FormControl>
|
</FormLabel>
|
||||||
<FormMessage />
|
<FormControl>
|
||||||
</FormItem>
|
<Input placeholder={t`My Folder`} {...field} />
|
||||||
)}
|
</FormControl>
|
||||||
/>
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setIsCreateFolderOpen(false)}
|
onClick={() => setIsCreateFolderOpen(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
<Trans>Cancel</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit">Create</Button>
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
</DialogFooter>
|
<Trans>Create</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@@ -11,6 +10,7 @@ import { z } from 'zod';
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -32,22 +32,22 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type FolderDeleteDialogProps = {
|
export type FolderDeleteDialogProps = {
|
||||||
folder: TFolderWithSubfolders | null;
|
folder: TFolderWithSubfolders;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDeleteDialogProps) => {
|
export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDeleteDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
|
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
|
||||||
|
|
||||||
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
|
const deleteMessage = t`delete ${folder.name}`;
|
||||||
|
|
||||||
const ZDeleteFolderFormSchema = z.object({
|
const ZDeleteFolderFormSchema = z.object({
|
||||||
confirmText: z.literal(deleteMessage, {
|
confirmText: z.literal(deleteMessage, {
|
||||||
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
|
errorMap: () => ({ message: t`You must type '${deleteMessage}' to confirm` }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,8 +61,6 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
const onFormSubmit = async () => {
|
||||||
if (!folder) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteFolder({
|
await deleteFolder({
|
||||||
id: folder.id,
|
id: folder.id,
|
||||||
@@ -71,15 +69,15 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Folder deleted successfully',
|
title: t`Folder deleted successfully`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Folder not found',
|
title: t`Folder not found`,
|
||||||
description: _(msg`The folder you are trying to delete does not exist.`),
|
description: t`The folder you are trying to delete does not exist.`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,8 +85,8 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to delete folder',
|
title: t`Failed to delete folder`,
|
||||||
description: _(msg`An unknown error occurred while deleting the folder.`),
|
description: t`An unknown error occurred while deleting the folder.`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -104,53 +102,65 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
|||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Folder</DialogTitle>
|
<DialogTitle>
|
||||||
|
<Trans>Delete Folder</Trans>
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete this folder?
|
<Trans>Are you sure you want to delete this folder?</Trans>
|
||||||
{folder && folder._count.documents > 0 && (
|
|
||||||
<span className="text-destructive mt-2 block">
|
|
||||||
This folder contains {folder._count.documents} document(s). Deleting it will also
|
|
||||||
delete all documents in the folder.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{folder && folder._count.subfolders > 0 && (
|
|
||||||
<span className="text-destructive mt-2 block">
|
|
||||||
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
|
|
||||||
delete all subfolders and their contents.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{(folder._count.documents > 0 ||
|
||||||
|
folder._count.templates > 0 ||
|
||||||
|
folder._count.subfolders > 0) && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>
|
||||||
|
This folder contains multiple items. Deleting it will also delete all items in the
|
||||||
|
folder, including nested folders and their contents.
|
||||||
|
</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
<FormField
|
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||||
control={form.control}
|
<FormField
|
||||||
name="confirmText"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="confirmText"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>
|
<FormItem>
|
||||||
<Trans>
|
<FormLabel>
|
||||||
Confirm by typing:{' '}
|
<Trans>
|
||||||
<span className="font-sm text-destructive font-semibold">
|
Confirm by typing:{' '}
|
||||||
{deleteMessage}
|
<span className="font-sm text-destructive font-semibold">
|
||||||
</span>
|
{deleteMessage}
|
||||||
</Trans>
|
</span>
|
||||||
</FormLabel>
|
</Trans>
|
||||||
<FormControl>
|
</FormLabel>
|
||||||
<Input {...field} placeholder={deleteMessage} />
|
<FormControl>
|
||||||
</FormControl>
|
<Input placeholder={deleteMessage} {...field} />
|
||||||
<FormMessage />
|
</FormControl>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
<DialogFooter>
|
/>
|
||||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
<DialogFooter>
|
||||||
</Button>
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
|
<Trans>Cancel</Trans>
|
||||||
Delete
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
</DialogFooter>
|
variant="destructive"
|
||||||
|
type="submit"
|
||||||
|
disabled={!form.formState.isValid}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { FolderIcon, HomeIcon } from 'lucide-react';
|
import { FolderIcon, HomeIcon, Search } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
FormItem,
|
FormItem,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export type FolderMoveDialogProps = {
|
export type FolderMoveDialogProps = {
|
||||||
@@ -48,9 +49,10 @@ export const FolderMoveDialog = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: FolderMoveDialogProps) => {
|
}: FolderMoveDialogProps) => {
|
||||||
const { _ } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
||||||
|
|
||||||
const form = useForm<TMoveFolderFormSchema>({
|
const form = useForm<TMoveFolderFormSchema>({
|
||||||
@@ -72,15 +74,15 @@ export const FolderMoveDialog = ({
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Folder moved successfully',
|
title: t`Folder moved successfully`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Folder not found',
|
title: t`Folder not found`,
|
||||||
description: _(msg`The folder you are trying to move does not exist.`),
|
description: t`The folder you are trying to move does not exist.`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,8 +90,8 @@ export const FolderMoveDialog = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Failed to move folder',
|
title: t`Failed to move folder`,
|
||||||
description: _(msg`An unknown error occurred while moving the folder.`),
|
description: t`An unknown error occurred while moving the folder.`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -98,69 +100,91 @@ export const FolderMoveDialog = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setSearchTerm('');
|
||||||
}
|
}
|
||||||
}, [isOpen, form]);
|
}, [isOpen, form]);
|
||||||
|
|
||||||
// Filter out the current folder and only show folders of the same type
|
// Filter out the current folder, only show folders of the same type, and filter by search term
|
||||||
const filteredFolders = foldersData?.filter(
|
const filteredFolders = foldersData?.filter(
|
||||||
(f) => f.id !== folder?.id && f.type === folder?.type,
|
(f) =>
|
||||||
|
f.id !== folder?.id &&
|
||||||
|
f.type === folder?.type &&
|
||||||
|
(searchTerm === '' || f.name.toLowerCase().includes(searchTerm.toLowerCase())),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Move Folder</DialogTitle>
|
<DialogTitle>
|
||||||
<DialogDescription>Select a destination for this folder.</DialogDescription>
|
<Trans>Move Folder</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Select a destination for this folder.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="targetFolderId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={!field.value ? 'default' : 'outline'}
|
|
||||||
className="w-full justify-start"
|
|
||||||
disabled={!folder?.parentId}
|
|
||||||
onClick={() => field.onChange(null)}
|
|
||||||
>
|
|
||||||
<HomeIcon className="mr-2 h-4 w-4" />
|
|
||||||
Root
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{filteredFolders &&
|
<div className="relative">
|
||||||
filteredFolders.map((f) => (
|
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
|
||||||
<Button
|
<Input
|
||||||
key={f.id}
|
placeholder={t`Search folders...`}
|
||||||
type="button"
|
value={searchTerm}
|
||||||
disabled={f.id === folder?.parentId}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
variant={field.value === f.id ? 'default' : 'outline'}
|
className="pl-8"
|
||||||
className="w-full justify-start"
|
/>
|
||||||
onClick={() => field.onChange(f.id)}
|
</div>
|
||||||
>
|
|
||||||
<FolderIcon className="mr-2 h-4 w-4" />
|
<Form {...form}>
|
||||||
{f.name}
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
</Button>
|
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||||
))}
|
<FormField
|
||||||
</div>
|
control={form.control}
|
||||||
</FormControl>
|
name="targetFolderId"
|
||||||
<FormMessage />
|
render={({ field }) => (
|
||||||
</FormItem>
|
<FormItem>
|
||||||
)}
|
<FormControl>
|
||||||
/>
|
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||||
<DialogFooter>
|
<Button
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
type="button"
|
||||||
Cancel
|
variant={!field.value ? 'default' : 'outline'}
|
||||||
</Button>
|
className="w-full justify-start"
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
disabled={!folder?.parentId}
|
||||||
Move Folder
|
onClick={() => field.onChange(null)}
|
||||||
</Button>
|
>
|
||||||
</DialogFooter>
|
<HomeIcon className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Home</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{filteredFolders &&
|
||||||
|
filteredFolders.map((f) => (
|
||||||
|
<Button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
disabled={f.id === folder?.parentId}
|
||||||
|
variant={field.value === f.id ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => field.onChange(f.id)}
|
||||||
|
>
|
||||||
|
<FolderIcon className="mr-2 h-4 w-4" />
|
||||||
|
{f.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
<Trans>Move</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router';
|
|||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -23,18 +24,21 @@ import {
|
|||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
type TemplateCreateDialogProps = {
|
type TemplateCreateDialogProps = {
|
||||||
templateRootPath: string;
|
|
||||||
folderId?: string;
|
folderId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TemplateCreateDialog = ({ templateRootPath, folderId }: TemplateCreateDialogProps) => {
|
export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||||
|
|
||||||
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
|
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
|
||||||
@@ -66,7 +70,7 @@ export const TemplateCreateDialog = ({ templateRootPath, folderId }: TemplateCre
|
|||||||
|
|
||||||
setShowTemplateCreateDialog(false);
|
setShowTemplateCreateDialog(false);
|
||||||
|
|
||||||
await navigate(`${templateRootPath}/${id}/edit`);
|
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { FolderPlusIcon } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useNavigate, useParams } from 'react-router';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
|
||||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
const ZCreateFolderFormSchema = z.object({
|
|
||||||
name: z.string().min(1, { message: 'Folder name is required' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
|
|
||||||
|
|
||||||
export type TemplateFolderCreateDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const TemplateFolderCreateDialog = ({
|
|
||||||
trigger,
|
|
||||||
...props
|
|
||||||
}: TemplateFolderCreateDialogProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { folderId } = useParams();
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<TCreateFolderFormSchema>({
|
|
||||||
resolver: zodResolver(ZCreateFolderFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data: TCreateFolderFormSchema) => {
|
|
||||||
try {
|
|
||||||
const newFolder = await createFolder({
|
|
||||||
name: data.name,
|
|
||||||
parentId: folderId,
|
|
||||||
type: FolderType.TEMPLATE,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsCreateFolderOpen(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
description: _(msg`Folder created successfully`),
|
|
||||||
});
|
|
||||||
|
|
||||||
const templatesPath = formatTemplatesPath(team.url);
|
|
||||||
|
|
||||||
void navigate(`${templatesPath}/f/${newFolder.id}`);
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Failed to create folder`),
|
|
||||||
description: _(msg`This folder name is already taken.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Failed to create folder`),
|
|
||||||
description: _(msg`An unknown error occurred while creating the folder.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isCreateFolderOpen) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [isCreateFolderOpen, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="outline" className="flex items-center space-x-2">
|
|
||||||
<FolderPlusIcon className="h-4 w-4" />
|
|
||||||
<span>Create Folder</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create New Folder</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Enter a name for your new folder. Folders help you organise your templates.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Folder Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="My Folder" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setIsCreateFolderOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit">Create</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type TemplateFolderDeleteDialogProps = {
|
|
||||||
folder: TFolderWithSubfolders | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const TemplateFolderDeleteDialog = ({
|
|
||||||
folder,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
}: TemplateFolderDeleteDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
|
|
||||||
|
|
||||||
const deleteMessage = _(msg`delete ${folder?.name ?? 'folder'}`);
|
|
||||||
|
|
||||||
const ZDeleteFolderFormSchema = z.object({
|
|
||||||
confirmText: z.literal(deleteMessage, {
|
|
||||||
errorMap: () => ({ message: _(msg`You must type '${deleteMessage}' to confirm`) }),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TDeleteFolderFormSchema = z.infer<typeof ZDeleteFolderFormSchema>;
|
|
||||||
|
|
||||||
const form = useForm<TDeleteFolderFormSchema>({
|
|
||||||
resolver: zodResolver(ZDeleteFolderFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
confirmText: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
|
||||||
if (!folder) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteFolder({
|
|
||||||
id: folder.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Folder deleted successfully',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
|
||||||
toast({
|
|
||||||
title: 'Folder not found',
|
|
||||||
description: _(msg`The folder you are trying to delete does not exist.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Failed to delete folder',
|
|
||||||
description: _(msg`An unknown error occurred while deleting the folder.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete Folder</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete this folder?
|
|
||||||
{folder && folder._count.documents > 0 && (
|
|
||||||
<span className="text-destructive mt-2 block">
|
|
||||||
This folder contains {folder._count.documents} document(s). Deleting it will also
|
|
||||||
delete all documents in the folder.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{folder && folder._count.subfolders > 0 && (
|
|
||||||
<span className="text-destructive mt-2 block">
|
|
||||||
This folder contains {folder._count.subfolders} subfolder(s). Deleting it will
|
|
||||||
delete all subfolders and their contents.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="confirmText"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>
|
|
||||||
Confirm by typing:{' '}
|
|
||||||
<span className="font-sm text-destructive font-semibold">
|
|
||||||
{deleteMessage}
|
|
||||||
</span>
|
|
||||||
</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} placeholder={deleteMessage} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" type="submit" disabled={!form.formState.isValid}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { FolderIcon, HomeIcon } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type TemplateFolderMoveDialogProps = {
|
|
||||||
foldersData: TFolderWithSubfolders[] | undefined;
|
|
||||||
folder: TFolderWithSubfolders | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZMoveFolderFormSchema = z.object({
|
|
||||||
targetFolderId: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TMoveFolderFormSchema = z.infer<typeof ZMoveFolderFormSchema>;
|
|
||||||
|
|
||||||
export const TemplateFolderMoveDialog = ({
|
|
||||||
foldersData,
|
|
||||||
folder,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
}: TemplateFolderMoveDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<TMoveFolderFormSchema>({
|
|
||||||
resolver: zodResolver(ZMoveFolderFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
targetFolderId: folder?.parentId ?? '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
|
||||||
if (!folder) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await moveFolder({
|
|
||||||
id: folder.id,
|
|
||||||
parentId: targetFolderId ?? '',
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Folder moved successfully',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
|
||||||
toast({
|
|
||||||
title: 'Folder not found',
|
|
||||||
description: _(msg`The folder you are trying to move does not exist.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Failed to move folder',
|
|
||||||
description: _(msg`An unknown error occurred while moving the folder.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [isOpen, form]);
|
|
||||||
|
|
||||||
// Filter out the current folder and only show folders of the same type
|
|
||||||
const filteredFolders = foldersData?.filter(
|
|
||||||
(f) => f.id !== folder?.id && f.type === folder?.type,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Move Folder</DialogTitle>
|
|
||||||
<DialogDescription>Select a destination for this folder.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4 py-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="targetFolderId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={!field.value ? 'default' : 'outline'}
|
|
||||||
className="w-full justify-start"
|
|
||||||
disabled={!folder?.parentId}
|
|
||||||
onClick={() => field.onChange(undefined)}
|
|
||||||
>
|
|
||||||
<HomeIcon className="mr-2 h-4 w-4" />
|
|
||||||
Root
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{filteredFolders &&
|
|
||||||
filteredFolders.map((f) => (
|
|
||||||
<Button
|
|
||||||
key={f.id}
|
|
||||||
type="button"
|
|
||||||
disabled={f.id === folder?.parentId}
|
|
||||||
variant={field.value === f.id ? 'default' : 'outline'}
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={() => field.onChange(f.id)}
|
|
||||||
>
|
|
||||||
<FolderIcon className="mr-2 h-4 w-4" />
|
|
||||||
{f.name}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
form.formState.isSubmitting ||
|
|
||||||
form.getValues('targetFolderId') === folder?.parentId
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Move Folder
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
|
||||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type TemplateFolderSettingsDialogProps = {
|
|
||||||
folder: TFolderWithSubfolders | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const ZUpdateFolderFormSchema = z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
visibility: z.nativeEnum(DocumentVisibility).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TUpdateFolderFormSchema = z.infer<typeof ZUpdateFolderFormSchema>;
|
|
||||||
|
|
||||||
export const TemplateFolderSettingsDialog = ({
|
|
||||||
folder,
|
|
||||||
isOpen,
|
|
||||||
onOpenChange,
|
|
||||||
}: TemplateFolderSettingsDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const team = useOptionalCurrentTeam();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
|
||||||
|
|
||||||
const isTeamContext = !!team;
|
|
||||||
const isTemplateFolder = folder?.type === FolderType.TEMPLATE;
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
|
|
||||||
resolver: zodResolver(ZUpdateFolderFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: folder?.name ?? '',
|
|
||||||
visibility: folder?.visibility ?? DocumentVisibility.EVERYONE,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (folder) {
|
|
||||||
form.reset({
|
|
||||||
name: folder.name,
|
|
||||||
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [folder, form]);
|
|
||||||
|
|
||||||
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
|
|
||||||
if (!folder) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateFolder({
|
|
||||||
id: folder.id,
|
|
||||||
name: data.name,
|
|
||||||
visibility:
|
|
||||||
isTeamContext && !isTemplateFolder
|
|
||||||
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
|
||||||
: DocumentVisibility.EVERYONE,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Folder updated successfully`),
|
|
||||||
});
|
|
||||||
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Folder not found`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Folder Settings</DialogTitle>
|
|
||||||
<DialogDescription>Manage the settings for this folder.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isTeamContext && !isTemplateFolder && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="visibility"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Visibility</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select visibility" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={DocumentVisibility.EVERYONE}>Everyone</SelectItem>
|
|
||||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
|
||||||
Managers and above
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value={DocumentVisibility.ADMIN}>Admins only</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="submit">Save Changes</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
@@ -59,9 +60,12 @@ export function TemplateMoveToFolderDialog({
|
|||||||
}: TemplateMoveToFolderDialogProps) {
|
}: TemplateMoveToFolderDialogProps) {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
const form = useForm<TMoveTemplateFormSchema>({
|
const form = useForm<TMoveTemplateFormSchema>({
|
||||||
resolver: zodResolver(ZMoveTemplateFormSchema),
|
resolver: zodResolver(ZMoveTemplateFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -84,6 +88,7 @@ export function TemplateMoveToFolderDialog({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
setSearchTerm('');
|
||||||
} else {
|
} else {
|
||||||
form.reset({ folderId: currentFolderId ?? null });
|
form.reset({ folderId: currentFolderId ?? null });
|
||||||
}
|
}
|
||||||
@@ -132,6 +137,10 @@ export function TemplateMoveToFolderDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredFolders = folders?.data?.filter((folder) =>
|
||||||
|
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
|
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@@ -145,6 +154,16 @@ export function TemplateMoveToFolderDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
|
||||||
|
<Input
|
||||||
|
placeholder={_(msg`Search folders...`)}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -155,8 +174,9 @@ export function TemplateMoveToFolderDialog({
|
|||||||
<FormLabel>
|
<FormLabel>
|
||||||
<Trans>Folder</Trans>
|
<Trans>Folder</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="space-y-2">
|
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||||
{isFoldersLoading ? (
|
{isFoldersLoading ? (
|
||||||
<div className="flex h-10 items-center justify-center">
|
<div className="flex h-10 items-center justify-center">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -171,10 +191,10 @@ export function TemplateMoveToFolderDialog({
|
|||||||
disabled={currentFolderId === null}
|
disabled={currentFolderId === null}
|
||||||
>
|
>
|
||||||
<HomeIcon className="mr-2 h-4 w-4" />
|
<HomeIcon className="mr-2 h-4 w-4" />
|
||||||
<Trans>Root (No Folder)</Trans>
|
<Trans>Home (No Folder)</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{folders?.data?.map((folder) => (
|
{filteredFolders?.map((folder) => (
|
||||||
<Button
|
<Button
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -187,6 +207,12 @@ export function TemplateMoveToFolderDialog({
|
|||||||
{folder.name}
|
{folder.name}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{searchTerm && filteredFolders?.length === 0 && (
|
||||||
|
<div className="text-muted-foreground px-2 py-2 text-center text-sm">
|
||||||
|
<Trans>No folders found</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,8 +30,12 @@ export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string })
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleSearch(searchTerm);
|
const currentQueryParam = searchParams.get('query') || '';
|
||||||
}, [debouncedSearchTerm]);
|
|
||||||
|
if (debouncedSearchTerm !== currentQueryParam) {
|
||||||
|
handleSearch(debouncedSearchTerm);
|
||||||
|
}
|
||||||
|
}, [debouncedSearchTerm, searchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -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 { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { formatFolderCount } from '@documenso/lib/utils/format-folder-count';
|
|
||||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type FolderCardProps = {
|
export type FolderCardProps = {
|
||||||
folder: TFolderWithSubfolders;
|
folder: TFolderWithSubfolders;
|
||||||
onNavigate: (folderId: string) => void;
|
|
||||||
onMove: (folder: TFolderWithSubfolders) => void;
|
onMove: (folder: TFolderWithSubfolders) => void;
|
||||||
onPin: (folderId: string) => void;
|
onPin: (folderId: string) => void;
|
||||||
onUnpin: (folderId: string) => void;
|
onUnpin: (folderId: string) => void;
|
||||||
@@ -23,66 +36,132 @@ export type FolderCardProps = {
|
|||||||
|
|
||||||
export const FolderCard = ({
|
export const FolderCard = ({
|
||||||
folder,
|
folder,
|
||||||
onNavigate,
|
|
||||||
onMove,
|
onMove,
|
||||||
onPin,
|
onPin,
|
||||||
onUnpin,
|
onUnpin,
|
||||||
onSettings,
|
onSettings,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: FolderCardProps) => {
|
}: FolderCardProps) => {
|
||||||
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const formatPath = () => {
|
||||||
|
const rootPath =
|
||||||
|
folder.type === FolderType.DOCUMENT
|
||||||
|
? formatDocumentsPath(team.url)
|
||||||
|
: formatTemplatesPath(team.url);
|
||||||
|
|
||||||
|
return `${rootPath}/f/${folder.id}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Link to={formatPath()} key={folder.id}>
|
||||||
key={folder.id}
|
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||||
className="border-border hover:border-muted-foreground/40 group relative flex flex-col rounded-lg border p-4 transition-all hover:shadow-sm"
|
<CardContent className="p-4">
|
||||||
>
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="flex items-start justify-between">
|
<FolderIcon className="text-documenso h-6 w-6 flex-shrink-0" />
|
||||||
<button
|
|
||||||
className="flex items-center space-x-2 text-left"
|
<div className="flex w-full min-w-0 items-center justify-between">
|
||||||
onClick={() => onNavigate(folder.id)}
|
<div className="min-w-0 flex-1">
|
||||||
>
|
<h3 className="flex min-w-0 items-center gap-2 font-medium">
|
||||||
<FolderIcon className="text-documenso h-6 w-6" />
|
<span className="truncate">{folder.name}</span>
|
||||||
<div>
|
{folder.pinned && <PinIcon className="text-documenso h-3 w-3 flex-shrink-0" />}
|
||||||
<div className="flex items-center gap-2">
|
</h3>
|
||||||
<h3 className="font-medium">{folder.name}</h3>
|
|
||||||
{folder.pinned && <PinIcon className="text-documenso h-3 w-3" />}
|
<div className="text-muted-foreground mt-1 flex space-x-2 truncate text-xs">
|
||||||
</div>
|
<span>
|
||||||
<div className="mt-1 flex space-x-2 text-xs text-gray-500">
|
{folder.type === FolderType.TEMPLATE ? (
|
||||||
<span>
|
<Plural
|
||||||
{formatFolderCount(
|
value={folder._count.templates}
|
||||||
folder.type === FolderType.TEMPLATE
|
one={<Trans># template</Trans>}
|
||||||
? folder._count.templates
|
other={<Trans># templates</Trans>}
|
||||||
: folder._count.documents,
|
/>
|
||||||
folder.type === FolderType.TEMPLATE ? 'template' : 'document',
|
) : (
|
||||||
folder.type === FolderType.TEMPLATE ? 'templates' : 'documents',
|
<Plural
|
||||||
)}
|
value={folder._count.documents}
|
||||||
</span>
|
one={<Trans># document</Trans>}
|
||||||
<span>•</span>
|
other={<Trans># documents</Trans>}
|
||||||
<span>{formatFolderCount(folder._count.subfolders, 'folder', 'folders')}</span>
|
/>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<DropdownMenu>
|
</Link>
|
||||||
<DropdownMenuTrigger asChild>
|
);
|
||||||
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100">
|
};
|
||||||
•••
|
|
||||||
</Button>
|
export const FolderCardEmpty = ({ type }: { type: FolderType }) => {
|
||||||
</DropdownMenuTrigger>
|
return (
|
||||||
|
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||||
<DropdownMenuContent align="end">
|
<CardContent className="p-4">
|
||||||
<DropdownMenuItem onClick={() => onMove(folder)}>Move</DropdownMenuItem>
|
<div className="flex items-center gap-3">
|
||||||
{folder.pinned ? (
|
<FolderPlusIcon className="text-muted-foreground/60 h-6 w-6" />
|
||||||
<DropdownMenuItem onClick={() => onUnpin(folder.id)}>Unpin</DropdownMenuItem>
|
|
||||||
) : (
|
<div>
|
||||||
<DropdownMenuItem onClick={() => onPin(folder.id)}>Pin</DropdownMenuItem>
|
<h3 className="text-muted-foreground flex items-center gap-2 font-medium">
|
||||||
)}
|
<Trans>Create folder</Trans>
|
||||||
<DropdownMenuItem onClick={() => onSettings(folder)}>Settings</DropdownMenuItem>
|
</h3>
|
||||||
<DropdownMenuItem className="text-red-500" onClick={() => onDelete(folder)}>
|
|
||||||
Delete
|
<div className="text-muted-foreground/60 mt-1 flex space-x-2 truncate text-xs">
|
||||||
</DropdownMenuItem>
|
{type === FolderType.DOCUMENT ? (
|
||||||
</DropdownMenuContent>
|
<Trans>Organise your documents</Trans>
|
||||||
</DropdownMenu>
|
) : (
|
||||||
</div>
|
<Trans>Organise your templates</Trans>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { OrganisationType } from '@prisma/client';
|
import { FolderType, OrganisationType } from '@prisma/client';
|
||||||
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
import { useParams, useSearchParams } from 'react-router';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
@@ -18,21 +16,14 @@ import {
|
|||||||
type TFindDocumentsInternalResponse,
|
type TFindDocumentsInternalResponse,
|
||||||
ZFindDocumentsInternalRequestSchema,
|
ZFindDocumentsInternalRequestSchema,
|
||||||
} from '@documenso/trpc/server/document-router/schema';
|
} from '@documenso/trpc/server/document-router/schema';
|
||||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
||||||
import { CreateFolderDialog } 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 { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
|
import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
|
||||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
import { DocumentSearch } from '~/components/general/document/document-search';
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
|
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||||
import { FolderCard } from '~/components/general/folder/folder-card';
|
|
||||||
import { PeriodSelector } from '~/components/general/period-selector';
|
import { PeriodSelector } from '~/components/general/period-selector';
|
||||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
import { DocumentsTable } from '~/components/tables/documents-table';
|
||||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
||||||
@@ -55,23 +46,14 @@ const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function DocumentsPage() {
|
export default function DocumentsPage() {
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const { folderId } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
||||||
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
|
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
|
||||||
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 [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
|
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
|
||||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
[ExtendedDocumentStatus.DRAFT]: 0,
|
||||||
@@ -87,26 +69,11 @@ export default function DocumentsPage() {
|
|||||||
[searchParams],
|
[searchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocumentsInternal.useQuery(
|
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery({
|
||||||
{
|
...findDocumentSearchParams,
|
||||||
...findDocumentSearchParams,
|
folderId,
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: foldersData,
|
|
||||||
isLoading: isFoldersLoading,
|
|
||||||
refetch: refetchFolders,
|
|
||||||
} = trpc.folder.getFolders.useQuery({
|
|
||||||
type: FolderType.DOCUMENT,
|
|
||||||
parentId: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refetch();
|
|
||||||
void refetchFolders();
|
|
||||||
}, [team?.url]);
|
|
||||||
|
|
||||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
@@ -124,7 +91,17 @@ export default function DocumentsPage() {
|
|||||||
params.delete('page');
|
params.delete('page');
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${formatDocumentsPath(team.url)}?${params.toString()}`;
|
let path = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
|
if (folderId) {
|
||||||
|
path += `/f/${folderId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
path += `?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -133,147 +110,19 @@ export default function DocumentsPage() {
|
|||||||
}
|
}
|
||||||
}, [data?.stats]);
|
}, [data?.stats]);
|
||||||
|
|
||||||
const navigateToFolder = (folderId?: string | null) => {
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
|
||||||
|
|
||||||
if (folderId) {
|
|
||||||
void navigate(`${documentsPath}/f/${folderId}`);
|
|
||||||
} else {
|
|
||||||
void navigate(documentsPath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewAllFolders = () => {
|
|
||||||
void navigate(`${formatDocumentsPath(team.url)}/folders`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentDropZoneWrapper>
|
<DocumentDropZoneWrapper>
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<FolderGrid type={FolderType.DOCUMENT} parentId={folderId ?? null} />
|
||||||
<div className="flex flex-1 items-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
|
|
||||||
onClick={() => navigateToFolder(null)}
|
|
||||||
>
|
|
||||||
<HomeIcon className="h-4 w-4" />
|
|
||||||
<span>Home</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{foldersData?.breadcrumbs.map((folder) => (
|
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||||
<div key={folder.id} className="flex items-center space-x-2">
|
|
||||||
<span>/</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center space-x-2 pl-1 hover:bg-transparent"
|
|
||||||
onClick={() => navigateToFolder(folder.id)}
|
|
||||||
>
|
|
||||||
<FolderIcon className="h-4 w-4" />
|
|
||||||
<span>{folder.name}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
|
||||||
<DocumentUploadDropzone />
|
|
||||||
<CreateFolderDialog />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isFoldersLoading ? (
|
|
||||||
<div className="mt-6 flex justify-center">
|
|
||||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{foldersData?.folders?.some((folder) => folder.pinned) && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{foldersData.folders
|
|
||||||
.filter((folder) => folder.pinned)
|
|
||||||
.map((folder) => (
|
|
||||||
<FolderCard
|
|
||||||
key={folder.id}
|
|
||||||
folder={folder}
|
|
||||||
onNavigate={navigateToFolder}
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{foldersData?.folders
|
|
||||||
?.filter((folder) => !folder.pinned)
|
|
||||||
.slice(0, 12)
|
|
||||||
.map((folder) => (
|
|
||||||
<FolderCard
|
|
||||||
key={folder.id}
|
|
||||||
folder={folder}
|
|
||||||
onNavigate={navigateToFolder}
|
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="mt-6 flex items-center justify-center">
|
|
||||||
{foldersData && foldersData.folders?.length > 12 && (
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => void handleViewAllFolders()}
|
|
||||||
>
|
|
||||||
View all folders
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
{team && (
|
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
<AvatarFallback className="text-muted-foreground text-xs">
|
||||||
<AvatarFallback className="text-muted-foreground text-xs">
|
{team.name.slice(0, 1)}
|
||||||
{team.name.slice(0, 1)}
|
</AvatarFallback>
|
||||||
</AvatarFallback>
|
</Avatar>
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h2 className="text-4xl font-semibold">
|
<h2 className="text-4xl font-semibold">
|
||||||
<Trans>Documents</Trans>
|
<Trans>Documents</Trans>
|
||||||
@@ -329,9 +178,7 @@ export default function DocumentsPage() {
|
|||||||
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<div>
|
<div>
|
||||||
{data &&
|
{data && data.count === 0 ? (
|
||||||
data.count === 0 &&
|
|
||||||
(!foldersData?.folders.length || foldersData.folders.length === 0) ? (
|
|
||||||
<DocumentsTableEmptyState
|
<DocumentsTableEmptyState
|
||||||
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
||||||
/>
|
/>
|
||||||
@@ -353,6 +200,7 @@ export default function DocumentsPage() {
|
|||||||
<DocumentMoveToFolderDialog
|
<DocumentMoveToFolderDialog
|
||||||
documentId={documentToMove}
|
documentId={documentToMove}
|
||||||
open={isMovingDocument}
|
open={isMovingDocument}
|
||||||
|
currentFolderId={folderId}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setIsMovingDocument(open);
|
setIsMovingDocument(open);
|
||||||
|
|
||||||
@@ -362,43 +210,6 @@ export default function DocumentsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FolderDeleteDialog
|
|
||||||
folder={folderToDelete}
|
|
||||||
isOpen={isDeletingFolder}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsDeletingFolder(open);
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
setFolderToDelete(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</DocumentDropZoneWrapper>
|
</DocumentDropZoneWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-372
@@ -1,374 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import DocumentPage, { meta } from './documents._index';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
export { meta };
|
||||||
import { FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
export default DocumentPage;
|
||||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import {
|
|
||||||
type TFindDocumentsInternalResponse,
|
|
||||||
ZFindDocumentsInternalRequestSchema,
|
|
||||||
} from '@documenso/trpc/server/document-router/schema';
|
|
||||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
|
|
||||||
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
|
||||||
import { CreateFolderDialog } 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 { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
|
|
||||||
import { DocumentSearch } from '~/components/general/document/document-search';
|
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
|
||||||
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
|
|
||||||
import { FolderCard } from '~/components/general/folder/folder-card';
|
|
||||||
import { PeriodSelector } from '~/components/general/period-selector';
|
|
||||||
import { DocumentsTable } from '~/components/tables/documents-table';
|
|
||||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
|
||||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
import { appMetaTags } from '~/utils/meta';
|
|
||||||
|
|
||||||
export function meta() {
|
|
||||||
return appMetaTags('Documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
|
||||||
status: true,
|
|
||||||
period: true,
|
|
||||||
page: true,
|
|
||||||
perPage: true,
|
|
||||||
query: true,
|
|
||||||
}).extend({
|
|
||||||
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function DocumentsPage() {
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
|
||||||
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
|
|
||||||
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 { folderId } = useParams();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
|
||||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
|
||||||
|
|
||||||
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
|
|
||||||
[ExtendedDocumentStatus.DRAFT]: 0,
|
|
||||||
[ExtendedDocumentStatus.PENDING]: 0,
|
|
||||||
[ExtendedDocumentStatus.COMPLETED]: 0,
|
|
||||||
[ExtendedDocumentStatus.REJECTED]: 0,
|
|
||||||
[ExtendedDocumentStatus.INBOX]: 0,
|
|
||||||
[ExtendedDocumentStatus.ALL]: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const findDocumentSearchParams = useMemo(
|
|
||||||
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
|
||||||
[searchParams],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocumentsInternal.useQuery(
|
|
||||||
{
|
|
||||||
...findDocumentSearchParams,
|
|
||||||
folderId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: foldersData,
|
|
||||||
isLoading: isFoldersLoading,
|
|
||||||
refetch: refetchFolders,
|
|
||||||
} = trpc.folder.getFolders.useQuery({
|
|
||||||
parentId: folderId,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refetch();
|
|
||||||
void refetchFolders();
|
|
||||||
}, [team.url]);
|
|
||||||
|
|
||||||
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
|
|
||||||
const params = new URLSearchParams(searchParams);
|
|
||||||
|
|
||||||
params.set('status', value);
|
|
||||||
|
|
||||||
if (value === ExtendedDocumentStatus.ALL) {
|
|
||||||
params.delete('status');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.has('page')) {
|
|
||||||
params.delete('page');
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${formatDocumentsPath(team.url)}/f/${folderId}?${params.toString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data?.stats) {
|
|
||||||
setStats(data.stats);
|
|
||||||
}
|
|
||||||
}, [data?.stats]);
|
|
||||||
|
|
||||||
const navigateToFolder = (folderId?: string | null) => {
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
|
||||||
|
|
||||||
if (folderId) {
|
|
||||||
void navigate(`${documentsPath}/f/${folderId}`);
|
|
||||||
} else {
|
|
||||||
void navigate(documentsPath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DocumentDropZoneWrapper>
|
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="flex flex-1 items-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
|
|
||||||
onClick={() => navigateToFolder(null)}
|
|
||||||
>
|
|
||||||
<HomeIcon className="h-4 w-4" />
|
|
||||||
<span>Home</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{foldersData?.breadcrumbs.map((folder) => (
|
|
||||||
<div key={folder.id} className="flex items-center space-x-2">
|
|
||||||
<span>/</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center space-x-2 pl-1 hover:bg-transparent"
|
|
||||||
onClick={() => navigateToFolder(folder.id)}
|
|
||||||
>
|
|
||||||
<FolderIcon className="h-4 w-4" />
|
|
||||||
<span>{folder.name}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
|
||||||
<DocumentUploadDropzone />
|
|
||||||
<CreateFolderDialog />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isFoldersLoading ? (
|
|
||||||
<div className="mt-6 flex justify-center">
|
|
||||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{foldersData?.folders && foldersData.folders.some((folder) => folder.pinned) && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{foldersData.folders
|
|
||||||
.filter((folder) => folder.pinned)
|
|
||||||
.map((folder) => (
|
|
||||||
<FolderCard
|
|
||||||
key={folder.id}
|
|
||||||
folder={folder}
|
|
||||||
onNavigate={navigateToFolder}
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{foldersData?.folders
|
|
||||||
.filter((folder) => !folder.pinned)
|
|
||||||
.map((folder) => (
|
|
||||||
<FolderCard
|
|
||||||
key={folder.id}
|
|
||||||
folder={folder}
|
|
||||||
onNavigate={navigateToFolder}
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
{team && (
|
|
||||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
|
||||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
|
||||||
<AvatarFallback className="text-muted-foreground text-xs">
|
|
||||||
{team.name.slice(0, 1)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h2 className="text-4xl font-semibold">
|
|
||||||
<Trans>Documents</Trans>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
|
||||||
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
|
|
||||||
<TabsList>
|
|
||||||
{[
|
|
||||||
ExtendedDocumentStatus.INBOX,
|
|
||||||
ExtendedDocumentStatus.PENDING,
|
|
||||||
ExtendedDocumentStatus.COMPLETED,
|
|
||||||
ExtendedDocumentStatus.DRAFT,
|
|
||||||
ExtendedDocumentStatus.ALL,
|
|
||||||
].map((value) => (
|
|
||||||
<TabsTrigger
|
|
||||||
key={value}
|
|
||||||
className="hover:text-foreground min-w-[60px]"
|
|
||||||
value={value}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link to={getTabHref(value)} preventScrollReset>
|
|
||||||
<DocumentStatus status={value} />
|
|
||||||
|
|
||||||
{value !== ExtendedDocumentStatus.ALL && (
|
|
||||||
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{team && <DocumentsTableSenderFilter teamId={team.id} />}
|
|
||||||
|
|
||||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
|
||||||
<PeriodSelector />
|
|
||||||
</div>
|
|
||||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
|
||||||
<DocumentSearch initialValue={findDocumentSearchParams.query} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
<div>
|
|
||||||
{data &&
|
|
||||||
data.count === 0 &&
|
|
||||||
(!foldersData?.folders.length || foldersData.folders.length === 0) ? (
|
|
||||||
<DocumentsTableEmptyState
|
|
||||||
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DocumentsTable
|
|
||||||
data={data}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isLoadingError={isLoadingError}
|
|
||||||
onMoveDocument={(documentId) => {
|
|
||||||
setDocumentToMove(documentId);
|
|
||||||
setIsMovingDocument(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{documentToMove && (
|
|
||||||
<DocumentMoveToFolderDialog
|
|
||||||
documentId={documentToMove}
|
|
||||||
open={isMovingDocument}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsMovingDocument(open);
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
setDocumentToMove(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
currentFolderId={folderId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FolderDeleteDialog
|
|
||||||
folder={folderToDelete}
|
|
||||||
isOpen={isDeletingFolder}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsDeletingFolder(open);
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
setFolderToDelete(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DocumentDropZoneWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { HomeIcon, Loader2 } from 'lucide-react';
|
import { HomeIcon, Loader2, SearchIcon } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||||
@@ -9,8 +9,9 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
import { CreateFolderDialog } from '~/components/dialogs/folder-create-dialog';
|
import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
|
||||||
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||||
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
|
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
|
||||||
@@ -23,6 +24,8 @@ export function meta() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DocumentsFoldersPage() {
|
export default function DocumentsFoldersPage() {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
@@ -32,6 +35,7 @@ export default function DocumentsFoldersPage() {
|
|||||||
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
||||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
const { data: foldersData, isLoading: isFoldersLoading } = trpc.folder.getFolders.useQuery({
|
const { data: foldersData, isLoading: isFoldersLoading } = trpc.folder.getFolders.useQuery({
|
||||||
type: FolderType.DOCUMENT,
|
type: FolderType.DOCUMENT,
|
||||||
@@ -51,6 +55,9 @@ export default function DocumentsFoldersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isFolderMatchingSearch = (folder: TFolderWithSubfolders) =>
|
||||||
|
folder.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
@@ -67,60 +74,41 @@ export default function DocumentsFoldersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-y-4 sm:flex-row sm:justify-end sm:gap-x-4">
|
<div className="flex flex-col gap-y-4 sm:flex-row sm:justify-end sm:gap-x-4">
|
||||||
<CreateFolderDialog />
|
<FolderCreateDialog type={FolderType.DOCUMENT} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="relative w-full max-w-md py-6">
|
||||||
{isFoldersLoading ? (
|
<SearchIcon className="text-muted-foreground absolute left-2 top-9 h-4 w-4" />
|
||||||
<div className="mt-6 flex justify-center">
|
<Input
|
||||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
placeholder={t`Search folders...`}
|
||||||
</div>
|
value={searchTerm}
|
||||||
) : (
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
<>
|
className="pl-8"
|
||||||
{foldersData?.folders?.some((folder) => folder.pinned) && (
|
/>
|
||||||
<div className="mt-6">
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{foldersData.folders
|
|
||||||
.filter((folder) => folder.pinned)
|
|
||||||
.map((folder) => (
|
|
||||||
<FolderCard
|
|
||||||
key={folder.id}
|
|
||||||
folder={folder}
|
|
||||||
onNavigate={navigateToFolder}
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-12">
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl">
|
||||||
<h1 className="truncate text-2xl font-semibold md:text-3xl">
|
<Trans>All Folders</Trans>
|
||||||
<Trans>All Folders</Trans>
|
</h1>
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
{isFoldersLoading ? (
|
||||||
{foldersData?.folders
|
<div className="mt-6 flex justify-center">
|
||||||
.filter((folder) => !folder.pinned)
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{foldersData?.folders?.some(
|
||||||
|
(folder) => folder.pinned && isFolderMatchingSearch(folder),
|
||||||
|
) && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{foldersData.folders
|
||||||
|
.filter((folder) => folder.pinned && isFolderMatchingSearch(folder))
|
||||||
.map((folder) => (
|
.map((folder) => (
|
||||||
<FolderCard
|
<FolderCard
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
folder={folder}
|
folder={folder}
|
||||||
onNavigate={navigateToFolder}
|
|
||||||
onMove={(folder) => {
|
onMove={(folder) => {
|
||||||
setFolderToMove(folder);
|
setFolderToMove(folder);
|
||||||
setIsMovingFolder(true);
|
setIsMovingFolder(true);
|
||||||
@@ -139,9 +127,42 @@ export default function DocumentsFoldersPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
<div>
|
||||||
|
{searchTerm && foldersData?.folders.filter(isFolderMatchingSearch).length === 0 && (
|
||||||
|
<div className="text-muted-foreground mt-6 text-center">
|
||||||
|
<Trans>No folders found matching "{searchTerm}"</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{foldersData?.folders
|
||||||
|
.filter((folder) => !folder.pinned)
|
||||||
|
.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>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<FolderMoveDialog
|
<FolderMoveDialog
|
||||||
foldersData={foldersData?.folders}
|
foldersData={foldersData?.folders}
|
||||||
@@ -168,17 +189,19 @@ export default function DocumentsFoldersPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FolderDeleteDialog
|
{folderToDelete && (
|
||||||
folder={folderToDelete}
|
<FolderDeleteDialog
|
||||||
isOpen={isDeletingFolder}
|
folder={folderToDelete}
|
||||||
onOpenChange={(open) => {
|
isOpen={isDeletingFolder}
|
||||||
setIsDeletingFolder(open);
|
onOpenChange={(open) => {
|
||||||
|
setIsDeletingFolder(open);
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setFolderToDelete(null);
|
setFolderToDelete(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Bird, FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
import { Bird } from 'lucide-react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
import { useParams, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
|
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||||
import { TemplateFolderCreateDialog } from '~/components/dialogs/template-folder-create-dialog';
|
|
||||||
import { TemplateFolderDeleteDialog } from '~/components/dialogs/template-folder-delete-dialog';
|
|
||||||
import { TemplateFolderMoveDialog } from '~/components/dialogs/template-folder-move-dialog';
|
|
||||||
import { TemplateFolderSettingsDialog } from '~/components/dialogs/template-folder-settings-dialog';
|
|
||||||
import { FolderCard } from '~/components/general/folder/folder-card';
|
|
||||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@@ -27,20 +18,10 @@ export function meta() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TemplatesPage() {
|
export default function TemplatesPage() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
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 team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
const { folderId } = useParams();
|
||||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const page = Number(searchParams.get('page')) || 1;
|
const page = Number(searchParams.get('page')) || 1;
|
||||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||||
@@ -48,174 +29,24 @@ export default function TemplatesPage() {
|
|||||||
const documentRootPath = formatDocumentsPath(team.url);
|
const documentRootPath = formatDocumentsPath(team.url);
|
||||||
const templateRootPath = formatTemplatesPath(team.url);
|
const templateRootPath = formatTemplatesPath(team.url);
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery({
|
const { data, isLoading, isLoadingError } = trpc.template.findTemplates.useQuery({
|
||||||
page: page,
|
page: page,
|
||||||
perPage: perPage,
|
perPage: perPage,
|
||||||
|
folderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
|
||||||
data: foldersData,
|
|
||||||
isLoading: isFoldersLoading,
|
|
||||||
refetch: refetchFolders,
|
|
||||||
} = trpc.folder.getFolders.useQuery({
|
|
||||||
type: FolderType.TEMPLATE,
|
|
||||||
parentId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refetch();
|
|
||||||
void refetchFolders();
|
|
||||||
}, [team.url]);
|
|
||||||
|
|
||||||
const navigateToFolder = (folderId?: string | null) => {
|
|
||||||
const templatesPath = formatTemplatesPath(team.url);
|
|
||||||
|
|
||||||
if (folderId) {
|
|
||||||
void navigate(`${templatesPath}/f/${folderId}`);
|
|
||||||
} else {
|
|
||||||
void navigate(templatesPath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNavigate = (folderId: string) => {
|
|
||||||
navigateToFolder(folderId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMove = (folder: TFolderWithSubfolders) => {
|
|
||||||
setFolderToMove(folder);
|
|
||||||
setIsMovingFolder(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePin = (folderId: string) => {
|
|
||||||
void pinFolder({ folderId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnpin = (folderId: string) => {
|
|
||||||
void unpinFolder({ folderId });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettings = (folder: TFolderWithSubfolders) => {
|
|
||||||
setFolderToSettings(folder);
|
|
||||||
setIsSettingsFolderOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (folder: TFolderWithSubfolders) => {
|
|
||||||
setFolderToDelete(folder);
|
|
||||||
setIsDeletingFolder(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewAllFolders = () => {
|
|
||||||
void navigate(`${formatTemplatesPath(team.url)}/folders`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
|
||||||
<div className="flex flex-1 items-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
|
|
||||||
onClick={() => navigateToFolder(null)}
|
|
||||||
>
|
|
||||||
<HomeIcon className="h-4 w-4" />
|
|
||||||
<span>Home</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{foldersData?.breadcrumbs.map((folder) => (
|
<div className="mt-8">
|
||||||
<div key={folder.id} className="flex items-center space-x-2">
|
|
||||||
<span>/</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center space-x-2 pl-1 hover:bg-transparent"
|
|
||||||
onClick={() => navigateToFolder(folder.id)}
|
|
||||||
>
|
|
||||||
<FolderIcon className="h-4 w-4" />
|
|
||||||
<span>{folder.name}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
|
||||||
<TemplateCreateDialog templateRootPath={templateRootPath} />
|
|
||||||
<TemplateFolderCreateDialog />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isFoldersLoading ? (
|
|
||||||
<div className="mt-6 flex justify-center">
|
|
||||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{foldersData?.folders && foldersData.folders.some((folder) => folder.pinned) && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{foldersData.folders
|
|
||||||
.filter((folder) => folder.pinned)
|
|
||||||
.map((folder) => (
|
|
||||||
<FolderCard
|
|
||||||
key={folder.id}
|
|
||||||
folder={folder}
|
|
||||||
onNavigate={handleNavigate}
|
|
||||||
onMove={handleMove}
|
|
||||||
onPin={handlePin}
|
|
||||||
onUnpin={handleUnpin}
|
|
||||||
onSettings={handleSettings}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{foldersData?.folders
|
|
||||||
?.filter((folder) => !folder.pinned)
|
|
||||||
.slice(0, 12)
|
|
||||||
.map((folder) => (
|
|
||||||
<FolderCard
|
|
||||||
key={folder.id}
|
|
||||||
folder={folder}
|
|
||||||
onNavigate={handleNavigate}
|
|
||||||
onMove={handleMove}
|
|
||||||
onPin={handlePin}
|
|
||||||
onUnpin={handleUnpin}
|
|
||||||
onSettings={handleSettings}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex items-center justify-center">
|
|
||||||
{foldersData && foldersData.folders?.length > 12 && (
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
size="sm"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => void handleViewAllFolders()}
|
|
||||||
>
|
|
||||||
View all folders
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-12">
|
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
{team && (
|
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
<AvatarFallback className="text-muted-foreground text-xs">
|
||||||
<AvatarFallback className="text-muted-foreground text-xs">
|
{team.name.slice(0, 1)}
|
||||||
{team.name.slice(0, 1)}
|
</AvatarFallback>
|
||||||
</AvatarFallback>
|
</Avatar>
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h1 className="truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="truncate text-2xl font-semibold md:text-3xl">
|
||||||
<Trans>Templates</Trans>
|
<Trans>Templates</Trans>
|
||||||
@@ -250,43 +81,6 @@ export default function TemplatesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TemplateFolderMoveDialog
|
|
||||||
foldersData={foldersData?.folders}
|
|
||||||
folder={folderToMove}
|
|
||||||
isOpen={isMovingFolder}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsMovingFolder(open);
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
setFolderToMove(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TemplateFolderSettingsDialog
|
|
||||||
folder={folderToSettings}
|
|
||||||
isOpen={isSettingsFolderOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsSettingsFolderOpen(open);
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
setFolderToSettings(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TemplateFolderDeleteDialog
|
|
||||||
folder={folderToDelete}
|
|
||||||
isOpen={isDeletingFolder}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsDeletingFolder(open);
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
setFolderToDelete(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-367
@@ -1,369 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import TemplatesPage, { meta } from './templates._index';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
export { meta };
|
||||||
import { Bird, FolderIcon, HomeIcon, Loader2, PinIcon } from 'lucide-react';
|
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
|
||||||
|
|
||||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
export default TemplatesPage;
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
|
||||||
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 { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
|
||||||
|
|
||||||
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 { TemplateFolderCreateDialog } from '~/components/dialogs/template-folder-create-dialog';
|
|
||||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
import { appMetaTags } from '~/utils/meta';
|
|
||||||
|
|
||||||
export function meta() {
|
|
||||||
return appMetaTags('Templates');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TemplatesPage() {
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const { folderId } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const page = Number(searchParams.get('page')) || 1;
|
|
||||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
|
||||||
|
|
||||||
const documentRootPath = formatDocumentsPath(team.url);
|
|
||||||
const templateRootPath = formatTemplatesPath(team.url);
|
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery({
|
|
||||||
page: page,
|
|
||||||
perPage: perPage,
|
|
||||||
folderId: folderId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: foldersData,
|
|
||||||
isLoading: isFoldersLoading,
|
|
||||||
refetch: refetchFolders,
|
|
||||||
} = trpc.folder.getFolders.useQuery({
|
|
||||||
parentId: folderId,
|
|
||||||
type: FolderType.TEMPLATE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
|
||||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
|
||||||
|
|
||||||
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
|
||||||
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
|
||||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
|
||||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
|
||||||
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
|
||||||
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refetch();
|
|
||||||
void refetchFolders();
|
|
||||||
}, [team?.url]);
|
|
||||||
|
|
||||||
const navigateToFolder = (folderId?: string) => {
|
|
||||||
const templatesPath = formatTemplatesPath(team.url);
|
|
||||||
|
|
||||||
if (folderId) {
|
|
||||||
void navigate(`${templatesPath}/f/${folderId}`);
|
|
||||||
} else {
|
|
||||||
void navigate(templatesPath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="flex flex-1 items-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
|
|
||||||
onClick={() => navigateToFolder()}
|
|
||||||
>
|
|
||||||
<HomeIcon className="h-4 w-4" />
|
|
||||||
<span>Home</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{foldersData?.breadcrumbs.map((folder) => (
|
|
||||||
<div key={folder.id} className="flex items-center space-x-2">
|
|
||||||
<span>/</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center space-x-2 pl-1 hover:bg-transparent"
|
|
||||||
onClick={() => navigateToFolder(folder.id)}
|
|
||||||
>
|
|
||||||
<FolderIcon className="h-4 w-4" />
|
|
||||||
<span>{folder.name}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
|
||||||
<TemplateFolderCreateDialog />
|
|
||||||
<TemplateCreateDialog templateRootPath={templateRootPath} folderId={folderId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isFoldersLoading ? (
|
|
||||||
<div className="mt-6 flex justify-center">
|
|
||||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{foldersData?.folders.some((folder) => folder.pinned) && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{foldersData?.folders
|
|
||||||
.filter((folder) => folder.pinned)
|
|
||||||
.map((folder) => (
|
|
||||||
<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={() => navigateToFolder(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>
|
|
||||||
<PinIcon className="text-documenso h-3 w-3" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex space-x-2 text-xs text-gray-500">
|
|
||||||
<span>{folder._count.templates || 0} templates</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{folder._count.subfolders} folders</span>
|
|
||||||
</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={() => {
|
|
||||||
setFolderToMove(folder);
|
|
||||||
setIsMovingFolder(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Move
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
void unpinFolder({ folderId: folder.id });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Unpin
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setFolderToSettings(folder);
|
|
||||||
setIsSettingsFolderOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-red-500"
|
|
||||||
onClick={() => {
|
|
||||||
setFolderToDelete(folder);
|
|
||||||
setIsDeletingFolder(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{foldersData?.folders
|
|
||||||
.filter((folder) => !folder.pinned)
|
|
||||||
.map((folder) => (
|
|
||||||
<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={() => navigateToFolder(folder.id)}
|
|
||||||
>
|
|
||||||
<FolderIcon className="text-documenso h-6 w-6" />
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium">{folder.name}</h3>
|
|
||||||
<div className="mt-1 flex space-x-2 text-xs text-gray-500">
|
|
||||||
<span>{folder._count.templates || 0} templates</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{folder._count.subfolders} folders</span>
|
|
||||||
</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={() => {
|
|
||||||
setFolderToMove(folder);
|
|
||||||
setIsMovingFolder(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Move
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
void pinFolder({ folderId: folder.id });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Pin
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setFolderToSettings(folder);
|
|
||||||
setIsSettingsFolderOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-red-500"
|
|
||||||
onClick={() => {
|
|
||||||
setFolderToDelete(folder);
|
|
||||||
setIsDeletingFolder(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative mt-12">
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
{team && (
|
|
||||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
|
||||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
|
||||||
<AvatarFallback className="text-muted-foreground text-xs">
|
|
||||||
{team.name.slice(0, 1)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h1 className="truncate text-2xl font-semibold md:text-3xl">
|
|
||||||
<Trans>Templates</Trans>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
{data && data.count === 0 ? (
|
|
||||||
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
|
|
||||||
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold">
|
|
||||||
<Trans>We're all empty</Trans>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="mt-2 max-w-[50ch]">
|
|
||||||
<Trans>
|
|
||||||
You have not yet created any templates. To create a template please upload one.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<TemplatesTable
|
|
||||||
data={data}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isLoadingError={isLoadingError}
|
|
||||||
documentRootPath={documentRootPath}
|
|
||||||
templateRootPath={templateRootPath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FolderDeleteDialog
|
|
||||||
folder={folderToDelete}
|
|
||||||
isOpen={isDeletingFolder}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsDeletingFolder(open);
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
setFolderToDelete(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { HomeIcon, Loader2 } from 'lucide-react';
|
import { HomeIcon, Loader2, SearchIcon } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||||
@@ -9,11 +9,12 @@ import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
|||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
import { TemplateFolderCreateDialog } from '~/components/dialogs/template-folder-create-dialog';
|
import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
|
||||||
import { TemplateFolderDeleteDialog } from '~/components/dialogs/template-folder-delete-dialog';
|
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||||
import { TemplateFolderMoveDialog } from '~/components/dialogs/template-folder-move-dialog';
|
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||||
import { TemplateFolderSettingsDialog } from '~/components/dialogs/template-folder-settings-dialog';
|
import { FolderSettingsDialog } from '~/components/dialogs/folder-settings-dialog';
|
||||||
import { FolderCard } from '~/components/general/folder/folder-card';
|
import { FolderCard } from '~/components/general/folder/folder-card';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@@ -23,6 +24,8 @@ export function meta() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TemplatesFoldersPage() {
|
export default function TemplatesFoldersPage() {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
@@ -32,6 +35,7 @@ export default function TemplatesFoldersPage() {
|
|||||||
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
||||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
const { data: foldersData, isLoading: isFoldersLoading } = trpc.folder.getFolders.useQuery({
|
const { data: foldersData, isLoading: isFoldersLoading } = trpc.folder.getFolders.useQuery({
|
||||||
type: FolderType.TEMPLATE,
|
type: FolderType.TEMPLATE,
|
||||||
@@ -51,6 +55,9 @@ export default function TemplatesFoldersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isFolderMatchingSearch = (folder: TFolderWithSubfolders) =>
|
||||||
|
folder.name.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
@@ -67,60 +74,41 @@ export default function TemplatesFoldersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-y-4 sm:flex-row sm:justify-end sm:gap-x-4">
|
<div className="flex flex-col gap-y-4 sm:flex-row sm:justify-end sm:gap-x-4">
|
||||||
<TemplateFolderCreateDialog />
|
<FolderCreateDialog type={FolderType.TEMPLATE} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="relative w-full max-w-md py-6">
|
||||||
{isFoldersLoading ? (
|
<SearchIcon className="text-muted-foreground absolute left-2 top-9 h-4 w-4" />
|
||||||
<div className="mt- flex justify-center">
|
<Input
|
||||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
placeholder={t`Search folders...`}
|
||||||
</div>
|
value={searchTerm}
|
||||||
) : (
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
<>
|
className="pl-8"
|
||||||
{foldersData?.folders?.some((folder) => folder.pinned) && (
|
/>
|
||||||
<div className="mt-6">
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
||||||
{foldersData.folders
|
|
||||||
.filter((folder) => folder.pinned)
|
|
||||||
.map((folder) => (
|
|
||||||
<FolderCard
|
|
||||||
key={folder.id}
|
|
||||||
folder={folder}
|
|
||||||
onNavigate={navigateToFolder}
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-12">
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl">
|
||||||
<h1 className="truncate text-2xl font-semibold md:text-3xl">
|
<Trans>All Folders</Trans>
|
||||||
<Trans>All Folders</Trans>
|
</h1>
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
{isFoldersLoading ? (
|
||||||
{foldersData?.folders
|
<div className="mt- flex justify-center">
|
||||||
.filter((folder) => !folder.pinned)
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{foldersData?.folders?.some(
|
||||||
|
(folder) => folder.pinned && isFolderMatchingSearch(folder),
|
||||||
|
) && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{foldersData.folders
|
||||||
|
.filter((folder) => folder.pinned && isFolderMatchingSearch(folder))
|
||||||
.map((folder) => (
|
.map((folder) => (
|
||||||
<FolderCard
|
<FolderCard
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
folder={folder}
|
folder={folder}
|
||||||
onNavigate={navigateToFolder}
|
|
||||||
onMove={(folder) => {
|
onMove={(folder) => {
|
||||||
setFolderToMove(folder);
|
setFolderToMove(folder);
|
||||||
setIsMovingFolder(true);
|
setIsMovingFolder(true);
|
||||||
@@ -139,11 +127,44 @@ export default function TemplatesFoldersPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TemplateFolderMoveDialog
|
<div>
|
||||||
|
{searchTerm && foldersData?.folders.filter(isFolderMatchingSearch).length === 0 && (
|
||||||
|
<div className="text-muted-foreground mt-6 text-center">
|
||||||
|
<Trans>No folders found matching "{searchTerm}"</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{foldersData?.folders
|
||||||
|
.filter((folder) => !folder.pinned && isFolderMatchingSearch(folder))
|
||||||
|
.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>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FolderMoveDialog
|
||||||
foldersData={foldersData?.folders}
|
foldersData={foldersData?.folders}
|
||||||
folder={folderToMove}
|
folder={folderToMove}
|
||||||
isOpen={isMovingFolder}
|
isOpen={isMovingFolder}
|
||||||
@@ -156,7 +177,7 @@ export default function TemplatesFoldersPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TemplateFolderSettingsDialog
|
<FolderSettingsDialog
|
||||||
folder={folderToSettings}
|
folder={folderToSettings}
|
||||||
isOpen={isSettingsFolderOpen}
|
isOpen={isSettingsFolderOpen}
|
||||||
onOpenChange={(open: boolean) => {
|
onOpenChange={(open: boolean) => {
|
||||||
@@ -168,17 +189,19 @@ export default function TemplatesFoldersPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TemplateFolderDeleteDialog
|
{folderToDelete && (
|
||||||
folder={folderToDelete}
|
<FolderDeleteDialog
|
||||||
isOpen={isDeletingFolder}
|
folder={folderToDelete}
|
||||||
onOpenChange={(open: boolean) => {
|
isOpen={isDeletingFolder}
|
||||||
setIsDeletingFolder(open);
|
onOpenChange={(open: boolean) => {
|
||||||
|
setIsDeletingFolder(open);
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setFolderToDelete(null);
|
setFolderToDelete(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ test('[TEAMS]: create document folder button is visible', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}`,
|
redirectPath: `/t/${team.url}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Create Folder' })).toBeVisible();
|
await expect(page.getByTestId('folder-create-button')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[TEAMS]: can create document folder', async ({ page }) => {
|
test('[TEAMS]: can create document folder', async ({ page }) => {
|
||||||
@@ -33,7 +33,7 @@ test('[TEAMS]: can create document folder', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}`,
|
redirectPath: `/t/${team.url}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Folder' }).click();
|
await page.getByTestId('folder-create-button').click();
|
||||||
|
|
||||||
await page.getByLabel('Folder name').fill('Team Folder');
|
await page.getByLabel('Folder name').fill('Team Folder');
|
||||||
await page.getByRole('button', { name: 'Create' }).click();
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
@@ -59,7 +59,7 @@ test('[TEAMS]: can create document subfolder within a document folder', async ({
|
|||||||
|
|
||||||
await page.goto(`/t/${team.url}/documents/f/${teamFolder.id}`);
|
await page.goto(`/t/${team.url}/documents/f/${teamFolder.id}`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Folder' }).click();
|
await page.getByTestId('folder-create-button').click();
|
||||||
|
|
||||||
await page.getByLabel('Folder name').fill('Subfolder');
|
await page.getByLabel('Folder name').fill('Subfolder');
|
||||||
await page.getByRole('button', { name: 'Create' }).click();
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
@@ -115,7 +115,7 @@ test('[TEAMS]: can pin a document folder', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}/documents`,
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).click();
|
await page.getByTestId('folder-card-more-button').click();
|
||||||
await page.getByRole('menuitem', { name: 'Pin' }).click();
|
await page.getByRole('menuitem', { name: 'Pin' }).click();
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -140,7 +140,7 @@ test('[TEAMS]: can unpin a document folder', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}/documents`,
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).click();
|
await page.getByTestId('folder-card-more-button').click();
|
||||||
await page.getByRole('menuitem', { name: 'Unpin' }).click();
|
await page.getByRole('menuitem', { name: 'Unpin' }).click();
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -164,7 +164,7 @@ test('[TEAMS]: can rename a document folder', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}/documents`,
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).click();
|
await page.getByTestId('folder-card-more-button').click();
|
||||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||||
|
|
||||||
await page.getByLabel('Name').fill('Team Archive');
|
await page.getByLabel('Name').fill('Team Archive');
|
||||||
@@ -189,7 +189,7 @@ test('[TEAMS]: document folder visibility is visible to team member', async ({ p
|
|||||||
redirectPath: `/t/${team.url}/documents`,
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).click();
|
await page.getByTestId('folder-card-more-button').click();
|
||||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('combobox', { name: 'Visibility' })).toBeVisible();
|
await expect(page.getByRole('combobox', { name: 'Visibility' })).toBeVisible();
|
||||||
@@ -218,11 +218,11 @@ test('[TEAMS]: document folder can be moved to another document folder', async (
|
|||||||
redirectPath: `/t/${team.url}/documents`,
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).nth(0).click();
|
await page.getByTestId('folder-card-more-button').nth(0).click();
|
||||||
await page.getByRole('menuitem', { name: 'Move' }).click();
|
await page.getByRole('menuitem', { name: 'Move' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Team Clients' }).click();
|
await page.getByRole('button', { name: 'Team Clients' }).click();
|
||||||
await page.getByRole('button', { name: 'Move Folder' }).click();
|
await page.getByRole('button', { name: 'Move' }).click();
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
@@ -269,7 +269,7 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page }
|
|||||||
redirectPath: `/t/${team.url}/documents`,
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).click();
|
await page.getByTestId('folder-card-more-button').click();
|
||||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
|
||||||
await page.getByRole('textbox').fill(`delete ${folder.name}`);
|
await page.getByRole('textbox').fill(`delete ${folder.name}`);
|
||||||
@@ -295,7 +295,7 @@ test('[TEAMS]: create folder button is visible on templates page', async ({ page
|
|||||||
redirectPath: `/t/${team.url}/templates`,
|
redirectPath: `/t/${team.url}/templates`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Create Folder' })).toBeVisible();
|
await expect(page.getByTestId('folder-create-button')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[TEAMS]: can create a template folder', async ({ page }) => {
|
test('[TEAMS]: can create a template folder', async ({ page }) => {
|
||||||
@@ -307,7 +307,7 @@ test('[TEAMS]: can create a template folder', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}/templates`,
|
redirectPath: `/t/${team.url}/templates`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Folder' }).click();
|
await page.getByTestId('folder-create-button').click();
|
||||||
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
|
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel('Folder name').fill('Team template folder');
|
await page.getByLabel('Folder name').fill('Team template folder');
|
||||||
@@ -342,7 +342,7 @@ test('[TEAMS]: can create a template subfolder inside a template folder', async
|
|||||||
|
|
||||||
await expect(page.getByText('Team Client Templates')).toBeVisible();
|
await expect(page.getByText('Team Client Templates')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Folder' }).click();
|
await page.getByTestId('folder-create-button').click();
|
||||||
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
|
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel('Folder name').fill('Team Contract Templates');
|
await page.getByLabel('Folder name').fill('Team Contract Templates');
|
||||||
@@ -414,7 +414,7 @@ test('[TEAMS]: can pin a template folder', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}/templates`,
|
redirectPath: `/t/${team.url}/templates`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).click();
|
await page.getByTestId('folder-card-more-button').click();
|
||||||
await page.getByRole('menuitem', { name: 'Pin' }).click();
|
await page.getByRole('menuitem', { name: 'Pin' }).click();
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -440,7 +440,7 @@ test('[TEAMS]: can unpin a template folder', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}/templates`,
|
redirectPath: `/t/${team.url}/templates`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).click();
|
await page.getByTestId('folder-card-more-button').click();
|
||||||
await page.getByRole('menuitem', { name: 'Unpin' }).click();
|
await page.getByRole('menuitem', { name: 'Unpin' }).click();
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -466,7 +466,7 @@ test('[TEAMS]: can rename a template folder', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}/templates`,
|
redirectPath: `/t/${team.url}/templates`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).click();
|
await page.getByTestId('folder-card-more-button').click();
|
||||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||||
|
|
||||||
await page.getByLabel('Name').fill('Updated Team Template Folder');
|
await page.getByLabel('Name').fill('Updated Team Template Folder');
|
||||||
@@ -492,7 +492,7 @@ test('[TEAMS]: template folder visibility is not visible to team member', async
|
|||||||
redirectPath: `/t/${team.url}/templates`,
|
redirectPath: `/t/${team.url}/templates`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).click();
|
await page.getByTestId('folder-card-more-button').click();
|
||||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('menuitem', { name: 'Visibility' })).not.toBeVisible();
|
await expect(page.getByRole('menuitem', { name: 'Visibility' })).not.toBeVisible();
|
||||||
@@ -523,11 +523,11 @@ test('[TEAMS]: template folder can be moved to another template folder', async (
|
|||||||
redirectPath: `/t/${team.url}/templates`,
|
redirectPath: `/t/${team.url}/templates`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).nth(0).click();
|
await page.getByTestId('folder-card-more-button').nth(0).click();
|
||||||
await page.getByRole('menuitem', { name: 'Move' }).click();
|
await page.getByRole('menuitem', { name: 'Move' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Team Client Templates' }).click();
|
await page.getByRole('button', { name: 'Team Client Templates' }).click();
|
||||||
await page.getByRole('button', { name: 'Move Folder' }).click();
|
await page.getByRole('button', { name: 'Move' }).click();
|
||||||
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
@@ -576,7 +576,7 @@ test('[TEAMS]: template folder and its contents can be deleted', async ({ page }
|
|||||||
redirectPath: `/t/${team.url}/templates`,
|
redirectPath: `/t/${team.url}/templates`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).click();
|
await page.getByTestId('folder-card-more-button').click();
|
||||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
|
||||||
await page.getByRole('textbox').fill(`delete ${folder.name}`);
|
await page.getByRole('textbox').fill(`delete ${folder.name}`);
|
||||||
@@ -633,10 +633,10 @@ test('[TEAMS]: can navigate between template folders', async ({ page }) => {
|
|||||||
await page.getByText('Team Contract Templates').click();
|
await page.getByText('Team Contract Templates').click();
|
||||||
await expect(page.getByText('Team Contract Template 1')).toBeVisible();
|
await expect(page.getByText('Team Contract Template 1')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: parentFolder.name }).click();
|
await page.getByRole('link', { name: parentFolder.name }).click();
|
||||||
await expect(page.getByText('Team Contract Templates')).toBeVisible();
|
await expect(page.getByText('Team Contract Templates')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: subfolder.name }).click();
|
await page.getByRole('link', { name: subfolder.name }).click();
|
||||||
await expect(page.getByText('Team Contract Template 1')).toBeVisible();
|
await expect(page.getByText('Team Contract Template 1')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -754,7 +754,7 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}/documents`,
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Folder' }).click();
|
await page.getByTestId('folder-create-button').click();
|
||||||
await page.getByLabel('Name').fill('Admin Only Folder');
|
await page.getByLabel('Name').fill('Admin Only Folder');
|
||||||
await page.getByRole('button', { name: 'Create' }).click();
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
|
||||||
@@ -762,7 +762,7 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
|
|||||||
|
|
||||||
await page.goto(`/t/${team.url}/documents/`);
|
await page.goto(`/t/${team.url}/documents/`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).click();
|
await page.getByTestId('folder-card-more-button').click();
|
||||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Admins only');
|
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Admins only');
|
||||||
@@ -774,15 +774,15 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
|
|||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Folder' }).click();
|
await page.getByTestId('folder-create-button').click();
|
||||||
await page.getByLabel('Name').fill('Manager and above Folder');
|
await page.getByLabel('Name').fill('Manager and above Folder');
|
||||||
await page.getByRole('button', { name: 'Create' }).click();
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
|
||||||
await expect(page.getByText('Manager and above Folder')).toBeVisible();
|
await expect(page.getByText('Manager and above Folder')).toBeVisible();
|
||||||
|
|
||||||
await page.goto(`/t/${team.url}/documents/`);
|
await page.goto(`/t/${team.url}/documents`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).nth(0).click();
|
await page.getByTestId('folder-card-more-button').nth(0).click();
|
||||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Managers and above');
|
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Managers and above');
|
||||||
@@ -794,7 +794,7 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
|
|||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Folder' }).click();
|
await page.getByTestId('folder-create-button').click();
|
||||||
await page.getByLabel('Name').fill('Everyone Folder');
|
await page.getByLabel('Name').fill('Everyone Folder');
|
||||||
await page.getByRole('button', { name: 'Create' }).click();
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
|
||||||
@@ -802,7 +802,7 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
|
|||||||
|
|
||||||
await page.goto(`/t/${team.url}/documents/`);
|
await page.goto(`/t/${team.url}/documents/`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: '•••' }).nth(0).click();
|
await page.getByTestId('folder-card-more-button').nth(0).click();
|
||||||
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
await page.getByRole('menuitem', { name: 'Settings' }).click();
|
||||||
|
|
||||||
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Everyone');
|
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Everyone');
|
||||||
@@ -834,7 +834,7 @@ test('[TEAMS]: documents inherit folder visibility', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}/documents`,
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Create Folder' }).click();
|
await page.getByTestId('folder-create-button').click();
|
||||||
await page.getByLabel('Name').fill('Admin Only Folder');
|
await page.getByLabel('Name').fill('Admin Only Folder');
|
||||||
await page.getByRole('button', { name: 'Create' }).click();
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
|
||||||
@@ -2368,7 +2368,10 @@ test('[TEAMS]: team manager can see manager and everyone documents in manager fo
|
|||||||
redirectPath: `/t/${team.url}/documents/f/${managerFolder.id}`,
|
redirectPath: `/t/${team.url}/documents/f/${managerFolder.id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await expect(page.getByText('Manager Folder - Everyone Document')).toBeVisible();
|
await expect(page.getByText('Manager Folder - Everyone Document')).toBeVisible();
|
||||||
await expect(page.getByText('Manager Folder - Manager Document')).toBeVisible();
|
await expect(page.getByText('Manager Folder - Manager Document')).toBeVisible();
|
||||||
await expect(page.getByText('Manager Folder - Admin Document')).not.toBeVisible();
|
await expect(page.getByText('Manager Folder - Admin Document')).not.toBeVisible();
|
||||||
@@ -2426,7 +2429,10 @@ test('[TEAMS]: team manager can see manager and everyone documents in everyone f
|
|||||||
redirectPath: `/t/${team.url}/documents/f/${everyoneFolder.id}`,
|
redirectPath: `/t/${team.url}/documents/f/${everyoneFolder.id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Everyone Folder' }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await expect(page.getByText('Everyone Folder - Everyone Document')).toBeVisible();
|
await expect(page.getByText('Everyone Folder - Everyone Document')).toBeVisible();
|
||||||
await expect(page.getByText('Everyone Folder - Manager Document')).toBeVisible();
|
await expect(page.getByText('Everyone Folder - Manager Document')).toBeVisible();
|
||||||
await expect(page.getByText('Everyone Folder - Admin Document')).not.toBeVisible();
|
await expect(page.getByText('Everyone Folder - Admin Document')).not.toBeVisible();
|
||||||
@@ -2572,7 +2578,10 @@ test('[TEAMS]: team owner can see all documents in admin folder', async ({ page
|
|||||||
redirectPath: `/t/${team.url}/documents/f/${adminFolder.id}`,
|
redirectPath: `/t/${team.url}/documents/f/${adminFolder.id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Admin Only Folder' })).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Admin Only Folder' }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await expect(page.getByText('Admin Folder - Everyone Document')).toBeVisible();
|
await expect(page.getByText('Admin Folder - Everyone Document')).toBeVisible();
|
||||||
await expect(page.getByText('Admin Folder - Manager Document')).toBeVisible();
|
await expect(page.getByText('Admin Folder - Manager Document')).toBeVisible();
|
||||||
await expect(page.getByText('Admin Folder - Admin Document')).toBeVisible();
|
await expect(page.getByText('Admin Folder - Admin Document')).toBeVisible();
|
||||||
@@ -2622,7 +2631,9 @@ test('[TEAMS]: team owner can see all documents in manager folder', async ({ pag
|
|||||||
redirectPath: `/t/${team.url}/documents/f/${managerFolder.id}`,
|
redirectPath: `/t/${team.url}/documents/f/${managerFolder.id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' }),
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.getByText('Manager Folder - Everyone Document')).toBeVisible();
|
await expect(page.getByText('Manager Folder - Everyone Document')).toBeVisible();
|
||||||
await expect(page.getByText('Manager Folder - Manager Document')).toBeVisible();
|
await expect(page.getByText('Manager Folder - Manager Document')).toBeVisible();
|
||||||
await expect(page.getByText('Manager Folder - Admin Document')).toBeVisible();
|
await expect(page.getByText('Manager Folder - Admin Document')).toBeVisible();
|
||||||
@@ -2672,7 +2683,9 @@ test('[TEAMS]: team owner can see all documents in everyone folder', async ({ pa
|
|||||||
redirectPath: `/t/${team.url}/documents/f/${everyoneFolder.id}`,
|
redirectPath: `/t/${team.url}/documents/f/${everyoneFolder.id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Everyone Folder' }),
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.getByText('Everyone Folder - Everyone Document')).toBeVisible();
|
await expect(page.getByText('Everyone Folder - Everyone Document')).toBeVisible();
|
||||||
await expect(page.getByText('Everyone Folder - Manager Document')).toBeVisible();
|
await expect(page.getByText('Everyone Folder - Manager Document')).toBeVisible();
|
||||||
await expect(page.getByText('Everyone Folder - Admin Document')).toBeVisible();
|
await expect(page.getByText('Everyone Folder - Admin Document')).toBeVisible();
|
||||||
@@ -2772,7 +2785,10 @@ test('[TEAMS]: team admin can see all documents in admin folder', async ({ page
|
|||||||
redirectPath: `/t/${team.url}/documents/f/${adminFolder.id}`,
|
redirectPath: `/t/${team.url}/documents/f/${adminFolder.id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Admin Only Folder' })).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Admin Only Folder' }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await expect(page.getByText('Admin Folder - Everyone Document')).toBeVisible();
|
await expect(page.getByText('Admin Folder - Everyone Document')).toBeVisible();
|
||||||
await expect(page.getByText('Admin Folder - Manager Document')).toBeVisible();
|
await expect(page.getByText('Admin Folder - Manager Document')).toBeVisible();
|
||||||
await expect(page.getByText('Admin Folder - Admin Document')).toBeVisible();
|
await expect(page.getByText('Admin Folder - Admin Document')).toBeVisible();
|
||||||
@@ -2828,7 +2844,9 @@ test('[TEAMS]: team admin can see all documents in manager folder', async ({ pag
|
|||||||
redirectPath: `/t/${team.url}/documents/f/${managerFolder.id}`,
|
redirectPath: `/t/${team.url}/documents/f/${managerFolder.id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Manager Folder' }),
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.getByText('Manager Folder - Everyone Document')).toBeVisible();
|
await expect(page.getByText('Manager Folder - Everyone Document')).toBeVisible();
|
||||||
await expect(page.getByText('Manager Folder - Manager Document')).toBeVisible();
|
await expect(page.getByText('Manager Folder - Manager Document')).toBeVisible();
|
||||||
await expect(page.getByText('Manager Folder - Admin Document')).toBeVisible();
|
await expect(page.getByText('Manager Folder - Admin Document')).toBeVisible();
|
||||||
@@ -2884,7 +2902,9 @@ test('[TEAMS]: team admin can see all documents in everyone folder', async ({ pa
|
|||||||
redirectPath: `/t/${team.url}/documents/f/${everyoneFolder.id}`,
|
redirectPath: `/t/${team.url}/documents/f/${everyoneFolder.id}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
|
await expect(
|
||||||
|
page.getByTestId('folder-grid-breadcrumbs').getByRole('link', { name: 'Everyone Folder' }),
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.getByText('Everyone Folder - Everyone Document')).toBeVisible();
|
await expect(page.getByText('Everyone Folder - Everyone Document')).toBeVisible();
|
||||||
await expect(page.getByText('Everyone Folder - Manager Document')).toBeVisible();
|
await expect(page.getByText('Everyone Folder - Manager Document')).toBeVisible();
|
||||||
await expect(page.getByText('Everyone Folder - Admin Document')).toBeVisible();
|
await expect(page.getByText('Everyone Folder - Admin Document')).toBeVisible();
|
||||||
|
|||||||
Reference in New Issue
Block a user