From 12a686f574ba5a36d40d4b9db62a6ee4d5fb52ee Mon Sep 17 00:00:00 2001 From: Austin Turner Date: Mon, 10 Aug 2026 21:45:30 -0500 Subject: [PATCH] feat: redesign Load Records to Multiple Objects experience Full rewrite of the multi-object load page into a 2-step flow (Upload & Review, Load Data) with the data visible and errors actionable at every stage. Upload & Review - Teaching empty state explaining the worksheet layout, Reference Ids, {curly brace} linking, and record groups - Per-worksheet data preview grids with validation errors shown on the exact cells and rows, error-count badges per tab, and a clickable error summary (preview renders even when the file has errors) - Groups tab and per-row Group column that make the record groups visible, with a summary line (records, groups, largest group vs the 500 per-group limit, API request count) - Load options moved into a popover; changing them re-derives the graph Load & Results - Live progress (request x of y, records processed), cancel between requests, and a navigation guard while loading - Per-record result tables per worksheet with failures filter, retry of failed groups, and results downloads that include worksheet and row - State moved to jotai atoms so parsed files and results survive navigation Data layer fixes - getDataGraph replaced with pure buildDataGraph that reports all problems instead of throwing on the first one, including readable circular-reference errors and every oversized group at once - Fixed stateful global regex misdetecting {reference} brackets, input mutation that dropped cell-level references when load options changed after upload, case-sensitive reference header matching, upsert external Id error reported at B1 instead of B3, groups of exactly 500 being rejected, object-count seeding in request batching, and the failures download forcing success on 204 responses - Rewrote all validation messages to explain the fix, including the 500-records-per-group limit (per group of related records, not per file) - First unit tests for this feature (48 specs covering parsing, validation, graph building, batching, and retry) Related changes - Query results can be downloaded as a load template (record Id becomes the Reference Id) for org-to-org migration - Docs page rewritten with grouping semantics, limits, and a troubleshooting table - New load_multi_obj_* analytics events; template link now works on desktop/extension/canvas --- ...ecords to Multiple Objects - Template.xlsx | Bin 37926 -> 35642 bytes apps/docs/docs/load/load-with-related.mdx | 104 ++- .../src/LoadRecordsMultiObject.tsx | 351 ++++---- .../src/LoadRecordsMultiObjectEmptyState.tsx | 82 ++ .../src/LoadRecordsMultiObjectErrors.tsx | 70 -- .../src/LoadRecordsMultiObjectRecordModal.tsx | 141 ---- .../src/LoadRecordsMultiObjectResults.tsx | 102 --- .../LoadRecordsMultiObjectResultsTable.tsx | 73 -- .../LoadRecordsMultiObjectResultsTableRow.tsx | 205 ----- .../load-records-multi-object-utils.spec.ts | 792 ++++++++++++++++++ .../src/load-records-multi-object-types.ts | 56 +- .../src/load-records-multi-object-utils.ts | 425 +++++++--- .../src/load-records-multi-object.state.ts | 77 ++ .../src/load/LoadRecordsMultiObjectLoad.tsx | 241 ++++++ .../LoadRecordsMultiObjectResultsTables.tsx | 207 +++++ .../src/load/load-results-utils.ts | 128 +++ .../src/load/useDownloadResults.ts | 41 + .../LoadRecordsMultiObjectErrorSummary.tsx | 64 ++ .../LoadRecordsMultiObjectGroupsOverview.tsx | 92 ++ .../review/LoadRecordsMultiObjectReview.tsx | 198 +++++ .../LoadRecordsMultiObjectSheetPreview.tsx | 130 +++ .../src/review/__tests__/review-utils.spec.ts | 169 ++++ .../src/review/review-utils.ts | 160 ++++ .../src/useDownloadResults.ts | 73 -- .../src/useLoadFile.ts | 242 +++--- .../src/useProcessLoadFile.ts | 192 ++--- .../QueryResultsDownloadButton.tsx | 7 +- .../constants/src/lib/shared-constants.ts | 8 + .../src/record/ViewEditCloneRecord.tsx | 4 +- ...prepare-load-multi-object-template.spec.ts | 103 +++ .../ui-utils/src/lib/shared-ui-utils.ts | 61 +- .../src/lib/data-table/DataTableRenderers.tsx | 1 + .../RecordDownloadModal.tsx | 111 ++- .../download-modal-utils.ts | 2 + 34 files changed, 3453 insertions(+), 1259 deletions(-) create mode 100644 libs/features/load-records-multi-object/src/LoadRecordsMultiObjectEmptyState.tsx delete mode 100644 libs/features/load-records-multi-object/src/LoadRecordsMultiObjectErrors.tsx delete mode 100644 libs/features/load-records-multi-object/src/LoadRecordsMultiObjectRecordModal.tsx delete mode 100644 libs/features/load-records-multi-object/src/LoadRecordsMultiObjectResults.tsx delete mode 100644 libs/features/load-records-multi-object/src/LoadRecordsMultiObjectResultsTable.tsx delete mode 100644 libs/features/load-records-multi-object/src/LoadRecordsMultiObjectResultsTableRow.tsx create mode 100644 libs/features/load-records-multi-object/src/__tests__/load-records-multi-object-utils.spec.ts create mode 100644 libs/features/load-records-multi-object/src/load-records-multi-object.state.ts create mode 100644 libs/features/load-records-multi-object/src/load/LoadRecordsMultiObjectLoad.tsx create mode 100644 libs/features/load-records-multi-object/src/load/LoadRecordsMultiObjectResultsTables.tsx create mode 100644 libs/features/load-records-multi-object/src/load/load-results-utils.ts create mode 100644 libs/features/load-records-multi-object/src/load/useDownloadResults.ts create mode 100644 libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectErrorSummary.tsx create mode 100644 libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectGroupsOverview.tsx create mode 100644 libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectReview.tsx create mode 100644 libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectSheetPreview.tsx create mode 100644 libs/features/load-records-multi-object/src/review/__tests__/review-utils.spec.ts create mode 100644 libs/features/load-records-multi-object/src/review/review-utils.ts delete mode 100644 libs/features/load-records-multi-object/src/useDownloadResults.ts create mode 100644 libs/shared/ui-utils/src/lib/__tests__/prepare-load-multi-object-template.spec.ts diff --git a/apps/api/src/assets/content/Jetstream - Load Records to Multiple Objects - Template.xlsx b/apps/api/src/assets/content/Jetstream - Load Records to Multiple Objects - Template.xlsx index 31c9fd677629f395f3c2872b4ce485707ffe7a28..d0aa1f00286d3b2eb597b513d86ea22f861a0342 100644 GIT binary patch literal 35642 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 bj~h4z892z^4+@G5^81MZ1;yfb{qKJOm+vN! literal 37926 zcmeFZ1ymegw(pO-1$Pe;+$}(mV8LC3H}23l1PE>cf?JT_?ykYzgS)#+)9@$x-8J{l zU2o<;Z{Ey(_pQ0T&Z^qItGlae*Ey%_^F6zc;srXX=bBkq;}A-vIVB zaI(_m-GlPkt!$=PL$eJF6j{Z*4`X@w&sMn4zY_&A&b`hq{Oqd6_7R@8uY~Kns>ij1 zBXQfUNS^H#OC$Fo+1aeL5E6oa*FN1HaGW+6&aNP#()fc*H+%NGbSqUS6nTl0eK~ny zvZ>2AnY5ay9xUVLV$daPRW^i$DC3vZp|Nf({7d1%U#KmFWkB8OE2)_4;rcojZEYR& zZ~UWK2fPtYlxx&b35v5!oM6;hvH&<;%5@b6z;Qx4wm@$uXkD?<{Lx6i!q|8#3b|0E zJmU9gua$DFq09YbD9nN^I6-|Bf%p$n-Dv$}vHP@(UqZQG$Grus4y2*OMN@P5L`h;_}BT0z% z0L2>4f|zW?LfZoUOD)-;C3)?5!@2%ea2VE&l0v z_KA&4!#pe64}zF}9tS3`(Y{l`@keHB&^pK-bSD zYSf?V!%R6WKb$MtV@t~^ZKv_p$~rTIi%@Vb-K=f!)c@EI@FwR}_bkiUw^Vaf^k|hn zUfe`2CiH6_<-YMMZ2aOl%dK}>Y{FS(5jfQ2pE_Clt%&5@#Wwe}ue>Trmvc&% zSZp?n&c%6W@U3mGiPwBoB}} zYA=iX+Ud_b8RlOuwJ|~4ZdMf7HLNQ%s9SCMU~)P}GlnmGD^RWBAIZ-Q{(LeVp{lhCfjDdF~8Wy2|9MEtk|D?JD&o!+w!2rQjn2URr1?x z;_Bo*ONi|;>w6{Y#0Gr8QGR)HQ}T!dR8l_@ozD55c%#hb)R+|qYSv|Xle})&jM&u( z5{K}g+nQU`3``i~32X1=LYhVIRZ3N3PR2Y7bq{@#+7NxkC>^kB31=X^3}3V ze<&XxX0<2`G<95uZ4c6+ta2vr~qp>2%k}yf@KTs_huu`-?&UOl>s=;ekzA;_wM+&)sNEH=i)1zZ%k_WtDXV7L9;jy$)LPWEP(CQ^3 z7<1JA7NOn$X^o^ZakzzK(?p$HteGBvLo4Exfh@z5=nhh%P??|Okp%*!zyB8gnnhwQ z2O{&^W(VGMnNb?LIMr-qk$Fnky2q>H87eXKFMgFokZ4Y}$-r0fF-{OdDz9IEC`EqV zOXz{m#5Ka}1>q8Lz}72=R_?z7IOF#adt4jz{&X$#n#XGtwm#Lor#b?%1Qw4{O>`e$ zpK;BwLTRK4ubiiY&$TFI@cZRfGZSR-TFf-{XoQDbLWv%)yW;b=`<9qa`zr4_3vthAOc-Z%~R&$2iErg&DDiYXH5m z*I7UK6Qz%HkLQ+cY+2q4oA4cI0SyV@1Q@#=;1ekLIEEa?F6a{C>M(*)KVa5uS|oO% z+!{tH`0?!<0^bZTi*X-0_6O|>ndA!&9GaX5O9@_wd7MCdw$HXdgUF_tu3Q2$iK#pvNu(Ox<#HB%U_5%Dh*U0Ek1aE@eU!y z^G8D_&Bj3|bPDw5<&)gp=Q=K=Tak3)2VRQLxJD1Kw}7>hLijQzLB&VNUSEHXFsNYe zgf|U`^z%}p;J1jpPlVUY{mN~FGy46@*nN;B3f+v2Vjlg$l{6pQ;!Gu6PyFi5p-KFP z`WtxfHkT{k3~<-dF<4iATqzx{rmWZh6-sy`ivZY{ZU-Lv?+7LPuY0`z3FSW`lpOyP z%D=Z(y4pEfIhmW9IyZ#e-Ou>un@8?+!YQ!uk$nHdfeKA10=XJvF+4FB~4+Z_4KnR-=8HZ`iFoR~6wnDctN-e-Ak zad&ykcLF@<^ZAK=HP$n6UB7MVD@PzIaUd*)p{vr!h6hf_F(K8J?d$C+YRDKJvgiG}1Y1m%Ht81v(rU ziaS+kE_>FIbZstiI$g1zX|5Gav5ul?&1pO_PYtObdo{MZZm|Zw{B#YBS!X88pz^98 z>T_H>awoS|J{i;MOj|tw>^7uNT)sOPkqRfMeYG4h=vTecVITnqxN5cmjxJBEX!G^# z`sIIUPtCz`B1Sy#{t`_^VTkX@jfvS;j;_FdX$olTKT5aLw|6>kNkZAZY@1(d|ELn< zhBeua;PkSpYDF4Lc~y*rE2W(!%kb4A&_`SO`;7Gsm`DEAWmYI6IB8?~x-xXW?M#b9 zSwA#}8h7Z2QL^R{ZJwHu19jyEm}K!sQ@u*bWJvR*6m_I(Ud$k;vM_4#q?8TeF&ddN zUr+Q*zt#fRzAR1pko2zOWTUW!zFgCM8fdqzB~GwU>KS|-CNR)fnY5wqnBe;T!TFFk z?NDW!bsmtLXu~m?OH|~%cpP9=+K}mO9$+L)hl^+z(4<@^Muo`#^;Go=ZKx7s87E5eyh z?3u{ynL{2z8n$MqyNZ{)MJHow$C*S|zAi+ElT!XOHau#PCB>L3Sl&aL)C-vJ?Fx+2DjuZ2EF6I}}s>)Nh}+7b8|AiAGPxxAI|bv5sA|i?yOt973{2I0}1Jv8+)Kv94mtR=?eA}ihdUnDqUfAnK^`0 zcN6P?8zFX?<)ooefR<8qiQ4ZH&|B>zZdHHkEp8Ry5p8~~$dV2;rzV*ewVe4?4!)5T zlf~9p^)&@kmrmap3p(csP-rL-eOLQk0upk4>@Dg(w8yY&%!(d7wLKge64l5lE+n+e z*e}}tSPmc7DQ!X~DVcT=UbQ{d*HPEg(RNkHGZoUtlXb-%))ab;*{*W9t5V;1d?O;X zz1)UMxTUgkQZKUTWo;&uv901wR}!EQcz9AiFSM6ia`d$D?P+9j@WhU&W~8s)TP0q> z;QngCfBh-uSf6Qjq<;k+vMK3!B%7G;FdD@y;pgz6+D$94_~?Y&h%v9JOu}gJ#Q;bW zWhHN`FctG_svnkREw`0hnH%3Uxe`rV5iMjj*;F9P^cB6Ih6^`ee*N<&r8W?8mMPn_ z38pwvZLzT=#rR&exByjsqKUrZ)pS9MKX<_#wq|SD!l=v4=aDvT37d+DS;0>(3O9P_ z2FmhBFEImDIh9$5)>wwtbYg0;+>hLVKll*U%)OBeDDq9rwZUSwjPFjHXSbYTw+t|? zu|>AFCSY0xbJs;Apraa#_$cozF_q-#^|PAQOt)16Y!1mL6Q51Simrdc9Xc%Q*Sys1 zUJW||;aI16mPj})ccVv^H2gY%&NX5Is*q!e=NBM|+)RImSYi^<&IE!#_+R5)2u*^-NN@c&rBi69{snd)P!u)nx zm{OL?=EjHCu*ay=Ob&``CrdG4{dQ)@OV_Z6s?$siQfnuRGsKyjVwTGOb|$OSObwc7 zC(AO#S(vhy%I3un)Uf|{HXt%Zrj9SXtfErK4_;Q$spAhXt1qeJNR5b0F{x{We7aP@ zR$KOw#APwvT#gMAXmkDRe>kZdB>b)ZSBu;Ri7=~|1-wxCf#e1u$z8;EvQR7MHK#Bi z>aq#QnA7*99Vfrds^eeV4SIjSDgFK7FEKA~UICzkmpcr3?KRPe^Pq!|I}G32YoZhH zKnH_64By#nVi4271fzBuirZ_xA_l+&lXMzN*=u4F=fMOsbsEarYhn@azyu3+8Y5!z7!|e!#R3M;lbnU;yggHWi6cFne#i-!;?`q!NN}aL9 zuB}pcw#)eYCc-?khw9&PBkEE;d1F2zzy7Bj_>WPHN)GWOlve^`{|}n?JQ^3}Wm~Iy z1MMxbzMik2A7qYD&hgqhFy}&i178D!LeeRW%tC$=Q2y_kpVKJ}&7^)3U;=~w$NVD> z5$P1hX8k`2*nvU+WB!f<91kA~D(w(U_58uIGnY5`!l&-;t*1b9vks}=Si4|}*oK4M zl{p*e>fkO?KydL$D{5#z)x}rc&W$18ZVpwcb-gt$W1#=8v3iPu2Uu{sG1q)osln6k zb3am?WvFf*8^`y%D`uRJ;&V#!ekotm%-Z} ziZ65E}7aP4DW#w;Yq|C-oN^~boye~>l*81(#iar5_xXKCyQ z`EFK>AcYPR#?#7Fe+*HVCVDBo3XyGy073WhJBr26H#f2{HR5m7@U-06E*CR}BLS65 z^HJCMDu?MCfk_TQ@}5&o+UIj4Q`drK^Z^{>?!jTFZ0Dx#wQikg?5)yrM3__|AHsO} zg5l}oY{x}0g}&wFUQCGQvCp!9S`m$O6iQtlxJ&FDdKFh1x#3`gG|CP7lvv@dR!gYy z(O1*?{s*42r+|295{A`q7_x&ouD_dZ#%z*8;RFUfuym1B5YC#C8Rs-fB?>|P9fBh- zPPThaC-|f9O^__9D?v_xZ@gWeD)%cPi`NnXpna0AYUq~q$-MgIgS2rr7V+m}YDr`r zpWj2sc@l6m2rr`;cQ1oNe|ZS`-v)&KLtuZ0^p8=D zQha&MeQesiO~!AJwQ>fsKbvEIg4~<5vpb{I%%%oSS;J(zU6^D&VdCk3V;_6Id%6MK z02humQm=d7zSS8vn|g3`s?~09N_xI%_kAcEGXr%yde^qhm*j`vpHBHa_2E4OpKPl^ zHqRa@Xw}96*H1=I5B5KWgasD$+cInuL1wAPH9}829@Wiyq)~RR<)b%?%QY=cWX_&W zxOm|nz-HajU38L+)7-wD2M-rMa6_Y)lRzmtx*Tw763wPSL;FvkA82WvJ?YJyo0q^h0MK&f_Kh2Edzr0z@apN<;q=aN z&*O+K__4CjHGiss=<<>G`Le#Ahb(M0*m@T{-$QJ+8CUH~HuzkwqL~(WmaF#B$A{^$ zCLu9jzJ7M+YyT$G#l~aX_EHQugS{^qUlqYGE-qVG$CJmChr zRNo(;j0_cr-y8W5UO60RoRW=0>v+if0coUC`l$f9%6`{-h2$l|nftMSXlx3Q{uvCoLfc{J4AMo!HU?xIKf_Ukjy=Qj@R;xXjX zIwhjp2o((|2<;?V4i@gj4IElrJOqkd4{YfdSt!ty>x0scIn_X-srfvkiIy2Fe8*NQ z*HfzCOYZai^vmxl`L3gQmO@SZnJaaDL=WfXVgfYhSEobs!bmx|G)%+Vnl%-chErW- zfzZfPqR3pyxdYU4G#2dAandQWlP0k=ah%B|1154bR_t1F11YkzCRH?XT)$m;=Irm| z=u>1TOu}eXIFqvnu;qTc3R7ffOp0h!xRQ$oRORw4+128HyHaSTIFs`RIOXyz*yZ9Z zQe>x0e!I#BoaOSY*!AM}Q)K5%nrNoDl7G1X=Iml|1S!MgCP6eDoXJ@OD1W#Rkt1ms zIa4df?Yl&$Uf$3$a%NVHf9ev&LPCzDW8}^0Ab=gEvzPGWKuS|ui-0o2{x_GS1S7Pc z1$hN3Os`)DKSmNf6zH7+IRYb$UnqISD;Np?4jGIjL?{6f13CmoSieH@3LKbT{|;@8 zBxER05d&5PMmWC#@(KbNiGU7Ej3iX3W)TBk1V(tjeewztnBIU64~!&qD3FN3I|N1q zKSYWO3K)sc9bp(rub>1(4Wtkl5&h^XDqh3%e(p%YNWy~h6g5ypU_|nhrl_EYkqGQ4 z!brk_Y8ExnL|{brv!JM8g6R$HXu?Rsg93>f7$7jB_=Qqbu)#btdwM*)PV*D%kx{=TT4?ZIpM;s$@;ul?=&{il5r zQ$WnfOzH?FG@YW7gBlq0WAw|*{pa85^xfzrb3Zd9ZClTRAMGdeJliFG?1cBd=p^$t z_=oM}|L_?9!q4wCxI6N#=S|8<8`aD)<{o>j%63Je+?Rh%byBOqpK_TR+0D%r&odzMb^ z#elf_p^GQ(E@+vWmugj^i|4DsKl&I61;mWa%#Tna)3Yi$IDtXEqvJmb{@XiO8z<|_ zu38zqFJ)F+C+o{i%*1bB{eP>~2ws(Y*o}N|B;S3BK9UY%1;1>}4mvf>3 zgE~i9f4=0obsL!BAE5-K8&+`q%Q?j~AFa%J7uz%Px}%4>FM3`QSXSGa?bQ3`W&o|e z9tOZWZvnkwJ9SXES6xfvd<*Ca(D-y*Hevnjae1=s0opm+wP@FGOOc0vwyIw|5)x{7 zNg}N8o7XQ}U2(|(*Q5i{kK0qhHEs9aKsP~>@Vw%E)?-_Z!+lF2&!)AL;G!4Z@>oLi z{yKJEu~}c}Q(bBq%g$=wefZ_!$jQyf2z$$2_v(r@+Vi<%YbG$*_u16*5oPLp`((M> zZgfok`>t80&*T^yad{80UYpEkVWnN*WBu{@<)eF}XDxrmaCvI?dDX{Zyj#3`k7~Y* z`{m~5aABr$c{|&@!*1^xuR70=wWr-~0EqXd@>D;sJN4MQT1W1%8+_Fl-lnU6%#ygc zU$$de?^Uneo^qT~UdFuBoTz~=-L`n-)2wjWv-%q{p~m3k$$w6kMppn8ia)zhy zCp7QvD5Z*v9ep4$CiG?@E@HT$4r5$Ie71R4%dVC4`c7iEW1(k4xbC<~X=0*iQI_V( zjiRPc@=b!&mEe2Cw076binUN!rFbK?N{`<`2!y=Gsd*77-u%^6<-b7MeZa)o_kA0@UNf zVXwP1b4J&`5NN7zF?Akf(X3OZu}iz&^Jv0d$~>nrG?RK6m-)Oqwt5{mZ`-b^$7DNO zy#KgWZ{d28AMVQss#tky@)VE*&1ZF5y82uI+gDrO3NGulrQ6~);~kFF@IP*Ow6^LU zp)1te$-BOwcf)VeCfvRB@MzX7lPBeGS(1sqzP`S2^r&s~auq1OL6@tW8Zq+Kd^$OH ztOiXV-)w3e+a@M1UfX)N`Z_eWc^^4zc5d%qojW$^D?S^E)F)PDFZSHbxu%|?G4TYe zJyx}7o%lBE&sC0qF5_GmXNLMnS4k2bcy_i8pAt{)?1XT9QRZjCGd;xlo8dFQDBI6- z^ur2bZj*0V+S+j2N_w^{r%G%2XfEy;936nG7x~9M^MG@}!(q*{`{mTl)yC1)-2<4% zS4W-+3;#@!D%jPKU^wxM6;A0J&T+qSoX%PsDAj-SqU?b(}C z%J5EYZ-h(io?P8)eLdZn+6wmX9(>D%iNlA&K`$fAsmtKGK0sz~`mk+cad2NxAE*yK zT6mwu!3 zU1mvtO(Ptf(#Jac=DahFZ+7U+6Lwn|*Twk|`bPY~i)xWBqx^*=s`_Se~hj?8b zo77%%zh0TQJm!(K#jEmm6q`(DC$kpul2%1l&93Omf13tqtYLvi)@x^m#E9ku%h?_OZZy^-H_L&t;c z=DCUA*mN$f7WhneB(CJ+jG2GI7)u2S-p>q+$15Zujms<7i^i0EWb3vLA6*0Ayz@Bo zHs7*5L9I0>E`_JNkJo0nKQr|W-aft(dLpt8UX93qmQwI#iO^1}VYM=x=qd`7LvF@J zW0ZAbn*M{p{~XHyMBx7k%Kt>*^nVWJezs9$Za1Qn9PKby2J4(xmo7AtlN{YJS2pYJe<1Kbhw^@cN)(tpzYcB;e0ZqCcLt;g zN6>zLzSiFnRtR>KORQP*x%a%m_!Yex2kM_%Jj9 z9RLh`RH$MR18#&PIKOrB3SyYNfDRxAK04H)h=CBo5xgHHMFlww&F79F4E$G6459`S z2uBEh6ciQIFnOOl5-{+wpsYj<)ISKte`D@r6ny)3=c)T$P%0@eRr_)0splCH z+2bgD`}IqN{?B>;FR=9gk%sVZzxmHc@!vq~9Syr`(&?&oW`o;imP7051*6)!B(#m#at;Z?itTp9?V@7b7Izu9B-OG5>|i zz<*B^|E&&yi4ldRx)fs1a;P5}5rt(ph~(p5G{U!t!ixU?Xbs{2ITZgb89Z*u2scfR zm0fi+fUjhP+hWtRyt^S2(qhOA2mU@m;a?ubf6Ea6@+kg?3}N^*mbLDD3rx8w#|8ED z%+IVJuHy0xb#FShE(m%X);-&Bq-QQMx`N{7H+BHvbx>;0!CbRM_449%@LcgoGgv=w z+4da3-*%U|c+`&YxViu1qIQVAX5omA$$>cJ}tU@P6CK+x*eaZgrWltEc@5yzKFOTCUM- z(fnMW$rF6bN+tS?*|^ADD}(dg5y^Oo15>ZHV*E#08J~a8tKDz=&zQ1+x6{8`-E=5hc&+S$))u} z7Pak}Pxcdd#l_3pMjXpCx;A)Z3B^3s$3xpQ_X9WAGh(r1vy1wtQ!RJGzedxe_w@+h zzwmf$(cjJEzk&3B#pCKC;weBX(RyWcR_ZDEI%%nmoO&Lj9)ILS4&h5wUnRl(nB{@s zPC`V!u}?8d>gaiK_7?Jof)yAO=ES8Gh#as5gK7?WuZ)`z!D~<-LJ%<@@RvnGzImju z+8_4k=GOXjB}uFz1|Nixgvuoxe}3lvrkhkUi|JMBWH55HN48jZ{B6&C#)cBhw*_Ni zlM3ep^+QxP%;;*fLc{h8kK-CCReJsA@obavumAr%{_2I;P3(*n9qsIYJv{Ab`s-;k zn=eDZ5(wV6fAS4!QJ|4mEhwSZ`_u%57)psDFhr^x{*prQ<>u5uj7V|c(RnP*owGQd zw2I|QL~9CD`zy5=Rf%ez-M}Rvokbd#k|!2@*5Jbpet%K3$1aTVH zb?vIi3ARt0h{dq6r<6QWu)T0IJ&0jgF}qtnF}9iX*)27=4~0XEU_FVlups;^HWXA4dyhk= z5hJW_rqqg(u4RZahH@J^*Ci=;lj|T^XIyuX3bZfR9fMGP7P{eYMu3<#XM|ssyG}r_Zu3`mwvGufcdv$q&frW5;#KxJ z_|zo{9^x+3q4u_lE==AD;qXdib=hFNhL;~i9f!)Pj8{Y{*Ce?lFg$J2FkWF#pQY$u zcy(76ogej1mTq~7Hmn^)k=jA{T`!c2BSDn<)NhFd;MtS(t^8AL5@0Vv?X`*wqdJgmhL7e6|L@ zx-aP*o9{;tXbAe)(6&QzlZ$Ex`_~SLI=88X-%NryJz(HHwuHYReI?^Hg`lF`;!An8 zF5$qVbfl|stwij0yx)hyq^#8@)xn;mK`C=6B993&zc&zD6iM1(((CJ~iOx3xlGxETlZVx2hUNKPfz&U# z6j=BriQe8f^YOR(D}9&%U81bm006Byj~{JTm^eG1`A0ocyXRRLV=YX!LQJ3WG^Z)A z`Dcq&d}KE+Hu#ak+Wn8krd{;W$%v|hSW5&9Bpl&n*SV(3BU&bTX^Wb5O~*I33;;?C z(SoWU*$c}bv8u$E0A70HcvN!kZ4I0}rm+jIcFhum zHbUZC`PbE6?WQ0QQ^z>S{v*;7B0YstLhg6%z@=~f65Rs%?OSuM57USSA$(%Kngd3|^!B*& zkWM881mT29fsKP_sx(wzHiF_78x$ZA)&}jXpzZ|2>D`eH;b4}TgQE-y5v>;}AIQ}x zD@Af9P}fAiCku$gk`jEHn1jhWWrGa}$GRh$y5JN6egm_eVBfnFN<#4x2w<>|gios= zRbrPZzcsFx0*bO`g!bp3@2xndvm(-}k`Y&`z+~Fn_kBm{P)k^i;Gia^i4>(qe8lPu zK;jY~rUdXpn!@|VPEhREbE3ybDJE2?9$O-+Hf&v2;hWgDn6GpFlGa8X zBMYVP&-d2uZ*Q}$WzIAc?%v#pW$UPI#)+Ej77%F`#IrF&2I`dc^4-C)a*0#Ic*q|5 zlfQQi=%cTg*ho>sin?_^qzo0`o1nn; z@s#zeN3CfRtEWHwDI*doNEiS001fYU97!TcvdTolzRNQVN9>LwLG=TURO9RY*s+Ey z@uSWlOWn()DgxJ&yS1`hv>qEE>Y+t=1a6(V8PxzQ=V!AVquo%%wE)0=*OzP~cBXq` z2|_XJPw=;pUZkJP#xlj9v%_=V<4`YAb>zOq-fT>KYs$aSdkPs-Fsni5Z#dyAC z``Ci9k?w3;%75@?8@X%+CyF<|hr6fpKA`de)XKNNetrFeW4nQNIO$;{-$?a1A(o~XeE6GJITji=2d z#KvvY`^%7hOL`DnP+BgXln`)fg#2~F`v=LU+P7|3x|c5A_92_t+{CNMBU4`nR?r|hcXMd@QY1(Ue+=gE_yMxURc?XJswM+&71KLQ_OtG!-Bfp|@`o z3ZiJA9>9feJthNlxH&lhYxs8;pMo!4%U7RWoShrt`L>e|VnkflE1FnKm>;$D=m0&c zUjaR_NaamHD;H`V+qJ!DEM=G4x1?ZM?4(AKgT#YI_BplNS*_c9e1-f zmPGe~Cwa)Vk&j)YzgEhkm9b37bJVI$yjmiqW#);~un|gG-x4bJNZRDE*D+&!IhDJ! zd;-4K7(AQ$7G`y)D(MN~F8p*^7cxGa`yl8b#(A)h(t4sziZ)ZwbR{G)TDGstS&ABK zg(zT(Sbo1?>)MoW^El_dYUA7FEl?1z$Xu3TMof9|ri)00C9G@Kc4RgOq;Wf=bpoMi{SJm8tkIdn;Md?biAg2SbxkUVh#WV~6JY9RlohmB~5tZ8OSs&s2APOa6q&nI2* zwxC2-4Gqhb%B&2Gmxw8@mNZGGnx_qW6z^HuL+6X?HSAI|BG6L$`qJggd5Ft6SQ$_$ zIFTruHD9e@&!}SE9j{W1V{!EI9r?1&n9!@uk=;Es?chRpY7vu{`ZUjgx3O%gtZA$a z#HLmBJip&R8bZj=Nf9O$maGbVw}T+XrI|&X0;cQ}_NuYFYtCL1YMG&|LU8Q}s5)s~ zw3tR5|B?6GEUwnk1P1|;hx6C|`%wSwzt7p-+VuCIUXPZZQ#LC$7#2j9)KZV&Qv;lq zpG!#hp7$;gs4jmM03gJq`B8Q=XD2GI>JKHVV}`Uvvzy`BFIVPp)qog!9px55Kmxs6 z2hAw%UDx5v2j>e|84;{7g4~C~l$gM`Rze}AU6rW(iFwr|Dy50(*K1sXN$F8>{8K** zqqm+>R8cWF;VTDC@lH@BwS-f%5R=1u;V3ry{l}a0jC)5RxQsdJr>GoHfR9%kI$PrpOdPDL%=rP; zMAWz#Sx7(L>#cy==1d!^fsnzPIacX*bHY`M63mFRHmhfrQ#7BK@T&u zwl=V=T*h_k0L;fz=hVmo>W9a3dB&XbRpRPfJNK9x`w2MAyeN1=z?MJZEmG50WF=r= z--I@LJYT%g%r#IslxDF>=b+e%wcP^__#Az2SVg4IS(X7-u^~)Am5V7R8Ppx5H82W_ z-f+RYz81w)u!zWY#j>?3m_fOwN!pWE_V{vQYrl*w&qR^p=o5=~53{h`zjgbHqI__5 zm0SlhcTCP;Uq~3wm^@sfRz0`gF=u{x(q%7jA3B1v$~#s71~n#VLXLDwA*>@y;P|n_ zu3ls8oQ7_|fZe}vLK!xX(chOrV_?m#hs`^c4Ly8I3wHzxWZ5_K0C`9jJb_|^S&an1 z`kH;nDdO70`}E~FESiM~1HKXCjlE;)PHvuV#sq=VHlcGtfid9J?Qj zH4|XzYF3D||CZHAFZ1wqR~!DWUTWO{I9>HfhXcVyQwXko+D-&U^OB1OO2tNi|MQoma5>=9x6*kQ{kZ2}ce;E+ z7t_@*ce+9^QkP^#kq|UWTHiMRCanLzagFm;aufQf& zgPYqlC;IuI>||phA(ho10nb!Df~0+!irxxnq~j=E>-8=UYGR%cz!=(2=P{H?X+k@pgCW-%W)ZI&nxIiBo#)51h>MshzdY|!uQ zz;HBRa=?d+W*D|*7z7ER2qz>I>gvP`Vs=b<(#sIYXrMlw;m;NT`AyLw-WO(wEwtPq z?0o#@w){NK=2G_Qhi@2Zp3*x3R01OiY+kfHgf-T9toZtxe1OTGbFG-*VHZ*fNjx7Y zbfi>ESj`tO)Q-Nou{|~9$>BjV>~NKcm%GC)9)KE zazn(+ly`r*dJz6mJ^py|<3CoC-)dEBoZK(zCh+&OAE9yBNTPC0B2vque4i?5g=LOH zkLbTF8W%{ZV83U~E-Se$aX*cdT(K6G8(QhSC$#B}quW6#5!G8{-z*2^A9{|yl`b|7 z$7Np$*j;?IyhJI@W!)@px@7#UnRy;o;EB;yj1+P83TV(R=Qk>aG#Q^AR}}L)gfnWc zE0V*_Ct(1{ziFF~HG1O>|2)u}U4Zj8n;*Y|Q;C9ZSQO!F8O-2a^xG6^?5>6+fK;N% z*>wFnDtt&zMzKDVw!Sp{L!hqaS(1_USmzQW;-z2NISxenJGet4F5>F&##UBYe)#G( z)?rJuofGs3emBv}a8=3#CUK+@@na$*<4?H>)C$J|SV%qdyJPLY!@EisXN~s~Uh%&g zUgVeX{t?^X@%;Wp&hjOm5(?u2A@hBkCQ34)X>#-?cwwHO3OVZnnIgY@|h57VNLK-3a$2Y2V#8C-1}b%1y-aii5swV}AslAbej6qD#P+!hIX-0| zE?i4Zs-G}Vd4tM`8_RPrQXI{NUFt&BGFc+R1VBjOYU3Hb&ja0rqe4;|5=_sVF*)ds5D&07x4;_Q*d%S+X`M`G@Lu@CAG$Wu=`CD%3+*sNGPE zooX6~xXsA9$2A)0-N<8@t7Vl-Ivh|H!pu=$hYpc$)sE2~GL#Z&WRw`!o|l9OmtG$VA+uQ10jV_=;&sNL zQKI9t_u=rHLi!BWT44&a+#5Y?>796!f<*M)o!Ad%4ejbI)#i4=^%wrJO6Lnl9@+l0 z`F9E+{?PPyadEngTe03D?@}N~KMFY&Q$#{U_3?QwlD47bxjOu)EasNrJ#d0{N#v-} z5vy77(-)L#$D)Okv5~4(5(FE^37ICjw+`tc5?O!cP0M>>o_6PiR;UOGWpaAz8SNtqAZ#m?);@u1Vcjt%f&3ou zZd@p3$;vr&UzF>>k5_?Qx4_%z0dZVP@P~BQtK_hfq?QkiA<#JkMj@d@20c;~mV1R8 z{KgAs3IY4_>ijBos{$N_Zj`v9k7-l_J=*?JXIvSI)crmpJJzO1#BL3LwpuUz=O&)Ba&B5!^bQfl@JdxZkh6^P`3TXj-me z-=MgNH)N7-abu0y1oNu*!zqJ_EAw&8B)mAA0&Xq`lK=Q8I3G=4fbVxWt+&{?c4d0G zUI0L+IDQ&dX@vrP<|%o8X844BXds1^MUX1hr>Z<_b4TUx4wYf*jpc8{`4oD;Gh34Q zK%iz)6nr0IdIXLP3a9F3;-dbbqS>=KEfEQATdY}oH{J=Z9hac;vI2AL%)88f#>GMn zC^r`j=iyv@ALR7*<_kokT57VAU4Qj#>K7y$*IgAV;U9F%>SDPeRZsS4OUF==^b>U% z0HU6Ev}lt~P8AV%AONN>c#Mz2tjNuz$5K9_myFcZqd)#@CfG&eXf2kvEekW?)hstO zX1-e$hpS$Rst~nLRKs`+Y9cNcS;d#p3FEVbCls?Kn}cY`$D`!1!dmsGR1&VVxX~d~ zCN?U+n|KlqhMH+MF(wAhI^r(QSa($mR_^HBRAeLiX}_A+?RC36U|(M+8rkWY@V}KyxxP`ejMiVIyRB7i=XqT-XX-=#DtTAt4t39h zcaM2$3{O8)(Y#(dRO!ghhg`G#sjA@nhe12fJs-M7|8JF&Zf%jT&sQ|AX$*B&$a}%= z^YPhk_m+1a#J+=zIgGF)d9=a_q>yyp%=@MSg-VhNnZ?zq_XiPnpjAhf51rWA6O=m` z4E>042zN@!{amd_(FQGrJgx1h9%Sq716#PtDvFgr^1a5`#OM%M)|6Eo)FDnTwzM$} z%N%u;E33%j)X9!a?p{i53M1j6`3Lh?Zs-~U=~oUm)|X1T8^TJ=r(^1jQxZ9wn&zGv zvRb$J2NRAVf#lBvrd$ZG5ukJ56PvxkH%86ZTUV3T|LkxqH8mt^{@yy17~+Np(Dqb% z6+cswlJt{ZQ>lcS3*bIHL$tYeFPaTVA4Ki8vbyl8=St^JZ+3YP{d!8oCZ%@ zW{@#r7EP`OmBTfQC5bPN&&V{g_AX+dIXpFYp>R+`5foXc)@LHvs(6!08H9MtEHjLw zFEzF7QrC(Z$UE{SqP$3hu$?9&h_{?PY>CmK2mM~gN}rQLDd2Ie5zZ}A z@(84?d*|2clg|Rbq1oZOz_g+nNfrbvcs8vU`P;O-@rKQ$LZ4Qq=TpxHfLI1p;$8hE ze>h7l6Mra9hlY8hS0U||=))76sfrL-&v|VD@IV;G!6_?9uhuHAu#$d!gJ~H(-ei5V9T%uF}S^YdK#@<)&T3SU$ z6@W34{`ef~#@`vNg+0m%hLA6tZfoY}pUgpeG_mosK7~&(+=TK%P69AECz{ zM$RuO1tcJVHzZlQr}1GSM%`S^4%3PM&{taQ5^-htZ8opK0ZQR9lRcjd;CI z=H}65JvraCU6MHpZP(Y{t&*3;MOdRv#P0gzu-Rme#?;v1datf2+8e*d?2g^95Sb() zpiS$D$^4ta1u_Rrg_5*Vr2D+{=~yCF36#t{HLV@VHA{OO8wOq=yLB92EPnq)H1x`a zI;?h-B21G#(YMpj(G+uR%I;6N?6t(AVvTS7R1}#o?OWx}VA`lQG{Sr(bGsx(2N$l2 zoU#G$x}|p!7BJ+nYIMS76h2)1e~rC!kfcl3?%lR+PTO|(v~AmVPuteCZQHi(X&cj= zw#~2ie$M;u*>j!~@vVxA$ogYlu`;vnysIkf`lZ)Mj}30Nfn`6Q0osWk%@~CF%?s=L zVxfBKY+0!UFver?07u=$0y=Ve-PrB%vPEhbQ+F+XYPq0O1mOtrznS z%Bq#zgeJ^oqMQL7rHG%-ZYPVK-i5a5AS<)BaPIpF>8tryhdu$fDe~F?CFy;&i6(;T zXR;h-hB^x$k;;jA+JHHen4WOQ>fk&c7^&swsCgFnBLFG2g)g^X8RV8W(YI`-iyk&s zAuTr^f5r!qv8rwWb<6lZ*2P0oG6DKLxkwIc*qyZV1+OQU_VhzZc_M@Hz8S%m%N|^> z*%#W34Og{^@f8W6DW!c3_}U)uB0^xT?r6=B&D{g;4L^%Q#^i*)Z34kT({UMTbm_v@ z?nN3&rf?+Yf89Sct2&tyKXX<-&{Yb=kHjGsEp#c5~LnDZJmNhh{l;<&$1?5{+Q zITplXAnk18S|zl0sf{Wzq~PLR{KOhVZ?O$QYiN5DzoGUqaO7#e5CKKJz|qS&Ew zYmaAI>!^SgalEqE4s4-S;H|CdlO(OyncIjY_!?bkTnrtY-|I33>o_K*2m^Tu{t^f! ze1BSBna|GBC5Y+PWx6@yLj`x$GX^Nn47p~j7KG^+W!%0!l4GOP?Js)c$ju#4c5wWAZxAKnr zk#_IqbOix0A5{617}%FQ`2cX$Ky`kX~Ah#_PkdAQq(Ct(GFjV1D0)_F*v7o|h1 zTcdC4($q~kit@b*?p+aux%ok62TL(Z=g>m*QC^5^T#8bAVftCYIFja%^B&odqt?jj zJ!))5l$;+vspbJ*zBKD6ryTb`DT;mwI!ErW(X75GN1Q0B5~Q}B`V)sf+bbhPf=+&? zN_>6pS@%drVsciNq!WPxMeLR^zh@CG?Po?&!bXvyL9=I;}@;}07`zkl*n5U}Ur zPZ<#r7pYe~8PDI^=CI<;9`#1pd0x#S+maejjq&A<{TR#DU#zldo!5Mh;2NRaPrEUB zQZSbTs2wfEJx97xmEsjYnqnUES@dQ44(g(sd~j571xrA}yd4KBYin7SU@BCC-~(=& z$qT>~jG2}Q11GgF;WM!ssA>(5!XIsUfU6feeagx>Pzbvy0Nkkw;7BlqwQOekw(jvR z($sVN2+%I;(A<;|fWl{j4xg{)B&{g)ectKH^{D;~H#-p%Fl2x1p`Qhdm~yPReyUP2 zp?B+)SIz<-EsgYIq2dLo?Fdm*2l8#3T>%+?(N63fYx6EWK63cyujAut1z?4K9HBKH zT0jc)==gXgsFGbkrQ8gAC<#~tT|z#*NfG?c2rU81eLl)HK^+fFJs%$nTNrOh6bKx{ zA!*)=R4Vn60R@sDDMaxmm4=sAm(!#M6WF~*S~(}xI+ce?WR;FUd86cGY=8=gcWM1h z&yP;25i-B@etI@hPh;;-t@Odf7BxwXL3>z7EvQ*=AIo{Of?lp9g559(spVyWhaB9< z#H`tYD{xDXL=;si>a=B8_D?b-JW0ljX3|%?)sUtr6rxnvk+F{ykbdU}+FsDKoA-Y+ z7P>>P>`Lh%+6~JLU&0F=b^*xqE2LH4Mli6yIN*$-`d&6C z58&s|*~Bo@9nEJ-;A8j00AK2HPnbq0$`%?{1|nc1GIqY0<>wnH%IYFNk@OPo@_}JZ zdl$og*(`RXQg5uL9719Ea0q8w2li&&hCVNc?qfvGI zr1?*TGu1em`$9enzCBgE7;cU0w{pd?A!%glS{jND%&4?EQ6yh*XL00+v0W?G@4kvXTtH}Z(D05Zv+OmGqL6er%$e{|DLzOSCKhJq% zL|+UjMl&rb{82rNA4w??e7xcmFvEcG0B-lK0X+hk`G@8sM5vb!>DTKcB9GOe4ynA? z!-31MBbohTnKc&5&rTAKPio()s=T*;DG!8p@b!7~|J;-ZTe&jg(Lf;bwp;x^Y?>}l z-M*?L=!S%tgidyLJ;M2mhxbs7h!DY?L~T854z*L|xi*-+A7H($I?IdlQY-G6ciiv( zD5-zM56Bi_66+$W62dHRETU^@&w>v2c%BuCeOSv9rMVB(`a-7*^{2}3hapt%)Qbv#o zet1iX=9rsQwlw?}*km=xd7IQMR0TuH`K9jLG?|JBfpJ$~R6)!*=#NTgO#s_6hFqn3 znC!EtR}&f<%V;#TKnX!v&=d$6-detn5B0*QJ_i%H>*xvB%W-@i)+vgUFHYKRVz$`< z_KRR^tpM1diTY(byU`ZY*CL!~ek{q(u^=D?fL47n?R2WcFS*ijdlzV8`z(DqSR?Oa zk&^Xzq#IYlqp<07Y{wf{(brbW+4K5pdpK?NnN~^5+zRf=Q3AZfRS))p>1Tb(kr4oW zCQ*&rb4#AequxfxiK>VFXC8ZhQy@*&Fk9bSqZui}F6)75i|rx-t8tJ8t$u z1$JEWJ0YKY3ub^t;LY$1D0U{6!$t(M10}ItH(}GQSulw}G7@3y(_5neR-;gX5xJUh z5%L*n=-n@2eXHOetk6ud0R^#5AXlK#NwsWMXA*@@Y{Mek)Kf7kRj)LJkLO ze+BH{PLdU7Xa~XRKv>LRLDFf~0bJTT3-K3+zsj_KNv(j4ccVD@s*tOz%|pk_Kb!YG zDnxxG!Ad6=zes@G6){i)mm-GuLI!04cscCGe(VT63v&9=Ye=1ExIx>4eAl_A)2P7L zj;`BArpL^QmfYp^E#)n$j@g)k!)a0k&s#`o*76JP&bjg2>e;;h%P0vkK4GP!twcV} zN>qIu8~G&Hh>_;7;9QON-p-(JcRxhen7sV67HH#C96CK!l{pAC0h)1H%A`l(hq}N5 zxmmw(!&?quH_tRFMJMm{boIys=r{OD#1o%$L;vvz*BrfRRRmGfy-`A4&Vy>1fr%zX z2jk#;mZ}*Mk=XdSFm``Y4R$t@B0#g%_4F@#o=pe-=P-u2H!ZvEOB}#^Y?ed}ffU^G zJDRhb*;H#md;LsgcwcK+BNmj6(P8MnQS2yu?s#ChF)as7W4`u;<3R`6gL1s6?>O}0 z3F2l%I!9*-qW@f@>~x=>@tR_xIGkRQiAvT~sBT zjgwHh7gJjGLQ(;pX=x=Woc~xVl?mlxOm6O?WD))0esHFb+;xJ+NEof(kvHv!?(F7! z#YkzJM?M`hamZyC%tmKdS}D|yTSbuM374@8;5h#UY6BpF!|tx`Db*QmI=Bp23+0?F z(50~&#L!G1$^4x#!!A8}S6$zLo#;zLq%w-I+@6%(C_1F7lCis7{jc(Nj}}i%dRb)g z!32-1En0-M1Rn_J?m<;M7^G)dzjf4Ev{jRwj7MYV5^yu!pmSF(kQg~{bYT)f*2!xn z0CQ*A){KSOkA)--YUlf?_Mg20rf1^AbSz>1CJowh(wOg|IyIcOKI+dv$~~C7Mb4>C zYg+7=WGu_ML{U*P&|_07{X@ESq9*>8&84Td16XkrkP8Vd%Jxf!<2RFj>ByRkwz2yK zSi!?Wxy9*Mfdmu?ou<@jmpd4-9F10dEY*^WtlWz@>5>BRHk!fP2*U%Ov~}GLZ`qFO zI8OKPd=)tkXjwd)j1km-ZA9W{BCIlJ%YyXR1kzV9UG0Hgal|H@9vrScoSVDmLnt9@IVnZ<`<6N`Iwrhk?%cQt zQQ}IoaiMs>@Y1DHm-7kV#0N8H6omIeaghtpu=JvzJ-*CC8io0cHGC zL4RX&77-0tN*X-QI$S7W*?x)?i9^@W@DF-Wp5uh-Ze&E?e2m|@pG90U^dvr9lh=EV z5-4Ho+0V@3h}uM0MKaDf9Shr5z&2Zb;zz*^r{u(^fKY_4XS%#f7elHEkH;>~ZCVBM zBUWp#NU~%Mfsh@unIg0Z0a`w*`Ocs)?7j-IQrT#5C#VULih>_4 zMl=U1wJ`@58ZuI^w7w~esBkcxECToCTc_?e0B}stuh>Kb@xHI>;62FbvRTJ^y^fU6 zhmLr3O$<&BCxS_YV=y^9nPHX^x$5a16V$oVnA4HyHXZ4B0X#zyXjc5+x8E%}_1H>+ zRgvgtizN-v^cff}I4QNqGFD7Qh(PCU!vH4P0PkB?YA`7gYs_cLv`tfx<)}`(T}_DC z3?61D^5PR7Dn+?0!pxXiEUr__{gJmJy~AwKqu+H?h?PEm{qiqk3o)Z*hk3e$KE{+; z{pc>DlgTuTl}=EpcY6Y8Mz$h)$}kIN_I0F%D5lI^2@^gTJ${=$FAXH1*`cT=v}lnP ztZ#lwoH@yrgG`@wy(G7Wk3rdT55P$F!WVF#o)ONP;mKHC^ZoMkhxqOKLqZu3GftRN zC^>0*E{2byOO#Qoj*^`R1CrmJ@Pb0_TxDvyVY1v>tf;V<4C{rNFsHZS37{}zv6}XE zGAL@`1uIBmkG`(#6{Qd+rcXLVvqU{~XkQho>Avykx>#I=SKP3uX9>x@HYaS7C8gSM zH02>W3N-25{$OCBw+gNRAc93?@^gqX(^>qd_1I(D$xR{#axK1R!45iH)NRZoGhOqJOd! zC+F%@_67qQ7CFeHA6Nu?^e2OdCtggWd-Jg^(Z<7!Dj)~5QDjINtw=MKhZ@Yz(<}e z4ygt!f=GNxfEzZW=T7BV>Y@ajnnLYRHBugk$J+hJn-=R2t20`(&2<^>$jg z@QE=o@|3Qbbivp}Z?T4Bs(i>BnwHepj!nxR(^ku%Z#GB1#!A#QA25uo- z(#f62voeg}*b?y|?MEr=ypya&5-J`qY@@oN)hPkWWt$0t8QjJ8qkvv__6^0pWk*#} z&-N~O2Ne*Bj4B;$EFkb_j2Pukt)d~0cwrsIp{M(FE2OBiQhmkF*lf`L;tWoLau@dH z&@Mu)Y-s)SU!cdCNx;kbLs~rxY!^aO=N+(5wDF7n7pg{>FK1Z`(fSDW-I6<%H_UXt zBMz1OP+p-ax3RlLC;~mt%Y{Gt8k`}rknEX{dd5O>ORZUZf%SYJUKU&-2IW8;K3^lfO zf5{6>)aK3?1@tXTr42PdJJxJKZLlN;N6HN|2V9+%?IQq}Q&jQdx}Jwkhpt}955a$IVHIT=PXPaf#~dScrtS3c#{GURn&o;nWMhJmv4C))+9c+-xH_wtbF>k(u0B1?D^H-{5zpy9R6vCA(95ml~D zTiLsxdt&tkmk$yyHA5+kLp#Qv5(k?An^u8*tqQgB?eV@b&MLC2JBbp{2Qbe5N1XW5 zmM&@cC4=a5x&@>${7_agGocv(g5QLo_1Ks#^yz({W&~vyHfYX&KtgmAX*9!n_?}+(j=E9p7dFTe2`64T zMbGL>xeQ;f-^vpQu1PehH*hsRIUGaSx`ahTU^@u~U%T6~aiTF?<6- z-?9NfIjHzmv-;Vwl93h&6XjA|SgraI_4IbNL47w_?KP!pjHJ3TcGA^;xFaiw z9)+_~xw19$u!yb8?m+a$YeC{-V`kkLx-@2!q<@X=7iT27*mBPofS1LCm-;fwXu~0# z6$Pu$?_UEV{$(7GC{8zpGIcwHumVhRW>k^qdndk)+;O;^)7#fmHk8{6WQTHgmx_X! zwPUacW=!dPgaMBiH|@^8^Ru+nb^48*BclzHAOR$wx9|}hmqEQzg`AfZ4}oY)|8?C( zNiR&vHr@h7OhL2;yN^{t{*?4+RxNoX9gb1J;hYE&9_~WeD{1=d_OCovv!ZSJ!*u(O z^K5WpPq^BW2sZd??vcQu1$vKg1ca5H(U!CG8Tw>d;n8XIjdqq^vMzxPg`TA2I-6cqWTDTo!l8@`--;s6b2jR znPxj7^Brsd{5^l9W~jYP{`0e@aW>ky0zM<{ucz$9je4R55tn(HJ)@w%_G5S_zM&sW zeG(;`gGbS;9~+{!_zmHGJduuc)ycfL@h@!sgPML3pW}sZu5Xq`1#X`Z7d6-K&v4#a zdAh(rKeW2iBD*pfbA{O4S@S}jD1w_p38tl@H}wFdoLBZ}$90W6e2)cQbDgM5%Tv1H z?CEhqm6z;hlG`Nuaj^SJdKXsdELGMUT^WMO+bciQUgwTOpY&6rju1#mqjsC`!M)x` zDrMjGmjm;_5W=Inv1xEcP?JJNM%3UXS9>-{Ul8CvorG6oUfzIWb(wv@na2CQ2V^*6 z;;HqsF^ld%jCx`y?KgGkDqu;c94$U=K8V@mSX`pr;}_F8m}q-5RzzSHu10AZS=+Gq z4kpLdRU*H{S{5WM;zhQ=5ExPA;%Ae0$1h_ANzH4`PewLcS9uRwcx>i!O*80y*hI@_ zLMB!Twkxuw2&zq(;C{uatMmF4{pvND2dt>xV$ z178#rAO3aMWw}hAd3l@|kExFA6pDe+!;*UOQ}JQbpFKQK6|@6|?9Uig(&?@lbqY2`=dBAWBzBMpOtq zo3#GA)Os9x1=b4@0B>u-$^0fQ?9a8$56bv#Hm1;g2_N&$j!7xRCe- zTEH=`M~qF7e{3A{y+ZARme6yJMAME^xhHyP{;*L)D`KbWqjJc67*NV8X>4sdivXL5 zX>%`_1y&w=kiv1c3Wz>e-F5TI_ z?%FX%L{lXo$PK9Ofcm?&5nWf>psOuCag7qa)}@1?5~+Z5H$aOgg(BBm z&7^Z$psJ)6no!dn4Q;#9-nimJyuz@s8QYwwI>a-hx0?6t0M$n1GaJ9W%e) zlS;1|b>6eCJJwWfUN&$ow||`@BQpd&z8cb+9BV#>Y((ZrV>$_pwkPwdr z(7LAF+v$6?IN8D;A_r6$MyG0;?#U? zHlOv_=@fiUkfag}7dJhme9}**pk#46vK1F(5V7?jn9<^$K0pAmSdi~|xC~MpZSC7} zP=H_Dj_u7p-!E03<}K**;J7g1PHrI~$KX}7ZFBFC-qz4}xQDeuP{O9-p-HA_X}pN1 z0pPMfNE}S zm%#{jVxOpI%+kY-XA$N>VB9ehsNw;kIu;sc4G^_00t%@zg2Rvi4`wD-!sWF8(mmC} zGXA}0^{%GWiU2T*qm29WL0@%iHP>l~;WxtrGnE?(vY}^Oe9eAKbP*34jzB&04#+E_ zaUf1HlrF#}jLc{;>LwsDf21FelsacgNA*BzA8xN^H;N9LBZwvO=`*~gHzM8+%ve2? z3cv0X^1xe<0!$MZXR1b);~F$){6L)ZJ6AvlGG)yZaBzrYl%MtCA@LPw6>0h0jgJgv z3>DQB?A1in!1Er&o+)H-U+Vo93kdm~J|K5hUu8xa*JRDq9}j~Y837UiWb+tYUfn^* zC7l#T%aJ^J5U{`Rh@d)!Lm2UmVBAX?Pgd3RGFoxf_jI7WP|dcZ*@Y2W*-=mNTQTO8 zHimGsN%x>%$;_m)wBPo*AQpu3L@E(_2<>zsrSTGtuDFfF1B58b184NZ9a9%o&-1bQXQ0Y`_&jOi~3U1cr;_4UI@CGTW9p61D#P6=|!R@o59B(;$$lcVL^gN;tPvj}8!1zYcU29iGij`em<9`DJN%TlhjGaK$Qg~kxa zL_;dp6>cQF_y}SG6+Wrro`)?uof~^H9kB{9$k^x!N>PKj*7)!8uLhVOR>Rg!@B>51(XKK6W+1qM-YGaq62^b zV$gokPOF;Hp)?VFM4s4a4`@Op9dPZ+;x{|hu^ZiNF3*Af-KrZPY+H+5__<5#!JVV8O{AqclpAHBqqGY_gG1Y|Lc3qaZISLh zFZs^SUdrU*=OGkQIA)jESAw!PUPKdBWjVNT$XgiPc#R2D`YiYKHFy4jZpe0Y#xZ;4 zQMN=fZ$FvS{;oiSxCdJp>O>P>X*V{E3h%gfgHD6~>ac*Sv-Fk^VSqO}@Q_;lV4KW7 zGr2Ny>Dts}U#K~;Ha0o7*zXl5>jo00Jz2m#}O1>r=1ny|Mps8I5#2eG~saDmEcBIrsfd`H&`);~;D{CaMB z1x~G&st8Uezom{1e6O-6(vZzkUE=0f_tLIB7=mk318^w;v)SLSkm{T>dsJT4h#0Oa zWL@G5QQ(Z7>{0O%nyP2eC_tlIZ)q9~pWJOwd8)(<`epMvRv)G&r1eqLr?lVDL$ihW z0YVBxcW(HAhef)w$F20pey7~ngpF}wJPun1PilQt^^McFq$}5>JXvkZMj5F`i6{bTm?u4g=+;+xN*hBGu2e?0hov6&ONoT^TXpN-R547`~Ybx^nI zC1PZVwQHCLLcbc?qK`eY@dlKpDV+@Bs-^lS5XD!$4TIrf)=6}b6Xm&pQ@T-fC5E&! zUQ0Q~Z#GNLN?fd+nlwFkRc*JLkF!HGY?rZ}wykdY3(P#-5z`hF^V2kw; z!^U+)fp+-_OO@@?8Gmif!mrEQX3#e6sPjS>Vilx65VdSq)uZBM{5osmhv5z=5|=%U zq=p0b;Ffh;R;N7;*e9ER5tt#6e4c^}i97ixM;UtjNb7(sXe^`B3P-EubXovm!Lz=v z)uA)4B(g-}_Cx<;>>RmJChn0F;;M?UbWpQ5#-7V<%6t;R77vK`+h$>GgT@XPe}dLb@ovBCWTNEv4H{%_a$OTZBg`HN%A(;qz= zG<5jmat<^!=|3+ALAj8tKE9@5NSC_y1G7bG5PtyU6sqv@-&Kgg@*HKKFHyzrb%Kmd zEn1Gjok9+Fm~8Grm)!Z6^UumJQVn?3<(27_Bu+NZ=3Gvwr`ZtI1E(nnrajSx%X@BTf4~(mwLkyPQYs!qEDPbSNSMzA zfq8h}2K`(yluQ4CypAO36eeZ>H%W+qh^FlYg<;WS&4L1t{`re>!b8TZrwinXgSH^6}ftDrh_=KB`-jZ5~2Tp7bHH}v)Sa&-vt9QqZHo2gu(>y&Qc6Vy1doZRNY9f%#N-|U8fZD+znXqCVbYNC-))IBl1q6GWn*`Rj zHKwp1^(k1^@sM5p;)6NLXoF1PT7?TDSytp^qB6RBrd<2ows)*CJG=uuO7^j~8tPC) z^m6?sx}jB)U{zVb2dUz{k-MB`5oDwLe9K!!0H>QpcwUo^^zSdY(5+XH!Ig=$tQhry z{ZG3cblws%<*bqFi}PZXs4t0_F=feIR<4iENv+Aw8EmB?5D8%`K)1OOqg8AiI5q>+ z2jl>KGv2qwbBy0&i#R#8?Zm&WU~}7eBrXY5kNg7Vd?!C*1d5&JoNRJY)8+zTlqJ|@ zdy~ES1q1^B{u85v{MUCVd=+IYulk>5#O(E)H1ZZSJ@YXuCC_~I)^re?ZhVD4aLwva0b`XMmB70fU!H0mCs8Ru1smNseS4{wh5~tgRfkw6 zYJEAp16mE4<_V!>(5wT8zI4=|Y?F@Rk`SP=UB%YiGxJ-iW4G1O| zM71JUlt-)pJA#sw)i+jeHY}QwLaP*_C)A79F7?=!=E|QC)A)d2FUwt^#vONel|6|x zVn2L-Hu!q~u3H~X6dMRo1`PJG+IDwTFpzP+^c!hjkxZ@21sQGOk$v;1kTEkb4|wl-AH*fpvW1MDic11whtq}`AYU$ zZmve~?v7$;^xb_JTxCaZg9)O%T_I*&uKf_N@*YbYpmQWBG&awNm8JI{RSNZ85Iax> zauqIMz!VD0$UYg%!eP$<0wJ;ItD-h<_~>IuyC|+A?=`?o?N=y~>O^)LChm50)_1F= zt|HJ8(&!q6YLSQqMt&bI3mKGLL2Y}|CFgQdk}E8jVc6mdMK4@#+7M=H36Pr-JPyw% zoCKQ2?Au{-f9_W9aXd)n{((jG%#Gi=%faG#)6pSSn4iq{aWAT@?x-hk1n`$P=+N=vhrBp-8m{wGE zmzIc5#@2jp}%_DDXU@-;lKIm$A@+9?Gvlhf)LMVMhKMly~i4z(3r5eB0 zVw5up^Vb}u^_Pu#B*HKanJ|RyuoMr6T4=y6J(W&CtiqM*eyFsBG-Rng#dU;Eh@Mzy zcqP}@2eQY7rS{X_vD;$^Z&14Lz)0y)jzK`*NBELMuiR@iJm* zU50jR>2zaSYnK@FFmtyt2^i#QI-qTXp9zA8e_ogk-@Wp=KgwCF#pM`3JRb_sZJ@)zHVOAiDmryH zM1)*xm7gc`M59~0Dm!p3?7!wy1PNSIrMTfqSq54Y9V2junq?xSI@n$)VbKPV`9)T! zgL4oyHoi-n(4k^20MC~pS^;TII7=Q)X)Hv?U>9WA%i#{m_ovrFAPa!5x2}h16);$l zF9x}cP#1r>IcW%ejls-H0AF(T2|?tMYkUNgv=HZ&$)2BJa?}a=%gco2Np=&(3;+=J z`TTzyBL8AAX$sofI2zkH>MFU}8GqCM%QHKvye6|tkMIWcPIygav0gW8(P%LS95k%` zWf>mh)ZB1^%}vVuBTIa?LI_epUb9y+8pP_XuuGHO3$; zKqV7Ycduki{eK+Zkh9?!ux9Q8Wm(hOv^@p4AtB_mKrz0=Syn%YiR#zVLpjO)h!PxY ztiJ)FJJmpG#x^;+-Zm$MN6_10vrh-!1&cUiq(NSFhFu_?-R}a(pC^Cauap(pWoT8D zf=II%IJJ60nl1CXV7?&S=lhwWWK;19A7>RJ-a3B!-Al_08J`dC;mU}+;EXr79|nsN zr$5)<@;WEj(lbjs7Ype)B%?*&%*=3;n%&smaj8%+jVtsySr%otppDErT=o!e3FNIc zU!YH3Dx%2h^3#jE!ju0cJ?NJu*O(?(&gQEIjRWs~msfCu$7un{l;_EIj0Uvh4<@{Z zGc`{7_k84Yia_ORy0z~f_fUUZ)CxOnl0NNekKlhzDw*JgMWQ&t%{Y*jnRR8o?{}#HRnxEPKad!So z<8PbipF;aK5RJb+j|_|Z#18#iNFVfXAtMKUm%k81bk5dRLjP$q(0;c66aqIe4*>nF z2K>|Z{TKWEpNNI{x6uDl{IC7`f2*uR{#TX1aq|EFYa#ozd47)0|90yCG?@Onj{a2n z{iwQs_s9X}ZlNXZiiRtmLnMs{B^g-&+6m z{rx-L?k@!3Kas)zUx0vr_Ud +### Reference Ids -:::tip +Every record needs a Reference Id in column A: -Prepare data sheets quickly using the Query page to download records +- It must be **unique across all worksheets** in the file. +- It can contain letters, numbers, and underscores, and must start with a letter or number. +- It is only used during the load to link records together - it is never saved to Salesforce. +- **Tip**: when migrating records between orgs, use each record's Id from the source org as its Reference Id. Lookup columns you export from the source org will then already contain valid Reference Ids. -- Use the **Record Id** as the **Reference Id**. (See example template for an example) -- For related records, the lookup field will already contain the parent Record Id, which is the **Reference Id**, so you don't need to change anything in your data. +## Linking records -::: +There are three ways to set a lookup field on a record in your file: + +1. **Link a whole column** - wrap the column header in curly braces, e.g. `{AccountId}`. Every value in that column is treated as the Reference Id of another row in the workbook. Values can be left blank for rows that have no parent. +2. **Link a single cell** - keep a plain header (e.g. `AccountId`) and wrap just one value in curly braces, e.g. `{account1}`. Only that row links to another row in the file - the other rows in the column can hold real Salesforce Ids. +3. **Link by external Id** - use dot notation in the header with no braces, e.g. `Account.My_External_Id__c`. The lookup is set using an external Id value that already exists in the org. Use this when the parent records are not part of this load. + +{/* TODO: retake screenshot after UI redesign */} + +Example load file with a linked column + +## How records are grouped + +Rows that link to each other by Reference Id (directly or through other rows) form a **group**: -## Previewing your data +``` +Sheet "Accounts" Sheet "Contacts" -When you upload your file, Jetstream will calculate all the relationships and will perform validations to ensure that there are no errors with your file. +account1 ◄────────────── contact1 {AccountId} = account1 ┐ +account1 ◄────────────── contact2 {AccountId} = account1 ┴─► Group 1 (3 records, all-or-nothing) +account2 ◄────────────── contact3 {AccountId} = account2 ──► Group 2 (2 records, all-or-nothing) +``` -Jetstream will show a preview of all the groups of related records prior to being loaded into Salesforce. These may not be in the same order as your file depending on the calculated dependency graph. +Each group is sent to Salesforce as a single **all-or-nothing** transaction: if any record in the group fails, every record in the group is rolled back, so related records are never left in a partial state. Groups that are not linked to each other load independently - a failure in one group has no effect on any other group. + +The review screen shows every computed group and its size before anything is loaded, so you can confirm the relationships were detected the way you intended. + +{/* TODO: retake screenshot after UI redesign */} + +Preview of computed record groups + +## Limits + +:::info + +- **500 records per group of related records.** This limit is not per worksheet or per file - only records linked to each other count toward it. For example, a file with 5 worksheets of 263 rows each (1,315 records) loads fine when the rows form 263 groups of about 5 records. +- **Around 15 different objects per group.** +- **No overall file limit.** Jetstream automatically packs unrelated groups into multiple API calls and sends them one after another. + +::: -Load groups +## Reviewing and loading -### Resolving errors +When you upload your file, Jetstream validates everything before any data is sent to Salesforce: -If you have errors with your file, you will be presented with detailed information for all the problems in the file and which cell(s) the problem was detected in. +1. **Review your data** - each worksheet is shown as a preview grid. Validation errors are highlighted on the exact cells and rows where the problem was found, with a message explaining how to fix it. Fix the problems in Excel and upload the file again. +2. **Review the groups** - once the file is valid, you can see how the records were grouped before starting the load. +3. **Load** - progress is shown live while the groups are sent to Salesforce, and the load can be cancelled. +4. **Results** - every worksheet gets a per-record results table showing the created Ids and any failures. Failed groups can be retried after you correct the underlying issue (for example, deactivating a validation rule), and the results can be downloaded. -Load errors +{/* TODO: retake screenshot after UI redesign */} -## Loading into Salesforce +Validation errors shown on the review screen -When you load your data, if any record in a given group fails, the entire group will be rolled back. +## Troubleshooting -After the load is finished, you will be able to view and download the results. +| Error | Cause | Fix | +| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Cell B1 / B2 / B3 is blank or could not be read | The worksheet does not match the template layout - the object, operation, or external Id cell was moved or left empty | Keep the fixed layout: object API name in B1, operation in B2, external Id in B3 (Upsert only) | +| The operation is not valid | B2 contains something other than `Insert`, `Update`, or `Upsert` | Set B2 to one of the three valid operations | +| An external Id is required for upsert / must be marked as an external id | B3 is blank for an Upsert, the field is missing from your columns, or the field is not marked as an External Id in Salesforce | Add the external Id field API name to B3 **and** as a data column, and confirm the field is flagged as an External Id in Salesforce | +| The field does not exist on the object | A column header has a typo, or your user does not have access to the field | Correct the field API name in row 5, or check field-level security | +| The Reference Id is used for multiple records | The same Reference Id appears on more than one row - Reference Ids must be unique across **all** worksheets, not just within one sheet | Give every record a unique Reference Id | +| Rows are missing a Reference Id | Accidental data in unused rows or columns makes Excel think those rows are records | Clear the contents of any unused rows and columns below and beside your data | +| The value refers to a Reference Id that does not exist | A `{value}` in a linked column has a typo, or curly braces were put around a literal value such as a real Salesforce Id | Fix the typo so it matches a Reference Id in the file, or remove the braces if the value should load as-is | +| These records link to each other in a loop | A circular reference (for example, record A links to B and B links back to A), so there is no valid order to load them | Follow the loop described in the error message and remove one of the `{reference}` values to break it | +| The record is connected to too many related records (limit is 500 per group) | More than 500 records are linked together into one group - the limit is per group of related records, not per file | Remove some `{reference}` links, or split the group across multiple loads | diff --git a/libs/features/load-records-multi-object/src/LoadRecordsMultiObject.tsx b/libs/features/load-records-multi-object/src/LoadRecordsMultiObject.tsx index b52c42093d..bbc3ff375b 100644 --- a/libs/features/load-records-multi-object/src/LoadRecordsMultiObject.tsx +++ b/libs/features/load-records-multi-object/src/LoadRecordsMultiObject.tsx @@ -1,4 +1,4 @@ -import { ANALYTICS_KEYS, DATE_FORMATS, INPUT_ACCEPT_FILETYPES, TITLES } from '@jetstream/shared/constants'; +import { ANALYTICS_KEYS, INPUT_ACCEPT_FILETYPES, TITLES } from '@jetstream/shared/constants'; import { APP_ROUTES } from '@jetstream/shared/ui-router'; import { formatNumber, @@ -6,39 +6,44 @@ import { isBrowserExtension, isCanvasApp, isDesktop, + useGoBackShortcut, useNonInitialEffect, + usePrimaryActionShortcut, useTitle, } from '@jetstream/shared/ui-utils'; import { getErrorMessage } from '@jetstream/shared/utils'; -import { InputReadFileContent, InputReadGoogleSheet, LocalOrGoogle, Maybe } from '@jetstream/types'; +import { InputReadFileContent, InputReadGoogleSheet } from '@jetstream/types'; import { AutoFullHeightContainer, - Checkbox, - ConfirmationModalPromise, - EmptyState, FileOrGoogleSelector, - Grid, Icon, - OpenRoadIllustration, Page, PageHeader, PageHeaderActions, PageHeaderRow, PageHeaderTitle, - ScopedNotification, - Select, Spinner, fireToast, } from '@jetstream/ui'; import { useAmplitude } from '@jetstream/ui-core'; import { applicationCookieState, googleDriveAccessState, selectedOrgState, selectedOrgType } from '@jetstream/ui/app-state'; -import { useAtomValue } from 'jotai'; -import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useEffect, useMemo, useRef } from 'react'; import * as XLSX from 'xlsx'; -import LoadRecordsMultiObjectErrors from './LoadRecordsMultiObjectErrors'; -import LoadRecordsMultiObjectResults from './LoadRecordsMultiObjectResults'; -import { LoadMultiObjectRequestWithResult } from './load-records-multi-object-types'; -import useLoadFile from './useLoadFile'; +import LoadRecordsMultiObjectEmptyState from './LoadRecordsMultiObjectEmptyState'; +import { + allBlockingErrorsState, + currentStepIdxState, + datasetsState, + inputFileTypeState, + inputFilenameState, + isReadyToLoadState, + loadIsRunningState, + loadRunsState, + resetLoadRecordsMultiObjectState, +} from './load-records-multi-object.state'; +import LoadRecordsMultiObjectLoad from './load/LoadRecordsMultiObjectLoad'; +import LoadRecordsMultiObjectReview from './review/LoadRecordsMultiObjectReview'; import useProcessLoadFile from './useProcessLoadFile'; initXlsx(XLSX); @@ -46,100 +51,101 @@ initXlsx(XLSX); const TEMPLATE_DOWNLOAD_LINK = '/assets/content/Jetstream%20-%20Load%20Records%20to%20Multiple%20Objects%20-%20Template.xlsx'; const HEIGHT_BUFFER = 170; +/** Step 0 = upload and review, step 1 = load data and results */ +const STEP_COUNT = 2; + export const LoadRecordsMultiObject = () => { useTitle(TITLES.LOAD); - const isMounted = useRef(true); const { trackEvent } = useAmplitude(); const selectedOrg = useAtomValue(selectedOrgState); const orgType = useAtomValue(selectedOrgType); - - const [inputFilename, setInputFilename] = useState>(null); - const [inputFileType, setInputFileType] = useState(); - const [inputFileData, setInputFileData] = useState(); const { serverUrl, defaultApiVersion, google_apiKey, google_appId, google_clientId } = useAtomValue(applicationCookieState); const { hasGoogleDriveAccess, googleShowUpgradeToPro } = useAtomValue(googleDriveAccessState); const googleApiConfig = useMemo( () => ({ apiKey: google_apiKey, appId: google_appId, clientId: google_clientId }), [google_apiKey, google_appId, google_clientId], ); - const [templateUrl] = useState(`${TEMPLATE_DOWNLOAD_LINK}`); - const [insertNulls, setInsertNulls] = useState(false); - const [dateFormat, setDateFormat] = useState(DATE_FORMATS.MM_DD_YYYY); - const [loadStarted, setLoadStarted] = useState(false); - const { - processFile, - reset: fileProcessingReset, - data: fileProcessingData, - errors: fileProcessingErrors, - loading: fileProcessingLoading, - } = useProcessLoadFile(selectedOrg, defaultApiVersion, { dateFormat, insertNulls }); - const { - loadFile, - reset: loadResultsReset, - data: loadResultsData, - loading: dataLoadLoading, - } = useLoadFile(selectedOrg, serverUrl, defaultApiVersion); - const [data, setData] = useState(null); - /** This only stores the data provided from the init file read process, so if the user loads again this has the pre-load state */ - const [initialData, setInitialData] = useState(); + const [inputFilename, setInputFilename] = useAtom(inputFilenameState); + const [inputFileType, setInputFileType] = useAtom(inputFileTypeState); + const [currentStepIdx, setCurrentStepIdx] = useAtom(currentStepIdxState); + const datasets = useAtomValue(datasetsState); + const allBlockingErrors = useAtomValue(allBlockingErrorsState); + const isReadyToLoad = useAtomValue(isReadyToLoadState); + const loadIsRunning = useAtomValue(loadIsRunningState); + const runs = useAtomValue(loadRunsState); + const resetAll = useSetAtom(resetLoadRecordsMultiObjectState); - useEffect(() => { - isMounted.current = true; - return () => { - isMounted.current = false; - }; - }, []); + const { processFile, loading: fileParsing } = useProcessLoadFile(selectedOrg, defaultApiVersion); - useNonInitialEffect(() => { - handleStartOver(); - }, [selectedOrg]); + // Served by the API server, so the link must be absolute for desktop/browser-extension/canvas shells + const templateUrl = `${serverUrl}${TEMPLATE_DOWNLOAD_LINK}`; + + const lastTrackedParseRef = useRef(null); + const lastTrackedRunIdRef = useRef(null); useNonInitialEffect(() => { + resetAll(); + }, [selectedOrg, resetAll]); + + // Live progress in the browser tab: loading state, then final success/failure counts + useEffect(() => { try { - if (dataLoadLoading) { + if (loadIsRunning) { document.title = `Loading Records ${TITLES.BAR_JETSTREAM}`; - } else if (loadStarted && data) { - const success = data.flatMap((row) => row.results?.filter((row) => row.isSuccessful) || []).length; - const failures = data.flatMap((row) => row.results?.filter((row) => !row.isSuccessful) || []).length; + } else if (runs.length) { + const { success, failures } = runs + .flatMap(({ requests }) => requests) + .flatMap(({ results }) => results || []) + .reduce( + (output, result) => { + const recordCount = result.graphResponse.compositeResponse.length; + result.isSuccessful ? (output.success += recordCount) : (output.failures += recordCount); + return output; + }, + { success: 0, failures: 0 }, + ); document.title = `${formatNumber(success)} Success - ${formatNumber(failures)} Failed ${TITLES.BAR_JETSTREAM}`; } else { document.title = TITLES.LOAD; } - } catch (ex) { + } catch { // ignore } - }, [data, dataLoadLoading, loadStarted]); + }, [loadIsRunning, runs]); - // prefer loadResultsData if it is set, otherwise use fileProcessingData - // It is all the same data, but loadResultsData clones the data as it is being processed and adds results useEffect(() => { - setData(loadResultsData || fileProcessingData); - }, [fileProcessingData, loadResultsData]); + if (datasets && datasets !== lastTrackedParseRef.current) { + lastTrackedParseRef.current = datasets; + trackEvent(ANALYTICS_KEYS.load_multi_obj_FileProcessed, { + worksheets: datasets.length, + records: datasets.reduce((count, { data }) => count + data.length, 0), + errors: allBlockingErrors.length, + }); + } + }, [datasets, allBlockingErrors, trackEvent]); useEffect(() => { - setInitialData(JSON.parse(JSON.stringify(fileProcessingData))); - }, [fileProcessingData]); - - useNonInitialEffect(() => { - if (inputFileData) { - processFile(inputFileData); + if (!loadIsRunning && runs.length) { + const latestRun = runs[runs.length - 1]; + if (latestRun.finishedAt && latestRun.runId !== lastTrackedRunIdRef.current) { + lastTrackedRunIdRef.current = latestRun.runId; + trackEvent(ANALYTICS_KEYS.load_multi_obj_Finished, { + type: latestRun.type, + cancelled: latestRun.cancelled, + durationMs: latestRun.startedAt && latestRun.finishedAt ? latestRun.finishedAt.getTime() - latestRun.startedAt.getTime() : null, + }); + } } - }, [inputFileData]); - - function handleDateFormatChange(event: ChangeEvent) { - setDateFormat(event.target.value); - } + }, [loadIsRunning, runs, trackEvent]); function handleFile({ content, filename }: InputReadFileContent) { try { - setLoadStarted(false); const workbook = XLSX.read(content, { cellText: false, cellDates: true, type: 'array' }); + resetAll(); setInputFilename(filename); setInputFileType('local'); - setInputFileData(workbook); - fileProcessingReset(); - loadResultsReset(); + processFile(workbook); } catch (ex) { fireToast({ message: `There was an error reading your file. ${getErrorMessage(ex)}`, @@ -149,37 +155,29 @@ export const LoadRecordsMultiObject = () => { } function handleGoogleFile({ workbook, selectedFile }: InputReadGoogleSheet) { - setLoadStarted(false); + resetAll(); setInputFilename(selectedFile.name); setInputFileType('google'); - setInputFileData(workbook); - fileProcessingReset(); - loadResultsReset(); + processFile(workbook); } - async function handleLoadStarted() { - if (!initialData) { - return; - } - if ( - !loadStarted || - (await ConfirmationModalPromise({ - content: 'This file has already been loaded, are you sure you want to load it again?', - })) - ) { - setLoadStarted(true); - loadFile(initialData); - } + function handleStartOver() { + resetAll(); + trackEvent(ANALYTICS_KEYS.load_StartOver, { page: 'load-multi-object' }); } - function handleStartOver() { - setLoadStarted(false); - setInputFilename(null); - fileProcessingReset(); - loadResultsReset(); - trackEvent(ANALYTICS_KEYS.load_StartOver); + function changeStep(changeBy: number) { + setCurrentStepIdx(Math.min(Math.max(currentStepIdx + changeBy, 0), STEP_COUNT - 1)); } + const nextStepDisabled = currentStepIdx >= STEP_COUNT - 1 || !isReadyToLoad; + const prevStepDisabled = currentStepIdx === 0 || loadIsRunning; + + usePrimaryActionShortcut(() => changeStep(1), { disabled: nextStepDisabled || fileParsing }); + useGoBackShortcut(() => changeStep(-1), { disabled: prevStepDisabled }); + + const hasData = !!datasets?.length; + return ( @@ -191,114 +189,87 @@ export const LoadRecordsMultiObject = () => { /> + + - {fileProcessingLoading && } - -

- Download the{' '} - - Excel template - {' '} - to get started. -

- - - -
- Load Options - - - - - - - - - -
- Choose an Excel file that is in the correct format from the provided template. -
-
-
- - {!data && !fileProcessingErrors?.length && ( + {fileParsing && } + {currentStepIdx === 0 && (
- }> -

- Download the{' '} - +

+ {!hasData && !fileParsing && ( + trackEvent(ANALYTICS_KEYS.load_multi_obj_TemplateDownloaded)} + /> + )} + {hasData && }
)} - {/* TODO: what if there are no rows in any of the loaded data? */} - {fileProcessingErrors?.length > 0 && } - {data && data.length > 0 && ( - + {currentStepIdx === 1 && ( + )} diff --git a/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectEmptyState.tsx b/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectEmptyState.tsx new file mode 100644 index 0000000000..0e3407e255 --- /dev/null +++ b/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectEmptyState.tsx @@ -0,0 +1,82 @@ +import { css } from '@emotion/react'; +import { EmptyState, Icon, OpenRoadIllustration } from '@jetstream/ui'; +import { FunctionComponent } from 'react'; + +export interface LoadRecordsMultiObjectEmptyStateProps { + templateUrl: string; + onTemplateDownload: () => void; +} + +const HOW_IT_WORKS: { icon: string; title: string; description: string }[] = [ + { + icon: 'table', + title: 'One worksheet per object', + description: 'Cell B1 is the object, B2 the operation, B3 the external Id (upsert only). Headers go on row 5, records start on row 6.', + }, + { + icon: 'record', + title: 'Every record gets a Reference Id', + description: 'Column A holds a temporary name for each row. It must be unique across all worksheets and is never saved to Salesforce.', + }, + { + icon: 'link', + title: 'Link records with {curly braces}', + description: 'Wrap a header like {AccountId} to make a whole column reference other rows, or wrap a single cell value like {account1}.', + }, + { + icon: 'groups', + title: 'Linked records form groups', + description: + 'Each group saves as one all-or-nothing transaction, with up to 500 related records per group. Unrelated groups load independently.', + }, +]; + +/** Pre-upload teaching state: the template CTA plus the mental model the template file itself cannot show */ +export const LoadRecordsMultiObjectEmptyState: FunctionComponent = ({ + templateUrl, + onTemplateDownload, +}) => { + return ( +
+ }> + onTemplateDownload()} + > + + Download Excel Template + + +

How it works

+
+ {HOW_IT_WORKS.map(({ icon, title, description }, i) => ( +
+
+
+ {i + 1} +
+
+ {title} +
+
+

{description}

+
+ ))} +
+
+ ); +}; + +export default LoadRecordsMultiObjectEmptyState; diff --git a/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectErrors.tsx b/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectErrors.tsx deleted file mode 100644 index f2a6508e9f..0000000000 --- a/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectErrors.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { ScopedNotification } from '@jetstream/ui'; -import groupBy from 'lodash/groupBy'; -import { Fragment, FunctionComponent, useEffect, useState } from 'react'; -import { LoadMultiObjectDataError } from './load-records-multi-object-types'; - -export interface LoadRecordsMultiObjectErrorsProps { - errors: LoadMultiObjectDataError[]; -} - -function getErrorLocation(error: LoadMultiObjectDataError) { - if (!error.location) { - return null; - } - let descriptor = ''; - switch (error.locationType) { - case 'ROW': - descriptor = 'Row'; - break; - case 'COLUMN': - descriptor = 'Column'; - break; - case 'CELL': - default: - descriptor = 'Cell'; - break; - } - return ( - - ({descriptor} {error.location}) - - ); -} - -export const LoadRecordsMultiObjectErrors: FunctionComponent = ({ errors }) => { - const [errorsByWorksheet, setErrorsByWorksheet] = useState>(); - - useEffect(() => { - setErrorsByWorksheet(groupBy(errors, 'worksheet')); - }, [errors]); - - return ( - // eslint-disable-next-line react/jsx-no-useless-fragment - - {!!errors?.length && errorsByWorksheet && ( - -
There are problems with your data that must be corrected to continue:
- {Object.keys(errorsByWorksheet).map((worksheet) => { - const currErrors = errorsByWorksheet[worksheet]; - return ( -
-
{worksheet} worksheet
- -
    - {currErrors.map((error, i) => ( -
  • - {error.message} {getErrorLocation(error)} -
  • - ))} -
-
- ); - })} -
-
- )} -
- ); -}; - -export default LoadRecordsMultiObjectErrors; diff --git a/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectRecordModal.tsx b/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectRecordModal.tsx deleted file mode 100644 index 238626845d..0000000000 --- a/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectRecordModal.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { formatNumber } from '@jetstream/shared/ui-utils'; -import { pluralizeFromNumber, pluralizeIfMultiple } from '@jetstream/shared/utils'; -import { CompositeGraphRequest, CompositeRequestBody, Maybe } from '@jetstream/types'; -import { Icon, Modal, Tooltip, Tree, TreeItems } from '@jetstream/ui'; -import { Fragment, FunctionComponent, useEffect, useState } from 'react'; -import { LoadMultiObjectRequestWithResult, RecordWithResponse } from './load-records-multi-object-types'; -import classNames from 'classnames'; -import isBoolean from 'lodash/isBoolean'; - -export interface LoadRecordsMultiObjectRecordModalProps { - data: LoadMultiObjectRequestWithResult; -} - -function getIcon(isSuccess?: Maybe) { - return isSuccess ? ( - - ) : ( - - ); -} - -function getParentTreeItem(item: CompositeGraphRequest, data: LoadMultiObjectRequestWithResult): TreeItems { - /** - * Item is finished, show icon and add text color - */ - if (isBoolean(data.dataWithResultsByGraphId[item.graphId].isSuccess)) { - const graphResults = data.dataWithResultsByGraphId[item.graphId]; - return { - id: `parent-${item.graphId}`, - label: ( -
- {getIcon(graphResults.isSuccess)} Record Group - {formatNumber(item.compositeRequest?.length)}{' '} - {pluralizeFromNumber('record', item.compositeRequest?.length)} -
- ), - treeItems: item.compositeRequest?.map((item) => getTreeItem(data.recordWithResponseByRefId[item.referenceId], item)), - }; - } - /** - * Item is not yet processed - */ - return { - id: `parent-${item.graphId}`, - label: `Record Group - ${formatNumber(item.compositeRequest?.length)} ${pluralizeFromNumber('record', item.compositeRequest?.length)}`, - treeItems: item.compositeRequest?.map((item) => getTreeItem(data.recordWithResponseByRefId[item.referenceId], item)), - }; -} - -function getTreeItem(record: RecordWithResponse, item?: CompositeRequestBody): TreeItems { - if (record.response?.body) { - const extraContent = ( - - {record.request?.body && ( -
-
Request:
- {JSON.stringify(record.request.body).slice(0, 1000)} -
- )} - - } - > - {getIcon(record.response.body.success)} -
- ); - - return { - id: record.referenceId, - label: ( -
- {extraContent} {record.referenceId} ({record.sobject} - {record.operation}) - {record.response.body.id ?
Record Id: {record.response.body.id}
: undefined} - {record.response.body.message ?
{record.response.body.message}
: undefined} -
- ), - }; - } - return { - id: record.referenceId, - label: `${record.referenceId} (${record.sobject} - ${record.operation})`, - }; -} - -export const LoadRecordsMultiObjectRecordModal: FunctionComponent = ({ data }) => { - const [isOpen, setIsOpen] = useState(false); - const [items, setItems] = useState([]); - - useEffect(() => { - if (isOpen && data) { - setItems( - data.data.map( - (item, i) => getParentTreeItem(item, data), - // item.compositeRequest.length <= 1 - // ? getTreeItem(data.recordWithResponseByRefId[item.compositeRequest[0].referenceId]) - // : getParentTreeItem(item, data) - ), - ); - } else { - setItems([]); - } - }, [isOpen, data]); - - return ( - - {isOpen && ( - setIsOpen(false)}> - Close - - } - closeOnEsc - closeOnBackdropClick - onClose={() => setIsOpen(false)} - > -
- -
-
- )} - -
- ); -}; - -export default LoadRecordsMultiObjectRecordModal; diff --git a/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectResults.tsx b/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectResults.tsx deleted file mode 100644 index 5a5ef88489..0000000000 --- a/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectResults.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { formatNumber } from '@jetstream/shared/ui-utils'; -import { pluralizeFromNumber } from '@jetstream/shared/utils'; -import { FileExtAllTypes, Maybe, SalesforceOrgUi, SalesforceOrgUiType } from '@jetstream/types'; -import { Badge, FileDownloadModal, Grid, Icon } from '@jetstream/ui'; -import { fromJetstreamEvents, useAmplitude } from '@jetstream/ui-core'; -import { fromAppState, googleDriveAccessState } from '@jetstream/ui/app-state'; -import { useAtomValue } from 'jotai'; -import { useEffect, useState } from 'react'; -import LoadRecordsMultiObjectResultsTable from './LoadRecordsMultiObjectResultsTable'; -import { LoadMultiObjectRequestWithResult } from './load-records-multi-object-types'; -import useDownloadResults from './useDownloadResults'; - -export interface LoadRecordsMultiObjectResultsProps { - selectedOrg: SalesforceOrgUi; - orgType: Maybe; - data: LoadMultiObjectRequestWithResult[]; - loading: boolean; - loadFinished: boolean; - onLoadStarted: () => void; -} - -export const LoadRecordsMultiObjectResults = ({ - selectedOrg, - orgType, - data, - loading, - loadFinished, - onLoadStarted, -}: LoadRecordsMultiObjectResultsProps) => { - const { trackEvent } = useAmplitude(); - const { google_apiKey, google_appId, google_clientId } = useAtomValue(fromAppState.applicationCookieState); - const { hasGoogleDriveAccess, googleShowUpgradeToPro } = useAtomValue(googleDriveAccessState); - const { downloadRequests, downloadResults, handleCloseDownloadModal, downloadModalData } = useDownloadResults(); - const [numGroups, setNumGroups] = useState(0); - const [totalRecordCount, setTotalRecordCount] = useState(0); - - useEffect(() => { - setNumGroups(data.reduce((output, group) => output + Object.keys(group.dataWithResultsByGraphId).length, 0)); - setTotalRecordCount(data.reduce((output, group) => output + Object.keys(group.recordWithResponseByRefId).length, 0)); - }, [data]); - - return ( -
- {downloadModalData.open && ( - - )} - {/* Summary and load button */} -
-
- - {orgType} - - {selectedOrg.username} -
-
- -
-
- -
- -
- {data.length > 1 && loadFinished && ( - - )} -
-
- -
-
- - -
-
- ); -}; - -export default LoadRecordsMultiObjectResults; diff --git a/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectResultsTable.tsx b/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectResultsTable.tsx deleted file mode 100644 index d3cdb6cd3a..0000000000 --- a/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectResultsTable.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { css } from '@emotion/react'; -import { FunctionComponent } from 'react'; -import { LoadMultiObjectRequestWithResult } from './load-records-multi-object-types'; -import LoadRecordsMultiObjectResultsTableRow from './LoadRecordsMultiObjectResultsTableRow'; - -export interface LoadRecordsMultiObjectResultsTableProps { - data: LoadMultiObjectRequestWithResult[]; - onDownloadResults: (data: LoadMultiObjectRequestWithResult[], which: 'results' | 'failures') => void; -} - -export const LoadRecordsMultiObjectResultsTable: FunctionComponent = ({ - data, - onDownloadResults, -}) => { - return ( -
- - - - - - - - - - - - - - {data.map((row) => ( - - ))} - -
-
- Results -
-
-
- Start Time -
-
-
- End Time -
-
-
- Total Records -
-
-
- Processing Results -
-
-
- Status -
-
-
- ); -}; - -export default LoadRecordsMultiObjectResultsTable; diff --git a/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectResultsTableRow.tsx b/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectResultsTableRow.tsx deleted file mode 100644 index 65960cfb02..0000000000 --- a/libs/features/load-records-multi-object/src/LoadRecordsMultiObjectResultsTableRow.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { formatNumber } from '@jetstream/shared/ui-utils'; -import { Grid, Icon, Spinner } from '@jetstream/ui'; -import classNames from 'classnames'; -import { FunctionComponent, useEffect, useState } from 'react'; -import { LoadMultiObjectRequestWithResult } from './load-records-multi-object-types'; -import LoadRecordsMultiObjectRecordModal from './LoadRecordsMultiObjectRecordModal'; - -const STATUSES = { - NOT_STARTED: 'Not Started', - IN_PROGRESS: 'In-Progress', - FAILED: 'Failed', - FINISHED: 'Finished', -}; - -export interface LoadRecordsMultiObjectResultsTableRowProps { - row: LoadMultiObjectRequestWithResult; - onDownloadResults: (data: LoadMultiObjectRequestWithResult[], which: 'results' | 'failures') => void; -} - -export const LoadRecordsMultiObjectResultsTableRow: FunctionComponent = ({ - row, - onDownloadResults, -}) => { - const { loading, started, finished, errorMessage, results, recordWithResponseByRefId } = row; - - const [{ successCount, failureCount }, setSuccessFailureCount] = useState({ successCount: 0, failureCount: 0 }); - const [startTime, setStartTime] = useState(null); - const [endTime, setEndTime] = useState(null); - const [status, setStatus] = useState(null); - - const totalCount = Object.keys(recordWithResponseByRefId).length; - const total = formatNumber(totalCount); - const finishedSuccessfully = finished && successCount > 0 && failureCount === 0; - const finishedAllFailed = finished && successCount === 0; - const finishedPartialSuccess = finished && successCount > 0 && failureCount > 0; - - useEffect(() => { - if ((!finished && successCount) || failureCount) { - setSuccessFailureCount({ successCount: 0, failureCount: 0 }); - } - }, [finished]); - - useEffect(() => { - if (results) { - const counts = results.reduce( - (output, result) => { - if (result.isSuccessful) { - output.successCount += result.graphResponse.compositeResponse.length; - } else { - output.failureCount += result.graphResponse.compositeResponse.length; - } - return output; - }, - { - successCount: 0, - failureCount: 0, - }, - ); - - // if there is a bonkers failure, then there might only be one returned record - if (finished && counts.successCount + counts.failureCount !== totalCount) { - counts.failureCount = totalCount - counts.successCount; - } - - setSuccessFailureCount(counts); - } - }, [results]); - - useEffect(() => { - if (started) { - setStartTime(started.toLocaleString()); - } else if (startTime) { - setStartTime(null); - } - }, [started, startTime]); - - useEffect(() => { - if (finished) { - setEndTime(finished.toLocaleString()); - } else if (endTime) { - setEndTime(null); - } - }, [finished, endTime]); - - useEffect(() => { - if (!started && !loading) { - setStatus(STATUSES.NOT_STARTED); - } else if (started && finished && !loading && !errorMessage) { - setStatus(STATUSES.FINISHED); - } else if (started && finished && !loading && errorMessage) { - setStatus(STATUSES.FAILED); - } else { - setStatus(STATUSES.IN_PROGRESS); - } - }, [started, finished, loading, errorMessage]); - - return ( - - - {finishedAllFailed && ( - - )} - {finishedSuccessfully && ( - - )} - {finishedPartialSuccess && ( - - )} - - {loading && } - - - {status === STATUSES.FAILED && {errorMessage}} - {status === STATUSES.FINISHED && ( - - {/* All Results */} - {successCount > 0 && ( -
- -
- )} - {/* Failure Results */} - {failureCount > 0 && ( -
- -
- )} -
- )} - - - {startTime && ( -
- {startTime} -
- )} - - - {endTime && ( -
- {endTime} -
- )} - - - -
- {total} -
- -
- - - {status !== STATUSES.NOT_STARTED && ( -
-
- Success: {successCount} -
-
- Failures:{' '} - 0, - 'slds-text-color_success': failureCount === 0, - })} - > - {failureCount} - -
-
- )} - - -
- {status} -
- - - ); -}; - -export default LoadRecordsMultiObjectResultsTableRow; diff --git a/libs/features/load-records-multi-object/src/__tests__/load-records-multi-object-utils.spec.ts b/libs/features/load-records-multi-object/src/__tests__/load-records-multi-object-utils.spec.ts new file mode 100644 index 0000000000..fd859bf5ee --- /dev/null +++ b/libs/features/load-records-multi-object/src/__tests__/load-records-multi-object-utils.spec.ts @@ -0,0 +1,792 @@ +import { describeSObject } from '@jetstream/shared/data'; +import { Field, SalesforceOrgUi } from '@jetstream/types'; +import * as XLSX from 'xlsx'; +import { LoadMultiObjectData, LoadMultiObjectRequestWithResult } from '../load-records-multi-object-types'; +import { + MAX_RECORDS_PER_GROUP, + buildDataGraph, + buildRetryRequests, + parseWorkbook, + splitRequestsToMaxSize, +} from '../load-records-multi-object-utils'; + +vi.mock('@jetstream/shared/data', () => ({ + describeSObject: vi.fn(), +})); + +const describeSObjectMock = vi.mocked(describeSObject); + +const ORG = {} as SalesforceOrgUi; +const API_VERSION = 'v62.0'; +const GRAPH_OPTIONS = { insertNulls: false, dateFormat: 'MM/dd/yyyy' }; + +function mockField(name: string, type = 'string', overrides: Partial = {}): Field { + return { name, type, externalId: false, relationshipName: null, referenceTo: [], ...overrides } as unknown as Field; +} + +const OBJECT_METADATA: Record = { + account: { + name: 'Account', + fields: [ + mockField('Id', 'id'), + mockField('Name'), + mockField('ParentId', 'reference', { referenceTo: ['Account'] }), + mockField('My_Ext_Id__c', 'string', { externalId: true }), + ], + }, + contact: { + name: 'Contact', + fields: [ + mockField('Id', 'id'), + mockField('FirstName'), + mockField('LastName'), + mockField('AccountId', 'reference', { referenceTo: ['Account'] }), + ], + }, +}; + +interface SheetSpec { + sobject: string; + operation: string; + externalId?: string; + headers: string[]; + rows: any[][]; +} + +function buildSheetAoa({ sobject, operation, externalId, headers, rows }: SheetSpec): any[][] { + return [['Object Api Name', sobject], ['Operation', operation], ['External Id (for upsert)', externalId ?? ''], [], headers, ...rows]; +} + +function buildWorkbook(sheets: Record): XLSX.WorkBook { + const workbook = XLSX.utils.book_new(); + Object.entries(sheets).forEach(([sheetName, sheet]) => { + const aoa = Array.isArray(sheet) ? sheet : buildSheetAoa(sheet); + XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(aoa), sheetName); + }); + return workbook; +} + +/** The workbook shape of the downloadable template: parent accounts + contacts referencing them */ +function buildTemplateWorkbook(): XLSX.WorkBook { + return buildWorkbook({ + Instructions: [['Instructions here']], + 'Create Accounts': { + sobject: 'Account', + operation: 'Insert', + headers: ['Reference Id', 'Name', '{ParentId}'], + rows: [ + ['account1', 'Account 1', ''], + ['account2', 'Account 2', ''], + ['account3', 'Account 3', 'account1'], + ], + }, + 'Create Contacts': { + sobject: 'Contact', + operation: 'Insert', + headers: ['Reference Id', 'FirstName', 'LastName', '{AccountId}'], + rows: [ + ['contact1', 'Bob', 'Smith', 'account1'], + ['contact2', 'Janet', 'Smith', 'account1'], + ['contact3', 'Alan', 'Jackson', 'account2'], + ], + }, + }); +} + +async function parseDatasets(workbook: XLSX.WorkBook): Promise { + const { datasets } = await parseWorkbook(workbook, ORG); + return datasets; +} + +function allErrors(datasets: LoadMultiObjectData[]) { + return datasets.flatMap(({ errors }) => errors); +} + +beforeEach(() => { + describeSObjectMock.mockReset(); + describeSObjectMock.mockImplementation((_org, sobject: string) => { + const metadata = OBJECT_METADATA[sobject.toLowerCase()]; + if (!metadata) { + return Promise.reject(new Error('The requested resource does not exist')); + } + return Promise.resolve({ data: metadata } as any); + }); +}); + +describe('parseWorkbook', () => { + it('parses the template shape, skipping the Instructions sheet with no warning', async () => { + const { datasets, workbookErrors } = await parseWorkbook(buildTemplateWorkbook(), ORG); + + expect(workbookErrors).toEqual([]); + expect(datasets).toHaveLength(2); + + const [accounts, contacts] = datasets; + expect(accounts.worksheet).toBe('Create Accounts'); + expect(accounts.sobject).toBe('Account'); + expect(accounts.operation).toBe('INSERT'); + expect(accounts.referenceColumnHeader).toBe('Reference Id'); + expect(accounts.headers).toEqual(['Name', 'ParentId']); + expect(accounts.referenceHeaders).toEqual(new Set(['ParentId'])); + expect(accounts.data).toHaveLength(3); + expect(accounts.errors).toEqual([]); + + expect(contacts.sobject).toBe('Contact'); + expect(contacts.referenceHeaders).toEqual(new Set(['AccountId'])); + expect(contacts.errors).toEqual([]); + }); + + it('reports a missing object name at cell B1', async () => { + const workbook = buildWorkbook({ + Sheet1: [[], ['Operation', 'Insert'], [], [], ['Reference Id', 'Name'], ['rec1', 'Test']], + }); + const datasets = await parseDatasets(workbook); + + const [error] = allErrors(datasets); + expect(error.property).toBe('sobject'); + expect(error.location).toBe('B1'); + expect(error.locationType).toBe('CELL'); + expect(error.message).toContain('Cell B1 must contain the Object API Name'); + }); + + it('reports an invalid operation at cell B2', async () => { + const workbook = buildWorkbook({ + Sheet1: { sobject: 'Account', operation: 'Delete', headers: ['Reference Id', 'Name'], rows: [['rec1', 'Test']] }, + }); + const datasets = await parseDatasets(workbook); + + const [error] = allErrors(datasets); + expect(error.property).toBe('operation'); + expect(error.location).toBe('B2'); + expect(error.message).toContain('The operation is not valid: "DELETE"'); + }); + + it('reports a missing external Id for upsert at cell B3 (not B1)', async () => { + const workbook = buildWorkbook({ + Sheet1: { sobject: 'Account', operation: 'Upsert', headers: ['Reference Id', 'Name'], rows: [['rec1', 'Test']] }, + }); + const datasets = await parseDatasets(workbook); + + const [error] = allErrors(datasets); + expect(error.property).toBe('externalId'); + expect(error.location).toBe('B3'); + expect(error.message).toBe('An external Id is required for upsert.'); + }); + + it('requires the upsert external Id to be a column and flagged as external id in Salesforce', async () => { + const workbook = buildWorkbook({ + Sheet1: { + sobject: 'Account', + operation: 'Upsert', + externalId: 'Name', + headers: ['Reference Id', 'Name'], + rows: [['rec1', 'Test']], + }, + }); + const datasets = await parseDatasets(workbook); + + const [error] = allErrors(datasets); + expect(error.location).toBe('B3'); + expect(error.message).toContain('must exist and must be marked as an external id'); + }); + + it('proper-cases a valid upsert external Id', async () => { + const workbook = buildWorkbook({ + Sheet1: { + sobject: 'Account', + operation: 'Upsert', + externalId: 'my_ext_id__c', + headers: ['Reference Id', 'Name', 'My_Ext_Id__c'], + rows: [['rec1', 'Test', 'ext-1']], + }, + }); + const [dataset] = await parseDatasets(workbook); + + expect(dataset.errors).toEqual([]); + expect(dataset.externalId).toBe('My_Ext_Id__c'); + }); + + it('reports duplicate headers on row 5', async () => { + const workbook = buildWorkbook({ + Sheet1: { sobject: 'Account', operation: 'Insert', headers: ['Reference Id', 'Name', 'Name'], rows: [['rec1', 'a', 'b']] }, + }); + const datasets = await parseDatasets(workbook); + + const duplicateHeaderError = allErrors(datasets).find(({ message }) => message.includes('duplicate values in your header')); + expect(duplicateHeaderError).toBeTruthy(); + expect(duplicateHeaderError?.locationType).toBe('ROW'); + expect(duplicateHeaderError?.location).toBe('5'); + }); + + it('reports duplicate Reference Ids within a sheet with the exact cell and row index', async () => { + const workbook = buildWorkbook({ + Sheet1: { + sobject: 'Account', + operation: 'Insert', + headers: ['Reference Id', 'Name'], + rows: [ + ['rec1', 'a'], + ['rec1', 'b'], + ], + }, + }); + const datasets = await parseDatasets(workbook); + + const duplicateError = allErrors(datasets).find(({ message }) => message.includes('used for multiple records')); + expect(duplicateError?.location).toBe('A7'); + expect(duplicateError?.rowIndexes).toEqual([1]); + }); + + it('reports Reference Ids duplicated across worksheets', async () => { + const workbook = buildWorkbook({ + Sheet1: { sobject: 'Account', operation: 'Insert', headers: ['Reference Id', 'Name'], rows: [['rec1', 'a']] }, + Sheet2: { sobject: 'Contact', operation: 'Insert', headers: ['Reference Id', 'LastName'], rows: [['rec1', 'b']] }, + }); + const datasets = await parseDatasets(workbook); + + const duplicateError = allErrors(datasets).find(({ message }) => message.includes('used for multiple records')); + expect(duplicateError?.worksheet).toBe('Sheet2'); + expect(duplicateError?.rowIndexes).toEqual([0]); + }); + + it('reports each unknown field as its own column error with the header attached', async () => { + const workbook = buildWorkbook({ + Sheet1: { + sobject: 'Account', + operation: 'Insert', + headers: ['Reference Id', 'Name', 'Bogus__c', 'AlsoBogus__c'], + rows: [['rec1', 'a', 'b', 'c']], + }, + }); + const datasets = await parseDatasets(workbook); + + const fieldErrors = allErrors(datasets).filter(({ message }) => message.includes('does not exist on the object')); + expect(fieldErrors).toHaveLength(2); + expect(fieldErrors.map(({ header }) => header)).toEqual(['Bogus__c', 'AlsoBogus__c']); + expect(fieldErrors.every(({ locationType }) => locationType === 'COLUMN')).toBe(true); + }); + + it('reports missing Reference Ids with the offending row indexes', async () => { + const workbook = buildWorkbook({ + Sheet1: { + sobject: 'Account', + operation: 'Insert', + headers: ['Reference Id', 'Name'], + rows: [ + ['rec1', 'a'], + ['', 'b'], + ['rec3', 'c'], + ['', 'd'], + ], + }, + }); + const datasets = await parseDatasets(workbook); + + const missingError = allErrors(datasets).find(({ message }) => message.includes('missing a Reference Id')); + expect(missingError?.rowIndexes).toEqual([1, 3]); + }); + + it('reports invalid Reference Ids with row indexes, and allows single-character Reference Ids', async () => { + const workbook = buildWorkbook({ + Sheet1: { + sobject: 'Account', + operation: 'Insert', + headers: ['Reference Id', 'Name'], + rows: [ + ['a', 'single char is valid'], + ['bad id!', 'invalid'], + ['good_1', 'valid'], + ], + }, + }); + const datasets = await parseDatasets(workbook); + + const invalidError = allErrors(datasets).find(({ message }) => message.includes('invalid characters')); + expect(invalidError?.rowIndexes).toEqual([1]); + expect(invalidError?.message).toContain('"bad id!"'); + }); + + it('normalizes the Reference Id header from cell A5 so surrounding whitespace or braces still line up with the rows', async () => { + for (const referenceHeader of ['Reference Id ', '{Reference Id}']) { + const workbook = buildWorkbook({ + Sheet1: { + sobject: 'Account', + operation: 'Insert', + headers: [referenceHeader, 'Name'], + rows: [['account1', 'Account 1']], + }, + }); + const [dataset] = await parseDatasets(workbook); + + expect(dataset.referenceColumnHeader).toBe('Reference Id'); + expect(dataset.headers).toEqual(['Name']); + expect(Object.keys(dataset.dataById)).toEqual(['account1']); + expect(dataset.errors).toEqual([]); + } + }); + + it('normalizes Reference Id values, so numbers and stray whitespace are not reported as missing or invalid', async () => { + const workbook = buildWorkbook({ + Sheet1: { + sobject: 'Account', + operation: 'Insert', + headers: ['Reference Id', 'Name'], + rows: [ + [0, 'numeric zero is a valid Reference Id'], + [1, 'numeric one'], + ['account1 ', 'trailing whitespace is trimmed, not invalid'], + ], + }, + }); + const [dataset] = await parseDatasets(workbook); + + expect(Object.keys(dataset.dataById)).toEqual(['0', '1', 'account1']); + expect(dataset.errors).toEqual([]); + }); + + it('reports an empty data sheet as a sheet-level error', async () => { + const workbook = buildWorkbook({ + Sheet1: { sobject: 'Account', operation: 'Insert', headers: ['Reference Id', 'Name'], rows: [] }, + }); + const datasets = await parseDatasets(workbook); + + const [error] = allErrors(datasets); + expect(error.locationType).toBe('SHEET'); + expect(error.message).toContain('no data rows'); + }); + + it('errors when the workbook has no data worksheets at all', async () => { + const workbook = buildWorkbook({ Instructions: [['only instructions']] }); + const { datasets, workbookErrors } = await parseWorkbook(workbook, ORG); + + expect(datasets).toEqual([]); + expect(workbookErrors).toHaveLength(1); + expect(workbookErrors[0].message).toContain('No data worksheets were found'); + }); + + it('warns when a non-template sheet is skipped by the instructions name rule', async () => { + const workbook = buildWorkbook({ + 'Account instructions': [['this will be skipped']], + Sheet1: { sobject: 'Account', operation: 'Insert', headers: ['Reference Id', 'Name'], rows: [['rec1', 'a']] }, + }); + const { workbookErrors } = await parseWorkbook(workbook, ORG); + + expect(workbookErrors).toHaveLength(1); + expect(workbookErrors[0].severity).toBe('warning'); + expect(workbookErrors[0].worksheet).toBe('Account instructions'); + }); + + it('reports an unknown object with guidance pointing at cell B1', async () => { + const workbook = buildWorkbook({ + Sheet1: { sobject: 'NotARealObject__c', operation: 'Insert', headers: ['Reference Id', 'Name'], rows: [['rec1', 'a']] }, + }); + const datasets = await parseDatasets(workbook); + + const [error] = allErrors(datasets); + expect(error.location).toBe('B1'); + expect(error.message).toContain('could not be loaded from the org'); + expect(error.message).toContain('notarealobject__c'); + }); +}); + +describe('buildDataGraph', () => { + it('builds one group per connected set of related records, topologically ordered', async () => { + const datasets = await parseDatasets(buildTemplateWorkbook()); + const { requests, errors, groupsByRefId } = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + + expect(errors).toEqual([]); + expect(requests).toHaveLength(1); + + const graphs = requests[0].data; + expect(graphs).toHaveLength(2); + + // account1's cluster: account1 + account3 (ParentId) + contact1 + contact2 + const account1Graph = graphs.find(({ graphId }) => graphId === 'account1'); + const account1Members = account1Graph?.compositeRequest?.map(({ referenceId }) => referenceId) || []; + expect(new Set(account1Members)).toEqual(new Set(['account1', 'account3', 'contact1', 'contact2'])); + // parent must be created before its dependents + expect(account1Members[0]).toBe('account1'); + + const account2Graph = graphs.find(({ graphId }) => graphId === 'account2'); + expect(new Set(account2Graph?.compositeRequest?.map(({ referenceId }) => referenceId))).toEqual(new Set(['account2', 'contact3'])); + + expect(groupsByRefId['contact1']).toEqual({ graphId: 'account1', size: 4 }); + expect(groupsByRefId['contact3']).toEqual({ graphId: 'account2', size: 2 }); + }); + + it('emits the composite-graph reference syntax for dependent fields', async () => { + const datasets = await parseDatasets(buildTemplateWorkbook()); + const { requests } = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + + const contact1 = requests[0].recordWithResponseByRefId['contact1']; + expect(contact1.request.body.AccountId).toBe('@{account1.id}'); + expect(contact1.request.method).toBe('POST'); + expect(contact1.request.url).toBe(`/services/data/${API_VERSION}/sobjects/Contact`); + }); + + it('keeps unrelated records as independent single-record groups', async () => { + const workbook = buildWorkbook({ + Sheet1: { + sobject: 'Account', + operation: 'Insert', + headers: ['Reference Id', 'Name'], + rows: [ + ['a1', 'One'], + ['a2', 'Two'], + ['a3', 'Three'], + ], + }, + }); + const datasets = await parseDatasets(workbook); + const { requests, groupsByRefId } = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + + expect(requests).toHaveLength(1); + expect(requests[0].data).toHaveLength(3); + expect(groupsByRefId['a2']).toEqual({ graphId: 'a2', size: 1 }); + }); + + it('merges groups when a record depends on records from two different clusters (diamond)', async () => { + // give Contact a ReportsToId reference field for this test + describeSObjectMock.mockImplementation((_org, sobject: string) => { + if (sobject.toLowerCase() === 'contact') { + return Promise.resolve({ + data: { + name: 'Contact', + fields: [...OBJECT_METADATA.contact.fields, mockField('ReportsToId', 'reference', { referenceTo: ['Contact'] })], + }, + } as any); + } + return Promise.resolve({ data: OBJECT_METADATA[sobject.toLowerCase()] } as any); + }); + + // contact1 references accountA via the column and contactBoss (in accountB's cluster) via a cell-level + // reference, chaining both clusters into one group + const workbook = buildWorkbook({ + Accounts: { + sobject: 'Account', + operation: 'Insert', + headers: ['Reference Id', 'Name'], + rows: [ + ['accountA', 'A'], + ['accountB', 'B'], + ], + }, + Contacts: { + sobject: 'Contact', + operation: 'Insert', + headers: ['Reference Id', 'LastName', '{AccountId}', 'ReportsToId'], + rows: [ + ['contactBoss', 'Boss', 'accountB', ''], + ['contact1', 'Smith', 'accountA', '{contactBoss}'], + ], + }, + }); + + const datasets = await parseDatasets(workbook); + const { requests, errors } = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + + expect(errors).toEqual([]); + expect(requests[0].data).toHaveLength(1); + expect(requests[0].data[0].compositeRequest).toHaveLength(4); + }); + + it('detects dependencies for reference headers whose casing does not match the field API name', async () => { + const workbook = buildWorkbook({ + Accounts: { sobject: 'Account', operation: 'Insert', headers: ['Reference Id', 'Name'], rows: [['account1', 'A']] }, + Contacts: { + sobject: 'Contact', + operation: 'Insert', + headers: ['Reference Id', 'LastName', '{accountid}'], + rows: [['contact1', 'Smith', 'account1']], + }, + }); + const datasets = await parseDatasets(workbook); + const { requests, errors } = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + + expect(errors).toEqual([]); + expect(requests[0].data).toHaveLength(1); + expect(requests[0].recordWithResponseByRefId['contact1'].request.body.AccountId).toBe('@{account1.id}'); + }); + + it('strips curly braces from cell values inside a reference column', async () => { + const workbook = buildWorkbook({ + Accounts: { + sobject: 'Account', + operation: 'Insert', + headers: ['Reference Id', 'Name', '{ParentId}'], + rows: [ + ['account1', 'Parent', ''], + ['account2', 'Child', '{account1}'], + ], + }, + }); + const datasets = await parseDatasets(workbook); + const { requests, errors } = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + + expect(errors).toEqual([]); + expect(requests[0].recordWithResponseByRefId['account2'].request.body.ParentId).toBe('@{account1.id}'); + }); + + it('resolves numeric Reference Ids - the graph keys nodes by identity, so ids and lookups must both be strings', async () => { + const workbook = buildWorkbook({ + Accounts: { + sobject: 'Account', + operation: 'Insert', + headers: ['Reference Id', 'Name', '{ParentId}'], + rows: [ + [0, 'Grandparent', ''], + [1, 'Parent', 0], + [2, 'Child', 1], + ], + }, + }); + const datasets = await parseDatasets(workbook); + const { requests, errors, groupsByRefId } = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + + expect(errors).toEqual([]); + expect(Object.keys(groupsByRefId)).toEqual(['0', '1', '2']); + expect(requests[0].recordWithResponseByRefId['1'].request.body.ParentId).toBe('@{0.id}'); + expect(requests[0].recordWithResponseByRefId['2'].request.body.ParentId).toBe('@{1.id}'); + }); + + it('reports an unknown Reference Id with the row it came from and does not throw', async () => { + const workbook = buildWorkbook({ + Contacts: { + sobject: 'Contact', + operation: 'Insert', + headers: ['Reference Id', 'LastName', '{AccountId}'], + rows: [ + ['contact1', 'Smith', 'account_typo'], + ['contact2', 'Jones', ''], + ], + }, + }); + const datasets = await parseDatasets(workbook); + const { requests, errors } = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + + expect(requests).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('"account_typo" refers to a Reference Id that does not exist'); + expect(errors[0].rowIndexes).toEqual([0]); + expect(errors[0].location).toBe('6'); + }); + + it('reports circular references as a readable loop instead of throwing', async () => { + const workbook = buildWorkbook({ + Accounts: { + sobject: 'Account', + operation: 'Insert', + headers: ['Reference Id', 'Name', '{ParentId}'], + rows: [ + ['account1', 'A', 'account2'], + ['account2', 'B', 'account1'], + ], + }, + }); + const datasets = await parseDatasets(workbook); + const { requests, errors } = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + + expect(requests).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('link to each other in a loop'); + expect(errors[0].message).toContain('sheet "Accounts"'); + }); + + it('allows a group of exactly 500 records and rejects a group of 501 with a per-group explanation', async () => { + function buildChainWorkbook(childCount: number) { + return buildWorkbook({ + Accounts: { + sobject: 'Account', + operation: 'Insert', + headers: ['Reference Id', 'Name', '{ParentId}'], + rows: [['parent', 'The Parent', ''], ...Array.from({ length: childCount }, (_, i) => [`child_${i}`, `Child ${i}`, 'parent'])], + }, + }); + } + + const okResult = buildDataGraph(await parseDatasets(buildChainWorkbook(MAX_RECORDS_PER_GROUP - 1)), API_VERSION, GRAPH_OPTIONS); + expect(okResult.errors).toEqual([]); + expect(okResult.groupsByRefId['parent'].size).toBe(MAX_RECORDS_PER_GROUP); + + const overResult = buildDataGraph(await parseDatasets(buildChainWorkbook(MAX_RECORDS_PER_GROUP)), API_VERSION, GRAPH_OPTIONS); + expect(overResult.requests).toEqual([]); + expect(overResult.errors).toHaveLength(1); + expect(overResult.errors[0].message).toContain('This limit is per group, not per file'); + // group info is still returned so the UI can show the oversized group + expect(overResult.groupsByRefId['parent'].size).toBe(MAX_RECORDS_PER_GROUP + 1); + }); + + it('is idempotent - running twice on the same datasets produces identical output (no input mutation)', async () => { + const datasets = await parseDatasets(buildTemplateWorkbook()); + + const first = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + const second = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + + expect(second.errors).toEqual([]); + expect(JSON.parse(JSON.stringify(second))).toEqual(JSON.parse(JSON.stringify(first))); + }); + + it('is idempotent for cell-level references in non-reference columns', async () => { + const workbook = buildWorkbook({ + Accounts: { sobject: 'Account', operation: 'Insert', headers: ['Reference Id', 'Name'], rows: [['account1', 'A']] }, + Contacts: { + sobject: 'Contact', + operation: 'Insert', + headers: ['Reference Id', 'LastName', 'AccountId'], + rows: [['contact1', 'Smith', '{account1}']], + }, + }); + const datasets = await parseDatasets(workbook); + + const first = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + const second = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + + expect(first.requests[0].recordWithResponseByRefId['contact1'].request.body.AccountId).toBe('@{account1.id}'); + expect(second.requests[0].recordWithResponseByRefId['contact1'].request.body.AccountId).toBe('@{account1.id}'); + expect(second.requests[0].data).toHaveLength(1); + }); + + it('builds upsert URLs with the external Id in the path and strips it from the body', async () => { + const workbook = buildWorkbook({ + Sheet1: { + sobject: 'Account', + operation: 'Upsert', + externalId: 'My_Ext_Id__c', + headers: ['Reference Id', 'Name', 'My_Ext_Id__c'], + rows: [['rec1', 'Test', 'ext-1']], + }, + }); + const datasets = await parseDatasets(workbook); + const { requests, errors } = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + + expect(errors).toEqual([]); + const { request } = requests[0].recordWithResponseByRefId['rec1']; + expect(request.method).toBe('PATCH'); + expect(request.url).toBe(`/services/data/${API_VERSION}/sobjects/Account/My_Ext_Id__c/ext-1`); + expect(request.body.My_Ext_Id__c).toBeUndefined(); + }); + + it('builds update URLs with the record Id and PATCH', async () => { + const workbook = buildWorkbook({ + Sheet1: { + sobject: 'Account', + operation: 'Update', + headers: ['Reference Id', 'Id', 'Name'], + rows: [['rec1', '001000000000001AAA', 'Updated Name']], + }, + }); + const datasets = await parseDatasets(workbook); + const { requests, errors } = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + + expect(errors).toEqual([]); + const { request } = requests[0].recordWithResponseByRefId['rec1']; + expect(request.method).toBe('PATCH'); + expect(request.url).toBe(`/services/data/${API_VERSION}/sobjects/Account/001000000000001AAA`); + }); + + it('carries worksheet, row index, and graph id onto every record result shell', async () => { + const datasets = await parseDatasets(buildTemplateWorkbook()); + const { requests } = buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS); + + const contact3 = requests[0].recordWithResponseByRefId['contact3']; + expect(contact3.worksheet).toBe('Create Contacts'); + expect(contact3.rowIndex).toBe(2); + expect(contact3.graphId).toBe('account2'); + }); +}); + +describe('splitRequestsToMaxSize', () => { + function singleRecordGraph(id: string, sobject = 'Account'): { graphId: string; compositeRequest: any[] } { + return { + graphId: id, + compositeRequest: [{ method: 'POST', url: `/services/data/v62.0/sobjects/${sobject}`, referenceId: id, body: {} }], + }; + } + + it('caps payloads at 75 graphs', () => { + const graphs = Array.from({ length: 76 }, (_, i) => singleRecordGraph(`g${i}`)); + const payloads = splitRequestsToMaxSize(graphs, 500); + expect(payloads.map((payload) => payload.length)).toEqual([75, 1]); + }); + + it('allows a payload of exactly maxSize records', () => { + const graphs = [ + { + graphId: 'a', + compositeRequest: Array.from({ length: 300 }, (_, i) => ({ url: '/services/data/v62.0/sobjects/Account', referenceId: `a${i}` })), + }, + { + graphId: 'b', + compositeRequest: Array.from({ length: 200 }, (_, i) => ({ url: '/services/data/v62.0/sobjects/Account', referenceId: `b${i}` })), + }, + ] as any[]; + const payloads = splitRequestsToMaxSize(graphs, 500); + expect(payloads).toHaveLength(1); + }); + + it('splits when records exceed maxSize', () => { + const graphs = [ + { + graphId: 'a', + compositeRequest: Array.from({ length: 300 }, (_, i) => ({ url: '/services/data/v62.0/sobjects/Account', referenceId: `a${i}` })), + }, + { + graphId: 'b', + compositeRequest: Array.from({ length: 201 }, (_, i) => ({ url: '/services/data/v62.0/sobjects/Account', referenceId: `b${i}` })), + }, + ] as any[]; + const payloads = splitRequestsToMaxSize(graphs, 500); + expect(payloads.map((payload) => payload.length)).toEqual([1, 1]); + }); + + it('seeds the object count for each new payload (distinct-object cap regression)', () => { + const graphs = Array.from({ length: 30 }, (_, i) => singleRecordGraph(`g${i}`, `Object${i}__c`)); + const payloads = splitRequestsToMaxSize(graphs, 500); + // 14 distinct objects per payload; before the fix the 2nd payload took 15 because its first item's objects were not counted + expect(payloads.map((payload) => payload.length)).toEqual([14, 14, 2]); + }); +}); + +describe('buildRetryRequests', () => { + async function buildLoadedRequests(): Promise { + const datasets = await parseDatasets(buildTemplateWorkbook()); + return buildDataGraph(datasets, API_VERSION, GRAPH_OPTIONS).requests; + } + + it('returns an empty array when nothing failed', async () => { + const requests = await buildLoadedRequests(); + requests.forEach((request) => { + Object.values(request.dataWithResultsByGraphId).forEach((graphResult) => { + graphResult.isSuccess = true; + }); + }); + + expect(buildRetryRequests(requests)).toEqual([]); + }); + + it('rebuilds only the failed groups with record context preserved', async () => { + const requests = await buildLoadedRequests(); + requests[0].dataWithResultsByGraphId['account1'].isSuccess = true; + requests[0].dataWithResultsByGraphId['account2'].isSuccess = false; + + const retryRequests = buildRetryRequests(requests); + + expect(retryRequests).toHaveLength(1); + expect(retryRequests[0].key).toBe('retry-0'); + expect(retryRequests[0].data).toHaveLength(1); + expect(retryRequests[0].data[0].graphId).toBe('account2'); + + const contact3 = retryRequests[0].recordWithResponseByRefId['contact3']; + expect(contact3.worksheet).toBe('Create Contacts'); + expect(contact3.response).toBeNull(); + }); + + it('treats every group in a request that failed at the HTTP level as failed', async () => { + const requests = await buildLoadedRequests(); + requests[0].errorMessage = 'Something went wrong'; + + const retryRequests = buildRetryRequests(requests); + + expect(retryRequests).toHaveLength(1); + expect(retryRequests[0].data).toHaveLength(2); + }); +}); diff --git a/libs/features/load-records-multi-object/src/load-records-multi-object-types.ts b/libs/features/load-records-multi-object/src/load-records-multi-object-types.ts index 1ecdf48564..a14a1810f7 100644 --- a/libs/features/load-records-multi-object/src/load-records-multi-object-types.ts +++ b/libs/features/load-records-multi-object/src/load-records-multi-object-types.ts @@ -10,11 +10,18 @@ import { } from '@jetstream/types'; export interface LoadMultiObjectDataError { - property: Omit; + property: Exclude | null; worksheet: string; - location: string; - locationType: 'CELL' | 'ROW' | 'COLUMN'; + location: string | null; + /** SHEET errors apply to the worksheet as a whole (e.g. B1/B2/B3 configuration or an empty sheet) and have no single cell to point at */ + locationType: 'CELL' | 'ROW' | 'COLUMN' | 'SHEET'; message: string; + /** 0-based indexes into dataset.data for errors that apply to specific rows (e.g. missing or invalid Reference Ids) */ + rowIndexes?: number[]; + /** Header name for errors tied to a specific column */ + header?: string; + /** Warnings do not block the load. Absent = 'error'. */ + severity?: 'error' | 'warning'; } export interface LoadMultiObjectData { @@ -35,6 +42,12 @@ export interface LoadMultiObjectData { errors: LoadMultiObjectDataError[]; } +export interface ParseWorkbookResult { + datasets: LoadMultiObjectData[]; + /** Errors/warnings that apply to the workbook rather than one dataset (e.g. skipped sheets, no data worksheets at all) */ + workbookErrors: LoadMultiObjectDataError[]; +} + export interface LoadMultiObjectRecord { sobject: string; operation: InsertUpdateUpsert; @@ -43,6 +56,7 @@ export interface LoadMultiObjectRecord { recordIdForUpdate?: Maybe; referenceId: string; record: any; + worksheet: string; recordIdx: number; dependsOn: string[]; } @@ -52,6 +66,11 @@ export interface RecordWithResponse { sobject: string; operation: InsertUpdateUpsert; externalId?: Maybe; + worksheet: string; + /** 0-based index of the record within its worksheet's data rows */ + rowIndex: number; + /** Reference Id of the top-level record of the group this record belongs to */ + graphId: string; request: CompositeRequestBody; response: CompositeResponseItem | null; } @@ -77,3 +96,34 @@ export interface LoadMultiObjectRequestWithResult { // Same data as above, just grouped together by record reference id recordWithResponseByRefId: Record; } + +export interface LoadMultiObjectGroupInfo { + /** Reference Id of the top-level record of the group */ + graphId: string; + /** Number of records in the group */ + size: number; +} + +export interface BuildDataGraphResult { + requests: LoadMultiObjectRequestWithResult[]; + errors: LoadMultiObjectDataError[]; + /** Group membership for every record, keyed by Reference Id. Populated whenever groups could be computed, even if a group exceeds the size limit. */ + groupsByRefId: Record; +} + +export interface LoadMultiObjectRun { + runId: number; + type: 'initial' | 'retry'; + requests: LoadMultiObjectRequestWithResult[]; + startedAt: Date | null; + finishedAt: Date | null; + cancelled: boolean; +} + +export interface LoadMultiObjectProgress { + /** 1-based request number currently processing (0 = not started) */ + current: number; + total: number; + recordsProcessed: number; + recordsTotal: number; +} diff --git a/libs/features/load-records-multi-object/src/load-records-multi-object-utils.ts b/libs/features/load-records-multi-object/src/load-records-multi-object-utils.ts index 75031d9380..1fdde7e11f 100644 --- a/libs/features/load-records-multi-object/src/load-records-multi-object-utils.ts +++ b/libs/features/load-records-multi-object/src/load-records-multi-object-utils.ts @@ -3,12 +3,20 @@ import { describeSObject } from '@jetstream/shared/data'; import { formatNumber, initXlsx } from '@jetstream/shared/ui-utils'; import { getErrorMessage, getHttpMethod, groupByFlat, pluralizeFromNumber, transformRecordForDataLoad } from '@jetstream/shared/utils'; import { CompositeGraphRequest, Field, SalesforceOrgUi } from '@jetstream/types'; -import { DepGraph } from 'dependency-graph'; +import { DepGraph, DepGraphCycleError } from 'dependency-graph'; import isNil from 'lodash/isNil'; import isString from 'lodash/isString'; import uniqueId from 'lodash/uniqueId'; import * as XLSX from 'xlsx'; -import { LoadMultiObjectData, LoadMultiObjectRecord, LoadMultiObjectRequestWithResult } from './load-records-multi-object-types'; +import { + BuildDataGraphResult, + LoadMultiObjectData, + LoadMultiObjectDataError, + LoadMultiObjectGroupInfo, + LoadMultiObjectRecord, + LoadMultiObjectRequestWithResult, + ParseWorkbookResult, +} from './load-records-multi-object-types'; initXlsx(XLSX); @@ -21,19 +29,84 @@ const WORKSHEET_LOCATIONS = { dataStartRow: 4, }; -const SURROUNDING_BRACKETS_RGX = /^{|}$/g; -const IS_REFERENCE_HEADER_RGX = new RegExp('^{.+}$'); -const VALID_REF_ID_RGX = /^[0-9A-Za-z][0-9A-Za-z_]+$/; +// Only ever used with .replace() - a global regex is unsafe with .test() because lastIndex persists between calls +const SURROUNDING_BRACKETS_RGX = /^\{|\}$/g; +const IS_REFERENCE_RGX = /^\{.+\}$/; +const VALID_REF_ID_RGX = /^[0-9A-Za-z][0-9A-Za-z_]*$/; const VALID_OPERATIONS = ['INSERT', 'UPDATE', 'UPSERT']; -const MAX_REQ_SIZE = 500; +/** Salesforce limit on records within one composite graph (one atomic group of related records) */ +export const MAX_RECORDS_PER_GROUP = 500; + +/** Minimal record info required to map composite requests/results back to source rows */ +type RecordLookup = Record>; + +function getExcelRow(recordIdx: number) { + return WORKSHEET_LOCATIONS.dataStartRow + 2 + recordIdx; +} + +/** Returns the inner value if wrapped in {curly braces}, otherwise null */ +function getBracketedValue(value: unknown): string | null { + if (isString(value) && IS_REFERENCE_RGX.test(value.trim())) { + return value.trim().replace(SURROUNDING_BRACKETS_RGX, '').trim(); + } + return null; +} /** - * Parses an excel workbook and builds datasets that can be validated and - * @param workbook - * @returns + * Row keys drop the {curly braces} that mark a lookup column, so every header - including the + * Reference Id header read from cell A5 - must go through this to line up with the parsed rows. + */ +function normalizeHeader(header: unknown): string { + const trimmedHeader = isNil(header) ? '' : String(header).trim(); + return IS_REFERENCE_RGX.test(trimmedHeader) ? trimmedHeader.replace(SURROUNDING_BRACKETS_RGX, '').trim() : trimmedHeader; +} + +/** + * Reference Ids are matched as strings across all worksheets, but Excel hands back raw cell values, + * so a Reference Id of `1` arrives as a number while every lookup to it arrives as the string `'1'`. + * Normalize to a trimmed string before using one as a key, a graph node, or a comparison. */ -export async function parseWorkbook(workbook: XLSX.WorkBook, org: SalesforceOrgUi): Promise { - const sheetNames = workbook.SheetNames.filter((sheet) => !sheet.toLowerCase().includes('instructions')); +export function getReferenceId(record: Record | undefined, referenceColumnHeader: string | undefined): string | null { + const value = record?.[referenceColumnHeader || '']; + if (isNil(value)) { + return null; + } + return String(value).trim() || null; +} + +/** + * Parses an excel workbook and builds datasets that can be validated and previewed. + * Datasets are always returned, even when they contain errors, so the UI can render the data with errors annotated. + */ +export async function parseWorkbook(workbook: XLSX.WorkBook, org: SalesforceOrgUi): Promise { + const workbookErrors: LoadMultiObjectDataError[] = []; + const sheetNames = workbook.SheetNames.filter((sheetName) => { + const isSkipped = sheetName.toLowerCase().includes('instructions'); + // The template ships with a tab named exactly "Instructions" - only warn when some other sheet is skipped by the name rule + if (isSkipped && sheetName.trim().toLowerCase() !== 'instructions') { + workbookErrors.push({ + property: null, + worksheet: sheetName, + location: null, + locationType: 'SHEET', + severity: 'warning', + message: `The worksheet "${sheetName}" was skipped because its name contains "instructions".`, + }); + } + return !isSkipped; + }); + + if (sheetNames.length === 0) { + workbookErrors.push({ + property: null, + worksheet: 'Workbook', + location: null, + locationType: 'SHEET', + message: `No data worksheets were found in this file. Add at least one worksheet based on the template. Any worksheet with "instructions" in its name is skipped.`, + }); + return { datasets: [], workbookErrors }; + } + const datasets = sheetNames.reduce((output: LoadMultiObjectData[], sheetName) => { const worksheet = workbook.Sheets[sheetName]; const dataset: Partial = { worksheet: sheetName, errors: [] }; @@ -48,7 +121,7 @@ export async function parseWorkbook(workbook: XLSX.WorkBook, org: SalesforceOrgU worksheet: sheetName, location: WORKSHEET_LOCATIONS.sobject, locationType: 'CELL', - message: `Error getting the object name.`, + message: `Cell B1 must contain the Object API Name (e.g. "Account"). The cell is blank or could not be read.`, }); } try { @@ -61,7 +134,7 @@ export async function parseWorkbook(workbook: XLSX.WorkBook, org: SalesforceOrgU worksheet: sheetName, location: WORKSHEET_LOCATIONS.operation, locationType: 'CELL', - message: `Error getting the operation.`, + message: `Cell B2 must contain the operation: Insert, Update, or Upsert. The cell is blank or could not be read.`, }); } try { @@ -74,9 +147,9 @@ export async function parseWorkbook(workbook: XLSX.WorkBook, org: SalesforceOrgU dataset.errors.push({ property: 'externalId', worksheet: sheetName, - location: WORKSHEET_LOCATIONS.sobject, + location: WORKSHEET_LOCATIONS.externalId, locationType: 'CELL', - message: `Error getting the external Id`, + message: `Cell B3 must contain the API name of an External Id field when the operation is Upsert. The cell is blank or could not be read.`, }); } } @@ -91,7 +164,7 @@ export async function parseWorkbook(workbook: XLSX.WorkBook, org: SalesforceOrgU header: 1, }); - const dataHeaders = data[0].filter(Boolean).map((header) => header.trim()); + const dataHeaders = (data[0] || []).filter(Boolean).map((header) => header.trim()); if (dataHeaders.length !== new Set(dataHeaders).size) { dataset.errors = dataset.errors || []; @@ -110,38 +183,45 @@ export async function parseWorkbook(workbook: XLSX.WorkBook, org: SalesforceOrgU return currRow; } - const currHeader = SURROUNDING_BRACKETS_RGX.test(dataHeaders[i]) - ? dataHeaders[i].replace(SURROUNDING_BRACKETS_RGX, '').trim() - : dataHeaders[i]; - currRow[currHeader] = cell ?? null; + currRow[normalizeHeader(dataHeaders[i])] = cell ?? null; return currRow; }, {}), ); // init remaining data - dataset.referenceColumnHeader = worksheet[WORKSHEET_LOCATIONS.referenceId]?.v; + dataset.referenceColumnHeader = normalizeHeader(worksheet[WORKSHEET_LOCATIONS.referenceId]?.v); const headers = dataHeaders.filter( - (field) => !!field && !field.toLowerCase().startsWith('__empty') && field !== dataset.referenceColumnHeader, - ); - dataset.headers = headers.map((header) => header.replace(SURROUNDING_BRACKETS_RGX, '').trim()); - dataset.referenceHeaders = new Set( - headers.filter((header) => IS_REFERENCE_HEADER_RGX.test(header)).map((header) => header.replace(SURROUNDING_BRACKETS_RGX, '')), + (field) => !!field && !field.toLowerCase().startsWith('__empty') && normalizeHeader(field) !== dataset.referenceColumnHeader, ); + dataset.headers = headers.map(normalizeHeader); + dataset.referenceHeaders = new Set(headers.filter((header) => IS_REFERENCE_RGX.test(header.trim())).map(normalizeHeader)); dataset.dataById = dataset.data.reduce((output: Record, row, i) => { - const referenceId = row[dataset.referenceColumnHeader || ''] || uniqueId('reference_'); + const referenceId = getReferenceId(row, dataset.referenceColumnHeader) || uniqueId('reference_'); if (output[referenceId]) { dataset.errors = dataset.errors || []; dataset.errors.push({ property: 'data', worksheet: sheetName, - location: `A${WORKSHEET_LOCATIONS.dataStartRow + 2 + i}`, + location: `A${getExcelRow(i)}`, locationType: 'CELL', + rowIndexes: [i], message: `The Reference Id "${referenceId}" is used for multiple records. Every record across all worksheets must have a unique Reference Id.`, }); } output[referenceId] = row; return output; }, {}); + + if (dataset.data.length === 0) { + dataset.errors = dataset.errors || []; + dataset.errors.push({ + property: 'data', + worksheet: sheetName, + location: null, + locationType: 'SHEET', + message: `This worksheet has no data rows. Add records starting on row 6, or remove the worksheet.`, + }); + } } catch (ex) { logger.warn('Error parsing record data', ex); dataset.errors = dataset.errors || []; @@ -150,16 +230,22 @@ export async function parseWorkbook(workbook: XLSX.WorkBook, org: SalesforceOrgU worksheet: sheetName, location: WORKSHEET_LOCATIONS.dataStartCell, locationType: 'CELL', - message: `Error getting the record data.`, + message: `Jetstream could not read the data on this worksheet. Check that headers are on row 5, data starts on row 6, and the sheet matches the template layout.`, }); } + // Ensure the dataset is always renderable by the preview UI, even when parsing partially failed + dataset.data = dataset.data || []; + dataset.dataById = dataset.dataById || {}; + dataset.headers = dataset.headers || []; + dataset.referenceHeaders = dataset.referenceHeaders || new Set(); + return [...output, dataset as LoadMultiObjectData]; }, []); await validateObjectData(org, datasets); - return datasets; + return { datasets, workbookErrors }; } /** @@ -229,7 +315,9 @@ async function validateObjectData(org: SalesforceOrgUi, datasets: LoadMultiObjec worksheet, location: WORKSHEET_LOCATIONS.sobject, locationType: 'CELL', - message: `${getErrorMessage(ex)} - "${dataset.sobject}".`, + message: `The object "${dataset.sobject}" could not be loaded from the org. Check the API name in cell B1 and that you have access to this object. (Salesforce: ${getErrorMessage( + ex, + )})`, }); } } @@ -296,47 +384,59 @@ async function validateObjectData(org: SalesforceOrgUi, datasets: LoadMultiObjec } const missingFields = headers.filter((header) => !dataset.fieldsByName?.[header.toLowerCase()]); - if (missingFields.length) { + missingFields.forEach((header) => { errors.push({ property: 'data', worksheet, - location: `${WORKSHEET_LOCATIONS.dataStartRow + 1}`, - locationType: 'ROW', - message: `The following fields do not exist on the object "${ - dataset.sobject - }" or you do not have permissions configured correctly: "${missingFields.join('", "')}".`, - }); - } - - const missingRefIds = dataset.data.filter((row) => !row[dataset.referenceColumnHeader]); - if (missingRefIds.length) { - errors.push({ - property: 'data', - worksheet, - location: 'A', + location: header, locationType: 'COLUMN', - message: `${formatNumber(missingRefIds.length)} ${pluralizeFromNumber('row', missingRefIds.length)} ${ - missingRefIds.length === 1 ? 'is' : 'are' - } missing a Reference Id. Make sure you do not have any accidental data in your spreadsheet and clear the contents of any unused rows/columns in your spreadsheet.`, + header, + message: `The field "${header}" does not exist on the object "${dataset.sobject}" or you do not have permission to access it.`, }); - } + }); - const invalidRefIds = dataset.data.filter( - (row) => !!row[dataset.referenceColumnHeader] && !VALID_REF_ID_RGX.test(row[dataset.referenceColumnHeader]), - ); - if (invalidRefIds.length) { - errors.push({ - property: 'data', - worksheet, - location: 'A', - locationType: 'COLUMN', - message: `The following Reference ${pluralizeFromNumber('Id', invalidRefIds.length)} have invalid characters: ${invalidRefIds - .slice(0, 25) - .map((row) => `"${row[dataset.referenceColumnHeader]}"`) - .join( - ', ', - )}. The referenceId must start with a letter or a number and must not contain anything besides letters, numbers, or underscores.`, - }); + if (referenceColumnHeader) { + const missingRefIdRowIndexes = dataset.data.reduce((output: number[], row, i) => { + if (!getReferenceId(row, referenceColumnHeader)) { + output.push(i); + } + return output; + }, []); + if (missingRefIdRowIndexes.length) { + errors.push({ + property: 'data', + worksheet, + location: 'A', + locationType: 'COLUMN', + header: referenceColumnHeader, + rowIndexes: missingRefIdRowIndexes, + message: `${formatNumber(missingRefIdRowIndexes.length)} ${pluralizeFromNumber('row', missingRefIdRowIndexes.length)} ${ + missingRefIdRowIndexes.length === 1 ? 'is' : 'are' + } missing a Reference Id. Make sure you do not have any accidental data in your spreadsheet and clear the contents of any unused rows/columns in your spreadsheet.`, + }); + } + + const invalidRefIds = dataset.data.reduce((output: { rowIndex: number; referenceId: string }[], row, i) => { + const referenceId = getReferenceId(row, referenceColumnHeader); + if (referenceId && !VALID_REF_ID_RGX.test(referenceId)) { + output.push({ rowIndex: i, referenceId }); + } + return output; + }, []); + if (invalidRefIds.length) { + errors.push({ + property: 'data', + worksheet, + location: 'A', + locationType: 'COLUMN', + header: referenceColumnHeader, + rowIndexes: invalidRefIds.map(({ rowIndex }) => rowIndex), + message: `The following Reference ${pluralizeFromNumber('Id', invalidRefIds.length)} have invalid characters: ${invalidRefIds + .slice(0, 25) + .map(({ referenceId }) => `"${referenceId}"`) + .join(', ')}. A Reference Id must start with a letter or number and may only contain letters, numbers, and underscores.`, + }); + } } } @@ -346,8 +446,9 @@ async function validateObjectData(org: SalesforceOrgUi, datasets: LoadMultiObjec errors.push({ property: 'data', worksheet, - location: `A${WORKSHEET_LOCATIONS.dataStartRow + 2 + i}`, + location: `A${getExcelRow(i)}`, locationType: 'CELL', + rowIndexes: [i], message: `The Reference Id "${referenceId}" is used for multiple records. Every record across all worksheets must have a unique Reference Id.`, }); } @@ -367,24 +468,31 @@ async function validateObjectData(org: SalesforceOrgUi, datasets: LoadMultiObjec } /** - * Build a collection of graphs based on the dataset + * Build a collection of graphs based on the dataset. + * + * Pure function: never mutates the provided datasets and never throws for data problems. + * All problems are collected and returned in `errors`. * * @param datasets * @param apiVersion * @returns */ -export function getDataGraph( +export function buildDataGraph( datasets: LoadMultiObjectData[], apiVersion: string, options: { insertNulls: boolean; dateFormat: string }, -): LoadMultiObjectRequestWithResult[] { +): BuildDataGraphResult { const { dateFormat, insertNulls } = options; + const errors: LoadMultiObjectDataError[] = []; const graphs: Record = {}; /** If there is an error during dependency processing, this links back to the dataset for error identification */ const refIdToDataset = datasets.reduce((output: Record, dataset) => { dataset.data.forEach((record) => { - output[record[dataset.referenceColumnHeader]] = dataset; + const referenceId = getReferenceId(record, dataset.referenceColumnHeader); + if (referenceId) { + output[referenceId] = dataset; + } }); return output; }, {}); @@ -410,30 +518,30 @@ export function getDataGraph( ) => { const isRelatedField = header.includes('.'); const field = dataset.fieldsByName[header.toLowerCase()]; - let value = record[header]; - const valueIsNull = isNil(value) || (isString(value) && !value); - let isDependentField = false; - if (dataset.referenceHeaders.has(header)) { - isDependentField = true; - } else if (SURROUNDING_BRACKETS_RGX.test(record[header])) { - record[header] = record[header].replace(SURROUNDING_BRACKETS_RGX, '').trim(); - isDependentField = true; + if (!field) { + // Missing fields are reported during validation - skip here so graph building never throws + return { transformedRecord, externalIdValue, recordIdForUpdate, dependencies }; } - - if (isDependentField) { - if (record[field.name]) { + const rawValue = record[header]; + const valueIsNull = isNil(rawValue) || (isString(rawValue) && !rawValue); + const bracketedValue = getBracketedValue(rawValue); + + if (dataset.referenceHeaders.has(header) || bracketedValue !== null) { + // Lookup to another record in this load - either the whole column is a reference column, or this one cell is {wrapped} + const referenceValue = bracketedValue ?? (valueIsNull ? null : String(rawValue).trim()); + if (referenceValue) { // do not transform dependent field using transformRecordForDataLoad because we know it is a lookup - transformedRecord[field.name] = `@{${record[field.name]}.id}`; - dependencies.push(record[field.name]); + transformedRecord[field.name] = `@{${referenceValue}.id}`; + dependencies.push(referenceValue); } else if (insertNulls) { transformedRecord[field.name] = null; } } else if (dataset.operation === 'UPSERT' && !isRelatedField && lowercaseExternalId === field.name.toLowerCase()) { // External id used for upsert, this needs to be omitted from the record as the value is included in the URL - externalIdValue = value; + externalIdValue = rawValue; } else if (insertNulls || !valueIsNull) { - value = transformRecordForDataLoad(record[header], field.type, dateFormat); - if (header.includes('.')) { + const value = transformRecordForDataLoad(rawValue, field.type, dateFormat); + if (isRelatedField) { const [relationshipName] = header.toLowerCase().split('.'); const relationshipField = dataset.fieldsByRelationshipName?.[relationshipName]; if (relationshipField?.relationshipName && relationshipField.referenceTo?.length) { @@ -463,8 +571,10 @@ export function getDataGraph( externalId: dataset.externalId, externalIdValue, recordIdForUpdate, - referenceId: record[dataset.referenceColumnHeader], + // must match the stringified dependency values below - the graph keys nodes by identity, not by coercion + referenceId: getReferenceId(record, dataset.referenceColumnHeader) ?? '', record: transformedRecord, + worksheet: dataset.worksheet, recordIdx, dependsOn: dependencies, }; @@ -475,7 +585,6 @@ export function getDataGraph( }, {}); const overallGraph = new DepGraph(); - let hasError = false; Object.values(recordsByRefId).forEach((value) => { overallGraph.addNode(value.referenceId); @@ -485,25 +594,54 @@ export function getDataGraph( value.dependsOn.forEach((dependency) => { try { overallGraph.addDependency(value.referenceId, dependency); - } catch (ex) { - hasError = true; + } catch { const dataset = refIdToDataset[value.referenceId]; - dataset.errors.push({ + errors.push({ property: 'data', - worksheet: dataset.worksheet, - location: `${WORKSHEET_LOCATIONS.dataStartRow + 1 + value.recordIdx + 1}`, + worksheet: dataset?.worksheet ?? value.worksheet, + location: `${getExcelRow(value.recordIdx)}`, locationType: 'ROW', - message: `The Reference Id "${dependency}" is invalid, there is not a row in your file that has this Reference Id.`, + rowIndexes: [value.recordIdx], + message: `The value "${dependency}" refers to a Reference Id that does not exist on any worksheet. Check for typos, or remove the curly braces if this should be a literal value.`, }); } }); }); - if (hasError) { - throw new Error('There was an error parsing the record dependencies'); + if (errors.length) { + return { requests: [], errors, groupsByRefId: {} }; + } + + let topLevelNodes: string[]; + try { + topLevelNodes = overallGraph.overallOrder(true); + } catch (ex) { + if (ex instanceof DepGraphCycleError) { + const { cyclePath } = ex; + const cycleDescription = cyclePath + .map((refId, i) => { + const cycleRecord = recordsByRefId[refId]; + // the last entry repeats the first - render it bare to close the loop visually + if (!cycleRecord || (i === cyclePath.length - 1 && refId === cyclePath[0])) { + return refId; + } + return `${refId} (sheet "${cycleRecord.worksheet}", row ${getExcelRow(cycleRecord.recordIdx)})`; + }) + .join(' → '); + const firstRecord = recordsByRefId[cyclePath[0]]; + errors.push({ + property: 'data', + worksheet: firstRecord?.worksheet ?? 'Unknown', + location: firstRecord ? `${getExcelRow(firstRecord.recordIdx)}` : null, + locationType: 'ROW', + rowIndexes: firstRecord ? [firstRecord.recordIdx] : undefined, + message: `These records link to each other in a loop, so there is no valid order to load them: ${cycleDescription}. Remove one of the {reference} values to break the loop.`, + }); + return { requests: [], errors, groupsByRefId: {} }; + } + throw ex; } - const topLevelNodes = overallGraph.overallOrder(true); const unprocessedTopLevelNodes = new Set(topLevelNodes); const nodeToTopLevelNodes: Record = {}; @@ -549,23 +687,76 @@ export function getDataGraph( }; }); - if ((graphs[topLevelNode]?.compositeRequest?.length || 0) >= MAX_REQ_SIZE) { + const graphSize = graphs[topLevelNode]?.compositeRequest?.length || 0; + if (graphSize > MAX_RECORDS_PER_GROUP) { const dataset = refIdToDataset[topLevelNode]; const record = recordsByRefId[topLevelNode]; - dataset.errors.push({ + errors.push({ property: 'data', - worksheet: dataset.worksheet, - location: `${WORKSHEET_LOCATIONS.dataStartRow + 1 + record.recordIdx + 1}`, + worksheet: dataset?.worksheet ?? record.worksheet, + location: `${getExcelRow(record.recordIdx)}`, locationType: 'ROW', - message: `The Reference Id "${topLevelNode}" has ${formatNumber( - graphs[topLevelNode]?.compositeRequest?.length || 0, - )} dependent records. A maximum of ${MAX_REQ_SIZE} records can be related to each other in one data load`, + rowIndexes: [record.recordIdx], + message: `The record with Reference Id "${topLevelNode}" is connected to ${formatNumber( + graphSize, + )} related records. Rows linked by {Reference Id} values form one group, loaded as a single all-or-nothing transaction limited to ${MAX_RECORDS_PER_GROUP} records. This limit is per group, not per file. To fix: remove some {reference} links or split this group across multiple loads.`, }); - throw new Error('There was an error parsing the record dependencies'); } }); - return transformGraphRequestsToRequestWithResults(Object.values(graphs), recordsByRefId); + const groupsByRefId: Record = {}; + Object.values(graphs).forEach(({ graphId, compositeRequest }) => { + const size = compositeRequest?.length || 0; + compositeRequest?.forEach(({ referenceId }) => { + groupsByRefId[referenceId] = { graphId, size }; + }); + }); + + if (errors.length) { + // groupsByRefId is still returned so the UI can show group membership/sizes alongside the errors + return { requests: [], errors, groupsByRefId }; + } + + return { requests: transformGraphRequestsToRequestWithResults(Object.values(graphs), recordsByRefId), errors: [], groupsByRefId }; +} + +/** + * Builds a fresh set of requests containing only the groups whose LATEST attempt failed, + * re-packed into new HTTP payloads. Pass requests in run order (initial load first, then retries) + * so a group fixed by a later retry is not retried again. Returns an empty array when nothing failed. + */ +export function buildRetryRequests(requests: LoadMultiObjectRequestWithResult[]): LoadMultiObjectRequestWithResult[] { + const latestByGraphId: Record = {}; + const recordLookup: RecordLookup = {}; + + requests.forEach((request) => { + Object.values(request.dataWithResultsByGraphId).forEach(({ graphId, isSuccess, compositeRequest }) => { + // errorMessage means the entire HTTP request failed, so every graph in it failed + const isFailed = request.errorMessage ? true : isSuccess === false; + latestByGraphId[graphId] = { isFailed, graph: { graphId, compositeRequest } }; + compositeRequest.forEach(({ referenceId }) => { + const recordInfo = request.recordWithResponseByRefId[referenceId]; + if (recordInfo) { + recordLookup[referenceId] = { + sobject: recordInfo.sobject, + operation: recordInfo.operation, + externalId: recordInfo.externalId, + worksheet: recordInfo.worksheet, + recordIdx: recordInfo.rowIndex, + }; + } + }); + }); + }); + + const failedGraphs = Object.values(latestByGraphId) + .filter(({ isFailed }) => isFailed) + .map(({ graph }) => graph); + + if (!failedGraphs.length) { + return []; + } + return transformGraphRequestsToRequestWithResults(failedGraphs, recordLookup, 'retry'); } function processGraphDependency( @@ -587,7 +778,6 @@ function processGraphDependency( // Pull in related graph and process all nodes in that graph graph.addNode(dependency); nodeToTopLevelNodes[dependency].forEach((relatedTopLevelNode) => { - // unprocessedTopLevelNodes.delete(relatedTopLevelNode); if (unprocessedTopLevelNodes.has(relatedTopLevelNode)) { unprocessedTopLevelNodes.delete(relatedTopLevelNode); graph.addNode(relatedTopLevelNode); @@ -604,10 +794,11 @@ function processGraphDependency( function transformGraphRequestsToRequestWithResults( graphs: CompositeGraphRequest[], - recordsByRefId: Record, + recordsByRefId: RecordLookup, + keyPrefix = 'request', ): LoadMultiObjectRequestWithResult[] { - return splitRequestsToMaxSize(graphs, MAX_REQ_SIZE).map((compositeRequestGraph, i): LoadMultiObjectRequestWithResult => ({ - key: `request-${i}`, + return splitRequestsToMaxSize(graphs, MAX_RECORDS_PER_GROUP).map((compositeRequestGraph, i): LoadMultiObjectRequestWithResult => ({ + key: `${keyPrefix}-${i}`, loading: false, started: null, finished: null, @@ -624,12 +815,15 @@ function transformGraphRequestsToRequestWithResults( ), recordWithResponseByRefId: groupByFlat( compositeRequestGraph.flatMap( - ({ compositeRequest }) => + ({ compositeRequest, graphId }) => compositeRequest?.map((item) => ({ referenceId: item.referenceId, sobject: recordsByRefId[item.referenceId].sobject, operation: recordsByRefId[item.referenceId].operation, externalId: recordsByRefId[item.referenceId].externalId, + worksheet: recordsByRefId[item.referenceId].worksheet, + rowIndex: recordsByRefId[item.referenceId].recordIdx, + graphId, request: item, response: null, })) || [], @@ -639,7 +833,7 @@ function transformGraphRequestsToRequestWithResults( })); } -function splitRequestsToMaxSize(items: CompositeGraphRequest[], maxSize: number): CompositeGraphRequest[][] { +export function splitRequestsToMaxSize(items: CompositeGraphRequest[], maxSize: number): CompositeGraphRequest[][] { if (!maxSize || maxSize < 1) { throw new Error('maxSize must be greater than 0'); } @@ -662,14 +856,15 @@ function splitRequestsToMaxSize(items: CompositeGraphRequest[], maxSize: number) * 14 nodes per payload (node is determined by sobject endpoint + api version) * -> if there are more than 15 nodes in first dataset, then ignore this restriction (but it will likely fail to load) */ - if (!currSet.length || (currSet.length < 75 && currCount + item.compositeRequest.length < maxSize && currPayloadNodes.size < 15)) { + if (!currSet.length || (currSet.length < 75 && currCount + item.compositeRequest.length <= maxSize && currPayloadNodes.size < 15)) { currSet.push(item); currCount += item.compositeRequest.length; } else { output.push(currSet); currSet = [item]; currCount = item.compositeRequest.length; - currPayloadNodes = new Set(); + // seed with the current item's nodes - a plain new Set() would under-count objects for every payload after the first + currPayloadNodes = new Set(item.compositeRequest.map((req) => req.url.split('/')[5])); } }); if (currSet.length > 0) { diff --git a/libs/features/load-records-multi-object/src/load-records-multi-object.state.ts b/libs/features/load-records-multi-object/src/load-records-multi-object.state.ts new file mode 100644 index 0000000000..7bdec8d19a --- /dev/null +++ b/libs/features/load-records-multi-object/src/load-records-multi-object.state.ts @@ -0,0 +1,77 @@ +import { DATE_FORMATS } from '@jetstream/shared/constants'; +import { LocalOrGoogle, Maybe } from '@jetstream/types'; +import { atom } from 'jotai'; +import { RESET, atomWithReset } from 'jotai/utils'; +import { + LoadMultiObjectData, + LoadMultiObjectDataError, + LoadMultiObjectGroupInfo, + LoadMultiObjectProgress, + LoadMultiObjectRequestWithResult, + LoadMultiObjectRun, +} from './load-records-multi-object-types'; + +/** + * State lives in atoms (not component state) so an accidental navigation away from the page + * does not silently discard a parsed file or a finished load - parity with the single-object load. + */ + +export const inputFilenameState = atomWithReset>(null); + +export const inputFileTypeState = atomWithReset>(null); + +export const fileParsingState = atomWithReset(false); + +/** Parsed worksheets - always populated after parse, even when they contain errors, so the preview can render */ +export const datasetsState = atomWithReset(null); + +/** Blocking errors found while parsing/validating the workbook (workbook-level + per-sheet) */ +export const parseErrorsState = atomWithReset([]); + +/** Non-blocking warnings (e.g. sheets skipped by the "instructions" name rule) */ +export const parseWarningsState = atomWithReset([]); + +/** Errors found while building the dependency graph (unknown references, cycles, oversized groups) */ +export const graphErrorsState = atomWithReset([]); + +/** The composite graph requests, present only when the file is fully valid */ +export const requestsState = atomWithReset(null); + +/** Group membership for every record, present whenever groups could be computed (even with errors) */ +export const groupsByRefIdState = atomWithReset>({}); + +export const dateFormatState = atomWithReset(DATE_FORMATS.MM_DD_YYYY); + +export const insertNullsState = atomWithReset(false); + +export const currentStepIdxState = atomWithReset(0); + +export const loadRunsState = atomWithReset([]); + +export const loadIsRunningState = atomWithReset(false); + +export const loadProgressState = atomWithReset(null); + +export const allBlockingErrorsState = atom((get) => [...get(parseErrorsState), ...get(graphErrorsState)]); + +export const isReadyToLoadState = atom((get) => { + const requests = get(requestsState); + return !!requests?.length && get(allBlockingErrorsState).length === 0; +}); + +export const resetLoadRecordsMultiObjectState = atom(null, (_get, set) => { + set(inputFilenameState, RESET); + set(inputFileTypeState, RESET); + set(fileParsingState, RESET); + set(datasetsState, RESET); + set(parseErrorsState, RESET); + set(parseWarningsState, RESET); + set(graphErrorsState, RESET); + set(requestsState, RESET); + set(groupsByRefIdState, RESET); + set(currentStepIdxState, RESET); + set(loadRunsState, RESET); + set(loadIsRunningState, RESET); + set(loadProgressState, RESET); + // dateFormat/insertNulls are user preferences within the session - intentionally kept across Start Over +}); diff --git a/libs/features/load-records-multi-object/src/load/LoadRecordsMultiObjectLoad.tsx b/libs/features/load-records-multi-object/src/load/LoadRecordsMultiObjectLoad.tsx new file mode 100644 index 0000000000..92a41a8555 --- /dev/null +++ b/libs/features/load-records-multi-object/src/load/LoadRecordsMultiObjectLoad.tsx @@ -0,0 +1,241 @@ +import { css } from '@emotion/react'; +import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; +import { formatNumber } from '@jetstream/shared/ui-utils'; +import { pluralizeFromNumber } from '@jetstream/shared/utils'; +import { FileExtAllTypes, Maybe, SalesforceOrgUi, SalesforceOrgUiType } from '@jetstream/types'; +import { Badge, ConfirmationModalPromise, DropDown, FileDownloadModal, Grid, Icon } from '@jetstream/ui'; +import { ConfirmPageChange, fromJetstreamEvents, useAmplitude } from '@jetstream/ui-core'; +import { fromAppState, googleDriveAccessState } from '@jetstream/ui/app-state'; +import { useAtomValue } from 'jotai'; +import { FunctionComponent, useMemo } from 'react'; +import { LoadMultiObjectRun } from '../load-records-multi-object-types'; +import { buildRetryRequests } from '../load-records-multi-object-utils'; +import { groupsByRefIdState, loadProgressState, requestsState } from '../load-records-multi-object.state'; +import { getGroupNumbersByGraphId } from '../review/review-utils'; +import useLoadFile from '../useLoadFile'; +import LoadRecordsMultiObjectResultsTables from './LoadRecordsMultiObjectResultsTables'; +import { buildRecordResultRows, getLoadResultsSummary } from './load-results-utils'; +import useDownloadResults from './useDownloadResults'; + +export interface LoadRecordsMultiObjectLoadProps { + selectedOrg: SalesforceOrgUi; + orgType: Maybe; + serverUrl: string; + apiVersion: string; +} + +/** Step 2: confirm the target org, run the load with live progress, then review/retry per-record results */ +export const LoadRecordsMultiObjectLoad: FunctionComponent = ({ + selectedOrg, + orgType, + serverUrl, + apiVersion, +}) => { + const { trackEvent } = useAmplitude(); + const { google_apiKey, google_appId, google_clientId } = useAtomValue(fromAppState.applicationCookieState); + const { hasGoogleDriveAccess, googleShowUpgradeToPro } = useAtomValue(googleDriveAccessState); + const { downloadRequests, downloadResults, handleCloseDownloadModal, downloadModalData } = useDownloadResults(); + + const requests = useAtomValue(requestsState); + const groupsByRefId = useAtomValue(groupsByRefIdState); + const progress = useAtomValue(loadProgressState); + const { loadFile, retryFailed, cancel, loading, runs } = useLoadFile(selectedOrg, serverUrl, apiVersion); + + const groupNumbersByGraphId = useMemo(() => getGroupNumbersByGraphId(groupsByRefId), [groupsByRefId]); + // Before the first run, show every record as pending so the user can review exactly what will be loaded + const runsToDisplay = useMemo((): LoadMultiObjectRun[] => { + if (runs.length || !requests?.length) { + return runs; + } + return [{ runId: -1, type: 'initial', requests, startedAt: null, finishedAt: null, cancelled: false }]; + }, [runs, requests]); + const resultRows = useMemo(() => buildRecordResultRows(runsToDisplay, groupNumbersByGraphId), [runsToDisplay, groupNumbersByGraphId]); + const { successCount, failureCount, pendingCount } = useMemo(() => getLoadResultsSummary(resultRows), [resultRows]); + + const totalRecordCount = useMemo( + () => (requests || []).reduce((count, request) => count + Object.keys(request.recordWithResponseByRefId).length, 0), + [requests], + ); + const totalGroupCount = useMemo( + () => (requests || []).reduce((count, request) => count + Object.keys(request.dataWithResultsByGraphId).length, 0), + [requests], + ); + + const loadFinished = runs.length > 0 && !loading; + const failedGroupRequests = useMemo( + () => (loadFinished ? buildRetryRequests(runs.flatMap(({ requests: runRequests }) => runRequests)) : []), + [loadFinished, runs], + ); + const failedGroupCount = useMemo( + () => failedGroupRequests.reduce((count, request) => count + Object.keys(request.dataWithResultsByGraphId).length, 0), + [failedGroupRequests], + ); + + async function handleLoadStarted() { + if (!requests?.length) { + return; + } + if ( + runs.length === 0 || + (await ConfirmationModalPromise({ + content: 'This file has already been loaded, are you sure you want to load it again?', + })) + ) { + trackEvent(ANALYTICS_KEYS.load_multi_obj_Submitted, { + records: totalRecordCount, + groups: totalGroupCount, + requests: requests.length, + isReload: runs.length > 0, + }); + loadFile(requests); + } + } + + function handleRetryFailed() { + trackEvent(ANALYTICS_KEYS.load_multi_obj_RetryFailed, { groups: failedGroupCount }); + retryFailed(failedGroupRequests); + } + + function handleCancel() { + trackEvent(ANALYTICS_KEYS.load_multi_obj_Cancelled, { progress }); + cancel(); + } + + function handleDownloadResults(which: 'results' | 'failures') { + trackEvent(ANALYTICS_KEYS.load_multi_obj_DownloadResults, { which }); + downloadResults(resultRows, which); + } + + const progressPercent = progress && progress.recordsTotal ? Math.round((progress.recordsProcessed / progress.recordsTotal) * 100) : 0; + + return ( +
+ + {downloadModalData.open && ( + + )} + + +
+ + {orgType} + + {selectedOrg.username} +
+ requests && downloadRequests(requests)} + /> +
+ +
+ {!loading && ( + + )} + {loading && ( + + )} + {loadFinished && failedGroupCount > 0 && ( + + )} + {loadFinished && ( + + + {failureCount > 0 && ( + + )} + + )} +
+ + {(loading || runs.length > 0) && ( +
+ {loading && progress && ( +
+
+ Request {formatNumber(Math.max(progress.current, 1))} of {formatNumber(progress.total)} ·{' '} + {formatNumber(progress.recordsProcessed)} of {formatNumber(progress.recordsTotal)} records +
+
+ +
+
+ )} + {runs.length > 0 && ( +
+ + + {formatNumber(successCount)} success + + + + {formatNumber(failureCount)} {pluralizeFromNumber('failure', failureCount)} + + {pendingCount > 0 && {formatNumber(pendingCount)} not processed} +
+ )} +
+ )} + + 1} + isRunning={loading} + selectedOrg={selectedOrg} + serverUrl={serverUrl} + /> +
+ ); +}; + +export default LoadRecordsMultiObjectLoad; diff --git a/libs/features/load-records-multi-object/src/load/LoadRecordsMultiObjectResultsTables.tsx b/libs/features/load-records-multi-object/src/load/LoadRecordsMultiObjectResultsTables.tsx new file mode 100644 index 0000000000..a2ad9d7d52 --- /dev/null +++ b/libs/features/load-records-multi-object/src/load/LoadRecordsMultiObjectResultsTables.tsx @@ -0,0 +1,207 @@ +import { css } from '@emotion/react'; +import { formatNumber } from '@jetstream/shared/ui-utils'; +import { pluralizeFromNumber } from '@jetstream/shared/utils'; +import { SalesforceOrgUi, UiTabSection } from '@jetstream/types'; +import type { ColumnWithFilter, RenderCellProps, RowWithKey } from '@jetstream/ui'; +import { + AutoFullHeightContainer, + Checkbox, + DataTable, + Grid, + Icon, + Spinner, + Tabs, + getRecordErrorColumn, + getRecordErrorRowHeight, +} from '@jetstream/ui'; +import groupBy from 'lodash/groupBy'; +import { FunctionComponent, ReactNode, useMemo, useState } from 'react'; +import { RecordResultRow } from './load-results-utils'; + +const ALL_TAB_ID = '__all__'; + +export interface LoadRecordsMultiObjectResultsTablesProps { + rows: RecordResultRow[]; + hasMultipleRuns: boolean; + /** Shows a spinner (instead of a neutral dash) on rows that have not been processed yet */ + isRunning: boolean; + selectedOrg: SalesforceOrgUi; + serverUrl: string; +} + +function SuccessRenderer({ row, isRunning }: RenderCellProps & { isRunning: boolean }) { + const { _success } = row as unknown as RecordResultRow; + let content: ReactNode; + if (_success === true) { + content = ( + + ); + } else if (_success === false) { + content = ( + + ); + } else if (isRunning) { + content = ; + } else { + content = ( + + Pending + + ); + } + return ( +
+ {content} +
+ ); +} + +function getColumns({ + includeWorksheet, + includeRun, + isRunning, +}: { + includeWorksheet: boolean; + includeRun: boolean; + isRunning: boolean; +}): ColumnWithFilter[] { + const columns: ColumnWithFilter[] = [ + { + name: '', + key: '_success', + width: 90, + resizable: false, + sortable: true, + filters: ['SET'], + renderCell: (props) => , + getValue: ({ row }) => { + const { _success } = row as unknown as RecordResultRow; + return _success === true ? 'Success' : _success === false ? 'Failed' : 'Pending'; + }, + }, + ...(includeWorksheet + ? [{ name: 'Worksheet', key: 'worksheet', width: 160, sortable: true, filters: ['SET'] } as ColumnWithFilter] + : []), + { name: 'Row', key: 'rowNumber', width: 80, sortable: true, filters: ['NUMBER'] }, + { name: 'Group', key: 'group', width: 110, sortable: true, filters: ['SET'] }, + { name: 'Reference Id', key: 'referenceId', width: 180, sortable: true, filters: ['TEXT', 'SET'] }, + { name: 'Object', key: 'sobject', width: 130, sortable: true, filters: ['SET'] }, + { name: 'Operation', key: 'operation', width: 110, sortable: true, filters: ['SET'] }, + { name: 'Record Id', key: '_id', width: 180, sortable: true, filters: ['TEXT'] }, + ...(includeRun + ? [ + { + name: 'Run', + key: 'runType', + width: 90, + sortable: true, + filters: ['SET'], + getValue: ({ row }) => ((row as unknown as RecordResultRow).runType === 'retry' ? 'Retry' : 'Initial'), + renderCell: ({ row }: RenderCellProps) => + (row as unknown as RecordResultRow).runType === 'retry' ? Retry : null, + } as ColumnWithFilter, + ] + : []), + getRecordErrorColumn(), + ]; + return columns; +} + +/** Per-record load results, one tab per worksheet plus a combined view */ +export const LoadRecordsMultiObjectResultsTables: FunctionComponent = ({ + rows, + hasMultipleRuns, + isRunning, + selectedOrg, + serverUrl, +}) => { + const [showFailuresOnly, setShowFailuresOnly] = useState(false); + + const visibleRows = useMemo( + () => (showFailuresOnly ? rows.filter(({ _success }) => _success === false) : rows), + [rows, showFailuresOnly], + ); + + const tabs = useMemo((): UiTabSection[] => { + const rowsByWorksheet = groupBy(visibleRows, 'worksheet'); + + function buildTable(tableRows: RecordResultRow[], includeWorksheet: boolean) { + return ( + + (row as unknown as RecordResultRow)._key} + org={selectedOrg} + serverUrl={serverUrl} + rowHeight={({ row, columnWidths }) => getRecordErrorRowHeight(row, columnWidths)} + rowClass={(row) => ((row as unknown as RecordResultRow)._success === false ? 'save-error' : undefined)} + /> + + ); + } + + const failureCountByWorksheet = groupBy( + rows.filter(({ _success }) => _success === false), + 'worksheet', + ); + + return [ + { + id: ALL_TAB_ID, + titleText: 'All Worksheets', + title: All Worksheets, + content: buildTable(visibleRows, true), + }, + ...Object.entries(rowsByWorksheet).map(([worksheet, worksheetRows]): UiTabSection => ({ + id: worksheet, + titleText: worksheet, + title: ( + + {worksheet} + {failureCountByWorksheet[worksheet]?.length > 0 && ( + + {formatNumber(failureCountByWorksheet[worksheet].length)} + + )} + + ), + content: buildTable(worksheetRows, false), + })), + ]; + }, [visibleRows, rows, hasMultipleRuns, isRunning, selectedOrg, serverUrl]); + + if (!rows.length) { + return null; + } + + return ( +
+ + + + +
+ ); +}; + +export default LoadRecordsMultiObjectResultsTables; diff --git a/libs/features/load-records-multi-object/src/load/load-results-utils.ts b/libs/features/load-records-multi-object/src/load/load-results-utils.ts new file mode 100644 index 0000000000..47be90c60a --- /dev/null +++ b/libs/features/load-records-multi-object/src/load/load-results-utils.ts @@ -0,0 +1,128 @@ +import { InsertUpdateUpsert, Maybe } from '@jetstream/types'; +import { LoadMultiObjectRun } from '../load-records-multi-object-types'; + +export interface RecordResultRow { + _key: string; + worksheet: string; + /** Excel row number the record came from (data starts on row 6) */ + rowNumber: number; + group: string; + referenceId: string; + sobject: string; + operation: InsertUpdateUpsert; + /** null = not processed yet (in flight or cancelled before its request was sent) */ + _success: boolean | null; + _id: string; + created: boolean; + /** Error message - rendered by the standalone record-error column */ + status?: string; + severity?: 'error' | 'none'; + runType: LoadMultiObjectRun['type']; + runId: number; +} + +export interface LoadResultsSummary { + successCount: number; + failureCount: number; + pendingCount: number; + totalCount: number; +} + +/** + * Flatten runs into one row per record, keeping only the LATEST outcome per Reference Id + * (a retry run supersedes the initial run for the groups it contains). + */ +export function buildRecordResultRows(runs: LoadMultiObjectRun[], groupNumbersByGraphId: Record): RecordResultRow[] { + const rowsByRefId: Record = {}; + + runs.forEach((run) => { + run.requests.forEach((request) => { + Object.values(request.recordWithResponseByRefId).forEach((record) => { + const graphResult = request.dataWithResultsByGraphId[record.graphId]; + const responseBody: Maybe<{ id?: string; success?: boolean; created?: boolean; errorCode?: string; message?: string }> = + record.response?.body; + + let success: boolean | null = null; + let errorMessage: string | undefined; + if (request.errorMessage) { + success = false; + errorMessage = request.errorMessage; + } else if (graphResult?.isSuccess === true) { + success = true; + } else if (graphResult?.isSuccess === false) { + // groups are atomic - every record in a failed group failed (or was rolled back) + success = false; + errorMessage = responseBody?.errorCode ? `${responseBody.errorCode}: ${responseBody.message}` : responseBody?.message; + } + + const groupNumber = groupNumbersByGraphId[record.graphId]; + rowsByRefId[record.referenceId] = { + _key: record.referenceId, + worksheet: record.worksheet, + rowNumber: record.rowIndex + 6, + group: groupNumber ? `Group ${groupNumber}` : record.graphId, + referenceId: record.referenceId, + sobject: record.sobject, + operation: record.operation, + _success: success, + // PATCH (update/upsert-update) returns 204 with no body - success is known from the graph result + _id: responseBody?.id || '', + created: !!responseBody?.created || (record.operation === 'INSERT' && success === true), + status: errorMessage, + severity: errorMessage ? 'error' : 'none', + runType: run.type, + runId: run.runId, + }; + }); + }); + }); + + return Object.values(rowsByRefId); +} + +export function getLoadResultsSummary(rows: RecordResultRow[]): LoadResultsSummary { + return rows.reduce( + (summary, { _success }) => { + if (_success === true) { + summary.successCount++; + } else if (_success === false) { + summary.failureCount++; + } else { + summary.pendingCount++; + } + return summary; + }, + { successCount: 0, failureCount: 0, pendingCount: 0, totalCount: rows.length }, + ); +} + +/** Rows for the results download - one flat sheet with worksheet identity preserved */ +export function buildResultsDownloadRows(rows: RecordResultRow[], which: 'results' | 'failures') { + return rows + .filter(({ _success }) => (which === 'failures' ? _success === false : true)) + .map((row) => ({ + Worksheet: row.worksheet, + Row: row.rowNumber, + Group: row.group, + Object: row.sobject, + Operation: row.operation, + 'Reference Id': row.referenceId, + Id: row._id, + Success: row._success === true, + Created: row.created, + Error: row.status || '', + })); +} + +export const RESULTS_DOWNLOAD_HEADER = [ + 'Worksheet', + 'Row', + 'Group', + 'Object', + 'Operation', + 'Reference Id', + 'Id', + 'Success', + 'Created', + 'Error', +]; diff --git a/libs/features/load-records-multi-object/src/load/useDownloadResults.ts b/libs/features/load-records-multi-object/src/load/useDownloadResults.ts new file mode 100644 index 0000000000..fadd25c0b2 --- /dev/null +++ b/libs/features/load-records-multi-object/src/load/useDownloadResults.ts @@ -0,0 +1,41 @@ +import { useCallback, useState } from 'react'; +import { LoadMultiObjectRequestWithResult } from '../load-records-multi-object-types'; +import { RESULTS_DOWNLOAD_HEADER, RecordResultRow, buildResultsDownloadRows } from './load-results-utils'; + +export const useDownloadResults = () => { + const [downloadModalData, setDownloadModalData] = useState({ + open: false, + data: [] as any[], + header: [] as string[], + fileNameParts: [] as string[], + allowedTypes: ['xlsx', 'csv', 'json'], + }); + + const downloadRequests = useCallback((requests: LoadMultiObjectRequestWithResult[]) => { + setDownloadModalData({ + open: true, + data: requests.map((request) => ({ groupId: request.key, data: Object.values(request.dataWithResultsByGraphId) })), + header: [], + fileNameParts: ['load-to-multiple-objects', 'request'], + allowedTypes: ['json'], + }); + }, []); + + const downloadResults = useCallback((rows: RecordResultRow[], which: 'results' | 'failures') => { + setDownloadModalData({ + open: true, + data: buildResultsDownloadRows(rows, which), + header: RESULTS_DOWNLOAD_HEADER, + fileNameParts: ['load-to-multiple-objects', which], + allowedTypes: ['xlsx', 'csv', 'json'], + }); + }, []); + + function handleCloseDownloadModal() { + setDownloadModalData({ open: false, data: [], header: [], fileNameParts: [], allowedTypes: ['xlsx', 'csv', 'json'] }); + } + + return { downloadRequests, downloadResults, handleCloseDownloadModal, downloadModalData }; +}; + +export default useDownloadResults; diff --git a/libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectErrorSummary.tsx b/libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectErrorSummary.tsx new file mode 100644 index 0000000000..653db1f947 --- /dev/null +++ b/libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectErrorSummary.tsx @@ -0,0 +1,64 @@ +import { css } from '@emotion/react'; +import { ScopedNotification } from '@jetstream/ui'; +import groupBy from 'lodash/groupBy'; +import { FunctionComponent } from 'react'; +import { LoadMultiObjectDataError } from '../load-records-multi-object-types'; +import { getErrorLocationLabel } from './review-utils'; + +export interface LoadRecordsMultiObjectErrorSummaryProps { + errors: LoadMultiObjectDataError[]; + warnings: LoadMultiObjectDataError[]; + /** Called when the user clicks an error's worksheet - switches the preview to that sheet's tab */ + onSelectWorksheet: (worksheet: string) => void; +} + +/** All validation problems grouped by worksheet, each acting as a jump-link to the offending sheet */ +export const LoadRecordsMultiObjectErrorSummary: FunctionComponent = ({ + errors, + warnings, + onSelectWorksheet, +}) => { + const errorsByWorksheet = groupBy(errors, 'worksheet'); + + return ( +
+ {errors.length > 0 && ( + +

Fix these problems in your file, then upload it again:

+ {Object.entries(errorsByWorksheet).map(([worksheet, worksheetErrors]) => ( +
+ +
    + {worksheetErrors.map((error, i) => ( +
  • + {error.message} {getErrorLocationLabel(error)} +
  • + ))} +
+
+ ))} +
+ )} + {warnings.length > 0 && ( + +
    1 ? 'slds-list_dotted' : undefined}> + {warnings.map((warning, i) => ( +
  • {warning.message}
  • + ))} +
+
+ )} +
+ ); +}; + +export default LoadRecordsMultiObjectErrorSummary; diff --git a/libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectGroupsOverview.tsx b/libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectGroupsOverview.tsx new file mode 100644 index 0000000000..fb1705e381 --- /dev/null +++ b/libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectGroupsOverview.tsx @@ -0,0 +1,92 @@ +import { formatNumber } from '@jetstream/shared/ui-utils'; +import { pluralizeFromNumber } from '@jetstream/shared/utils'; +import type { ColumnWithFilter, RowWithKey } from '@jetstream/ui'; +import { AutoFullHeightContainer, DataTree, ScopedNotification } from '@jetstream/ui'; +import { FunctionComponent, useMemo } from 'react'; +import { LoadMultiObjectData, LoadMultiObjectGroupInfo } from '../load-records-multi-object-types'; +import { MAX_RECORDS_PER_GROUP, getReferenceId } from '../load-records-multi-object-utils'; + +export interface LoadRecordsMultiObjectGroupsOverviewProps { + datasets: LoadMultiObjectData[]; + groupsByRefId: Record; + groupNumbersByGraphId: Record; +} + +interface GroupOverviewRow { + _key: string; + _group: string; + _groupSort: number; + worksheet: string; + referenceId: string; + sobject: string; + operation: string; +} + +const COLUMNS: ColumnWithFilter[] = [ + { name: 'Group', key: '_group', width: 220, sortable: true, filters: ['SET'] }, + { name: 'Reference Id', key: 'referenceId', width: 200, sortable: true, filters: ['TEXT', 'SET'] }, + { name: 'Worksheet', key: 'worksheet', width: 180, sortable: true, filters: ['SET'] }, + { name: 'Object', key: 'sobject', width: 150, sortable: true, filters: ['SET'] }, + { name: 'Operation', key: 'operation', width: 120, sortable: true, filters: ['SET'] }, +]; + +/** + * The mental-model view: every record grouped by its computed record group, making the + * "500 related records per group" limit tangible before loading. + */ +export const LoadRecordsMultiObjectGroupsOverview: FunctionComponent = ({ + datasets, + groupsByRefId, + groupNumbersByGraphId, +}) => { + const rows = useMemo((): GroupOverviewRow[] => { + const output: GroupOverviewRow[] = []; + datasets.forEach((dataset) => { + dataset.data.forEach((record, i) => { + const referenceId = getReferenceId(record, dataset.referenceColumnHeader); + const groupInfo = referenceId ? groupsByRefId[referenceId] : null; + if (!referenceId || !groupInfo) { + return; + } + const groupNumber = groupNumbersByGraphId[groupInfo.graphId]; + output.push({ + _key: `${dataset.worksheet}:${i}`, + _group: `Group ${groupNumber} - ${formatNumber(groupInfo.size)} ${pluralizeFromNumber('record', groupInfo.size)}`, + _groupSort: groupNumber, + worksheet: dataset.worksheet, + referenceId, + sobject: dataset.sobject, + operation: dataset.operation, + }); + }); + }); + return output.sort((rowA, rowB) => rowA._groupSort - rowB._groupSort); + }, [datasets, groupsByRefId, groupNumbersByGraphId]); + + if (!rows.length) { + return ( + + Groups will appear here once the file has no validation errors. + + ); + } + + return ( +
+

+ Records that reference each other form a group. Each group is sent to Salesforce as one all-or-nothing transaction: if any record in + it fails, the whole group is rolled back. A group can contain up to {formatNumber(MAX_RECORDS_PER_GROUP)} records. +

+ + (row as unknown as GroupOverviewRow)._key} + groupBy={['_group']} + /> + +
+ ); +}; + +export default LoadRecordsMultiObjectGroupsOverview; diff --git a/libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectReview.tsx b/libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectReview.tsx new file mode 100644 index 0000000000..04edb0b506 --- /dev/null +++ b/libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectReview.tsx @@ -0,0 +1,198 @@ +import { css } from '@emotion/react'; +import { DATE_FORMATS } from '@jetstream/shared/constants'; +import { formatNumber } from '@jetstream/shared/ui-utils'; +import { pluralizeFromNumber } from '@jetstream/shared/utils'; +import { UiTabSection } from '@jetstream/types'; +import { Checkbox, Grid, Icon, Popover, Select, Tabs, TabsRef, Tooltip } from '@jetstream/ui'; +import { useAtom, useAtomValue } from 'jotai'; +import { FunctionComponent, useMemo, useRef } from 'react'; +import { MAX_RECORDS_PER_GROUP } from '../load-records-multi-object-utils'; +import { + datasetsState, + dateFormatState, + graphErrorsState, + groupsByRefIdState, + insertNullsState, + loadIsRunningState, + parseErrorsState, + parseWarningsState, + requestsState, +} from '../load-records-multi-object.state'; +import LoadRecordsMultiObjectErrorSummary from './LoadRecordsMultiObjectErrorSummary'; +import LoadRecordsMultiObjectGroupsOverview from './LoadRecordsMultiObjectGroupsOverview'; +import LoadRecordsMultiObjectSheetPreview from './LoadRecordsMultiObjectSheetPreview'; +import { buildSheetPreviewData, getGroupNumbersByGraphId, getGroupSummary } from './review-utils'; + +const GROUPS_TAB_ID = '__groups__'; + +const GROUPS_TOOLTIP = + 'Records that reference each other (directly or through other records) form a group. Each group is sent to Salesforce as one ' + + `all-or-nothing transaction: if any record in it fails, the whole group is rolled back. One group can contain up to ${MAX_RECORDS_PER_GROUP} records.`; + +/** Step 1 (post-upload): summary, load options, error triage, and per-worksheet data preview */ +export const LoadRecordsMultiObjectReview: FunctionComponent = () => { + const datasets = useAtomValue(datasetsState); + const parseErrors = useAtomValue(parseErrorsState); + const parseWarnings = useAtomValue(parseWarningsState); + const graphErrors = useAtomValue(graphErrorsState); + const groupsByRefId = useAtomValue(groupsByRefIdState); + const requests = useAtomValue(requestsState); + const loadIsRunning = useAtomValue(loadIsRunningState); + const [dateFormat, setDateFormat] = useAtom(dateFormatState); + const [insertNulls, setInsertNulls] = useAtom(insertNullsState); + + const tabsRef = useRef(null); + + const allErrors = useMemo(() => [...parseErrors, ...graphErrors], [parseErrors, graphErrors]); + const groupNumbersByGraphId = useMemo(() => getGroupNumbersByGraphId(groupsByRefId), [groupsByRefId]); + const { groupCount, largestGroupSize } = useMemo(() => getGroupSummary(groupsByRefId), [groupsByRefId]); + + const totalRecords = useMemo(() => (datasets || []).reduce((count, { data }) => count + data.length, 0), [datasets]); + + const sheetPreviews = useMemo( + () => + (datasets || []).map((dataset) => ({ + dataset, + previewData: buildSheetPreviewData({ + dataset, + errors: [...dataset.errors, ...graphErrors.filter(({ worksheet }) => worksheet === dataset.worksheet)], + groupsByRefId, + groupNumbersByGraphId, + }), + })), + [datasets, graphErrors, groupsByRefId, groupNumbersByGraphId], + ); + + const tabs = useMemo((): UiTabSection[] => { + const sheetTabs = sheetPreviews.map(({ dataset, previewData }): UiTabSection => { + const { errorCount } = previewData; + return { + id: dataset.worksheet, + titleText: dataset.worksheet, + title: ( + + {dataset.worksheet} + {errorCount > 0 && ( + + {formatNumber(errorCount)} + + )} + + ), + content: , + }; + }); + return [ + { + id: GROUPS_TAB_ID, + titleText: 'Groups', + title: ( + + + Groups + + ), + content: ( + + ), + }, + ...sheetTabs, + ]; + }, [sheetPreviews, datasets, groupsByRefId, groupNumbersByGraphId]); + + if (!datasets?.length) { + return null; + } + + return ( +
+ +
+ + {formatNumber(totalRecords)} {pluralizeFromNumber('record', totalRecords)} across {formatNumber(datasets.length)}{' '} + {pluralizeFromNumber('worksheet', datasets.length)} + + {allErrors.length > 0 ? ( + + {formatNumber(allErrors.length)} {pluralizeFromNumber('error', allErrors.length)} to resolve + + ) : ( + + + + {formatNumber(groupCount)} {pluralizeFromNumber('group', groupCount)} + + + {' · largest group '} + {formatNumber(largestGroupSize)}/{formatNumber(MAX_RECORDS_PER_GROUP)} + {requests?.length ? ` · ${formatNumber(requests.length)} API ${pluralizeFromNumber('request', requests.length)}` : ''} + + )} +
+ +

Load Options

+ + } + content={ +
+ + setDateFormat(event.target.value)} + > + + + + + +
+ } + buttonProps={{ className: 'slds-button slds-button_neutral' }} + > + + Load Options +
+
+ tabsRef.current?.changeTab(worksheet)} + /> + +
+ ); +}; + +export default LoadRecordsMultiObjectReview; diff --git a/libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectSheetPreview.tsx b/libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectSheetPreview.tsx new file mode 100644 index 0000000000..a9c0f8a595 --- /dev/null +++ b/libs/features/load-records-multi-object/src/review/LoadRecordsMultiObjectSheetPreview.tsx @@ -0,0 +1,130 @@ +import { formatNumber } from '@jetstream/shared/ui-utils'; +import { pluralizeFromNumber } from '@jetstream/shared/utils'; +import type { ColumnWithFilter, RenderCellProps, RowWithKey } from '@jetstream/ui'; +import { + AutoFullHeightContainer, + DataTable, + Grid, + ScopedNotification, + SearchInput, + getRecordErrorColumn, + getRecordErrorRowHeight, + withCellValidation, +} from '@jetstream/ui'; +import { FunctionComponent, useMemo, useState } from 'react'; +import { LoadMultiObjectData } from '../load-records-multi-object-types'; +import { SheetPreviewData, SheetPreviewRow, getErrorLocationLabel } from './review-utils'; + +export interface LoadRecordsMultiObjectSheetPreviewProps { + dataset: LoadMultiObjectData; + previewData: SheetPreviewData; +} + +function GroupCellRenderer({ row }: RenderCellProps) { + const { _group, _groupSize } = row as SheetPreviewRow; + if (!_group) { + return null; + } + return ( + + {_group} + + ); +} + +/** One worksheet's data as a virtualized grid with validation errors annotated on the exact cells/rows */ +export const LoadRecordsMultiObjectSheetPreview: FunctionComponent = ({ + dataset, + previewData, +}) => { + const { rows, bannerErrors, hasRowErrors } = previewData; + const [quickFilterText, setQuickFilterText] = useState(null); + + // Worksheet names allow spaces and punctuation, neither of which is valid in an element id + const filterInputId = `sheet-filter-${dataset.worksheet.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + + const columns = useMemo((): ColumnWithFilter[] => { + const referenceColumnKey = dataset.referenceColumnHeader || 'Reference Id'; + const dataColumns: ColumnWithFilter[] = [ + { + name: referenceColumnKey, + key: referenceColumnKey, + frozen: true, + width: 170, + resizable: true, + sortable: true, + filters: ['TEXT', 'SET'], + renderCell: withCellValidation(), + }, + { + name: 'Group', + key: '_group', + width: 110, + resizable: true, + sortable: true, + filters: ['SET'], + renderCell: GroupCellRenderer, + }, + ...dataset.headers.map((header): ColumnWithFilter => ({ + // show reference columns in the user's own file vocabulary: {AccountId} + name: dataset.referenceHeaders.has(header) ? `{${header}}` : header, + key: header, + resizable: true, + sortable: true, + filters: ['TEXT', 'SET'], + renderCell: withCellValidation(), + })), + ]; + if (hasRowErrors) { + dataColumns.push(getRecordErrorColumn()); + } + return dataColumns; + }, [dataset, hasRowErrors]); + + return ( +
+ {bannerErrors.length > 0 && ( + +
    1 ? 'slds-list_dotted' : undefined}> + {bannerErrors.map((error, i) => ( +
  • + {error.message} {getErrorLocationLabel(error)} +
  • + ))} +
+
+ )} + +
+ {formatNumber(rows.length)} {pluralizeFromNumber('record', rows.length)} • {dataset.sobject} • {dataset.operation} + {dataset.operation === 'UPSERT' && dataset.externalId ? ` (${dataset.externalId})` : ''} +
+ +
+ {rows.length > 0 && ( + + (row as SheetPreviewRow)._key} + includeQuickFilter + quickFilterText={quickFilterText} + rowHeight={({ row, columnWidths }) => getRecordErrorRowHeight(row, columnWidths)} + rowClass={(row) => { + const { status, _fieldErrors } = row as SheetPreviewRow; + return status || _fieldErrors ? 'save-error' : undefined; + }} + /> + + )} +
+ ); +}; + +export default LoadRecordsMultiObjectSheetPreview; diff --git a/libs/features/load-records-multi-object/src/review/__tests__/review-utils.spec.ts b/libs/features/load-records-multi-object/src/review/__tests__/review-utils.spec.ts new file mode 100644 index 0000000000..6bece2437e --- /dev/null +++ b/libs/features/load-records-multi-object/src/review/__tests__/review-utils.spec.ts @@ -0,0 +1,169 @@ +import { LoadMultiObjectData, LoadMultiObjectDataError, LoadMultiObjectGroupInfo } from '../../load-records-multi-object-types'; +import { buildSheetPreviewData, getGroupNumbersByGraphId, getGroupSummary } from '../review-utils'; + +function buildDataset(overrides: Partial = {}): LoadMultiObjectData { + return { + worksheet: 'Sheet1', + sobject: 'Account', + operation: 'INSERT', + data: [ + { 'Reference Id': 'rec1', Name: 'One' }, + { 'Reference Id': 'rec2', Name: 'Two' }, + { 'Reference Id': 'rec3', Name: 'Three' }, + ], + dataById: {}, + referenceColumnHeader: 'Reference Id', + headers: ['Name'], + referenceHeaders: new Set(), + metadata: {} as LoadMultiObjectData['metadata'], + fieldsByName: {}, + errors: [], + ...overrides, + }; +} + +function buildError(overrides: Partial = {}): LoadMultiObjectDataError { + return { + property: 'data', + worksheet: 'Sheet1', + location: null, + locationType: 'SHEET', + message: 'Something is wrong', + ...overrides, + }; +} + +const GROUPS: Record = { + rec1: { graphId: 'rec1', size: 2 }, + rec2: { graphId: 'rec1', size: 2 }, + rec3: { graphId: 'rec3', size: 1 }, +}; + +describe('getGroupNumbersByGraphId', () => { + it('assigns sequential numbers by first appearance', () => { + expect(getGroupNumbersByGraphId(GROUPS)).toEqual({ rec1: 1, rec3: 2 }); + }); +}); + +describe('getGroupSummary', () => { + it('counts distinct groups and the largest group size', () => { + expect(getGroupSummary(GROUPS)).toEqual({ groupCount: 2, largestGroupSize: 2 }); + }); + + it('handles empty groups', () => { + expect(getGroupSummary({})).toEqual({ groupCount: 0, largestGroupSize: 0 }); + }); +}); + +describe('buildSheetPreviewData', () => { + it('builds rows with keys, reference ids, and group labels', () => { + const { rows, bannerErrors, hasRowErrors } = buildSheetPreviewData({ + dataset: buildDataset(), + errors: [], + groupsByRefId: GROUPS, + groupNumbersByGraphId: getGroupNumbersByGraphId(GROUPS), + }); + + expect(rows).toHaveLength(3); + expect(rows[0]._key).toBe('Sheet1:0'); + expect(rows[0]._referenceId).toBe('rec1'); + expect(rows[0]._group).toBe('Group 1'); + expect(rows[0]._groupSize).toBe(2); + expect(rows[2]._group).toBe('Group 2'); + expect(bannerErrors).toEqual([]); + expect(hasRowErrors).toBe(false); + }); + + it('maps COLUMN errors with row indexes onto the correct rows and column', () => { + const error = buildError({ locationType: 'COLUMN', location: 'A', header: 'Reference Id', rowIndexes: [1] }); + const { rows, bannerErrors, hasRowErrors } = buildSheetPreviewData({ + dataset: buildDataset(), + errors: [error], + groupsByRefId: {}, + groupNumbersByGraphId: {}, + }); + + expect(rows[1]._fieldErrors).toEqual({ 'Reference Id': 'Something is wrong' }); + expect(rows[0]._fieldErrors).toBeUndefined(); + expect(bannerErrors).toEqual([]); + expect(hasRowErrors).toBe(true); + }); + + it('maps CELL errors with row indexes to the reference column when no header is given', () => { + const error = buildError({ locationType: 'CELL', location: 'A7', rowIndexes: [1] }); + const { rows } = buildSheetPreviewData({ + dataset: buildDataset(), + errors: [error], + groupsByRefId: {}, + groupNumbersByGraphId: {}, + }); + + expect(rows[1]._fieldErrors).toEqual({ 'Reference Id': 'Something is wrong' }); + }); + + it('maps ROW errors with row indexes to record-level status', () => { + const error = buildError({ locationType: 'ROW', location: '7', rowIndexes: [1] }); + const { rows } = buildSheetPreviewData({ + dataset: buildDataset(), + errors: [error], + groupsByRefId: {}, + groupNumbersByGraphId: {}, + }); + + expect(rows[1].status).toBe('Something is wrong'); + expect(rows[1].severity).toBe('error'); + }); + + it('maps ROW errors without row indexes using the Excel row number offset', () => { + const error = buildError({ locationType: 'ROW', location: '8' }); + const { rows } = buildSheetPreviewData({ + dataset: buildDataset(), + errors: [error], + groupsByRefId: {}, + groupNumbersByGraphId: {}, + }); + + expect(rows[2].status).toBe('Something is wrong'); + }); + + it('routes header-row and sheet-level errors to the banner', () => { + const headerRowError = buildError({ locationType: 'ROW', location: '5' }); + const sheetError = buildError({ locationType: 'SHEET' }); + const configError = buildError({ locationType: 'CELL', location: 'B1' }); + const { rows, bannerErrors } = buildSheetPreviewData({ + dataset: buildDataset(), + errors: [headerRowError, sheetError, configError], + groupsByRefId: {}, + groupNumbersByGraphId: {}, + }); + + expect(bannerErrors).toHaveLength(3); + expect(rows.every(({ status, _fieldErrors }) => !status && !_fieldErrors)).toBe(true); + }); + + it('falls back to the banner when row indexes are out of range', () => { + const error = buildError({ locationType: 'ROW', location: '99', rowIndexes: [50] }); + const { bannerErrors } = buildSheetPreviewData({ + dataset: buildDataset(), + errors: [error], + groupsByRefId: {}, + groupNumbersByGraphId: {}, + }); + + expect(bannerErrors).toHaveLength(1); + }); + + it('stacks multiple errors on the same cell and row', () => { + const cellError1 = buildError({ locationType: 'COLUMN', location: 'A', header: 'Name', rowIndexes: [0], message: 'First' }); + const cellError2 = buildError({ locationType: 'COLUMN', location: 'A', header: 'Name', rowIndexes: [0], message: 'Second' }); + const { rows, errorCount } = buildSheetPreviewData({ + dataset: buildDataset(), + errors: [cellError1, cellError2], + groupsByRefId: {}, + groupNumbersByGraphId: {}, + }); + + expect(rows[0]._fieldErrors?.['Name']).toBe('First\nSecond'); + expect(errorCount).toBe(2); + }); +}); diff --git a/libs/features/load-records-multi-object/src/review/review-utils.ts b/libs/features/load-records-multi-object/src/review/review-utils.ts new file mode 100644 index 0000000000..aa4b0cfbf3 --- /dev/null +++ b/libs/features/load-records-multi-object/src/review/review-utils.ts @@ -0,0 +1,160 @@ +import { LoadMultiObjectData, LoadMultiObjectDataError, LoadMultiObjectGroupInfo } from '../load-records-multi-object-types'; +import { getReferenceId } from '../load-records-multi-object-utils'; + +/** Excel rows start at 6 for data (row 5 is the header row) */ +const DATA_START_EXCEL_ROW = 6; + +export interface SheetPreviewRow { + _key: string; + _referenceId: string | null; + /** Friendly group label, e.g. "Group 3" - null while the graph could not be computed */ + _group: string | null; + _groupSize: number | null; + /** Per-cell errors keyed by column key - drives the red ring + tooltip via withCellValidation */ + _fieldErrors?: Record; + /** Row-level error text - rendered by the standalone record-error column */ + status?: string; + severity?: 'error' | 'none'; + [header: string]: unknown; +} + +export interface SheetPreviewData { + rows: SheetPreviewRow[]; + /** Errors with no grid cell to attach to (B1/B2/B3 config, header row, empty sheet, unmappable) */ + bannerErrors: LoadMultiObjectDataError[]; + /** Total number of distinct errors attributed to this sheet (banner + inline) */ + errorCount: number; + hasRowErrors: boolean; +} + +/** Assign stable, human-friendly group numbers by order of first appearance */ +export function getGroupNumbersByGraphId(groupsByRefId: Record): Record { + const groupNumbers: Record = {}; + let nextNumber = 1; + Object.values(groupsByRefId).forEach(({ graphId }) => { + if (!groupNumbers[graphId]) { + groupNumbers[graphId] = nextNumber++; + } + }); + return groupNumbers; +} + +export function getGroupSummary(groupsByRefId: Record) { + const sizeByGraphId: Record = {}; + Object.values(groupsByRefId).forEach(({ graphId, size }) => { + sizeByGraphId[graphId] = size; + }); + const sizes = Object.values(sizeByGraphId); + return { + groupCount: sizes.length, + largestGroupSize: sizes.length ? Math.max(...sizes) : 0, + }; +} + +/** + * Combine a parsed dataset with its errors and group info into renderable grid rows. + * Errors that can be located map onto specific cells/rows; everything else lands in bannerErrors. + */ +export function buildSheetPreviewData({ + dataset, + errors, + groupsByRefId, + groupNumbersByGraphId, +}: { + dataset: LoadMultiObjectData; + /** All errors attributed to this worksheet (dataset errors + graph errors) */ + errors: LoadMultiObjectDataError[]; + groupsByRefId: Record; + groupNumbersByGraphId: Record; +}): SheetPreviewData { + const referenceColumnKey = dataset.referenceColumnHeader || 'Reference Id'; + + const rows: SheetPreviewRow[] = dataset.data.map((record, i) => { + const referenceId = getReferenceId(record, referenceColumnKey); + const groupInfo = referenceId ? groupsByRefId[referenceId] : null; + const groupNumber = groupInfo ? groupNumbersByGraphId[groupInfo.graphId] : null; + return { + ...record, + _key: `${dataset.worksheet}:${i}`, + _referenceId: referenceId, + _group: groupNumber ? `Group ${groupNumber}` : null, + _groupSize: groupInfo?.size ?? null, + }; + }); + + const bannerErrors: LoadMultiObjectDataError[] = []; + + function addFieldError(rowIndex: number, columnKey: string, message: string) { + const row = rows[rowIndex]; + if (!row) { + return false; + } + row._fieldErrors = { + ...row._fieldErrors, + [columnKey]: row._fieldErrors?.[columnKey] ? `${row._fieldErrors[columnKey]}\n${message}` : message, + }; + return true; + } + + function addRowError(rowIndex: number, message: string) { + const row = rows[rowIndex]; + if (!row) { + return false; + } + row.status = row.status ? `${row.status}\n${message}` : message; + row.severity = 'error'; + return true; + } + + errors.forEach((error) => { + const { locationType, location, rowIndexes, header, message } = error; + + if (rowIndexes?.length) { + const mapped = rowIndexes.map((rowIndex) => { + if (locationType === 'ROW') { + return addRowError(rowIndex, message); + } + // CELL errors with row indexes are Reference Id problems (column A); COLUMN errors carry the header + return addFieldError(rowIndex, header || referenceColumnKey, message); + }); + if (!mapped.some(Boolean)) { + bannerErrors.push(error); + } + return; + } + + // ROW errors from parse-time carry only the Excel row number + if (locationType === 'ROW' && location) { + const excelRow = parseInt(location, 10); + if (excelRow >= DATA_START_EXCEL_ROW && addRowError(excelRow - DATA_START_EXCEL_ROW, message)) { + return; + } + } + + bannerErrors.push(error); + }); + + return { + rows, + bannerErrors, + errorCount: errors.length, + hasRowErrors: rows.some(({ status, _fieldErrors }) => !!status || !!_fieldErrors), + }; +} + +/** Human-readable location suffix for an error, e.g. "(Cell B1)" */ +export function getErrorLocationLabel({ location, locationType }: LoadMultiObjectDataError): string | null { + if (!location) { + return null; + } + switch (locationType) { + case 'CELL': + return `(Cell ${location})`; + case 'ROW': + return `(Row ${location})`; + case 'COLUMN': + return `(Column ${location})`; + default: + return null; + } +} diff --git a/libs/features/load-records-multi-object/src/useDownloadResults.ts b/libs/features/load-records-multi-object/src/useDownloadResults.ts deleted file mode 100644 index 5f24a77f09..0000000000 --- a/libs/features/load-records-multi-object/src/useDownloadResults.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { useCallback, useState } from 'react'; -import { LoadMultiObjectRequestWithResult } from './load-records-multi-object-types'; - -export const useDownloadResults = () => { - const [downloadModalData, setDownloadModalData] = useState({ - open: false, - data: [] as any[], - header: [] as string[], - fileNameParts: [] as string[], - allowedTypes: ['xlsx', 'csv', 'json'], - }); - - const downloadRequests = useCallback((data: LoadMultiObjectRequestWithResult[]) => { - setDownloadModalData({ - open: true, - data: data.map((item) => ({ groupId: item.key, data: Object.values(item.dataWithResultsByGraphId) })), - header: [], - fileNameParts: ['load-to-multiple-objects', 'request'], - allowedTypes: ['json'], - }); - }, []); - - const downloadResults = useCallback((data: LoadMultiObjectRequestWithResult[], which: 'results' | 'failures') => { - const outputData = - data.flatMap( - ({ results, recordWithResponseByRefId }) => - results - ?.filter(({ isSuccessful }) => (which === 'failures' ? !isSuccessful : true)) - .flatMap(({ graphResponse, graphId }) => - graphResponse.compositeResponse.map((response) => { - const { referenceId } = response; - const { operation, sobject, externalId } = recordWithResponseByRefId[referenceId] || {}; - // PATCH returns 204 with no body - if (!response.body) { - response.body = { - id: '', - success: true, - errors: [], - }; - } - const { id, success, message, created, errorCode } = response.body; - return { - Group: graphId, - Object: sobject, - Operation: operation, - 'External Id': externalId, - 'Reference Id': referenceId, - Id: id || '', - Success: !!success, - Created: !!created || (operation === 'INSERT' && !!success), - Error: errorCode ? `${errorCode}: ${message}` : '', - }; - }), - ) || [], - ) || []; - - setDownloadModalData({ - open: true, - data: outputData, - header: ['Group', 'Object', 'Operation', 'External Id', 'Reference Id', 'Id', 'Success', 'Created', 'Error'], - fileNameParts: ['load-to-multiple-objects', which], - allowedTypes: ['xlsx', 'csv', 'json'], - }); - }, []); - - function handleCloseDownloadModal() { - setDownloadModalData({ open: false, data: [], header: [], fileNameParts: [], allowedTypes: ['xlsx', 'csv', 'json'] }); - } - - return { downloadRequests, downloadResults, handleCloseDownloadModal, downloadModalData }; -}; - -export default useDownloadResults; diff --git a/libs/features/load-records-multi-object/src/useLoadFile.ts b/libs/features/load-records-multi-object/src/useLoadFile.ts index e873eaa326..7e3a7511bb 100644 --- a/libs/features/load-records-multi-object/src/useLoadFile.ts +++ b/libs/features/load-records-multi-object/src/useLoadFile.ts @@ -1,104 +1,57 @@ import { logger } from '@jetstream/shared/client-logger'; import { genericRequest } from '@jetstream/shared/data'; import { useBrowserNotifications } from '@jetstream/shared/ui-utils'; -import { getErrorMessage, getSuccessOrFailureChar, groupByFlat, pluralizeFromNumber } from '@jetstream/shared/utils'; +import { getErrorMessage, getSuccessOrFailureChar, pluralizeFromNumber } from '@jetstream/shared/utils'; import { CompositeGraphResponse, CompositeGraphResponseBody, SalesforceOrgUi } from '@jetstream/types'; -import { useCallback, useEffect, useReducer, useRef } from 'react'; -import { LoadMultiObjectRequestWithResult } from './load-records-multi-object-types'; - -type Action = - | { type: 'INIT'; payload: { loading: boolean; data: Record | null } } - | { type: 'ITEM_STARTED'; payload: { key: string } } - | { type: 'ITEM_FAILED'; payload: { key: string; errorMessage: string } } - | { type: 'ITEM_FINISHED'; payload: { key: string; results: CompositeGraphResponse[] } } - | { type: 'FINISHED' }; - -interface State { - loading: boolean; - data: Record | null; - dataArray: LoadMultiObjectRequestWithResult[] | null; - finished: boolean; +import { useAtom, useSetAtom } from 'jotai'; +import { useCallback, useEffect, useRef } from 'react'; +import { LoadMultiObjectRequestWithResult, LoadMultiObjectRun } from './load-records-multi-object-types'; +import { loadIsRunningState, loadProgressState, loadRunsState } from './load-records-multi-object.state'; + +function getRecordCount(requests: LoadMultiObjectRequestWithResult[]) { + return requests.reduce( + (count, request) => count + request.data.reduce((graphCount, graph) => graphCount + (graph.compositeRequest?.length || 0), 0), + 0, + ); } -function reducer(state: State, action: Action): State { - switch (action.type) { - case 'INIT': - return { - loading: action.payload.loading, - data: action.payload.data, - dataArray: action.payload.data ? Object.values(action.payload.data) : null, - finished: false, - }; - case 'ITEM_STARTED': { - const { key } = action.payload; - const data: Record = { ...state.data }; - data[key] = { - ...data[key], - started: new Date(), - loading: true, - }; - return { ...state, data, dataArray: Object.values(data) }; - } - case 'ITEM_FAILED': { - const { key, errorMessage } = action.payload; - const data: Record = { ...state.data }; - data[key] = { - ...data[key], - finished: new Date(), - loading: false, - errorMessage, - }; - logger.info('[LOAD MULTI OBJ] Failed data', { data }); - return { ...state, data, dataArray: Object.values(data) }; - } - case 'ITEM_FINISHED': { - const { key, results } = action.payload; - const data: Record = { ...state.data }; - data[key] = { - ...data[key], - finished: new Date(), - loading: false, - results, - dataWithResultsByGraphId: { ...data[key].dataWithResultsByGraphId }, - recordWithResponseByRefId: { ...data[key].recordWithResponseByRefId }, - }; - const currItem = data[key]; +/** Merge Salesforce composite graph responses into a fresh copy of the request (no shared-state mutation) */ +function applyResultsToRequest( + request: LoadMultiObjectRequestWithResult, + results: CompositeGraphResponse[], +): LoadMultiObjectRequestWithResult { + const dataWithResultsByGraphId = { ...request.dataWithResultsByGraphId }; + const recordWithResponseByRefId = { ...request.recordWithResponseByRefId }; + + results.forEach((result) => { + // in some random cases the response body is an array, but in all or most other cases it is an object + const compositeResponse = result.graphResponse.compositeResponse.map((response) => ({ + ...response, + body: Array.isArray(response.body) ? response.body[0] : response.body, + })); + + dataWithResultsByGraphId[result.graphId] = { + ...dataWithResultsByGraphId[result.graphId], + isSuccess: result.isSuccessful, + compositeResponse, + }; - // Update dataWithResultsByGraphId and recordWithResponseByRefId with response data - results.forEach((result) => { - // in some random cases the response is an array, but in all or most other cases the object is an object - result.graphResponse.compositeResponse.forEach((response) => { - response.body = Array.isArray(response.body) ? response.body[0] : response.body; - }); + compositeResponse.forEach((recordResponse) => { + recordWithResponseByRefId[recordResponse.referenceId] = { + ...recordWithResponseByRefId[recordResponse.referenceId], + response: recordResponse, + }; + }); + }); - currItem.dataWithResultsByGraphId[result.graphId] = { - ...currItem.dataWithResultsByGraphId[result.graphId], - isSuccess: result.isSuccessful, - compositeResponse: result.graphResponse.compositeResponse, - }; - - result.graphResponse.compositeResponse.forEach((recordResponse) => { - currItem.recordWithResponseByRefId[recordResponse.referenceId] = { - ...currItem.recordWithResponseByRefId[recordResponse.referenceId], - response: recordResponse, - }; - }); - }); - logger.info('[LOAD MULTI OBJ] Loaded data', { data }); - return { ...state, data, dataArray: Object.values(data) }; - } - case 'FINISHED': - return { ...state, loading: false, finished: true }; - default: - throw new Error('Invalid action'); - } + return { ...request, finished: new Date(), loading: false, results, dataWithResultsByGraphId, recordWithResponseByRefId }; } -function getNotification(dataToProcess: LoadMultiObjectRequestWithResult[]) { - const { success, failure } = dataToProcess.reduce( +function getNotification(requests: LoadMultiObjectRequestWithResult[]) { + const { success, failure } = requests.reduce( (output, item) => { if (item.errorMessage) { - output.failure += item.data.length; + output.failure += item.data.reduce((count, graph) => count + (graph.compositeRequest?.length || 0), 0); } else { item.results?.forEach((result) => { if (result.isSuccessful) { @@ -122,16 +75,19 @@ function getNotification(dataToProcess: LoadMultiObjectRequestWithResult[]) { )} failed`; } +/** + * Executes composite graph requests sequentially, tracked as a list of runs (initial load + retries). + * Supports cancellation between requests - a request already sent to Salesforce cannot be aborted + * because each graph is an atomic transaction on the server. + */ export const useLoadFile = (org: SalesforceOrgUi, serverUrl: string, apiVersion: string) => { const { notifyUser } = useBrowserNotifications(serverUrl); - const [{ loading, dataArray: data, finished }, dispatch] = useReducer(reducer, { - loading: false, - data: null, - dataArray: null, - finished: false, - }); + const [runs, setRuns] = useAtom(loadRunsState); + const [loading, setLoading] = useAtom(loadIsRunningState); + const setProgress = useSetAtom(loadProgressState); const isMounted = useRef(true); + const cancelRequestedRef = useRef(false); useEffect(() => { isMounted.current = true; @@ -140,48 +96,102 @@ export const useLoadFile = (org: SalesforceOrgUi, serverUrl: string, apiVersion: }; }, []); - useEffect(() => { - if (finished && data) { - notifyUser(`Your data load is finished`, { body: getNotification(data), tag: 'load-multi-object' }); - } - }, [finished]); - - function reset() { - dispatch({ type: 'INIT', payload: { loading: false, data: null } }); - } - - const loadFile = useCallback( - async (dataToProcess: LoadMultiObjectRequestWithResult[]) => { - dispatch({ type: 'INIT', payload: { loading: true, data: groupByFlat(dataToProcess, 'key') } }); + const updateRunRequest = useCallback( + (runId: number, key: string, applyUpdate: (request: LoadMultiObjectRequestWithResult) => LoadMultiObjectRequestWithResult) => { + setRuns((priorRuns) => + priorRuns.map((run) => + run.runId === runId + ? { ...run, requests: run.requests.map((request) => (request.key === key ? applyUpdate(request) : request)) } + : run, + ), + ); + }, + [setRuns], + ); - for (const currentRequest of dataToProcess) { + const executeRun = useCallback( + async (requestsToProcess: LoadMultiObjectRequestWithResult[], type: LoadMultiObjectRun['type']) => { + cancelRequestedRef.current = false; + // Deep clone so re-loads and retries never share result state with the pristine graph output + const requests: LoadMultiObjectRequestWithResult[] = JSON.parse(JSON.stringify(requestsToProcess)); + let runId = 0; + setRuns((priorRuns) => { + runId = priorRuns.length; + return [...priorRuns, { runId, type, requests, startedAt: new Date(), finishedAt: null, cancelled: false }]; + }); + setLoading(true); + + const recordsTotal = getRecordCount(requests); + let recordsProcessed = 0; + let cancelled = false; + setProgress({ current: 0, total: requests.length, recordsProcessed, recordsTotal }); + + for (let requestIdx = 0; requestIdx < requests.length; requestIdx++) { + const currentRequest = requests[requestIdx]; + if (cancelRequestedRef.current) { + cancelled = true; + break; + } + setProgress({ current: requestIdx + 1, total: requests.length, recordsProcessed, recordsTotal }); + updateRunRequest(runId, currentRequest.key, (request) => ({ ...request, loading: true, started: new Date() })); try { - dispatch({ type: 'ITEM_STARTED', payload: { key: currentRequest.key } }); const response = await genericRequest(org, { method: 'POST', url: `/services/data/${apiVersion}/composite/graph`, body: { graphs: currentRequest.data }, isTooling: false, }); - logger.info('[LOAD MULTI OBJ] Load Finished', { response }); + logger.info('[LOAD MULTI OBJ] Request finished', { response }); if (isMounted.current) { - dispatch({ type: 'ITEM_FINISHED', payload: { key: currentRequest.key, results: response.graphs } }); + updateRunRequest(runId, currentRequest.key, (request) => applyResultsToRequest(request, response.graphs)); } } catch (ex) { logger.error('[LOAD MULTI OBJ] Exception', ex); if (isMounted.current) { - dispatch({ type: 'ITEM_FAILED', payload: { key: currentRequest.key, errorMessage: getErrorMessage(ex) } }); + updateRunRequest(runId, currentRequest.key, (request) => ({ + ...request, + finished: new Date(), + loading: false, + errorMessage: getErrorMessage(ex), + })); } } + recordsProcessed += getRecordCount([currentRequest]); + if (isMounted.current) { + setProgress({ current: requestIdx + 1, total: requests.length, recordsProcessed, recordsTotal }); + } } + if (isMounted.current) { - dispatch({ type: 'FINISHED' }); + setLoading(false); + let finishedRequests: LoadMultiObjectRequestWithResult[] = []; + setRuns((priorRuns) => + priorRuns.map((run) => { + if (run.runId !== runId) { + return run; + } + finishedRequests = run.requests; + return { ...run, finishedAt: new Date(), cancelled }; + }), + ); + notifyUser(cancelled ? `Your data load was cancelled` : `Your data load is finished`, { + body: getNotification(finishedRequests), + tag: 'load-multi-object', + }); } }, - [org, apiVersion], + [org, apiVersion, notifyUser, setLoading, setProgress, setRuns, updateRunRequest], ); - return { loadFile, reset, loading, data }; + const loadFile = useCallback((requests: LoadMultiObjectRequestWithResult[]) => executeRun(requests, 'initial'), [executeRun]); + + const retryFailed = useCallback((requests: LoadMultiObjectRequestWithResult[]) => executeRun(requests, 'retry'), [executeRun]); + + const cancel = useCallback(() => { + cancelRequestedRef.current = true; + }, []); + + return { loadFile, retryFailed, cancel, loading, runs }; }; export default useLoadFile; diff --git a/libs/features/load-records-multi-object/src/useProcessLoadFile.ts b/libs/features/load-records-multi-object/src/useProcessLoadFile.ts index f72e649d55..ec57f960b5 100644 --- a/libs/features/load-records-multi-object/src/useProcessLoadFile.ts +++ b/libs/features/load-records-multi-object/src/useProcessLoadFile.ts @@ -1,47 +1,48 @@ import { logger } from '@jetstream/shared/client-logger'; import { getErrorMessage } from '@jetstream/shared/utils'; import { SalesforceOrgUi } from '@jetstream/types'; -import { useCallback, useEffect, useReducer, useRef } from 'react'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useCallback, useEffect, useRef } from 'react'; import type * as XLSX from 'xlsx'; -import { LoadMultiObjectData, LoadMultiObjectDataError, LoadMultiObjectRequestWithResult } from './load-records-multi-object-types'; -import { getDataGraph, parseWorkbook } from './load-records-multi-object-utils'; +import { LoadMultiObjectDataError } from './load-records-multi-object-types'; +import { buildDataGraph, parseWorkbook } from './load-records-multi-object-utils'; +import { + datasetsState, + dateFormatState, + fileParsingState, + graphErrorsState, + groupsByRefIdState, + insertNullsState, + parseErrorsState, + parseWarningsState, + requestsState, +} from './load-records-multi-object.state'; -type Action = - | { type: 'INIT'; payload: { loading: boolean } } - | { type: 'PROCESSED_DATASET'; payload: { data: LoadMultiObjectData[] } } - | { type: 'SUCCESS'; payload: { data: LoadMultiObjectRequestWithResult[] } } - | { type: 'FAILURE'; payload: { errors: any } }; - -interface State { - loading: boolean; - dataSet: LoadMultiObjectData[] | null; - dataGraph: LoadMultiObjectRequestWithResult[] | null; - errors: LoadMultiObjectDataError[]; -} - -function reducer(state: State, action: Action): State { - switch (action.type) { - case 'INIT': - return { ...state, loading: action.payload.loading, errors: [], dataSet: null, dataGraph: null }; - case 'PROCESSED_DATASET': - return { ...state, dataSet: action.payload.data }; - case 'SUCCESS': - return { ...state, loading: false, dataGraph: action.payload.data }; - case 'FAILURE': - return { ...state, loading: false, errors: action.payload.errors || [], dataGraph: null }; - default: - throw new Error('Invalid action'); - } +function toUnknownError(ex: unknown): LoadMultiObjectDataError { + return { + property: null, + worksheet: 'Unknown', + location: null, + locationType: 'SHEET', + message: getErrorMessage(ex), + }; } -export const useValidateLoadFile = (org: SalesforceOrgUi, apiVersion: string, options: { dateFormat: string; insertNulls: boolean }) => { - const { dateFormat, insertNulls } = options; - const [{ loading, dataSet, dataGraph, errors }, dispatch] = useReducer(reducer, { - loading: false, - dataSet: null, - dataGraph: null, - errors: [], - }); +/** + * Parses + validates the uploaded workbook into datasets, then derives the dependency graph. + * Datasets and errors coexist: the preview always renders from datasets, while the load button + * is only enabled once requests exist (no blocking errors anywhere). + */ +export const useProcessLoadFile = (org: SalesforceOrgUi, apiVersion: string) => { + const [loading, setLoading] = useAtom(fileParsingState); + const [datasets, setDatasets] = useAtom(datasetsState); + const [parseErrors, setParseErrors] = useAtom(parseErrorsState); + const setParseWarnings = useSetAtom(parseWarningsState); + const setGraphErrors = useSetAtom(graphErrorsState); + const setRequests = useSetAtom(requestsState); + const setGroupsByRefId = useSetAtom(groupsByRefIdState); + const dateFormat = useAtomValue(dateFormatState); + const insertNulls = useAtomValue(insertNullsState); const isMounted = useRef(true); @@ -52,91 +53,60 @@ export const useValidateLoadFile = (org: SalesforceOrgUi, apiVersion: string, op }; }, []); - const processFileGraph = useCallback(() => { - if (!dataSet) { - return; - } - try { - const data = getDataGraph(dataSet, apiVersion, { dateFormat, insertNulls }); - logger.info('[LOAD MULTI OBJ] Data', { data }); - dispatch({ type: 'SUCCESS', payload: { data } }); - } catch (ex) { - const errors = dataSet.flatMap((dataset) => dataset.errors); - logger.error('[LOAD MULTI OBJ] Errors', { errors }); - if (errors.length) { - dispatch({ type: 'FAILURE', payload: { errors } }); - } else { - // Fallback in case there was an exception thrown but no errors specified - dispatch({ - type: 'FAILURE', - payload: { - errors: [ - { - property: null, - worksheet: 'Unknown', - location: null, - message: getErrorMessage(ex), - }, - ], - }, - }); - } - } - }, [dataSet, apiVersion, dateFormat, insertNulls]); - - useEffect(() => { - if (dataSet && !errors.length) { - processFileGraph(); - } - }, [errors, dataSet, processFileGraph]); - - function reset() { - dispatch({ type: 'INIT', payload: { loading: false } }); - } - const processFile = useCallback( async (workbook: XLSX.WorkBook) => { - dispatch({ type: 'INIT', payload: { loading: true } }); + setLoading(true); + setDatasets(null); + setParseErrors([]); + setParseWarnings([]); try { - logger.info('[LOAD MULTI OBJ]', { workbook }); - const datasets = await parseWorkbook(workbook, org); - - logger.info('[LOAD MULTI OBJ]', { datasets }); + const { datasets: parsedDatasets, workbookErrors } = await parseWorkbook(workbook, org); + logger.info('[LOAD MULTI OBJ]', { datasets: parsedDatasets }); if (isMounted.current) { - const errors = datasets.flatMap((dataset) => dataset.errors); - - if (errors.length) { - logger.error('[LOAD MULTI OBJ] Errors', { errors }); - dispatch({ type: 'FAILURE', payload: { errors } }); - } else { - // this will automatically initiate the next part in the process - dispatch({ type: 'PROCESSED_DATASET', payload: { data: datasets } }); - } + const allParseErrors = [...workbookErrors, ...parsedDatasets.flatMap(({ errors }) => errors)]; + setDatasets(parsedDatasets); + setParseErrors(allParseErrors.filter(({ severity }) => severity !== 'warning')); + setParseWarnings(allParseErrors.filter(({ severity }) => severity === 'warning')); } } catch (ex) { + logger.error('[LOAD MULTI OBJ] Error parsing file', ex); if (isMounted.current) { - // Fallback in case there was an exception thrown but no errors specified - logger.error('[LOAD MULTI OBJ] Data', ex); - dispatch({ - type: 'FAILURE', - payload: { - errors: [ - { - property: null, - worksheet: 'Unknown', - location: null, - message: getErrorMessage(ex), - }, - ], - }, - }); + setDatasets([]); + setParseErrors([toUnknownError(ex)]); + } + } finally { + if (isMounted.current) { + setLoading(false); } } }, - [org], + [org, setDatasets, setLoading, setParseErrors, setParseWarnings], ); - return { processFile, reset, loading, data: dataGraph, errors }; + // Derive the dependency graph whenever the datasets or load options change. + // Graph building is skipped while parse errors exist - the per-sheet preview still renders from datasets. + useEffect(() => { + if (!datasets?.length || parseErrors.length) { + setRequests(null); + setGraphErrors([]); + setGroupsByRefId({}); + return; + } + try { + const { requests, errors, groupsByRefId } = buildDataGraph(datasets, apiVersion, { dateFormat, insertNulls }); + logger.info('[LOAD MULTI OBJ] Graph', { requests, errors, groupsByRefId }); + setGraphErrors(errors); + setGroupsByRefId(groupsByRefId); + setRequests(errors.length ? null : requests); + } catch (ex) { + logger.error('[LOAD MULTI OBJ] Error building graph', ex); + setGraphErrors([toUnknownError(ex)]); + setGroupsByRefId({}); + setRequests(null); + } + }, [datasets, parseErrors, apiVersion, dateFormat, insertNulls, setGraphErrors, setGroupsByRefId, setRequests]); + + return { processFile, loading }; }; -export default useValidateLoadFile; +export default useProcessLoadFile; diff --git a/libs/features/query/src/QueryResults/QueryResultsDownloadButton.tsx b/libs/features/query/src/QueryResults/QueryResultsDownloadButton.tsx index 7d05d99f3d..5716d2c405 100644 --- a/libs/features/query/src/QueryResults/QueryResultsDownloadButton.tsx +++ b/libs/features/query/src/QueryResults/QueryResultsDownloadButton.tsx @@ -1,7 +1,7 @@ import { logger } from '@jetstream/shared/client-logger'; import { ANALYTICS_KEYS } from '@jetstream/shared/constants'; -import { AsyncJobNew, BulkDownloadJob, FileExtCsvXLSXJsonGSheet, Maybe, QueryResultsColumn, SalesforceOrgUi } from '@jetstream/types'; -import { DownloadFromServerOpts, Icon, RecordDownloadModal } from '@jetstream/ui'; +import { AsyncJobNew, BulkDownloadJob, Maybe, QueryResultsColumn, SalesforceOrgUi } from '@jetstream/types'; +import { DownloadFromServerOpts, Icon, RecordDownloadFileFormat, RecordDownloadModal } from '@jetstream/ui'; import { fromJetstreamEvents, fromQueryState, useAmplitude } from '@jetstream/ui-core'; import { applicationCookieState, googleDriveAccessState } from '@jetstream/ui/app-state'; import { composeQuery, parseQuery } from '@jetstreamapp/soql-parser-js'; @@ -45,7 +45,7 @@ export const QueryResultsDownloadButton = ({ const [isDownloadModalOpen, setModalOpen] = useState(false); const includeDeletedRecords = useAtomValue(fromQueryState.queryIncludeDeletedRecordsState); - function handleDidDownload(fileFormat: FileExtCsvXLSXJsonGSheet, whichFields: 'all' | 'specified', includeSubquery: boolean) { + function handleDidDownload(fileFormat: RecordDownloadFileFormat, whichFields: 'all' | 'specified', includeSubquery: boolean) { trackEvent(ANALYTICS_KEYS.query_DownloadResults, { source: 'BROWSER', fileFormat, @@ -153,6 +153,7 @@ export const QueryResultsDownloadButton = ({ onDownload={handleDidDownload} includeDeletedRecords={includeDeletedRecords} onDownloadFromServer={handleDownloadFromServer} + loadTemplateOption={sObject && !isTooling ? { sobject: sObject } : undefined} source="query_results" trackEvent={trackEvent} /> diff --git a/libs/shared/constants/src/lib/shared-constants.ts b/libs/shared/constants/src/lib/shared-constants.ts index 33ffe7662b..69bb551cae 100644 --- a/libs/shared/constants/src/lib/shared-constants.ts +++ b/libs/shared/constants/src/lib/shared-constants.ts @@ -272,6 +272,14 @@ export const ANALYTICS_KEYS = { load_Submitted: 'load_Submitted', load_ViewRecords: 'load_ViewRecords', load_DownloadRecords: 'load_DownloadRecords', + /** LOAD MULTI OBJECT */ + load_multi_obj_TemplateDownloaded: 'load_multi_obj_TemplateDownloaded', + load_multi_obj_FileProcessed: 'load_multi_obj_FileProcessed', + load_multi_obj_Submitted: 'load_multi_obj_Submitted', + load_multi_obj_Finished: 'load_multi_obj_Finished', + load_multi_obj_Cancelled: 'load_multi_obj_Cancelled', + load_multi_obj_RetryFailed: 'load_multi_obj_RetryFailed', + load_multi_obj_DownloadResults: 'load_multi_obj_DownloadResults', /** MASS UPDATE */ mass_update_Submitted: 'mass_update_Submitted', mass_update_ApplyAll: 'mass_update_ApplyAll', diff --git a/libs/shared/ui-core/src/record/ViewEditCloneRecord.tsx b/libs/shared/ui-core/src/record/ViewEditCloneRecord.tsx index 280136a15b..da4c771860 100644 --- a/libs/shared/ui-core/src/record/ViewEditCloneRecord.tsx +++ b/libs/shared/ui-core/src/record/ViewEditCloneRecord.tsx @@ -14,7 +14,6 @@ import { CopyAsDataType, ErrorResult, Field, - FileExtCsvXLSXJsonGSheet, Maybe, PicklistFieldValues, RecordResult, @@ -31,6 +30,7 @@ import { Modal, NotSeeingRecentMetadataPopover, PopoverErrorButton, + RecordDownloadFileFormat, RecordDownloadModal, SalesforceLogin, Spinner, @@ -434,7 +434,7 @@ export const ViewEditCloneRecord: FunctionComponent = onClose(); } - function handleDidDownload(fileFormat: FileExtCsvXLSXJsonGSheet, whichFields: 'all' | 'specified', includeSubquery: boolean) { + function handleDidDownload(fileFormat: RecordDownloadFileFormat, whichFields: 'all' | 'specified', includeSubquery: boolean) { trackEvent(ANALYTICS_KEYS.record_modal_download, { fileFormat, whichFields, diff --git a/libs/shared/ui-utils/src/lib/__tests__/prepare-load-multi-object-template.spec.ts b/libs/shared/ui-utils/src/lib/__tests__/prepare-load-multi-object-template.spec.ts new file mode 100644 index 0000000000..64f98ddca4 --- /dev/null +++ b/libs/shared/ui-utils/src/lib/__tests__/prepare-load-multi-object-template.spec.ts @@ -0,0 +1,103 @@ +import * as XLSX from 'xlsx'; +import { prepareExcelFile, prepareLoadMultiObjectTemplate } from '../shared-ui-utils'; + +describe('prepareLoadMultiObjectTemplate', () => { + it('produces the exact Load Records to Multiple Objects template layout', () => { + const output = prepareLoadMultiObjectTemplate({ + sobject: 'Account', + fields: ['Id', 'Name', 'AnnualRevenue', 'IsDeleted'], + records: [ + { attributes: { type: 'Account' }, Id: '001000000000001', Name: 'Acme', AnnualRevenue: 1234.5, IsDeleted: false }, + { attributes: { type: 'Account' }, Id: '001000000000002', Name: 'Globex', AnnualRevenue: null, IsDeleted: true }, + ], + }); + + expect(output).toEqual({ + Account: [ + ['Object Api Name', 'Account'], + ['Operation', 'Insert'], + ['External Id (for upsert)', ''], + [], + ['Reference Id', 'Name', 'AnnualRevenue', 'IsDeleted'], + ['001000000000001', 'Acme', 1234.5, false], + ['001000000000002', 'Globex', '', true], + ], + }); + }); + + it('uses each record Id as the Reference Id and emits an empty string when Id is missing', () => { + const output = prepareLoadMultiObjectTemplate({ + sobject: 'Contact', + fields: ['Id', 'LastName'], + records: [{ Id: '003000000000001', LastName: 'Smith' }, { LastName: 'Jones' }], + }); + + const rows = output['Contact']; + expect(rows[5]).toEqual(['003000000000001', 'Smith']); + expect(rows[6]).toEqual(['', 'Jones']); + }); + + it('excludes Id, relationship (dot path), subquery, and object-valued fields from the columns', () => { + const output = prepareLoadMultiObjectTemplate({ + sobject: 'Account', + fields: ['Id', 'Name', 'Owner.Name', 'Contacts', 'BillingAddress'], + records: [ + { + Id: '001000000000001', + Name: 'Acme', + 'Owner.Name': 'Some Owner', + Contacts: { totalSize: 1, done: true, records: [{ Id: '003000000000001' }] }, + BillingAddress: { city: 'Austin', state: 'TX' }, + }, + // A record where the object-valued fields are null must not re-include those columns + { Id: '001000000000002', Name: 'Globex', 'Owner.Name': null, Contacts: null, BillingAddress: null }, + ], + }); + + const rows = output['Account']; + expect(rows[4]).toEqual(['Reference Id', 'Name']); + expect(rows[5]).toEqual(['001000000000001', 'Acme']); + expect(rows[6]).toEqual(['001000000000002', 'Globex']); + }); + + it('emits values raw except null/undefined becoming empty strings', () => { + const output = prepareLoadMultiObjectTemplate({ + sobject: 'Opportunity', + fields: ['Id', 'Name', 'Amount', 'IsClosed', 'CloseDate', 'NextStep'], + records: [{ Id: '006000000000001', Name: 'Big Deal', Amount: 0, IsClosed: false, CloseDate: '2026-01-31', NextStep: undefined }], + }); + + expect(output['Opportunity'][5]).toEqual(['006000000000001', 'Big Deal', 0, false, '2026-01-31', '']); + }); + + it('sanitizes the sheet name by stripping forbidden characters and truncating to 31 characters', () => { + const forbiddenChars = prepareLoadMultiObjectTemplate({ sobject: 'Bad:Name/With[Chars]?*\\', fields: ['Id'], records: [] }); + expect(Object.keys(forbiddenChars)).toEqual(['BadNameWithChars']); + + const longName = prepareLoadMultiObjectTemplate({ + sobject: 'A_Very_Long_Custom_Object_Api_Name__c', + fields: ['Id'], + records: [], + }); + expect(Object.keys(longName)).toEqual(['A_Very_Long_Custom_Object_Api_N']); + }); + + it('round-trips through prepareExcelFile as an array-of-array sheet', () => { + const output = prepareLoadMultiObjectTemplate({ + sobject: 'Account', + fields: ['Id', 'Name'], + records: [{ Id: '001000000000001', Name: 'Acme' }], + }); + + const fileData = prepareExcelFile(output, undefined, undefined); + const workbook = XLSX.read(fileData, { type: 'array' }); + expect(workbook.SheetNames).toEqual(['Account']); + + const rows = XLSX.utils.sheet_to_json(workbook.Sheets['Account'], { header: 1 }); + expect(rows[0]).toEqual(['Object Api Name', 'Account']); + expect(rows[1]).toEqual(['Operation', 'Insert']); + expect(rows[2]).toEqual(['External Id (for upsert)', '']); + expect(rows[4]).toEqual(['Reference Id', 'Name']); + expect(rows[5]).toEqual(['001000000000001', 'Acme']); + }); +}); diff --git a/libs/shared/ui-utils/src/lib/shared-ui-utils.ts b/libs/shared/ui-utils/src/lib/shared-ui-utils.ts index cf4338802e..ba64c6f17a 100644 --- a/libs/shared/ui-utils/src/lib/shared-ui-utils.ts +++ b/libs/shared/ui-utils/src/lib/shared-ui-utils.ts @@ -10,7 +10,16 @@ import { checkMetadataRetrieveResults, checkMetadataRetrieveResultsAndDeployToTarget, } from '@jetstream/shared/data'; -import { NOOP, REGEX, delay, ensureArray, ensureBoolean, getErrorMessage, orderObjectsBy } from '@jetstream/shared/utils'; +import { + NOOP, + REGEX, + delay, + ensureArray, + ensureBoolean, + getErrorMessage, + getExcelSafeSheetName, + orderObjectsBy, +} from '@jetstream/shared/utils'; import type { AddOrgHandlerFn, AndOr, @@ -429,6 +438,56 @@ export function excelWorkbookToArrayBuffer(workbook: XLSX.WorkBook, options?: XL return workbookArrayBuffer; } +/** + * Build the sheet rows for a "Load Records to Multiple Objects" template from records already in the browser. + * Returns a map of sheet name to array-of-array rows, ready to pass to prepareExcelFile (or FileDownloadModal). + * + * Each record's Id becomes its Reference Id and the operation defaults to Insert, so the file can be loaded + * into another org as-is. Relationship ("." path), subquery, and other non-primitive (e.g. compound address) + * fields are excluded because the template only supports writable fields on the target object. + */ +export function prepareLoadMultiObjectTemplate({ + sobject, + fields, + records, +}: { + sobject: string; + fields: string[]; + records: any[]; +}): Record { + const templateFields = fields.filter((field) => { + if (field === 'Id' || field.includes('.')) { + return false; + } + // Subquery results and compound values (e.g. address) surface as objects, which the template cannot represent + return !records.some((record) => record[field] !== null && typeof record[field] === 'object'); + }); + + function getCellValue(value: unknown) { + if (value === null || value === undefined) { + return ''; + } + // Object-valued fields were excluded above; this is a defensive guard for anything that slipped through + if (typeof value === 'object') { + return ''; + } + return value; + } + + const rows: any[][] = [ + ['Object Api Name', sobject], + ['Operation', 'Insert'], + ['External Id (for upsert)', ''], + [], + ['Reference Id', ...templateFields], + ...records.map((record) => [record.Id ?? '', ...templateFields.map((field) => getCellValue(record[field]))]), + ]; + + // Salesforce API names cannot contain Excel's forbidden characters, but sanitize defensively + const sheetName = getExcelSafeSheetName(sobject.replace(/[:\\/?*[\]]/g, '')); + return { [sheetName]: rows }; +} + export function prepareCsvFile(data: Record[], header: string[]): string { return unparse( { diff --git a/libs/ui/src/lib/data-table/DataTableRenderers.tsx b/libs/ui/src/lib/data-table/DataTableRenderers.tsx index 3c9ac1fb94..f8ff614bb9 100644 --- a/libs/ui/src/lib/data-table/DataTableRenderers.tsx +++ b/libs/ui/src/lib/data-table/DataTableRenderers.tsx @@ -18,6 +18,7 @@ export { getRecordErrorColumn, getRecordErrorRowHeight, getWrappedTextRowHeight, + withCellValidation, } from './grid/renderers/CellRenderers'; export type { RowWithRecordError } from './grid/renderers/CellRenderers'; export { SubqueryRenderer } from './grid/renderers/SubqueryRenderer'; diff --git a/libs/ui/src/lib/file-download-modal/RecordDownloadModal.tsx b/libs/ui/src/lib/file-download-modal/RecordDownloadModal.tsx index 380f5b6047..928baf5c70 100644 --- a/libs/ui/src/lib/file-download-modal/RecordDownloadModal.tsx +++ b/libs/ui/src/lib/file-download-modal/RecordDownloadModal.tsx @@ -11,22 +11,12 @@ import { isEnterKey, prepareCsvFile, prepareExcelFile, + prepareLoadMultiObjectTemplate, saveFile, tracker, } from '@jetstream/shared/ui-utils'; import { flattenRecords, getErrorMessage, getMapOfBaseAndSubqueryRecords } from '@jetstream/shared/utils'; -import { - FileExtCsv, - FileExtCsvXLSXJsonGSheet, - FileExtGDrive, - FileExtJson, - FileExtXLSX, - Maybe, - MimeType, - QueryResultsColumn, - SalesforceOrgUi, - SalesforceRecord, -} from '@jetstream/types'; +import { FileExtCsvXLSXJsonGSheet, Maybe, MimeType, QueryResultsColumn, SalesforceOrgUi, SalesforceRecord } from '@jetstream/types'; import { Fragment, FunctionComponent, KeyboardEvent, useEffect, useRef, useState } from 'react'; import FileDownloadGoogle from '../file-download-modal/options/FileDownloadGoogle'; import Checkbox from '../form/checkbox/Checkbox'; @@ -51,10 +41,14 @@ import { RADIO_FORMAT_GDRIVE, RADIO_FORMAT_JSON, RADIO_FORMAT_XLSX, + RADIO_FORMAT_XLSX_LOAD_TEMPLATE, RADIO_SELECTED, saveFileFormatToStorage, } from './download-modal-utils'; +/** All formats the modal can produce - the load template format is only offered when `loadTemplateOption` is provided */ +export type RecordDownloadFileFormat = FileExtCsvXLSXJsonGSheet | typeof RADIO_FORMAT_XLSX_LOAD_TEMPLATE; + export interface DownloadFromServerOpts { fileFormat: FileExtCsvXLSXJsonGSheet; fileName: string; @@ -91,15 +85,21 @@ export interface RecordDownloadModalProps { totalRecordCount?: number; includeDeletedRecords?: boolean; source: string; + /** + * When provided, adds a "Load template (Excel)" format that generates a file in the + * "Load Records to Multiple Objects" template format for the given object. + * Only works with records already in the browser, so the server-side "all records" scope is disabled while selected. + */ + loadTemplateOption?: { sobject: string }; trackEvent: (key: string, value?: Record) => void; onModalClose: (canceled?: boolean) => void; - onDownload?: (fileFormat: FileExtCsvXLSXJsonGSheet, whichFields: 'all' | 'specified', includeSubquery: boolean) => void; + onDownload?: (fileFormat: RecordDownloadFileFormat, whichFields: 'all' | 'specified', includeSubquery: boolean) => void; onDownloadFromServer?: (options: DownloadFromServerOpts) => void; children?: React.ReactNode; } const PROHIBITED_BULK_APEX_TYPES = new Set(['Address', 'Location', 'complexvaluetype']); -const FILE_FORMAT_ALLOWED_BULK_API = new Set(['csv', 'gdrive']); +const FILE_FORMAT_ALLOWED_BULK_API = new Set(['csv', 'gdrive']); const ALLOW_BULK_API_COUNT = 5_000; const REQUIRE_BULK_API_COUNT = 500_000; @@ -123,6 +123,7 @@ export const RecordDownloadModal: FunctionComponent = totalRecordCount, includeDeletedRecords = false, source, + loadTemplateOption, trackEvent, onModalClose, onDownload, @@ -139,7 +140,7 @@ export const RecordDownloadModal: FunctionComponent = const [downloadRecordsValue, setDownloadRecordsValue] = useState(() => getWhichRecordsDefaultValue({ hasMoreRecords: false, records, selectedRecords }), ); - const [fileFormat, setFileFormat] = useState(() => getInitialDownloadFileFormat(allowedTypes, LS_KEY)); + const [fileFormat, setFileFormat] = useState(() => getInitialDownloadFileFormat(allowedTypes, LS_KEY)); const [downloadMethod, setDownloadMethod] = useState( RADIO_DOWNLOAD_METHOD_STANDARD, ); @@ -161,6 +162,10 @@ export const RecordDownloadModal: FunctionComponent = const hasSubqueryFields = subqueryFields && !!Object.keys(subqueryFields).length && (fileFormat === 'xlsx' || fileFormat === 'gdrive'); + const isLoadTemplate = fileFormat === RADIO_FORMAT_XLSX_LOAD_TEMPLATE; + // The load template format is a regular .xlsx file - the format value is only distinct for option selection + const fileExtension = isLoadTemplate ? RADIO_FORMAT_XLSX : fileFormat; + // Big objects report -1 as totalSize const totalRecordCountText = (totalRecordCount || records.length) < 0 @@ -196,6 +201,13 @@ export const RecordDownloadModal: FunctionComponent = } }, [isBulkApi]); + // The load template can only be generated from records already in the browser + useEffect(() => { + if (isLoadTemplate && downloadRecordsValue === RADIO_ALL_SERVER) { + setDownloadRecordsValue(RADIO_ALL_BROWSER); + } + }, [isLoadTemplate, downloadRecordsValue]); + useEffect(() => { if (!fileName || (fileFormat === 'gdrive' && !isSignedInWithGoogle)) { setInvalidConfig(true); @@ -204,14 +216,17 @@ export const RecordDownloadModal: FunctionComponent = } }, [fileName, fileFormat, googleFolder, isSignedInWithGoogle]); + function getDefaultFileName() { + if (records.length > 0 && records[0].attributes && records[0].attributes.type) { + return getFilename(org, ['records', records[0].attributes.type]); + } + return getFilename(org, ['records']); + } + useEffect(() => { if (downloadModalOpen) { setDoFocusInput(true); - if (records.length > 0 && records[0].attributes && records[0].attributes.type) { - setFileName(getFilename(org, ['records', records[0].attributes.type])); - } else { - setFileName(getFilename(org, ['records'])); - } + setFileName(getDefaultFileName()); } else { setDownloadRecordsValue(getWhichRecordsDefaultValue({ hasMoreRecords, records, selectedRecords })); setFileFormat(getInitialDownloadFileFormat(allowedTypes, LS_KEY)); @@ -234,6 +249,17 @@ export const RecordDownloadModal: FunctionComponent = setDownloadRecordsValue(getWhichRecordsDefaultValue({ hasMoreRecords: hasMoreRecordsTemp, records, selectedRecords })); }, [totalRecordCount, records, selectedRecords]); + function handleFileFormatChange(value: string) { + const newFileFormat = value as RecordDownloadFileFormat; + // Entering/leaving the load template format swaps the default filename to match the output + if (newFileFormat === RADIO_FORMAT_XLSX_LOAD_TEMPLATE && loadTemplateOption) { + setFileName(`${loadTemplateOption.sobject}-load-template`); + } else if (fileFormat === RADIO_FORMAT_XLSX_LOAD_TEMPLATE && newFileFormat !== RADIO_FORMAT_XLSX_LOAD_TEMPLATE) { + setFileName(getDefaultFileName()); + } + setFileFormat(newFileFormat); + } + function handleModalClose(canceled?: boolean) { onModalClose(canceled); if (whichFields !== 'specified') { @@ -260,7 +286,7 @@ export const RecordDownloadModal: FunctionComponent = } try { - const fileNameWithExt = `${fileName}${fileFormat !== 'gdrive' ? `.${fileFormat}` : ''}`; + const fileNameWithExt = `${fileName}${fileFormat !== 'gdrive' ? `.${fileExtension}` : ''}`; let activeRecords = records; if (downloadRecordsValue === RADIO_FILTERED) { @@ -270,7 +296,7 @@ export const RecordDownloadModal: FunctionComponent = } /** Google will always load in the background to account for upload to Google */ - if (fileFormat === 'gdrive' || downloadRecordsValue === RADIO_ALL_SERVER) { + if ((fileFormat === 'gdrive' || downloadRecordsValue === RADIO_ALL_SERVER) && fileFormat !== RADIO_FORMAT_XLSX_LOAD_TEMPLATE) { // emit event, which starts job, which downloads in the background if (onDownloadFromServer) { onDownloadFromServer({ @@ -305,6 +331,16 @@ export const RecordDownloadModal: FunctionComponent = mimeType = MIME_TYPES.XLSX; break; } + case RADIO_FORMAT_XLSX_LOAD_TEMPLATE: { + const data = prepareLoadMultiObjectTemplate({ + sobject: loadTemplateOption?.sobject || '', + fields: fieldsToUse, + records: activeRecords, + }); + fileData = prepareExcelFile(data, undefined, undefined, { onCellsTruncated: notifyExcelCellsTruncated }); + mimeType = MIME_TYPES.XLSX; + break; + } case 'csv': { const data = flattenRecords(activeRecords, fields); fileData = prepareCsvFile(data, fields); @@ -335,7 +371,7 @@ export const RecordDownloadModal: FunctionComponent = // Cell values are truncated before writing, but other SheetJS limits (e.g. the 1,048,576-row cap) can still throw. if (getErrorMessage(ex).includes('32767')) { setErrorMessage(`One or more values exceed Excel's 32,767 character cell limit. Download as CSV or JSON to get full values.`); - } else if (fileFormat === 'xlsx') { + } else if (fileFormat === 'xlsx' || fileFormat === RADIO_FORMAT_XLSX_LOAD_TEMPLATE) { setErrorMessage('There was a problem preparing your file download. Try downloading as CSV or JSON.'); } else { setErrorMessage('There was a problem preparing your file download.'); @@ -418,6 +454,7 @@ export const RecordDownloadModal: FunctionComponent = value={RADIO_ALL_SERVER} checked={downloadRecordsValue === RADIO_ALL_SERVER} onChange={setDownloadRecordsValue} + disabled={isLoadTemplate} /> = label="Excel" value={RADIO_FORMAT_XLSX} checked={fileFormat === RADIO_FORMAT_XLSX} - onChange={(value: string) => setFileFormat(value as FileExtXLSX)} + onChange={handleFileFormatChange} disabled={isBulkApi} /> = label="CSV" value={RADIO_FORMAT_CSV} checked={fileFormat === RADIO_FORMAT_CSV} - onChange={(value: string) => setFileFormat(value as FileExtCsv)} + onChange={handleFileFormatChange} /> setFileFormat(value as FileExtJson)} + onChange={handleFileFormatChange} disabled={isBulkApi} /> + {loadTemplateOption && ( + + )} {hasGoogleInputConfigured && ( setFileFormat(value as FileExtGDrive)} + onChange={handleFileFormatChange} /> )} {!googleIntegrationEnabled && googleShowUpgradeToPro && ( @@ -485,6 +532,14 @@ export const RecordDownloadModal: FunctionComponent = /> )} + {isLoadTemplate && ( + + Creates an Excel file in the Load Records to Multiple Objects template format. Each record's Id becomes its Reference + Id and the operation is set to Insert - ideal for migrating records to another org. Adjust the operation, remove unwanted + columns, and wrap lookup column headers in {'{curly braces}'} to link related records in the same file. Relationship and + subquery fields are not included. + + )} {hasSubqueryFields && ( = id="download-filename" label="Filename" isRequired - rightAddon={fileFormat !== RADIO_FORMAT_GDRIVE ? `.${fileFormat}` : undefined} + rightAddon={fileFormat !== RADIO_FORMAT_GDRIVE ? `.${fileExtension}` : undefined} errorMessage="This field is required" errorMessageId="filename-error" > diff --git a/libs/ui/src/lib/file-download-modal/download-modal-utils.ts b/libs/ui/src/lib/file-download-modal/download-modal-utils.ts index 0812ace932..61f7432163 100644 --- a/libs/ui/src/lib/file-download-modal/download-modal-utils.ts +++ b/libs/ui/src/lib/file-download-modal/download-modal-utils.ts @@ -4,6 +4,8 @@ import { FileExtCsv, FileExtJson, FileExtXLSX, FileExtXml, FileExtZip, Maybe } f import { fireToast } from '../toast/AppToast'; export const RADIO_FORMAT_XLSX: FileExtXLSX = 'xlsx'; +/** Excel download in the "Load Records to Multiple Objects" template format - only offered when the modal is given a `loadTemplateOption` */ +export const RADIO_FORMAT_XLSX_LOAD_TEMPLATE = 'xlsx-load-template' as const; export const RADIO_FORMAT_CSV: FileExtCsv = 'csv'; export const RADIO_FORMAT_JSON: FileExtJson = 'json'; export const RADIO_FORMAT_GDRIVE = 'gdrive';