mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: move template to team (#1217)
Allows users to move templates from their personal account to a team account.
This commit is contained in:
@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Copy, Edit, MoreHorizontal, Share2Icon, Trash2 } from 'lucide-react';
|
||||
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
||||
@ -18,6 +18,7 @@ import {
|
||||
|
||||
import { DeleteTemplateDialog } from './delete-template-dialog';
|
||||
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
||||
import { MoveTemplateDialog } from './move-template-dialog';
|
||||
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
@ -36,6 +37,7 @@ export const DataTableActionDropdown = ({
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
@ -73,6 +75,13 @@ export const DataTableActionDropdown = ({
|
||||
Direct link
|
||||
</DropdownMenuItem>
|
||||
|
||||
{!teamId && (
|
||||
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
||||
<MoveRight className="mr-2 h-4 w-4" />
|
||||
Move to Team
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={!isOwner && !isTeamTemplate}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
@ -95,6 +104,12 @@ export const DataTableActionDropdown = ({
|
||||
onOpenChange={setTemplateDirectLinkDialogOpen}
|
||||
/>
|
||||
|
||||
<MoveTemplateDialog
|
||||
templateId={row.id}
|
||||
open={isMoveDialogOpen}
|
||||
onOpenChange={setMoveDialogOpen}
|
||||
/>
|
||||
|
||||
<DeleteTemplateDialog
|
||||
id={row.id}
|
||||
teamId={row.teamId || undefined}
|
||||
|
||||
120
apps/web/src/app/(dashboard)/templates/move-template-dialog.tsx
Normal file
120
apps/web/src/app/(dashboard)/templates/move-template-dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type MoveTemplateDialogProps = {
|
||||
templateId: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const MoveTemplateDialog = ({ templateId, open, onOpenChange }: MoveTemplateDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||
|
||||
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||
const { mutateAsync: moveTemplate, isLoading } = trpc.template.moveTemplateToTeam.useMutation({
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
toast({
|
||||
title: 'Template moved',
|
||||
description: 'The template has been successfully moved to the selected team.',
|
||||
duration: 5000,
|
||||
});
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.message || 'An error occurred while moving the template.',
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onMove = async () => {
|
||||
if (!selectedTeamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await moveTemplate({ templateId, teamId: selectedTeamId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Move Template to Team</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a team to move this template to. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a team" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingTeams ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
Loading teams...
|
||||
</SelectItem>
|
||||
) : (
|
||||
teams?.map((team) => (
|
||||
<SelectItem key={team.id} value={team.id.toString()}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-8 w-8">
|
||||
{team.avatarImageId && (
|
||||
<AvatarImage
|
||||
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AvatarFallback className="text-sm text-gray-400">
|
||||
{team.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span>{team.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onMove} loading={isLoading} disabled={!selectedTeamId || isLoading}>
|
||||
{isLoading ? 'Moving...' : 'Move'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
57
packages/lib/server-only/template/move-template-to-team.ts
Normal file
57
packages/lib/server-only/template/move-template-to-team.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type MoveTemplateToTeamOptions = {
|
||||
templateId: number;
|
||||
teamId: number;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const moveTemplateToTeam = async ({
|
||||
templateId,
|
||||
teamId,
|
||||
userId,
|
||||
}: MoveTemplateToTeamOptions) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const template = await tx.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
userId,
|
||||
teamId: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Template not found or already associated with a team.',
|
||||
});
|
||||
}
|
||||
|
||||
const team = await tx.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You are not a member of this team.',
|
||||
});
|
||||
}
|
||||
|
||||
const updatedTemplate = await tx.template.update({
|
||||
where: { id: templateId },
|
||||
data: { teamId },
|
||||
});
|
||||
|
||||
return updatedTemplate;
|
||||
});
|
||||
};
|
||||
@ -12,6 +12,7 @@ import { deleteTemplateDirectLink } from '@documenso/lib/server-only/template/de
|
||||
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
|
||||
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
||||
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
|
||||
import { moveTemplateToTeam } from '@documenso/lib/server-only/template/move-template-to-team';
|
||||
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
|
||||
import { updateTemplateSettings } from '@documenso/lib/server-only/template/update-template-settings';
|
||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
@ -28,6 +29,7 @@ import {
|
||||
ZDuplicateTemplateMutationSchema,
|
||||
ZFindTemplatesQuerySchema,
|
||||
ZGetTemplateWithDetailsByIdQuerySchema,
|
||||
ZMoveTemplatesToTeamSchema,
|
||||
ZToggleTemplateDirectLinkMutationSchema,
|
||||
ZUpdateTemplateSettingsMutationSchema,
|
||||
} from './schema';
|
||||
@ -296,4 +298,30 @@ export const templateRouter = router({
|
||||
throw AppError.parseErrorToTRPCError(error);
|
||||
}
|
||||
}),
|
||||
|
||||
moveTemplateToTeam: authenticatedProcedure
|
||||
.input(ZMoveTemplatesToTeamSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { templateId, teamId } = input;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
return await moveTemplateToTeam({
|
||||
templateId,
|
||||
teamId,
|
||||
userId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof TRPCError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to move this template. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@ -110,6 +110,11 @@ export const ZGetTemplateWithDetailsByIdQuerySchema = z.object({
|
||||
id: z.number().min(1),
|
||||
});
|
||||
|
||||
export const ZMoveTemplatesToTeamSchema = z.object({
|
||||
templateId: z.number(),
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
|
||||
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
||||
typeof ZCreateDocumentFromTemplateMutationSchema
|
||||
@ -119,3 +124,4 @@ export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutati
|
||||
export type TGetTemplateWithDetailsByIdQuerySchema = z.infer<
|
||||
typeof ZGetTemplateWithDetailsByIdQuerySchema
|
||||
>;
|
||||
export type TMoveTemplatesToSchema = z.infer<typeof ZMoveTemplatesToTeamSchema>;
|
||||
|
||||
Reference in New Issue
Block a user