From 12d8cebd4c9a1dbd042e5232f974edfa18475032 Mon Sep 17 00:00:00 2001 From: Mythie Date: Wed, 26 Jul 2023 18:52:53 +1000 Subject: [PATCH] feat: use server-actions for authoring flow This change actually makes the authoring flow work for the most part by tying in emailing and more. We have also done a number of quality of life updates to simplify the codebase overall making it easier to continue work on the refresh. --- .env.example | 43 +- apps/marketing/package.json | 2 +- apps/web/next.config.js | 16 +- apps/web/package.json | 2 +- apps/web/public/static/clock.png | Bin 0 -> 2469 bytes apps/web/public/static/completed.png | Bin 0 -> 1812 bytes apps/web/public/static/document.png | Bin 0 -> 16295 bytes apps/web/public/static/download.png | Bin 0 -> 784 bytes apps/web/public/static/logo.png | Bin 0 -> 4119 bytes apps/web/public/static/review.png | Bin 0 -> 708 bytes .../documents/[id]/edit-document.tsx | 111 ++ .../documents/[id]/loadable-pdf-card.tsx | 2 +- .../(dashboard)/documents/[id]/loading.tsx | 8 +- .../app/(dashboard)/documents/[id]/page.tsx | 17 +- .../(dashboard)/documents/[id]/sent/page.tsx | 18 + apps/web/src/app/layout.tsx | 5 +- .../document-dropzone/document-dropzone.tsx | 14 +- .../(dashboard)/metric-card/metric-card.tsx | 6 +- .../components/formatter/document-status.tsx | 17 +- .../src/components/forms/edit-document.tsx | 251 --- .../forms/edit-document/add-fields.action.ts | 31 + .../forms/edit-document/add-fields.tsx | 616 +++++--- .../forms/edit-document/add-fields.types.ts | 21 + .../forms/edit-document/add-signers.action.ts | 26 + .../forms/edit-document/add-signers.tsx | 170 ++- .../forms/edit-document/add-signers.types.ts | 19 + .../forms/edit-document/add-subject.action.ts | 21 + .../forms/edit-document/add-subject.tsx | 206 ++- .../forms/edit-document/add-subject.types.ts | 10 + .../forms/edit-document/container.tsx | 152 ++ .../forms/edit-document/field-item.tsx | 39 +- .../send-document-action-dialog.tsx | 64 + apps/web/src/components/forms/password.tsx | 2 +- apps/web/src/helpers/getBoundingClientRect.ts | 10 + .../web/src/pages/api/stripe/webhook/index.ts | 8 +- package-lock.json | 1346 +++++++++++++++-- packages/email/index.ts | 2 +- packages/email/mailer.ts | 50 + packages/email/package.json | 16 +- packages/email/render.ts | 1 + packages/email/templates/document-pending.tsx | 1 - packages/email/transports/mailchannels.ts | 154 ++ packages/eslint-config/package.json | 2 +- packages/lib/package.json | 4 +- .../server-only/document/send-document.tsx | 94 ++ .../field/set-fields-for-document.ts | 30 +- .../recipient/set-recipients-for-document.ts | 35 +- packages/tailwind-config/index.cjs | 4 + .../trpc/server/document-router/router.ts | 22 + .../trpc/server/document-router/schema.ts | 6 + packages/tsconfig/base.json | 3 +- packages/tsconfig/process-env.d.ts | 36 + packages/ui/primitives/tooltip.tsx | 20 +- turbo.json | 17 +- 54 files changed, 2890 insertions(+), 860 deletions(-) create mode 100644 apps/web/public/static/clock.png create mode 100644 apps/web/public/static/completed.png create mode 100644 apps/web/public/static/document.png create mode 100644 apps/web/public/static/download.png create mode 100644 apps/web/public/static/logo.png create mode 100644 apps/web/public/static/review.png create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx create mode 100644 apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx delete mode 100644 apps/web/src/components/forms/edit-document.tsx create mode 100644 apps/web/src/components/forms/edit-document/add-fields.action.ts create mode 100644 apps/web/src/components/forms/edit-document/add-fields.types.ts create mode 100644 apps/web/src/components/forms/edit-document/add-signers.action.ts create mode 100644 apps/web/src/components/forms/edit-document/add-signers.types.ts create mode 100644 apps/web/src/components/forms/edit-document/add-subject.action.ts create mode 100644 apps/web/src/components/forms/edit-document/add-subject.types.ts create mode 100644 apps/web/src/components/forms/edit-document/container.tsx create mode 100644 apps/web/src/components/forms/edit-document/send-document-action-dialog.tsx create mode 100644 apps/web/src/helpers/getBoundingClientRect.ts create mode 100644 packages/email/mailer.ts create mode 100644 packages/email/render.ts create mode 100644 packages/email/transports/mailchannels.ts create mode 100644 packages/lib/server-only/document/send-document.tsx create mode 100644 packages/tsconfig/process-env.d.ts diff --git a/.env.example b/.env.example index fea246621..3ce57722b 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,56 @@ +# [[AUTH]] NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="secret" +# [[APP]] NEXT_PUBLIC_SITE_URL="http://localhost:3000" NEXT_PUBLIC_APP_URL="http://localhost:3000" +# [[DATABASE]] NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" +# [[SMTP]] +# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels +NEXT_PRIVATE_SMTP_TRANSPORT="smtp-auth" +# OPTIONAL: Defines the host to use for sending emails. +NEXT_PRIVATE_SMTP_HOST="127.0.0.1" +# OPTIONAL: Defines the port to use for sending emails. +NEXT_PRIVATE_SMTP_PORT=2500 +# OPTIONAL: Defines the username to use with the SMTP server. +NEXT_PRIVATE_SMTP_USERNAME="documenso" +# OPTIONAL: Defines the password to use with the SMTP server. +NEXT_PRIVATE_SMTP_PASSWORD="password" +# OPTIONAL: Defines the API key user to use with the SMTP server. +NEXT_PRIVATE_SMTP_APIKEY_USER= +# OPTIONAL: Defines the API key to use with the SMTP server. +NEXT_PRIVATE_SMTP_APIKEY= +# OPTIONAL: Defines whether to force the use of TLS. +NEXT_PRIVATE_SMTP_SECURE= +# REQUIRED: Defines the sender name to use for the from address. +NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso" +# REQUIRED: Defines the email address to use as the from address. +NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com" +# OPTIONAL: The API key to use for the MailChannels proxy endpoint. +NEXT_PRIVATE_MAILCHANNELS_API_KEY= +# OPTIONAL: The endpoint to use for the MailChannels API if using a proxy. +NEXT_PRIVATE_MAILCHANNELS_ENDPOINT= +# OPTIONAL: The domain to use for DKIM signing. +NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN= +# OPTIONAL: The selector to use for DKIM signing. +NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR= +# OPTIONAL: The private key to use for DKIM signing. +NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY= + +# [[STRIPE]] +NEXT_PRIVATE_STRIPE_API_KEY= +NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID= -NEXT_PRIVATE_STRIPE_API_KEY= -NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= - +# [[FEATURES]] NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED=false # This is only required for the marketing site +# [[REDIS]] NEXT_PRIVATE_REDIS_URL= NEXT_PRIVATE_REDIS_TOKEN= diff --git a/apps/marketing/package.json b/apps/marketing/package.json index e34c66b99..523a23a90 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -18,7 +18,7 @@ "framer-motion": "^10.12.8", "lucide-react": "^0.214.0", "micro": "^10.0.1", - "next": "13.4.1", + "next": "13.4.9", "next-auth": "^4.22.1", "next-plausible": "^3.7.2", "perfect-freehand": "^1.2.0", diff --git a/apps/web/next.config.js b/apps/web/next.config.js index b57b41780..09760f806 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -7,9 +7,23 @@ const { parsed: env } = require('dotenv').config({ /** @type {import('next').NextConfig} */ const config = { + experimental: { + serverActions: true, + }, reactStrictMode: true, - transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'], + transpilePackages: [ + '@documenso/lib', + '@documenso/prisma', + '@documenso/trpc', + '@documenso/ui', + '@documenso/email', + ], env, + modularizeImports: { + 'lucide-react': { + transform: 'lucide-react/dist/esm/icons/{{ kebabCase member }}', + }, + }, }; module.exports = config; diff --git a/apps/web/package.json b/apps/web/package.json index d493b92d9..32d0d61b3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,7 @@ "lucide-react": "^0.214.0", "micro": "^10.0.1", "nanoid": "^4.0.2", - "next": "13.4.1", + "next": "13.4.9", "next-auth": "^4.22.1", "next-plausible": "^3.7.2", "next-themes": "^0.2.1", diff --git a/apps/web/public/static/clock.png b/apps/web/public/static/clock.png new file mode 100644 index 0000000000000000000000000000000000000000..ce8ee6481710523491c43e23e9c2d2bbb83c2f36 GIT binary patch literal 2469 zcmV;W30n4vP)@~0drDELIAGL9O(c600d`2O+f$vv5yP-TlZitkmWe;`@bCaU0Myjf)YRNp5Gu^>H`m(` zUT=FK9!r63Ale3kHUOzV>fd*uk$N^#Zxj%~NJ#n>1q?&c46eRv4WXh`N`NOPGBd96W}1Swqru5=`NCV}?dRk)D=1PutAugeyr6ahM2YEQ)b zeL(R{nUoHc3Br&_zP>to)Q1vSsQ_H@*WSkiBzd^P)^3X$J7jOwLaa=H&rUuZ2%>HU zD}J7p3Q@QKY~u;&xO7vKouJvor-T1(1yCS`2@u=)@v-{y5Y`w%>|VXx>OnyiBEWyT zW8V{Wp}zDX7c@fU*Mr0#N&PL509t9@ut@5&1wK|^cOXB7 z^#sxl$yH7wGbp94YXPLyV8Gpxix?Sxh2MzumrA6k~0DJUcG;#e%wA}f-4?gH|W8siYdd4sCkR66OmJdr3J`1 z;I3p+jd8>z|GvIh_e>K7XWo*^cX8_N4djlu#U46kY1` z1!N@dnl_bBJ=c}b@IblX^V^df)hiss@4)Y3QM5go&;^HJAOo2MV7m_%b}?@G54%tR zS}%oQ8w!Bmh2P1ds6UN=Sz|2#cYT{dm+f&8E#H#DLfz3jAr35x9`bA(EU^|~>J^v> zTH#ji$w^gdh9AEGUd}WSh2Jf)5@7e$yL~X49vVfiX2~<#A{li5H3}s@jpsBP)Y`{q z(R{*;l>i9BLQ(9ki=Ur|U%LE`XdIMP#U9%X$VLHdTEFu3&#OMebxHKD=JB9?gXUgf z6u_qStJsc{$ly8=hVa~Eiqs7Gz|;%00$83UxuHaR zkr_%A0K@Cl!nX*D-o3C+#C2gZL$Z_hINb!HryaSK`58dX9gJp3M0=7FqzgdO;QiF* zCxOWfEeMq-1c>dwe!-(l$DzRtwRgMM2@w(kcyi){243TZngr|(@%c1e8BbUU zK+>?PB;~JAlL|`uQTKgHF+<5QK;UE0{SXuZ)TBa&9zdS=&-rJ`9$>Kl$fpf!QW1!O zwm^z=lk5RRK4_4kx3Mc;d6|Sl-#97RxKrFvef+xhNmywCvMh*~fQVIk56VQmZJ(}++aHjCj4 zDOd>JJ!mMB_`TBx;YxcsJkW(w>>%cd$QC4oXnLl7Ko$a^zIBB+vXBk1Szr-VK=7#& z;i)SjT=Z-qL&GFCIa5vwAVC}Jk%T@}1dhtO5@Ir;w|PY%3bl<>O8RsDSwa9b*jFU! zL&b0;MDFti(dm2*sNm2f z)v(mwm3mAy$|yqmBT4)c_2PVtO?{DU>{y#1yNyQcWrm8xWPTJuTIDl~H|JXHcdWWM zv(fm>8L7i6 zsRz>V^5w&wMc>x12T*A8i|=(Wn6`djSB6Mv4{&IE0A~!Dh}Lh0b>c#jWK^AL>;KJA zEVw*+@TsnVwg*@p!6_(7M%58f(hxmlcb-^})e)QwC&M=Z&X;77M<}>D+Ugt1m<5P7 zo!XuZFL}9g6|m_MHc3k?>Wr~Y*ks`mF@f@mb~e>3nxS`I3oaUuYdA;s@)0wqMh3?XveY38W%8D|1883*RO2=ivG6)wOG&Otg&0_#JiS?ygnD-&Qwb%iMBYOR$EFoT57{|F0L+r<}K(r z1Wu?V#Gp#Mx+^8XJS>PO(T}otMTw-4ud4Oqj4t!yM1!ZQXf@~0drDELIAGL9O(c600d`2O+f$vv5yPZn*yv062oR^}=5A2@63X(LB zR!>kK!9|fGCdHl3P?jQzvlJOk_bgGf@(T-BkL4dceuC&V}rEmgR z=@W5L-IEDgI7y)r_|L3kNgB#S?8k7T{G^6e4%63s& zF*5;k5w8^m@kE<|`4&83Ij;hiy5H%F&<;=v+pbfbR=s7uOf0Bb!2**Uwt|kXI zZ4YgJxZOa+jf`Y;+)8CNuW!gjKs<&==5Rt|d}{EnbNOViNRLfqtO2U6N#7c_j}yE(%mU~s2axRyIUTq{8q{>xk$)FK!%?84Sn?KoT9j2sXcl^d)zQw@>V8y zgoX?VqsZ#%(claBa=7$#jv*|6%MF7i`uMKpmU`$o`;Y+>HDL3@oh?B0#EXdAPj0;S z;0%#w5=yI`$3MKhpZ0tz0ljfPIutK1fbe~!kw9_6O4=xtY686=M*ykbq6p96wy-Hm z*Njrny~CC@LbVbQtG;aazI!F|V8L)H>TYnrC0wcpRo5D@i6+qV`qAIgHS+dXpU!#C zo)tVgRHEpJg0%4-Kc-RPsx&Hy8W5UD9%sq!7gcx$w+&HABSX!m1qlM!Ug#xF;a-1{ z?A`{q8|;Xtpm1IC1qlKI*J+8iuVCh>=RCk_u0Ym;6NeKuqeu`yA?+{1!~T=&ulC@a zzzI+G$hisjZf+*e4S}7XILo6f44fmd7D&E;Wg9nx>hPbyL{0gXjxFRY4G;hPc^xC; z@i9_Fmv_ETz1=pD432sx4trnj8v?1#vhXfa|KB<=M{o>cjM?E7TbyR2P&}Rv=Rhy| z6XSvr8Ap8p3Tf*(tAEPeV;ORS)9S*>5gGN^0|^3<(pWGjq)xmH`~x|mK}nihAHC-k z97PE?Lj|L`sDG;PoNuUBb|5E|-1geAtOF-l?LyrMfpNE5^RXjShY4+t=z3nKuP65lmU!dVGFjsXGvCF&wTiy&5q z#UzmM`)4V3JhBZ4;15ePC`zi;VM^p6p%k@8g<|Ioe`kCc^v7>j%3OmP zkb#8qY+i2rNN_Y73Zoc1+w3(nQb;J(;fa|ol|ldsD~65_-`#4$j9?P9F{}L<8kfD4 zDZEFKs3ogC(#a$F05=K`0_`N$0w_jk(oY*^#G{|y9MI1J%mE>X;R1}Vw1@wXW`yE= znjokpp}5G9)SqP=*EnG=p#Mmov!|685xwL|QvJEoZ2Vk;09!)wTxkJ{>x8}=gbDLY zt_xTaif~S}z`CSInXSw#L%g_P(Sohb*t;$!deiXa9D}P@$oYv$Rgr)cE#RSYe-TX~ zu2+n!Mv~yt9cyj?EU+#yarK4kJc9*8@QK?buV$w$=~)S2EzoGh5SBANJzHB0oVI06 zy2gc+Y0svn#Nyn#tqv#7)t@~HlS+6d)hK4F5_|Ce_MxpXQYqMTg7AKpimW0L+kC9{ za?f+n^Z3VCE0f-nsY=WV!&Qqvh|M8zoFi|F#L2Yaktq1&9)U%8f*(~XnQ{@C3nequ z<`q`KZ9~5@JtdvS$Th>vRVtf_1kNca*e-H8Jx57??&)>zV(HU5oaY&KgT$K~kXDwb zU0jXJ^t4&dvKN7E-nksE<}K5mx?Da(&F(N_GPqn`q3Vopj^ZAM&)?KeLetQkv*%2X zfmY@X7;)L&o2kO|88=MaY8s`#&J7d8U~N!JtqbL8rJ%^l^g;+=uy0UN*UPojBPYrz z$uE4{9D@@QTx?b3wmDAJ6uk-K^xfrwwW1n^jYzAO26YWHY@Fm?rdIjn0N+9c9rwH9 z_X>`D59W;i=BW4&VvQGNwL+g(Y>%bQR2R5Fx%eNk{HodmS~Up(0000fd#V51=ukr*ajJ}z%~zC*hJV4IR3*B5)v6vMTFNt zOo_xP%2WhajG=f`Ku#!Pk#eOXh1Ebp%DUF~WgGtxe0=FXf>|N8c? z@7Leyb00HzW>@z8YVMp@pFVxg`JM0G-PT-0a?V+~ZLM{3Kepw1>6k2|>tT7hJ*7*B zthq=eK~Vya+*bmareS3ySk08C1d@QkI)Cz5X@6;3uN(1kwbfZ_$CS2)F6sl9VzWhj z#$v;Pu^w)z%MKIaQ<^MY6!4Wmq4YbH8CZt`itFtP=Y@6dD_vgfck!H3d)U_OdQw%G z0&`3( ze2{h8Cc3o6+GbqDB|nC-IEvt2Zj`dI&0$lWqd8{rn5TL@s=z46MzQd9*|u}+h^oiM z;@}zQlZw3j$_L%VI5(*bf$Y2pyufWf^q~)><7Na>z~lLb^>90uO{+Xd9v5w3J2L>b z0B;=bm$+uz{i*$y9JvjYqF}2^!)57Ls%uM00#E-+$K`o)8P|>8$NdB91o#42xNrad z{ndarm5x{9r}xVx&#lz;OV7fvT%m21yeD~$SWh+RSq^&c`g!sCE$3&?x9xom+umwQ z54`j>kmOe?zZ7zu!b2^(Po(}PzGAV<8UIpCTt(||)X4%#&IOJ|u7&-Pk|_P;CqGG$ zvmCGC2xe7h#d6}3=7S&nAUS7tUvZ!0o19d+WUqxc=C`w)ZlqZ0Q_xF-Tv0NW-za_}l`h{j<^X+ec zdq)ES`|#uW0GZrYKn>^lxV{Z@xbqJ@ z9EUi@HpC{jZ@TFw>j4yGhd-zC`?LB{9(DO&*#5h}`#Z}rIQP*bke_uOOO|Ni$o02FP;HBbhi1|SGhfCbwCpXY}70PqXf1Sr{7+$gs}C=Wo#<4MPi zA66(EpH;f=2x#b^3Fv&he4eOBJe!jHCj()jj#e7g!!QYId;vC(E)a?OymVu>@9=dB zSjDeL8V7tKr5A&J;8Y$M07C*n+-CrS-~7%0@Vaf=x4&hrUVFn#r*lQURv$5yU8s!x z?AqGu!6%=5eBRf!aN~_PS^(4oGh8DYH34M4-UQI+o_o&TdFP!qL5U^K0awEHNB|Uo z8IObOB%oo5dche9JRXmnn%psaF7bG%3m+KP<8yG_*3VHqt5E@yfzJ-`8=5Vk8T?an z-xN4r7V;8EOB_)GPyiMWr{cpA94&FlD1s8qkNe5kd5xs?YO4x#Kc(*=5`KQmRXmqy5?J7cC-28Kl$^8x?Vfz4?f~tec-}{ z<-_;ifB(VDFTWf>?mYSAlg_WZ2(kb?v;*7?VDz9s8>}y9Q4XBZA4Z!3Czad{gmnP# znP;9!FFK0+@ZkW+{NUns$fcd+>R69}C-AKiK1})C3X(#rJKQJ1YWWeGf|xFOJmUMA z7%*FvQmMBUIHDQ0O_C1=Iu;%@9ws;$^(s7gh#+t{Y~vV~csM9a@rjp>^`H5f_dRgM z?#o}d`-)wzuIq44RaI$SlLw=zy--?SUbZWKeQm8aE6d9V{W)Lp?Bv09ShaWoV z+q$@Y`*!yo-|-#h;fEhKZ+`Qe37(1|1t4+!nrp6cXcIu|k2`>k+h`|11y1+C0}lj1 zf%Af!Vja(b+!5OpczEn^?bM6eQ;@M<DQ z``7Ni`|bcFf)n)8m%bGDQ+sY2I3q+nj$s|g0akKWf|DGa_lW!Daqg4%&V6`302po% zI;;MG$(% z9%~P#z)8Vb0a`9mR<8T{lAaeoRB~7BufTEnA(xg?C$uU{N53~sqkO<^I%DbC7OxQ- z)ZiXQaJUw3LbgrUK+)PGxTvs4UC8iD2iWjXWuuNBJ!&Oq!4f*wU;gF&Z`reV&nG?S zD?W^24{2E00iwr??5+o<2crik017U-=7G58xugf{!L`~Q`t6V0eZX7NcHh2z_LZ-E zr8|4}tbP3P$IZ5F+rqN~Sh2l(_wE3yrKKes0hHQ=4@|d@;+~xK%$YMapCg|io~8fQ zbP3^D6wxIPnDob}JC-bCQ+Q6YtQn1|HlK0YS3xsl z6^2$b_6@R*3p#wO4yYu{&<{EmZeVLBLt??fOrl=LB`qZ^BZmw1969o}cXwvy{?o2q zm+jfL^D>A!vr#I2Vb)%X07P5Un%HiBJZSzfnDqd`^~T{Fyv};V`SVL}{o_CRvv(~n zoP6)k|NPG%GSNDY(j?Qfc_IwIXq@z$t(c98LQOK7tZ*UE(&QBuEoX@>dgYVnTZ2(N z9~u-1P5h#GV}}ks^`oLlLr1zsvYSfat7hMVMb-9$!+KVK) zenctIwz)Y26=cuuD_*yI&y^2&jus>gbY>Ve03k(iTm~^KtYRmvX1s4EUbx(?7M8Z3 zT#~72v8`6)sKoWMXVt4j%M`Uk)Nd;gX?Gjv4h-YG?AsPpli#;8l!SKxdD^`17cmBcS- zMxSVe@t`V7AfOe?IHdoo%=R6#|LXExJ2nA$rG4&s2f#c7Dt=Hw_HGoAz4LeO+-}aF zzwisMdey6b;{zY~z@oRP;T$(`55YC$sfLFE4s*I`gmFk_LXSjkn<3HOZRA!n#c$%2 z&Xp4tN?wMlAgdeJj8o=0*JIeLO+f@3DV`~k*MD!@x6gU- zy!>6Tm|-2$jlX z7pKLp^eLgi$DTg9`j4)Dq1g=jdJwxCp+1uw>=E(Chgn#B&aBn-Vehm&?BCb{zkCEC z#oKTH$>1!9?~ra9>Cl!@7LYVh5H!$+hb{OGO^Xdxa=2K2JVw!*{CLnCs7HX6 zn=PYnEZ~9ot)f@H^VM=1sN_Hi8OAx!<&^KaRoXKni^#!u<|N3YDt-BfEbI&6m-^C{H_cQ0t z=HQDV#lCONZ^zXXH1J%~`S`X4mqe_4#Yb}LwR+wkeZ;T-(Y_mA@>#rZ&k<|W_x1KF z>ElClKO?Ur$|puQQ@RN&a`UuhDZT(F6xK~4S8kh`(&(gtrx^|2@9a#+`k(V6J00_! z@)h0H)mL~u^4(8;?dkt<{_OI<@m%l_j?p-X8QTP;ZM&{e?eY(S2jx#V6+@wD^L-ua z1AeYi{u_aomY3bhQ)gVa+jZV;QhN&)RxF-H?YFV7@vKNi zz;nJWRm5B&=G;Hn&=S+*RK9>lxvvj(vL9zHsZ+Kk!@H$2^~k(e#XF-#hPsokH`6y@ zowA10F4>-{&?sp-hkx{FOM?T1u*{}ex!E*ZSVr zGRHHyTOxLJ7mS`SQL)}kNu-aDA{}y`LFd?Cz+E&4b4ir8p&_z3ayrTNgm##&+vI=+ z&RWnTFAZvjvcg_h4y8(q)C{Q@ou`f-eR7^#lIPd{U3Xa}wWssePMC!@4I> zlxkhBExe|~STjj-n*0@J?P^V_k|(euxJP;D=}uOQ zR4ODbvYTUxmY2K1`8hV}7b!)<27tU#nvAF|vF*{Rs;t>@+4dF?_AAvo7NZ?jC3f

;HG6$5dje@nS!Jv~z8tNDRt<}NOJs__` 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 G5}E)N*(c8c literal 0 HcmV?d00001 diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx new file mode 100644 index 000000000..b79c46671 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useState } from 'react'; + +import dynamic from 'next/dynamic'; + +import { Loader } from 'lucide-react'; + +import { Document, Field, Recipient, User } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Card, CardContent } from '@documenso/ui/primitives/card'; + +import { AddFieldsFormPartial } from '~/components/forms/edit-document/add-fields'; +import { AddSignersFormPartial } from '~/components/forms/edit-document/add-signers'; +import { AddSubjectFormPartial } from '~/components/forms/edit-document/add-subject'; + +const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), { + ssr: false, + loading: () => ( +

+ + +

Loading document...

+
+ ), +}); + +export type EditDocumentFormProps = { + className?: string; + user: User; + document: Document; + recipients: Recipient[]; + fields: Field[]; +}; + +export const EditDocumentForm = ({ + className, + document, + recipients, + fields, + user: _user, +}: EditDocumentFormProps) => { + const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers'); + + const documentUrl = `data:application/pdf;base64,${document.document}`; + + const onNextStep = () => { + if (step === 'signers') { + setStep('fields'); + } + + if (step === 'fields') { + setStep('subject'); + } + }; + + const onPreviousStep = () => { + if (step === 'fields') { + setStep('signers'); + } + + if (step === 'subject') { + setStep('fields'); + } + }; + + return ( +
+ + + + + + +
+ {step === 'signers' && ( + + )} + + {step === 'fields' && ( + + )} + + {step === 'subject' && ( + + )} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx b/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx index b2008c921..5daa07e9e 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/loadable-pdf-card.tsx @@ -16,7 +16,7 @@ export type LoadablePDFCard = PDFViewerProps & { const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), { ssr: false, loading: () => ( -
+

Loading document...

diff --git a/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx b/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx index e68b149dc..e97489a6e 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/loading.tsx @@ -4,7 +4,7 @@ import { ChevronLeft, Loader } from 'lucide-react'; export default function Loading() { return ( -
+
Documents @@ -13,15 +13,15 @@ export default function Loading() { Loading Document...
-
-
+
+

Loading document...

-
+
); diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index 7f3b76909..94fb8ad96 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -1,14 +1,15 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { ChevronLeft } from 'lucide-react'; +import { ChevronLeft, Users2 } from 'lucide-react'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; -import { EditDocumentForm } from '~/components/forms/edit-document'; +import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; +import { DocumentStatus } from '~/components/formatter/document-status'; export type DocumentPageProps = { params: { @@ -61,6 +62,18 @@ export default async function DocumentPage({ params }: DocumentPageProps) { {document.title} +
+ + + {recipients.length > 0 && ( +
+ + + {recipients.length} Recipient(s) +
+ )} +
+ + + + Documents + + +

+ Loading Document... +

+
+ ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 9ce82a4c0..9c60bbadf 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import { Inter } from 'next/font/google'; import { TrpcProvider } from '@documenso/trpc/react'; import { Toaster } from '@documenso/ui/primitives/toaster'; +import { TooltipProvider } from '@documenso/ui/primitives/tooltip'; import { ThemeProvider } from '~/providers/next-theme'; import { PlausibleProvider } from '~/providers/plausible'; @@ -47,7 +48,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - {children} + + {children} + diff --git a/apps/web/src/components/(dashboard)/document-dropzone/document-dropzone.tsx b/apps/web/src/components/(dashboard)/document-dropzone/document-dropzone.tsx index 3688bdfca..0cc8dd22a 100644 --- a/apps/web/src/components/(dashboard)/document-dropzone/document-dropzone.tsx +++ b/apps/web/src/components/(dashboard)/document-dropzone/document-dropzone.tsx @@ -2,7 +2,6 @@ import { Variants, motion } from 'framer-motion'; import { Plus } from 'lucide-react'; -import { useTheme } from 'next-themes'; import { useDropzone } from 'react-dropzone'; import { cn } from '@documenso/ui/lib/utils'; @@ -92,8 +91,6 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo }, }); - const { theme } = useTheme(); - return ( - + {/* */}
@@ -127,7 +123,7 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
diff --git a/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx b/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx index 9e5b1db0e..f59d42096 100644 --- a/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx +++ b/apps/web/src/components/(dashboard)/metric-card/metric-card.tsx @@ -1,6 +1,4 @@ -import React from 'react'; - -import { LucideIcon } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import { cn } from '@documenso/ui/lib/utils'; @@ -15,7 +13,7 @@ export const CardMetric = ({ icon: Icon, title, value, className }: CardMetricPr return (
diff --git a/apps/web/src/components/formatter/document-status.tsx b/apps/web/src/components/formatter/document-status.tsx index 54feb3bd5..fbe1e1a3b 100644 --- a/apps/web/src/components/formatter/document-status.tsx +++ b/apps/web/src/components/formatter/document-status.tsx @@ -1,6 +1,7 @@ import { HTMLAttributes } from 'react'; -import { CheckCircle2, Clock, File, LucideIcon } from 'lucide-react'; +import { CheckCircle2, Clock, File } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; @@ -31,14 +32,24 @@ const FRIENDLY_STATUS_MAP: Record = { export type DocumentStatusProps = HTMLAttributes & { status: InternalDocumentStatus; + inheritColor?: boolean; }; -export const DocumentStatus = ({ className, status, ...props }: DocumentStatusProps) => { +export const DocumentStatus = ({ + className, + status, + inheritColor, + ...props +}: DocumentStatusProps) => { const { label, icon: Icon, color } = FRIENDLY_STATUS_MAP[status]; return ( - + {label} ); diff --git a/apps/web/src/components/forms/edit-document.tsx b/apps/web/src/components/forms/edit-document.tsx deleted file mode 100644 index 482c63fae..000000000 --- a/apps/web/src/components/forms/edit-document.tsx +++ /dev/null @@ -1,251 +0,0 @@ -'use client'; - -import { useId, useState } from 'react'; - -import dynamic from 'next/dynamic'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { Loader } from 'lucide-react'; -import { useForm } from 'react-hook-form'; - -import { Document, Field, Recipient, User } from '@documenso/prisma/client'; -import { trpc } from '@documenso/trpc/react'; -import { cn } from '@documenso/ui/lib/utils'; -import { Button } from '@documenso/ui/primitives/button'; -import { Card, CardContent } from '@documenso/ui/primitives/card'; - -import { AddFieldsFormPartial } from './edit-document/add-fields'; -import { AddSignersFormPartial } from './edit-document/add-signers'; -import { AddSubjectFormPartial } from './edit-document/add-subject'; -import { TEditDocumentFormSchema, ZEditDocumentFormSchema } from './edit-document/types'; - -const PDFViewer = dynamic(async () => import('~/components/(dashboard)/pdf-viewer/pdf-viewer'), { - ssr: false, - loading: () => ( -
- - -

Loading document...

-
- ), -}); - -const MAX_STEP = 2; - -export type EditDocumentFormProps = { - className?: string; - user: User; - document: Document; - recipients: Recipient[]; - fields: Field[]; -}; - -export const EditDocumentForm = ({ - className, - document, - recipients, - fields, - user: _user, -}: EditDocumentFormProps) => { - const initialId = useId(); - - const [step, setStep] = useState(0); - const [nextStepLoading, setNextStepLoading] = useState(false); - - const documentUrl = `data:application/pdf;base64,${document.document}`; - const defaultSigners = - recipients.length > 0 - ? recipients.map((recipient) => ({ - nativeId: recipient.id, - formId: `${recipient.id}-${recipient.documentId}`, - name: recipient.name, - email: recipient.email, - })) - : [ - { - formId: initialId, - name: '', - email: '', - }, - ]; - - const defaultFields = fields.map((field) => ({ - nativeId: field.id, - formId: `${field.id}-${field.documentId}`, - pageNumber: field.page, - type: field.type, - pageX: Number(field.positionX), - pageY: Number(field.positionY), - pageWidth: Number(field.width), - pageHeight: Number(field.height), - signerEmail: recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '', - })); - - const { mutateAsync: setRecipientsForDocument } = - trpc.document.setRecipientsForDocument.useMutation(); - - const { mutateAsync: setFieldsForDocument } = trpc.document.setFieldsForDocument.useMutation(); - - const { - control, - handleSubmit, - watch, - trigger, - formState: { errors, isSubmitting }, - } = useForm({ - mode: 'onBlur', - defaultValues: { - signers: defaultSigners, - fields: defaultFields, - email: { - subject: '', - message: '', - }, - }, - resolver: zodResolver(ZEditDocumentFormSchema), - }); - - const signersFormValue = watch('signers'); - const fieldsFormValue = watch('fields'); - - console.log({ state: watch(), errors }); - - const canGoBack = step > 0; - const canGoNext = step < MAX_STEP; - - const onGoBackClick = () => setStep((prev) => Math.max(0, prev - 1)); - const onGoNextClick = async () => { - setNextStepLoading(true); - - const passes = await trigger(); - - if (step === 0) { - await setRecipientsForDocument({ - documentId: document.id, - recipients: signersFormValue.map((signer) => ({ - id: signer.nativeId ?? undefined, - name: signer.name, - email: signer.email, - })), - }).catch((err: unknown) => console.error(err)); - } - - if (step === 1) { - await setFieldsForDocument({ - documentId: document.id, - fields: fieldsFormValue.map((field) => ({ - id: field.nativeId ?? undefined, - type: field.type, - signerEmail: field.signerEmail, - pageNumber: field.pageNumber, - pageX: field.pageX, - pageY: field.pageY, - pageWidth: field.pageWidth, - pageHeight: field.pageHeight, - })), - }).catch((err: unknown) => console.error(err)); - } - - if (passes) { - setStep((prev) => Math.min(MAX_STEP, prev + 1)); - } - - console.log({ passes }); - - setNextStepLoading(false); - }; - - return ( -
- - - - - - -
-
- {step === 0 && ( - - )} - - {step === 1 && ( - - )} - - {step === 2 && ( - - )} - -
-

- Add Signers ({step + 1}/{MAX_STEP + 1}) -

- -
-
-
- -
- - - {step < MAX_STEP && ( - - )} - - {step === MAX_STEP && ( - - )} -
-
- -
-
- ); -}; diff --git a/apps/web/src/components/forms/edit-document/add-fields.action.ts b/apps/web/src/components/forms/edit-document/add-fields.action.ts new file mode 100644 index 000000000..022c593de --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-fields.action.ts @@ -0,0 +1,31 @@ +'use server'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; + +import { TAddFieldsFormSchema } from './add-fields.types'; + +export type AddFieldsActionInput = TAddFieldsFormSchema & { + documentId: number; +}; + +export const addFields = async ({ documentId, fields }: AddFieldsActionInput) => { + 'use server'; + + const { id: userId } = await getRequiredServerComponentSession(); + + await setFieldsForDocument({ + userId, + documentId, + fields: fields.map((field) => ({ + id: field.nativeId, + signerEmail: field.signerEmail, + type: field.type, + pageNumber: field.pageNumber, + pageX: field.pageX, + pageY: field.pageY, + pageWidth: field.pageWidth, + pageHeight: field.pageHeight, + })), + }); +}; diff --git a/apps/web/src/components/forms/edit-document/add-fields.tsx b/apps/web/src/components/forms/edit-document/add-fields.tsx index cf48b1f1d..889cdbf29 100644 --- a/apps/web/src/components/forms/edit-document/add-fields.tsx +++ b/apps/web/src/components/forms/edit-document/add-fields.tsx @@ -3,12 +3,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Caveat } from 'next/font/google'; +import { useRouter } from 'next/navigation'; -import { Check, ChevronsUpDown } from 'lucide-react'; +import { Check, ChevronsUpDown, Info } from 'lucide-react'; import { nanoid } from 'nanoid'; -import { Control, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form'; +import { useFieldArray, useForm } from 'react-hook-form'; -import { FieldType } from '@documenso/prisma/client'; +import { Document, Field, FieldType, Recipient, SendStatus } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -20,11 +21,23 @@ import { CommandItem, } from '@documenso/ui/primitives/command'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { PDF_VIEWER_PAGE_SELECTOR } from '~/components/(dashboard)/pdf-viewer/types'; +import { getBoundingClientRect } from '~/helpers/getBoundingClientRect'; +import { addFields } from './add-fields.action'; +import { TAddFieldsFormSchema } from './add-fields.types'; +import { + EditDocumentFormContainer, + EditDocumentFormContainerActions, + EditDocumentFormContainerContent, + EditDocumentFormContainerFooter, + EditDocumentFormContainerStep, +} from './container'; import { FieldItem } from './field-item'; -import { FRIENDLY_FIELD_TYPE, TEditDocumentFormSchema } from './types'; +import { FRIENDLY_FIELD_TYPE } from './types'; const fontCaveat = Caveat({ weight: ['500'], @@ -40,31 +53,58 @@ const MIN_HEIGHT_PX = 60; const MIN_WIDTH_PX = 200; export type AddFieldsFormProps = { - className?: string; - control: Control; - watch: UseFormWatch; - errors: FieldErrors; - isSubmitting: boolean; + recipients: Recipient[]; + fields: Field[]; + document: Document; + onContinue?: () => void; + onGoBack?: () => void; }; export const AddFieldsFormPartial = ({ - className, - control: control, - watch, - errors: _errors, - isSubmitting: _isSubmitting, + recipients, + fields, + document, + onContinue, + onGoBack, }: AddFieldsFormProps) => { - const signers = watch('signers'); - const fields = watch('fields'); + const { toast } = useToast(); + const router = useRouter(); - const { append, remove, update } = useFieldArray({ + const { + control, + handleSubmit, + formState: { isSubmitting }, + } = useForm({ + defaultValues: { + fields: fields.map((field) => ({ + nativeId: field.id, + formId: `${field.id}-${field.documentId}`, + pageNumber: field.page, + type: field.type, + pageX: Number(field.positionX), + pageY: Number(field.positionY), + pageWidth: Number(field.width), + pageHeight: Number(field.height), + signerEmail: + recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '', + })), + }, + }); + + const { + append, + remove, + update, + fields: localFields, + } = useFieldArray({ control, name: 'fields', }); - const [selectedSigner, setSelectedSigner] = useState(() => signers[0]); - const [selectedField, setSelectedField] = useState(null); + const [selectedSigner, setSelectedSigner] = useState(null); + + const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT; const [visible, setVisible] = useState(false); const [coords, setCoords] = useState({ @@ -77,14 +117,60 @@ export const AddFieldsFormPartial = ({ width: 0, }); - const isWithinPageBounds = useCallback((event: MouseEvent) => { + /** + * Given a mouse event, find the nearest pdf page element. + */ + const getPage = (event: MouseEvent) => { if (!(event.target instanceof HTMLElement)) { - return false; + return null; } const target = event.target; + const $page = - target.closest(PDF_VIEWER_PAGE_SELECTOR) ?? target.querySelector(PDF_VIEWER_PAGE_SELECTOR); + target.closest(PDF_VIEWER_PAGE_SELECTOR) ?? + target.querySelector(PDF_VIEWER_PAGE_SELECTOR); + + if (!$page) { + return null; + } + + return $page; + }; + + /** + * Provided a page and a field, calculate the position of the field + * as a percentage of the page width and height. + */ + const getFieldPosition = (page: HTMLElement, field: HTMLElement) => { + const { + top: pageTop, + left: pageLeft, + height: pageHeight, + width: pageWidth, + } = getBoundingClientRect(page); + + const { + top: fieldTop, + left: fieldLeft, + height: fieldHeight, + width: fieldWidth, + } = getBoundingClientRect(field); + + return { + x: ((fieldLeft - pageLeft) / pageWidth) * 100, + y: ((fieldTop - pageTop) / pageHeight) * 100, + width: (fieldWidth / pageWidth) * 100, + height: (fieldHeight / pageHeight) * 100, + }; + }; + + /** + * Given a mouse event, determine if the mouse is within the bounds of the + * nearest pdf page element. + */ + const isWithinPageBounds = useCallback((event: MouseEvent) => { + const $page = getPage(event); if (!$page) { return false; @@ -121,28 +207,17 @@ export const AddFieldsFormPartial = ({ const onMouseClick = useCallback( (event: MouseEvent) => { - if (!selectedField) { + if (!selectedField || !selectedSigner) { return; } - if (!(event.target instanceof HTMLElement)) { - return; - } - - const target = event.target; - - const $page = - target.closest(PDF_VIEWER_PAGE_SELECTOR) ?? - target.querySelector(PDF_VIEWER_PAGE_SELECTOR); + const $page = getPage(event); if (!$page || !isWithinPageBounds(event)) { return; } - const { height, width } = $page.getBoundingClientRect(); - - const top = $page.offsetTop; - const left = $page.offsetLeft; + const { top, left, height, width } = getBoundingClientRect($page); const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10); @@ -172,14 +247,14 @@ export const AddFieldsFormPartial = ({ setVisible(false); setSelectedField(null); }, - [append, isWithinPageBounds, selectedField, selectedSigner.email], + [append, isWithinPageBounds, selectedField, selectedSigner], ); const onFieldResize = useCallback( (node: HTMLElement, index: number) => { - const field = fields[index]; + const field = localFields[index]; - const $page = document.querySelector( + const $page = window.document.querySelector( `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, ); @@ -187,71 +262,69 @@ export const AddFieldsFormPartial = ({ return; } - const { height: pageHeight, width: pageWidth } = $page.getBoundingClientRect(); - - const pageTop = $page.offsetTop; - const pageLeft = $page.offsetLeft; - - let { top: nodeTop, left: nodeLeft } = node.getBoundingClientRect(); - const { height, width } = node.getBoundingClientRect(); - - nodeTop += window.scrollY; - nodeLeft += window.scrollX; - - // Calculate width and height as a percentage of the page width and height - const newWidth = (width / pageWidth) * 100; - const newHeight = (height / pageHeight) * 100; - - // Calculate the new position as a percentage of the page width and height - const newX = ((nodeLeft - pageLeft) / pageWidth) * 100; - const newY = ((nodeTop - pageTop) / pageHeight) * 100; + const { + x: pageX, + y: pageY, + width: pageWidth, + height: pageHeight, + } = getFieldPosition($page, node); update(index, { ...field, - pageX: newX, - pageY: newY, - pageWidth: newWidth, - pageHeight: newHeight, + pageX, + pageY, + pageWidth, + pageHeight, }); }, - [fields, update], + [localFields, update], ); const onFieldMove = useCallback( (node: HTMLElement, index: number) => { - const field = fields[index]; + const field = localFields[index]; - const $page = document.querySelector( + const $page = window.document.querySelector( `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.pageNumber}"]`, ); - if (!$page || !($page instanceof HTMLElement)) { + if (!$page) { return; } - const { height: pageHeight, width: pageWidth } = $page.getBoundingClientRect(); - - const pageTop = $page.offsetTop; - const pageLeft = $page.offsetLeft; - - let { top: nodeTop, left: nodeLeft } = node.getBoundingClientRect(); - - nodeTop += window.scrollY; - nodeLeft += window.scrollX; - - // Calculate the new position as a percentage of the page width and height - const newX = ((nodeLeft - pageLeft) / pageWidth) * 100; - const newY = ((nodeTop - pageTop) / pageHeight) * 100; + const { x: pageX, y: pageY } = getFieldPosition($page, node); update(index, { ...field, - pageX: newX, - pageY: newY, + pageX, + pageY, }); }, - [fields, update], + [localFields, update], ); + const onFormSubmit = handleSubmit(async (data: TAddFieldsFormSchema) => { + try { + // Custom invocation server action + await addFields({ + documentId: document.id, + fields: data.fields, + }); + + router.refresh(); + + onContinue?.(); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while adding signers.', + variant: 'destructive', + }); + } + }); + useEffect(() => { if (selectedField) { window.addEventListener('mousemove', onMouseMove); @@ -265,7 +338,7 @@ export const AddFieldsFormPartial = ({ }, [onMouseClick, onMouseMove, selectedField]); useEffect(() => { - const $page = document.querySelector(PDF_VIEWER_PAGE_SELECTOR); + const $page = window.document.querySelector(PDF_VIEWER_PAGE_SELECTOR); if (!$page) { return; @@ -279,183 +352,226 @@ export const AddFieldsFormPartial = ({ }; }, []); + useEffect(() => { + setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]); + }, [recipients]); + return ( -
- {selectedField && visible && ( - - - {FRIENDLY_FIELD_TYPE[selectedField]} - - - )} - - {fields.map((field, index) => ( - onFieldResize(options, index)} - onMove={(options) => onFieldMove(options, index)} - onRemove={() => remove(index)} - /> - ))} - -

Add Fields

- -

- Add all relevant fields for each recipient. -

- -
- - - - - - - - - - - - - {signers.map((signer, index) => ( - setSelectedSigner(signer)}> - - {signer.name && ( - - {signer.name} ({signer.email}) - - )} - - {!signer.name && {signer.email}} - - ))} - - - - - -
-
- + )} - + + + + + + - + + {recipients.map((recipient, index) => ( + setSelectedSigner(recipient)} + > + {recipient.sendStatus !== SendStatus.SENT ? ( + + ) : ( + + + + + + This document has already been sent to this recipient. You can no longer + edit this recipient. + + + )} + + {recipient.name && ( + + {recipient.name} ({recipient.email}) + + )} + + {!recipient.name && ( + + {recipient.email} + + )} + + ))} + + + + + +
+
+ + + + + + + +
+
-
-
+ + + + + + onFormSubmit()} + onGoBackClick={onGoBack} + /> + + ); }; diff --git a/apps/web/src/components/forms/edit-document/add-fields.types.ts b/apps/web/src/components/forms/edit-document/add-fields.types.ts new file mode 100644 index 000000000..e7a509632 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-fields.types.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { FieldType } from '@documenso/prisma/client'; + +export const ZAddFieldsFormSchema = z.object({ + fields: z.array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + type: z.nativeEnum(FieldType), + signerEmail: z.string().min(1), + pageNumber: z.number().min(1), + pageX: z.number().min(0), + pageY: z.number().min(0), + pageWidth: z.number().min(0), + pageHeight: z.number().min(0), + }), + ), +}); + +export type TAddFieldsFormSchema = z.infer; diff --git a/apps/web/src/components/forms/edit-document/add-signers.action.ts b/apps/web/src/components/forms/edit-document/add-signers.action.ts new file mode 100644 index 000000000..a37c12af8 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-signers.action.ts @@ -0,0 +1,26 @@ +'use server'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; + +import { TAddSignersFormSchema } from './add-signers.types'; + +export type AddSignersActionInput = TAddSignersFormSchema & { + documentId: number; +}; + +export const addSigners = async ({ documentId, signers }: AddSignersActionInput) => { + 'use server'; + + const { id: userId } = await getRequiredServerComponentSession(); + + await setRecipientsForDocument({ + userId, + documentId, + recipients: signers.map((signer) => ({ + id: signer.nativeId, + email: signer.email, + name: signer.name, + })), + }); +}; diff --git a/apps/web/src/components/forms/edit-document/add-signers.tsx b/apps/web/src/components/forms/edit-document/add-signers.tsx index 88427eee2..08cdeb4cb 100644 --- a/apps/web/src/components/forms/edit-document/add-signers.tsx +++ b/apps/web/src/components/forms/edit-document/add-signers.tsx @@ -1,35 +1,76 @@ 'use client'; -import React from 'react'; +import React, { useId } from 'react'; + +import { useRouter } from 'next/navigation'; import { AnimatePresence, motion } from 'framer-motion'; import { Plus, Trash } from 'lucide-react'; import { nanoid } from 'nanoid'; -import { Control, Controller, FieldErrors, UseFormWatch, useFieldArray } from 'react-hook-form'; +import { Controller, useFieldArray, useForm } from 'react-hook-form'; -import { cn } from '@documenso/ui/lib/utils'; +import { Document, Field, Recipient, SendStatus } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { FormErrorMessage } from '~/components/form/form-error-message'; -import { TEditDocumentFormSchema } from './types'; +import { addSigners } from './add-signers.action'; +import { TAddSignersFormSchema } from './add-signers.types'; +import { + EditDocumentFormContainer, + EditDocumentFormContainerActions, + EditDocumentFormContainerContent, + EditDocumentFormContainerFooter, + EditDocumentFormContainerStep, +} from './container'; export type AddSignersFormProps = { - className?: string; - control: Control; - watch: UseFormWatch; - errors: FieldErrors; - isSubmitting: boolean; + recipients: Recipient[]; + fields: Field[]; + document: Document; + onContinue?: () => void; + onGoBack?: () => void; }; export const AddSignersFormPartial = ({ - className, - control, - errors, - isSubmitting, + recipients, + fields: _fields, + document: document, + onContinue, + onGoBack, }: AddSignersFormProps) => { + const { toast } = useToast(); + const router = useRouter(); + + const initialId = useId(); + + const { + control, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + signers: + recipients.length > 0 + ? recipients.map((recipient) => ({ + nativeId: recipient.id, + formId: String(recipient.id), + name: recipient.name, + email: recipient.email, + })) + : [ + { + formId: initialId, + name: '', + email: '', + }, + ], + }, + }); + const { append: appendSigner, fields: signers, @@ -39,10 +80,15 @@ export const AddSignersFormPartial = ({ name: 'signers', }); - const { remove: removeField, fields: fields } = useFieldArray({ - name: 'fields', - control, - }); + const hasBeenSentToRecipientId = (id?: number) => { + if (!id) { + return false; + } + + return recipients.some( + (recipient) => recipient.id === id && recipient.sendStatus === SendStatus.SENT, + ); + }; const onAddSigner = () => { appendSigner({ @@ -55,17 +101,17 @@ export const AddSignersFormPartial = ({ const onRemoveSigner = (index: number) => { const signer = signers[index]; + if (hasBeenSentToRecipientId(signer.nativeId)) { + toast({ + title: 'Cannot remove signer', + description: 'This signer has already received the document.', + variant: 'destructive', + }); + + return; + } + removeSigner(index); - - const fieldsToRemove: number[] = []; - - fields.forEach((field, fieldIndex) => { - if (field.signerEmail === signer.email) { - fieldsToRemove.push(fieldIndex); - } - }); - - removeField(fieldsToRemove); }; const onKeyDown = (event: React.KeyboardEvent) => { @@ -74,21 +120,42 @@ export const AddSignersFormPartial = ({ } }; + const onFormSubmit = handleSubmit(async (data: TAddSignersFormSchema) => { + try { + // Custom invocation server action + await addSigners({ + documentId: document.id, + signers: data.signers, + }); + + router.refresh(); + + onContinue?.(); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while adding signers.', + variant: 'destructive', + }); + } + }); + return ( -
-

Add Signers

- -

- Add the people who will sign the document. -

- -
- -
-
+ + +
{signers.map((signer, index) => ( - +
-
-
+ + + + + + onFormSubmit()} + onGoBackClick={onGoBack} + /> + + ); }; diff --git a/apps/web/src/components/forms/edit-document/add-signers.types.ts b/apps/web/src/components/forms/edit-document/add-signers.types.ts new file mode 100644 index 000000000..2b1418934 --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-signers.types.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const ZAddSignersFormSchema = z.object({ + signers: z + .array( + z.object({ + formId: z.string().min(1), + nativeId: z.number().optional(), + email: z.string().min(1).email(), + name: z.string(), + }), + ) + .refine((signers) => { + const emails = signers.map((signer) => signer.email); + return new Set(emails).size === emails.length; + }, 'Signers must have unique emails'), +}); + +export type TAddSignersFormSchema = z.infer; diff --git a/apps/web/src/components/forms/edit-document/add-subject.action.ts b/apps/web/src/components/forms/edit-document/add-subject.action.ts new file mode 100644 index 000000000..3beb166ca --- /dev/null +++ b/apps/web/src/components/forms/edit-document/add-subject.action.ts @@ -0,0 +1,21 @@ +'use server'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { sendDocument } from '@documenso/lib/server-only/document/send-document'; + +import { TAddSubjectFormSchema } from './add-subject.types'; + +export type CompleteDocumentActionInput = TAddSubjectFormSchema & { + documentId: number; +}; + +export const completeDocument = async ({ documentId }: CompleteDocumentActionInput) => { + 'use server'; + + const { id: userId } = await getRequiredServerComponentSession(); + + await sendDocument({ + userId, + documentId, + }); +}; diff --git a/apps/web/src/components/forms/edit-document/add-subject.tsx b/apps/web/src/components/forms/edit-document/add-subject.tsx index 2aedb0127..c5c2bdd83 100644 --- a/apps/web/src/components/forms/edit-document/add-subject.tsx +++ b/apps/web/src/components/forms/edit-document/add-subject.tsx @@ -1,111 +1,169 @@ 'use client'; -import { Control, Controller, FieldErrors, UseFormWatch } from 'react-hook-form'; +import { useRouter } from 'next/navigation'; -import { cn } from '@documenso/ui/lib/utils'; +import { useForm } from 'react-hook-form'; + +import { Document, DocumentStatus, Field, Recipient } from '@documenso/prisma/client'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { FormErrorMessage } from '~/components/form/form-error-message'; -import { TEditDocumentFormSchema } from './types'; +import { completeDocument } from './add-subject.action'; +import { TAddSubjectFormSchema } from './add-subject.types'; +import { + EditDocumentFormContainer, + EditDocumentFormContainerActions, + EditDocumentFormContainerContent, + EditDocumentFormContainerFooter, + EditDocumentFormContainerStep, +} from './container'; export type AddSubjectFormProps = { - className?: string; - control: Control; - watch: UseFormWatch; - errors: FieldErrors; - isSubmitting: boolean; + recipients: Recipient[]; + fields: Field[]; + document: Document; + onContinue?: () => void; + onGoBack?: () => void; }; export const AddSubjectFormPartial = ({ - className, - control, - errors, - isSubmitting, + recipients: _recipients, + fields: _fields, + document, + onContinue, + onGoBack, }: AddSubjectFormProps) => { + const { toast } = useToast(); + const router = useRouter(); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + email: { + subject: '', + message: '', + }, + }, + }); + + const onFormSubmit = handleSubmit(async (data: TAddSubjectFormSchema) => { + const { subject, message } = data.email; + + try { + await completeDocument({ + documentId: document.id, + email: { + subject, + message, + }, + }); + + router.refresh(); + + onContinue?.(); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while sending the document.', + variant: 'destructive', + }); + } + }); + return ( -
-

Add Subject

+ + +
+
+
+ -

- Add the subject and message you wish to send to signers. -

- -
- -
-
- - - ( - )} - /> - -
+ +
-
- +
+ - (