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 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 { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
||||||
@ -18,6 +18,7 @@ import {
|
|||||||
|
|
||||||
import { DeleteTemplateDialog } from './delete-template-dialog';
|
import { DeleteTemplateDialog } from './delete-template-dialog';
|
||||||
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
||||||
|
import { MoveTemplateDialog } from './move-template-dialog';
|
||||||
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
|
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
|
||||||
|
|
||||||
export type DataTableActionDropdownProps = {
|
export type DataTableActionDropdownProps = {
|
||||||
@ -36,6 +37,7 @@ export const DataTableActionDropdown = ({
|
|||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
|
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
|
||||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
|
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
@ -73,6 +75,13 @@ export const DataTableActionDropdown = ({
|
|||||||
Direct link
|
Direct link
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{!teamId && (
|
||||||
|
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
|
||||||
|
<MoveRight className="mr-2 h-4 w-4" />
|
||||||
|
Move to Team
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={!isOwner && !isTeamTemplate}
|
disabled={!isOwner && !isTeamTemplate}
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
@ -95,6 +104,12 @@ export const DataTableActionDropdown = ({
|
|||||||
onOpenChange={setTemplateDirectLinkDialogOpen}
|
onOpenChange={setTemplateDirectLinkDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MoveTemplateDialog
|
||||||
|
templateId={row.id}
|
||||||
|
open={isMoveDialogOpen}
|
||||||
|
onOpenChange={setMoveDialogOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
<DeleteTemplateDialog
|
<DeleteTemplateDialog
|
||||||
id={row.id}
|
id={row.id}
|
||||||
teamId={row.teamId || undefined}
|
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 { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
|
||||||
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
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 { 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 { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
|
||||||
import { updateTemplateSettings } from '@documenso/lib/server-only/template/update-template-settings';
|
import { updateTemplateSettings } from '@documenso/lib/server-only/template/update-template-settings';
|
||||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
@ -28,6 +29,7 @@ import {
|
|||||||
ZDuplicateTemplateMutationSchema,
|
ZDuplicateTemplateMutationSchema,
|
||||||
ZFindTemplatesQuerySchema,
|
ZFindTemplatesQuerySchema,
|
||||||
ZGetTemplateWithDetailsByIdQuerySchema,
|
ZGetTemplateWithDetailsByIdQuerySchema,
|
||||||
|
ZMoveTemplatesToTeamSchema,
|
||||||
ZToggleTemplateDirectLinkMutationSchema,
|
ZToggleTemplateDirectLinkMutationSchema,
|
||||||
ZUpdateTemplateSettingsMutationSchema,
|
ZUpdateTemplateSettingsMutationSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
@ -296,4 +298,30 @@ export const templateRouter = router({
|
|||||||
throw AppError.parseErrorToTRPCError(error);
|
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),
|
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 TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
|
||||||
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
||||||
typeof ZCreateDocumentFromTemplateMutationSchema
|
typeof ZCreateDocumentFromTemplateMutationSchema
|
||||||
@ -119,3 +124,4 @@ export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutati
|
|||||||
export type TGetTemplateWithDetailsByIdQuerySchema = z.infer<
|
export type TGetTemplateWithDetailsByIdQuerySchema = z.infer<
|
||||||
typeof ZGetTemplateWithDetailsByIdQuerySchema
|
typeof ZGetTemplateWithDetailsByIdQuerySchema
|
||||||
>;
|
>;
|
||||||
|
export type TMoveTemplatesToSchema = z.infer<typeof ZMoveTemplatesToTeamSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user