Compare commits

..

4 Commits

Author SHA1 Message Date
Adithya Krishna 7746619dac Merge branch 'main' of https://github.com/documenso/documenso into feat/publicProfile 2024-05-09 12:47:38 +05:30
Adithya Krishna 23672d47f1 chore: add copy button
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-01 17:57:31 +05:30
Adithya Krishna c60bcc6309 chore: add profile url
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-01 16:30:34 +05:30
Adithya Krishna 69c175d38e feat: initiated public profile
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-01 16:16:55 +05:30
37 changed files with 320 additions and 555 deletions
@@ -0,0 +1,9 @@
import React from 'react';
export type PublicProfileSettingsLayout = {
children: React.ReactNode;
};
export default function PublicProfileSettingsLayout({ children }: PublicProfileSettingsLayout) {
return <div className="col-span-12 md:col-span-9">{children}</div>;
}
@@ -0,0 +1,41 @@
import type { Metadata } from 'next';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Switch } from '@documenso/ui/primitives/switch';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { LinkTemplatesForm } from '~/components/forms/link-templates';
import { PublicProfileForm } from '~/components/forms/public-profile';
export const metadata: Metadata = {
title: 'Public profile',
};
export default async function PublicProfileSettingsPage() {
const { user } = await getRequiredServerComponentSession();
return (
<div>
<SettingsHeader
title="Public profile"
subtitle="You can choose to enable/disable your profile for public view"
className="justify mb-8"
hideDivider
>
<div className="flex flex-row">
<label className="mr-2 text-white" htmlFor="hide">
Hide
</label>
<Switch />
<label className="ml-2 text-white" htmlFor="show">
Show
</label>
</div>
</SettingsHeader>
<PublicProfileForm className="mb-8 max-w-xl" user={user} />
<LinkTemplatesForm />
</div>
);
}
@@ -4,14 +4,7 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type {
DocumentData,
Field,
Recipient,
Template,
TemplateDocumentMeta,
User,
} from '@documenso/prisma/client';
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -26,8 +19,6 @@ import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-settings';
import type { TTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EditTemplateFormProps = {
@@ -37,12 +28,11 @@ export type EditTemplateFormProps = {
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
documentMeta: TemplateDocumentMeta | null;
templateRootPath: string;
};
type EditTemplateStep = 'settings' | 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
type EditTemplateStep = 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
export const EditTemplateForm = ({
className,
@@ -52,28 +42,22 @@ export const EditTemplateForm = ({
user: _user,
documentData,
templateRootPath,
documentMeta,
}: EditTemplateFormProps) => {
const { toast } = useToast();
const router = useRouter();
const [step, setStep] = useState<EditTemplateStep>('settings');
const [step, setStep] = useState<EditTemplateStep>('signers');
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
settings: {
title: 'Settings',
description: 'Configure general settings for the template.',
stepIndex: 1,
},
signers: {
title: 'Add Placeholders',
description: 'Add all relevant placeholders for each recipient.',
stepIndex: 2,
stepIndex: 1,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 3,
stepIndex: 2,
},
};
@@ -81,8 +65,6 @@ export const EditTemplateForm = ({
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
const { mutateAsync: setSettingsForTemplate } =
trpc.template.setSettingsForTemplate.useMutation();
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
@@ -105,31 +87,6 @@ export const EditTemplateForm = ({
}
};
const onAddTemplateSettingsFormSubmit = async (data: TTemplateSettingsFormSchema) => {
try {
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
await setSettingsForTemplate({
templateId: template.id,
meta: {
subject,
message,
timezone,
dateFormat,
redirectUrl,
},
});
router.refresh();
setStep('signers');
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while updating the template settings.',
variant: 'destructive',
});
}
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try {
await addTemplateFields({
@@ -178,14 +135,6 @@ export const EditTemplateForm = ({
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
>
<AddTemplateSettingsFormPartial
template={template}
key={recipients.length}
documentFlow={documentFlow.settings}
recipients={recipients}
onSubmit={onAddTemplateSettingsFormSubmit}
documentMeta={documentMeta}
/>
<AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
@@ -44,7 +44,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
redirect(templateRootPath);
}
const { templateDocumentData, templateDocumentMeta } = template;
const { templateDocumentData } = template;
const [templateRecipients, templateFields] = await Promise.all([
getRecipientsForTemplate({
@@ -79,7 +79,6 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
recipients={templateRecipients}
fields={templateFields}
documentData={templateDocumentData}
documentMeta={templateDocumentMeta}
templateRootPath={templateRootPath}
/>
</div>
@@ -131,7 +131,7 @@ export default async function CompletedSigningPage({
</div>
))
.with({ deletedAt: null }, () => (
<div className="mt-4 flex items-center text-center text-blue-600">
<div className="flex items-center mt-4 text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span>
</div>
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
import { Braces, CreditCard, Globe2, Lock, User, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -101,6 +101,18 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
)}
<Link href="/settings/public-profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2 className="mr-2 h-5 w-5" />
Public profile
</Button>
</Link>
</div>
);
};
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
import { Braces, CreditCard, Globe2, Lock, User, Users, Webhook } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -104,6 +104,18 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
)}
<Link href="/settings/public-profile">
<Button
variant="ghost"
className={cn(
'w-full justify-start',
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
)}
>
<Globe2 className="mr-2 h-5 w-5" />
Public profile
</Button>
</Link>
</div>
);
};
@@ -0,0 +1,26 @@
'use client';
import { File } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export const LinkTemplatesForm = () => {
return (
<div className={cn('flex max-w-xl flex-row items-center justify-between')}>
<div>
<h3 className="text-lg font-medium">My templates</h3>
<p className="text-muted-foreground text-sm md:mt-2">
Create templates to display in your public profile
</p>
</div>
<div>
<Button type="submit" variant="outline" className="self-end p-4">
<File className="mr-2" /> Link template
</Button>
</div>
</div>
);
};
@@ -0,0 +1,182 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Copy } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZPublicProfileFormSchema = z.object({
url: z
.string()
.trim()
.toLowerCase()
.min(1, { message: 'Please enter a valid username.' })
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
}),
bio: z
.string()
.trim()
.max(256, {
message: 'Bio cannot be longer than 256 characters.',
})
.optional(),
});
export type TPublicProfileFormSchema = z.infer<typeof ZPublicProfileFormSchema>;
export type PublicProfileFormProps = {
className?: string;
user: User;
};
export const PublicProfileForm = ({ className, user }: PublicProfileFormProps) => {
const router = useRouter();
const { toast } = useToast();
const form = useForm<TPublicProfileFormSchema>({
values: {
url: user.url || '',
},
resolver: zodResolver(ZPublicProfileFormSchema),
});
const watchedBio = form.watch('bio');
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async ({ url, bio }: TPublicProfileFormSchema) => {
try {
await updatePublicProfile({
url,
bio,
});
toast({
title: 'Public profile updated',
description: 'Your public profile has been updated successfully.',
duration: 5000,
});
router.refresh();
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to sign you In. Please try again later.',
});
}
}
};
const handleCopyUrl = () => {
const profileUrl = `documenso.com/u/${user.url}`;
navigator.clipboard
.writeText(profileUrl)
.then(() => {
toast({
title: 'URL Copied',
description: 'The profile URL has been copied to your clipboard.',
duration: 3000,
});
})
.catch((err) => {
console.error('Failed to copy: ', err);
toast({
title: 'Error',
description: 'Failed to copy the URL to clipboard.',
variant: 'destructive',
duration: 3000,
});
});
};
return (
<Form {...form}>
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-8" disabled={isSubmitting}>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Public profile URL</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="bg-muted flex w-full items-center rounded pl-2">
<span className="text-xs">documenso.com/u/{user.url}</span>
<Button
variant="ghost"
onClick={handleCopyUrl}
className="flex items-center justify-center rounded p-2 hover:bg-gray-200"
aria-label="Copy URL"
>
<Copy className="h-5 w-5" />
</Button>
</div>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<>
<Textarea placeholder="Write about yourself..." {...field} />
<div className="text-muted-foreground text-left text-sm">
{256 - (watchedBio?.length || 0)} characters remaining
</div>
</>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isSubmitting} className="self-end">
{isSubmitting ? 'Saving changes...' : 'Save changes'}
</Button>
</form>
</Form>
);
};
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import { useEffect, useState } from 'react';
import { RefObject, useEffect, useState } from 'react';
/**
* Calculate the width and height of a text element.
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { Field } from '@documenso/prisma/client';
import { Field } from '@documenso/prisma/client';
export const useFieldPageCoords = (field: Field) => {
const [coords, setCoords] = useState({
@@ -9,7 +9,7 @@ import {
} from '@documenso/lib/constants/feature-flags';
import { getAllFlags } from '@documenso/lib/universal/get-feature-flag';
import type { TFeatureFlagValue } from './feature-flag.types';
import { TFeatureFlagValue } from './feature-flag.types';
export type FeatureFlagContextValue = {
getFlag: (_key: string) => TFeatureFlagValue;
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { Toast } from '@documenso/ui/primitives/use-toast';
export const TOAST_DOCUMENT_SHARE_SUCCESS: Toast = {
title: 'Copied to clipboard',
+1 -2
View File
@@ -1,4 +1,3 @@
import type { User } from '@documenso/prisma/client';
import { Role } from '@documenso/prisma/client';
import { Role, User } from '@documenso/prisma/client';
export const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
@@ -1,6 +1,6 @@
import { z } from 'zod';
import type { User } from '@documenso/prisma/client';
import { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricDecrypt } from '../../universal/crypto';
@@ -1,4 +1,4 @@
import type { User } from '@documenso/prisma/client';
import { User } from '@documenso/prisma/client';
import { getBackupCodes } from './get-backup-code';
+2 -2
View File
@@ -1,5 +1,5 @@
import type { NextApiResponse } from 'next';
import type { NextResponse } from 'next/server';
import { NextApiResponse } from 'next';
import { NextResponse } from 'next/server';
type NarrowedResponse<T> = T extends NextResponse
? NextResponse
@@ -1,58 +0,0 @@
'use server';
import { prisma } from '@documenso/prisma';
export type CreateTemplateDocumentMetaOptions = {
templateId: number;
subject?: string;
message?: string;
timezone?: string;
password?: string;
dateFormat?: string;
redirectUrl?: string;
};
export const upsertTemplateDocumentMeta = async ({
subject,
message,
timezone,
dateFormat,
templateId,
password,
redirectUrl,
}: CreateTemplateDocumentMetaOptions) => {
const templateDocumentMeta = await prisma.templateDocumentMeta.findFirstOrThrow({
where: {
templateId: templateId,
},
include: {
template: true,
},
});
return await prisma.$transaction(async (tx) => {
const upsertedTemplateDocumentMeta = await tx.templateDocumentMeta.upsert({
where: {
templateId,
},
update: {
subject,
message,
timezone,
password,
dateFormat,
redirectUrl,
},
create: {
templateId,
subject,
message,
timezone,
password,
dateFormat,
redirectUrl,
},
});
return upsertedTemplateDocumentMeta;
});
};
@@ -65,7 +65,6 @@ export const createDocumentFromTemplate = async ({
},
},
templateDocumentData: true,
templateDocumentMeta: true,
},
});
@@ -129,15 +128,6 @@ export const createDocumentFromTemplate = async ({
},
},
documentData: true,
documentMeta: {
where: {
subject: template.templateDocumentMeta?.subject,
message: template.templateDocumentMeta?.message,
dateFormat: template.templateDocumentMeta?.dateFormat,
timezone: template.templateDocumentMeta?.timezone,
redirectUrl: template.templateDocumentMeta?.redirectUrl,
},
},
},
});
@@ -38,7 +38,6 @@ export const duplicateTemplate = async ({
Recipient: true,
Field: true,
templateDocumentData: true,
templateDocumentMeta: true,
},
});
@@ -37,7 +37,6 @@ export const findTemplates = async ({
where: whereFilter,
include: {
templateDocumentData: true,
templateDocumentMeta: true,
team: {
select: {
id: true,
@@ -29,7 +29,6 @@ export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) =>
where: whereFilter,
include: {
templateDocumentData: true,
templateDocumentMeta: true,
},
});
};
@@ -1,7 +1,7 @@
import crypto from 'crypto';
import { prisma } from '@documenso/prisma';
import type { TForgotPasswordFormSchema } from '@documenso/trpc/server/profile-router/schema';
import { TForgotPasswordFormSchema } from '@documenso/trpc/server/profile-router/schema';
import { ONE_DAY, ONE_HOUR } from '../../constants/time';
import { sendForgotPassword } from '../auth/send-forgot-password';
@@ -5,9 +5,10 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
export type UpdatePublicProfileOptions = {
userId: number;
url: string;
bio?: string;
};
export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOptions) => {
export const updatePublicProfile = async ({ userId, url, bio }: UpdatePublicProfileOptions) => {
const isUrlTaken = await prisma.user.findFirst({
select: {
id: true,
@@ -37,10 +38,10 @@ export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOp
userProfile: {
upsert: {
create: {
bio: '',
bio: bio,
},
update: {
bio: '',
bio: bio,
},
},
},
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Field } from '@documenso/prisma/client';
import { Field } from '@documenso/prisma/client';
/**
* Sort the fields by the Y position on the document.
@@ -1,19 +0,0 @@
-- CreateTable
CREATE TABLE "TemplateDocumentMeta" (
"id" TEXT NOT NULL,
"subject" TEXT,
"message" TEXT,
"timezone" TEXT DEFAULT 'Etc/UTC',
"password" TEXT,
"dateFormat" TEXT DEFAULT 'yyyy-MM-dd hh:mm a',
"templateId" INTEGER NOT NULL,
"redirectUrl" TEXT,
CONSTRAINT "TemplateDocumentMeta_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TemplateDocumentMeta_templateId_key" ON "TemplateDocumentMeta"("templateId");
-- AddForeignKey
ALTER TABLE "TemplateDocumentMeta" ADD CONSTRAINT "TemplateDocumentMeta_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-13
View File
@@ -324,18 +324,6 @@ model DocumentMeta {
redirectUrl String?
}
model TemplateDocumentMeta {
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
redirectUrl String?
}
enum ReadStatus {
NOT_OPENED
OPENED
@@ -563,7 +551,6 @@ model Template {
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
templateDocumentMeta TemplateDocumentMeta?
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Recipient Recipient[]
Field Field[]
+1 -1
View File
@@ -1,6 +1,6 @@
import { TRPCClientError } from '@trpc/client';
import type { AppRouter } from '../server/router';
import { AppRouter } from '../server/router';
export const isTRPCBadRequestError = (err: unknown): err is TRPCClientError<AppRouter> => {
return err instanceof TRPCClientError && err.shape?.code === 'BAD_REQUEST';
@@ -88,7 +88,7 @@ export const profileRouter = router({
.input(ZUpdatePublicProfileMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { url } = input;
const { url, bio } = input;
if (IS_BILLING_ENABLED() && url.length < 6) {
const subscriptions = await getSubscriptionsByUserId({
@@ -108,6 +108,7 @@ export const profileRouter = router({
const user = await updatePublicProfile({
userId: ctx.user.id,
url,
bio,
});
return { success: true, url: user.url };
@@ -25,6 +25,13 @@ export const ZUpdatePublicProfileMutationSchema = z.object({
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
}),
bio: z
.string()
.trim()
.max(256, {
message: 'Bio cannot be longer than 256 characters.',
})
.optional(),
});
export const ZUpdatePasswordMutationSchema = z.object({
@@ -3,7 +3,6 @@ import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError } from '@documenso/lib/errors/app-error';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { upsertTemplateDocumentMeta } from '@documenso/lib/server-only/template-document-meta/upsert-template-document-meta';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
@@ -17,7 +16,6 @@ import {
ZCreateTemplateMutationSchema,
ZDeleteTemplateMutationSchema,
ZDuplicateTemplateMutationSchema,
ZSetSettingsForTemplateMutationSchema,
} from './schema';
export const templateRouter = router({
@@ -125,27 +123,4 @@ export const templateRouter = router({
});
}
}),
setSettingsForTemplate: authenticatedProcedure
.input(ZSetSettingsForTemplateMutationSchema)
.mutation(async ({ input }) => {
try {
const { meta, templateId } = input;
return await upsertTemplateDocumentMeta({
templateId,
subject: meta.subject,
message: meta.message,
dateFormat: meta.dateFormat,
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to set template settings. Please try again later.',
});
}
}),
});
@@ -1,7 +1,5 @@
import { z } from 'zod';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
export const ZCreateTemplateMutationSchema = z.object({
title: z.string().min(1).trim(),
teamId: z.number().optional(),
@@ -26,22 +24,6 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
sendDocument: z.boolean().optional(),
});
export const ZSetSettingsForTemplateMutationSchema = z.object({
templateId: z.number(),
meta: z.object({
subject: z.string().optional(),
message: z.string().optional(),
timezone: z.string().optional(),
dateFormat: z.string().optional(),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});
export const ZDuplicateTemplateMutationSchema = z.object({
templateId: z.number(),
teamId: z.number().optional(),
+1 -2
View File
@@ -3,8 +3,7 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { VariantProps, cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
+1 -2
View File
@@ -4,8 +4,7 @@ import { Eye, EyeOff } from 'lucide-react';
import { cn } from '../lib/utils';
import { Button } from './button';
import type { InputProps } from './input';
import { Input } from './input';
import { Input, InputProps } from './input';
const PasswordInput = React.forwardRef<HTMLInputElement, Omit<InputProps, 'type'>>(
({ className, ...props }, ref) => {
@@ -1,4 +1,4 @@
import type {
import {
MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
TouchEvent as ReactTouchEvent,
@@ -1,281 +0,0 @@
'use client';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { Template, TemplateDocumentMeta } from '@documenso/prisma/client';
import { type Recipient, SendStatus } from '@documenso/prisma/client';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Combobox } from '../combobox';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter,
DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root';
import type { DocumentFlowStep } from '../document-flow/types';
import { Input } from '../input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper';
import { Textarea } from '../textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import type { TTemplateSettingsFormSchema } from './add-template-settings.types';
import { ZTemplateSettingsFormSchema } from './add-template-settings.types';
export type AddSettingsFormProps = {
template: Template;
documentFlow: DocumentFlowStep;
recipients: Recipient[];
documentMeta: TemplateDocumentMeta | null;
onSubmit: (_data: TTemplateSettingsFormSchema) => void;
};
export const AddTemplateSettingsFormPartial = ({
documentFlow,
recipients,
documentMeta,
template,
onSubmit,
}: AddSettingsFormProps) => {
const form = useForm<TTemplateSettingsFormSchema>({
resolver: zodResolver(ZTemplateSettingsFormSchema),
defaultValues: {
title: template.title,
meta: {
subject: documentMeta?.subject ?? '',
message: documentMeta?.message ?? '',
timezone: documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: documentMeta?.redirectUrl ?? '',
},
},
});
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
const documentHasBeenSent = recipients.some(
(recipient) => recipient.sendStatus === SendStatus.SENT,
);
useEffect(() => {
if (!form.formState.touchedFields.meta?.timezone && !documentHasBeenSent) {
form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [documentHasBeenSent, form, form.setValue, form.formState.touchedFields.meta?.timezone]);
return (
<>
<DocumentFlowFormContainerContent>
<Form {...form}>
<fieldset
className="flex h-full flex-col space-y-6"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel required>Title</FormLabel>
<FormControl>
<Input className="bg-background" {...field} disabled={field.disabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Accordion type="multiple" className="mt-6">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="text-foreground mb-2 rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline">
Advanced Options
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-2 text-sm leading-relaxed">
<div className="flex flex-col space-y-6 ">
<FormField
control={form.control}
name="meta.dateFormat"
render={({ field }) => (
<FormItem>
<FormLabel>Date Format</FormLabel>
<FormControl>
<Select
{...field}
onValueChange={field.onChange}
disabled={documentHasBeenSent}
>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((format) => (
<SelectItem key={format.key} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.subject"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Subject{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
Add email subject
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input
id="subject"
className="bg-background mt-2"
disabled={field.disabled}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Message{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
Add message to send in the email
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea
id="message"
className="bg-background mt-2 h-24 resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>Time Zone</FormLabel>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
{...field}
onChange={(value) => value && field.onChange(value)}
disabled={documentHasBeenSent}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.redirectUrl"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Redirect URL{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
Add a URL to redirect the user to once the document is signed
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</fieldset>
</Form>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>
<DocumentFlowFormContainerStep
title={documentFlow.title}
step={currentStep}
maxStep={totalSteps}
/>
<DocumentFlowFormContainerActions
loading={form.formState.isSubmitting}
disabled={form.formState.isSubmitting}
canGoBack={stepIndex !== 0}
onGoBackClick={previousStep}
onGoNextClick={form.handleSubmit(onSubmit)}
/>
</DocumentFlowFormContainerFooter>
</>
);
};
@@ -1,44 +0,0 @@
import { z } from 'zod';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
export const ZMapNegativeOneToUndefinedSchema = z
.string()
.optional()
.transform((val) => {
if (val === '-1') {
return undefined;
}
return val;
});
export const ZTemplateSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentActionAuthTypesSchema.optional(),
),
meta: z.object({
subject: z.string().optional(),
message: z.string().optional(),
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});
export type TTemplateSettingsFormSchema = z.infer<typeof ZTemplateSettingsFormSchema>;