From 1b2f0793592335310ce22a5b95a1559cda1a1747 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:50:28 +0530 Subject: [PATCH] Page Layout - Addition of widgets (#14184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/user-attachments/assets/130f12d7-5cef-4b83-912f-a9d42241f69f ~~This could be considered a POC 😅~~ addressing - https://github.com/twentyhq/core-team-issues/issues/1400 https://github.com/twentyhq/core-team-issues/issues/1403 --- .../placeholders/background/no_widgets_bg.png | Bin 0 -> 6424 bytes .../dark-background/no_widgets_bg.png | Bin 0 -> 6070 bytes .../dark-moving-image/no_widgets.png | Bin 0 -> 16165 bytes .../placeholders/moving-image/no_widgets.png | Bin 0 -> 16218 bytes .../contexts/ActionMenuContextProvider.tsx | 13 +- .../src/modules/app/components/AppRouter.tsx | 12 +- .../modules/app/components/SettingsRoutes.tsx | 36 +- .../modules/app/hooks/useCreateAppRouter.tsx | 2 + .../components/CommandMenuRouter.tsx | 15 +- .../constants/CommandMenuPagesConfig.tsx | 10 + .../CommandMenuPageLayoutGraphTypeSelect.tsx | 77 +++++ .../CommandMenuPageLayoutWidgetTypeSelect.tsx | 93 ++++++ .../command-menu/types/CommandMenuPages.ts | 2 + .../types/CoreObjectNameSingular.ts | 1 + .../SettingsPageFullWidthContainer.tsx | 6 +- .../hooks/useSettingsNavigationItems.tsx | 4 +- .../PageLayoutInitializationEffect.tsx | 98 ++++++ .../PageLayoutWidgetPlaceholder.tsx | 131 ++++++++ .../PageLayoutWidgetPlaceholder.stories.tsx | 313 ++++++++++++++++++ .../page-layout/constants/EmptyLayout.ts | 6 + .../page-layout/constants/GridBufferRows.ts | 1 + .../page-layout/constants/GridMinRows.ts | 1 + .../constants/PageLayoutBreakpoints.ts | 14 + .../__tests__/usePageLayoutDraftState.test.ts | 80 +++++ .../usePageLayoutDragSelection.test.ts | 100 ++++++ .../usePageLayoutHandleLayoutChange.test.ts | 62 ++++ .../usePageLayoutWidgetCreate.test.ts | 50 +++ .../usePageLayoutWidgetDelete.test.ts | 41 +++ .../hooks/usePageLayoutDraftState.ts | 28 ++ .../hooks/usePageLayoutDragSelection.ts | 61 ++++ .../hooks/usePageLayoutHandleLayoutChange.ts | 32 ++ .../hooks/usePageLayoutSaveHandler.ts | 63 ++++ .../hooks/usePageLayoutWidgetCreate.ts | 98 ++++++ .../hooks/usePageLayoutWidgetDelete.ts | 41 +++ .../settings/page-layout/mocks/mockWidgets.ts | 119 +++++-- .../pageLayoutCurrentBreakpointState.ts | 8 + .../states/pageLayoutCurrentLayoutsState.ts | 7 + .../states/pageLayoutDraftState.ts | 16 + .../states/pageLayoutDraggedAreaState.ts | 13 + .../states/pageLayoutPersistedState.ts | 9 + .../states/pageLayoutSelectedCellsState.ts | 6 + .../states/pageLayoutWidgetsState.ts | 7 + .../states/savedPageLayoutsState.ts | 34 ++ ...lculateGridBoundsFromSelectedCells.test.ts | 138 ++++++++ .../__tests__/calculateTotalGridRows.test.ts | 40 +++ .../__tests__/convertLayoutsToWidgets.test.ts | 79 +++++ .../utils/__tests__/generateCellId.test.ts | 15 + .../getDefaultWidgetPosition.test.ts | 23 ++ .../parseCellIdToCoordinates.test.ts | 18 + .../calculateGridBoundsFromSelectedCells.ts | 30 ++ .../utils/calculateTotalGridRows.ts | 18 + .../utils/convertLayoutsToWidgets.ts | 31 ++ .../page-layout/utils/generateCellId.ts | 3 + .../page-layout/utils/getDefaultWidgetData.ts | 76 +++++ .../utils/getDefaultWidgetPosition.ts | 17 + .../utils/parseCellIdToCoordinates.ts | 9 + .../page-layout/utils/widgetRegistry.tsx | 69 ++++ .../src/modules/types/SettingsPath.ts | 4 +- .../layout/page/components/DefaultLayout.tsx | 6 +- .../page-layout/PageLayoutEdition.tsx | 184 ---------- .../page-layout/SettingsPageLayoutEdit.tsx | 311 +++++++++++++++++ .../page-layout/SettingsPageLayouts.tsx | 129 ++++++++ .../PageLayoutWidgetPlaceholder.tsx | 136 -------- .../PageLayoutWidgetPlaceholder.stories.tsx | 156 --------- .../page-layout/states/pageLayoutState.ts | 17 - .../display/icon/components/TablerIcons.ts | 7 +- packages/twenty-ui/src/display/index.ts | 7 +- .../constants/Background.ts | 1 + .../constants/DarkBackground.ts | 1 + .../constants/DarkMovingImage.ts | 1 + .../constants/MovingImage.ts | 1 + 71 files changed, 2696 insertions(+), 541 deletions(-) create mode 100644 packages/twenty-front/public/images/placeholders/background/no_widgets_bg.png create mode 100644 packages/twenty-front/public/images/placeholders/dark-background/no_widgets_bg.png create mode 100644 packages/twenty-front/public/images/placeholders/dark-moving-image/no_widgets.png create mode 100644 packages/twenty-front/public/images/placeholders/moving-image/no_widgets.png create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect.tsx create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx create mode 100644 packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutInitializationEffect.tsx create mode 100644 packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutWidgetPlaceholder.tsx create mode 100644 packages/twenty-front/src/modules/settings/page-layout/components/__stories__/PageLayoutWidgetPlaceholder.stories.tsx create mode 100644 packages/twenty-front/src/modules/settings/page-layout/constants/EmptyLayout.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/constants/GridBufferRows.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/constants/GridMinRows.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/constants/PageLayoutBreakpoints.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDraftState.test.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDragSelection.test.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutHandleLayoutChange.test.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetCreate.test.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetDelete.test.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDraftState.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDragSelection.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutHandleLayoutChange.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutSaveHandler.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetCreate.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetDelete.ts rename packages/twenty-front/src/{pages => modules}/settings/page-layout/mocks/mockWidgets.ts (51%) create mode 100644 packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentBreakpointState.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentLayoutsState.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraftState.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraggedAreaState.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutPersistedState.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutSelectedCellsState.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutWidgetsState.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/states/savedPageLayoutsState.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/calculateGridBoundsFromSelectedCells.test.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/calculateTotalGridRows.test.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/convertLayoutsToWidgets.test.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/generateCellId.test.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/getDefaultWidgetPosition.test.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/parseCellIdToCoordinates.test.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/utils/calculateGridBoundsFromSelectedCells.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/utils/calculateTotalGridRows.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/utils/convertLayoutsToWidgets.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/utils/generateCellId.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetData.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetPosition.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/utils/parseCellIdToCoordinates.ts create mode 100644 packages/twenty-front/src/modules/settings/page-layout/utils/widgetRegistry.tsx delete mode 100644 packages/twenty-front/src/pages/settings/page-layout/PageLayoutEdition.tsx create mode 100644 packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx create mode 100644 packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayouts.tsx delete mode 100644 packages/twenty-front/src/pages/settings/page-layout/components/PageLayoutWidgetPlaceholder.tsx delete mode 100644 packages/twenty-front/src/pages/settings/page-layout/components/__stories__/PageLayoutWidgetPlaceholder.stories.tsx delete mode 100644 packages/twenty-front/src/pages/settings/page-layout/states/pageLayoutState.ts diff --git a/packages/twenty-front/public/images/placeholders/background/no_widgets_bg.png b/packages/twenty-front/public/images/placeholders/background/no_widgets_bg.png new file mode 100644 index 0000000000000000000000000000000000000000..3df7d4c63915069412ca65c5beb10ecedd9fcddd GIT binary patch literal 6424 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 P17LXDOutO;{?q>hprNv+ literal 0 HcmV?d00001 diff --git a/packages/twenty-front/public/images/placeholders/dark-background/no_widgets_bg.png b/packages/twenty-front/public/images/placeholders/dark-background/no_widgets_bg.png new file mode 100644 index 0000000000000000000000000000000000000000..d247ddbb71d1611f8a4a9e5c7e30cc59c544fd77 GIT binary patch literal 6070 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 XhHqC)&kNAo?7*E{Ci>;Nj_CgZm=_0h literal 0 HcmV?d00001 diff --git a/packages/twenty-front/public/images/placeholders/dark-moving-image/no_widgets.png b/packages/twenty-front/public/images/placeholders/dark-moving-image/no_widgets.png new file mode 100644 index 0000000000000000000000000000000000000000..ee6278c35e49d587b6dfa22398d3eaaeac86799b GIT binary patch literal 16165 zcmYkjWmH>T*EQU7;qGq1-Q5Z9?$YA!p*R$GcPF?MD^|R?I}|AH?(T1TKi`ipV?;*A zN%lTl=30Bsxgu4SWl#_a5kGzUgd!&^ss8EHXA;QgMtE4rYX$1#5#$ZQSys>O(PE|%oGF)`w)^xPxwYP!Z1a%EfAEk41rL2j#m((S2pz;y z_+4x>WPW}gJ{WA%H4`D#IO6|Pi(!0Za#9pNumB6bh_Wu)+@27RAa-=%JA3aWz)q8) z+hQ3R9-2f9pCl0HjEzT|4tubKpC4w79K8_>2O3ItP`U`gfAA}te?N~^eZJTek3|Bk z+8miwzCl`H#V%78!!^@+MFuWuP3Dw;qkvH=d@HmAg;1_>9OqP6jM8pOXhfSUASICS z#{aBKMfLv8l`Hi?|Bboyg;=xdZ9gJGVH4Q|;gFP+;-^zQf^5x|6(I+EdmB2wu-7J}&~?g9_@cSkdJQ}a zDL$+)?wtegiKV4&-3ttWjuQ|+*HP90!4G46Nm9`WzI)jGkr3O}=D~kk_Cl{GW=h_~ z*PEyJ5JV}EEPPr8-&yuR5u?moKubVmP#NzCyh>L%I~Hb0H5LE%ZJh_+8@u=Lkqy$X zKz}h!wghw*hehtssZC;mxGXY|Uc;Gyd=t{z%Kwxg=CSegVSQ(Wg^Jut+Nv+fBgi)k z{hfv-CdF(RmYfjXGOfz+CU;R0QeZ$C6Nd)aTbhv*s}Sc$BbI3LA1F|N2HJ zFrX72m_OOO$A{3GvA)A=6k@oBWdiDms5M9P7}y-{Xd7t)r%~ewYJ*J=Y{3t==#mFw ztYU*SOiZVKMHmVu$@4POH1FwS7;bBrr0T6ih$Z_cpy>Z3Us%ptg=Y(Z5tiCwYCXEZzg9?!f%+zW;reu&z}!qL`Fdb zyg0&zFWUL++r$#pkyB>XOP#|Av%dB@eoOLVc@S6wL$P~p6f)z2<)EZh&{l4Z2=36W z6eC6T5~%rlK(Kj2dC6C3Vf!FqEx=M*U4fXGd;r)w54gmk*GllR$dSNxCjIy6 zrL&T)rsgeJ9v0eI!c!o(xD4AQsRv+G#u*llj2byaf>O0RRH)D5LBPntw7Jq6hqJla)u&VKO!9 zEvcEeqhpUWyigFEfWx#lkjw@iOlg>Mx=oo-AMrJecDS%Nrz0T_|G!H0?juZ4#$=fN znN;KSrAY4~-*3EzaEoC&-TPnk^WIjie=&@z_gp;+0ilLCqBYx3!p#}_)<19thy37+ z%z|qPiS*ZLE&_q%K3izqe<*S-0#G{;g+*=SXS9Y7e@X1rOCAcZi{q1ERaDS7WM`vr zX_;i5IdpxsFGa4~P#wb^&2({htBw=1z%kMbN_Wv|NA}@!0tfOhFF(NnoC!MD4@2#@yA6xOvhNS|spl%YZd@8F6lB zY`>u8`NnU7KslaApm(WpFc*N=8E0kr?dl!1sc{soe8#3Y1 z!$(&mY-Y;jX#t9vc1UkLLJU*zJ(b{KEJjD3bfkxn#Ev?PIijJX35;%eC)4Fy70&7C zaF{jPrQ_KIx(xBCiHLN#$6Vpg;amS!?j!-sB&2JLBEOqkA6^JnowzQliV+McoDRk^ zqz1IXx1P>b$HuXyGfWS#HP<>35gDvvxX!$YSrA>}iaQ!nsurgr17(CUI80NnRue6+ zZ@vFm{IVkE5ciI^2G97nmCRhZo6Y^2GIyn@7zCYKO9L-$}?ai z_KoJ`^x~j9x24Wt3=X<^3e?vz5BGV!9@iY1~>&F#A(auTiq)$VA{XnjR z)Y;WamBEAdIa3~D?Wvj{2rH`F@VFo}?Ef(S7OKM$6jFcGr8+DX+qde6(GHm=c_kgE zMV}5!knh@(suMng4*K9Ae;$0Rp<3tpIrUB4tTqx#$!z{@9w~qR*pBp2`blBLz2z+8 z4;QQ9y{J7`hWdhoL%Un)&LK1Dp%gQTvGp4Vry}*xVfqJ3-(Hg5C6L=Hj;nifLQ3Ay zv*HF9UmBlN<}q(4ZK$Cm@4uPeW5n4IM)3$WBzZF}&|;W%_HD9VBgGN4mLE7Kt$z45 z0a;4-b!SYZ)(sc|@chEm zdS;zCc+PWT&ryaSvHfx;%aZ>l#5(?X_%;6TD(A4;1y^@kZ~d`(79x@_HPM)ZgAbP= zQxlaSed}pWb&1@``%=e}2hixDEI*IZO=d~S7O0}RMWZ6f^3~A99vbyZ6LN8%7ZS=& z)_K7LX!x)(bsCSyG)?EE5Hi5V5eZc{30EV2c8)Ff;29Rk94+@jM#2fPN&Ol!wgSo? z_L;}?hmURbR|}R0L6hPQWAwY2gJot3gBo3CS}iu2wu^@#JhQpnXEdb0;gDabc+gkW zaR@L|8e%v2HEp-Ia|_~uP%&KHNe`GcVvkx|VTiV*e#D>}yuW|uQP}8G#iRbKaBD0% zyx`^A6dE?_N-1Q>NyekF+4xGr?GAVVb$lksZrzatSUQzBVY3OTjq&9(q&eD*lUd!nw z?ng%}DSi=zOVjQTen=eFRjCaLkrZ_WZYXOfZA+GX`4sdV7urscicBRfMqURDG0Yw6 zo)VMfXzgcZAV3Kns*Z`Di3ra`k&TUt64{+h7z58BkO>lPVFiTNsday^2md`yOZ;5P z--^=acjDD?s9Z}6$cY@PM~Bx30BdRhC&JYdHMpHJRn>n{c{isIL)dkg$X)0-88A*a z8a!XOUFdE`}DSTb7Es#HxeDzT*9xG5%eOPMI%>(SzF!xGfB& ziNem|>pRnj^SnF&+t)Kyx>UZ$QIz53XHw~R6WZw?2irf9@1C90r+@H%DqC(@+T=UR zRw8!}fvgHNqjr-br+OmSo*xgA0q2$fZstm*3mYSS{rAJk#)>F(L7MXQ;^-J|$(XW%Z36x@IV^)9`pJ)uv=KYYrXZuLll0oN{En(* zuxvn{rAcfvf?VUXvw6hs9sJH5yLIGR@-k9V*JS3yekq}Oi8eZUMy2}%w z0UuV$?`A`_S3dA@2^{|d!;|<@^YRV&@NAk6dP~M;pt8l5Ra7FE{b2#>j#9%%DD42< zv?kT?LH3awNaseH=pDdszqsUm``C-2t)4GsdN22)|FwSi%9ro6(rept?MzA=fOB*S;3xnZ9tZT~JJdExe zdSNNk=3v+lN&wP?c}x(T;khd~Q<=gwpWaC##1P5~f|PdCO(*5WP)X}%y#E{NNlSA3 zCD49TzsUs|?bdu-S;Dum3r{{R|5G?~Az{SNGb7WtWJ8_#B=J;$fHA>Ck2x$~!IztK z=gU1JbWwLw(^@~`<2sRPc2=2@5bL0rg!YjAB-;u!Bm&XI6-sNQ`q2e$*$cptBp9`B z1sJ@X3UBGFJUTcuco;He*q@VG?C7zDl1z&!OSnR7LaaI$|{!mJ&C5MoChGAsR z3MG(5+~D8MsuHB^;1Xt-F9VCT;&&mB5=5B%d7D924jA^u5(&EI|{7mB)pIfpNI!kuXD2?*nuSX7#iX5~YF8I_T zt?-vewl!EjT6E(6su-9IRu88oPqGmnSNBr1rfJbj(SZLO4d2=Z>*RJSc2RH8ZKS7X zRIzZpZh1ZpV@WG5ff@Fg>;A%9&{~5iqa%u{)I9?qb2NrBc@@AS_B6vBLEwYM%eN=f zvVCdS0{Heip$Zp%qg5TuI%OKPQF>?+@gJ`4sBQj8i^5U*b}p@NW}zDDN&B3&2VS^JCHm*_ znTc_S1PU2xbNv#`p?~vzxQnk6z-rHMH$tRrQ-jl*PRcYPat8p)(px0cA^hon=>1ux zfIyZTjjACUCHg(yleNOT2m*b`ZDJy=gxNO9cL&6t2;5tH_%Mx}L>k+%;Rk0_v$ZJH z3a9tzKN5rAkyRvSt%mk zlQvjkWeGun{aD3!oxRl&jk`+_k;S*QkT&rcCh`!X$bx>0>i~R*vE!9W6C_vwTu6T< zoRC|O6}|W%d_}eedQjSE^0`s$p&edNIjT95+|3d<$*7ZEn zGFB#K?XUQ2n2C`kWVpY_hTTDH#C9FFasTo``4ch|(RptL+id6mn3zogIk z+NLra6Rw+q9r3(HXa|M!o+OOfwpmo73b*?a$*N;>$X_9^)Qe}qoH25Czl$ z;L4DDcbn*}1B5NKYfIC&EuSA>(wryQ5_OH!+OC;dT6N6@w#8M~SOiy?BRbM>bBio| zRek%KtyxaMi^tBHp8&%DR+OV^?cduRTAZ>XF%q6Or23;gC2faZJH^e*d_1&r=i<9y zLGoc>MPY>Iv`CwRDOh7I?5H78N5tBgo9CE_nn1FP<;-a8RY1#G_)kez93?SxZ88ld zp2ssSDz8!|o4zNPbk`b3B#?zrOMkV7Z6IFWh685Kr#z+?E14dizeOg*FzB8<+_3mT zRX=R~Q+{uSBd0qc>B1}BNM`QQwsFDT7QB_vLdlBXL~OQctZZ~YRkH}n*|PUCQdu~W zqg=*%7z%Ytqa^3R@Hd%x4NW=!Kx}JxK_4`g;NXjG!`yv!qV9c;GX-d)^YX#0Y#hA# z0_O&=MzBwrzmA>26>NgDeP}h%O$9;=C{@ArdHm-G3R|3fvkso|IX3Zy&0qoyiBe~@ zFl2>uUPdq~VrYOQRJSr?oRh>r;>0u?a~cbmu)*$#iN3w_D|vY^_iR&#AI=vJkLc2! zh||+T`_czRs}Do52^f>Bpoa7>;k{FNeVGBG!5vN;Y#RqlqUQtkya$;r6$!VeGE?B~ zex5F+nw6`7_3$4H-Rxu$y<+0Ns9U+ZFT?IwWlt(I2R={R;yRV_s0L%$cx$Kt29aL~?$5P7Hp^4ZZE zUti5!bh`ntal4b~$*ILoAdbActW~cm}|Bbxmf5H!Qh#6lG)OsM-D%W5dE(c zw#Q!imxlY(sR(?oOuY12cC7;rFluB%sa9RWkBT;A?kCz-3r9MksMn^vTt#sR7MF~` zMM3!U?^dG0i_WDhA#r)JmysR-zDVuw)wQ%$C!y6KmDVOzDnGF=|DmWZI|Pe8a|_Bi{L)aq zm*wUv+>>+2D2LCCB;tKUm`eNk29CS#&=gO;%R4Z>?7D3Rpst}odF%aV>f@kJpPTi! zIE(R*5;F%~EnI2TxBsHWaXNyGOdMn79rHjjfwj(8k+=&bbk9_|ou3J`<5gVjURl%I zCr&V`)nS_Lhf`)*g%FOo2l82)5Q`!T8WHTNpKPGZluoDbBvp_rbAU zusr0udC_4L^F~w~8b$p+?0V&K-J=LoHmQ7qzS)Sx2N5&|vszUdsDm>F$YbI~OKotV zkN8s$;tTE%7ghh@W?#qv8@^q5Q%!u?yNMdA89+_tIY5+q)Cl z5@2zkVMrRaqI1KPVcg&7OxBk5Bo_#qc?pN=8>vW+XPji#5RM2_d3Quzyd%a+|3?`u z&qV4)E2O~X&oGfnE^VUu>FyE>0Ek&Ei8N}xM$*Ln7YLcCz{`tMSl zD~~+B#6u41>Ey>*Lr>ik6u2H|)&~B`F{Qk6CU-PfFB$8yN3pP@Jl*FwV7BsMV;)eUAb_KLj@jw@#_-J z>pXT3$`aOK-K$gM-MsA%2!Ecn51Hc-XqdHN$`g94rH&v8K)Ps==)IX)yx8^=1}24F zn_ahb#J)~AQChxE_!{6WcTu z6I|2~XwS1WFjA3EMZDA7Xc+SQsRhf~pu(ud(9I8dY^V>Zjji|sWgU?;)VF-^K?h5S z6I7oT&HY1ys;kC_@T`2JlKj3DROmlP*;H2i9uOw_1<#z^eJ70ldYQ)sAFXDP5&F|? z1%vNMgoP`4n1+&;wN1gg6e~HzQhx?LGIR~^851xSmewmwjh!3_?(ZU|!8QovPI1EG zxnjZI-vgKa9PbdlXb-r&+HmbcX+mcayk)j^jk|WZ{)XtU8`?2(k#Rw3r1Ax_pcZ7h z#gCprHNpDf&XD||^k8p~wfWYO>FVinl=QwdVfIt^lAa2{e)ViMfomb9PgH1f9^W`p z+JXHwsBa=!CVM$4zrLRIxXwD-EXwmI*4;UH;b)RkZJ}+qJ8XJ8QCQ?^-Z@-tkKli& zLMN@bxDH1=_1%w@nmrh&IbFj-8FaW5YN|z>oF~?y@+69Z!GRZcUx%hWo!2J>HZ0fT}a>Wa+F>2UHZ9ms&<26_qMm zb%-g+3sA9!bMQKZS~7g6G?F{oPEeo$5NGhp!B2FdF09UA{-PDu3~!tGKH^KPsroUd z&tm`7k$z*%)Xt+D^SuAUH0jgj0ANyJG?{8R>G6JANp#$aaTL4}t1*s`n6_Ry<0f^;)C;&5| z+2X>Jbi^KzV9aeKZ5 zIr@K8>l;@+E1NH=ZghhtcJc`sS%F^+dd9*Y7uh+P+(XNL4XKcd1jFysvUmkf=s9pQIrMV# zobXG|t@W<70!uR?jpyZI^`{Q;`uGEkB$XJaDu=e9fAa9oM&ru+aF-NIcuga^(o?t) z4+KD})%~KOm@j9HOV`Z3csBO8%ch#MXr;cSCO zs3s2@>CSza+_tcy7apSg#LM~JL5&9a$;V)S!{9g8NlDL-D|lt%-uSgHHo}t3F8~e; z^jd=-BJa6cd#!RUCSc8X0}^%~4q?-_pnzUB5sM(pTC8`)b_6-n zxbnosO9#SiiFK9jTNuNEgO{bZ!XZO6r4N@`KZ>M+svlyf{ru%qqT7ih*df-b$9{*L zOP;FFSH<;!+S0FUl-1hdT#D`XaYV68Rk##X7E~K}_?f>aXFU63IwKWe8}+NKo{?jH z3*VrdGU3UD2rzez!`e)Y1f%R!!A0-rb{d@S2#V{D_11_xjE+1#6mq z5aQGpN@9j{<%(f;T)}6>7PX9|$1K7pfkyuPut+AVPc7KSS(7%ipz$Z0TT^UpFM$a~ zCYJYWmFmf($wQvWLlLOH)rbEOtJgPz)?fII5X^p6VdljJF!=Xdf{+_l1~ zxj)*a5I^Etbw47x311uxme73g-3xINW;MFNN0I$r8xLmvGIis(&GJG05F&r?7Z=QG z6fc6c@BBGSmki?U>RRTgOKIUx~73 zG&A8o4mG(JkC(zVzYkWXK~4WHj%zU(*pnL5B z&AY$lKCJ-r6veB#R<(wtoNFp9iGwb2Eu+F*g8Zyfi>pnbPQ&xSmS_}8hh6nblKgG9~0=VV7 zUx@N=sL}pQAsfndHw?*8p5gbbT2`DQBIC5+X=Espj=pIJ(V|VQ~0Lr%wuxTxPHxiBMtcochLpcmg2} z#Q)nt)?P+=_~JV;60pVq_g|vTD6veth%rj3@|2bGYDrHpK+KXtwP#pBZusnw7a{*j z4YNS#=npb4r=>cK#hYv7^@=*ZN=J_eb3}?3pkkRbECG@H@ynF)l(Kn2bmwk%y7N`{ zl>P}%po!{3ZbY%qy0mk*i;>8~A-?`32Vs_KPCY5C11X}Y=d@l|?3g=|)vbWaP*QW+ zqQ`5qXo|mh#(fu8!t=Py6ot>uSjHm|mMfTLM=pE?H-yr6no7}A{Q1wQ;Wr!x<<|(C zs?h?=AhKAUiPt`8Dr6Eg>uLr1LznVj^G1W=_<5gJx#T^CIo5R(ZL5^!F+?$C-n~R& zH`;?}?=cK6?k}!eb((!hu0N9FzJLEzHDo{IfVlb(_jQO!2q2ur=inOPQpO6p@RoL- z*7|Tq%5~oc;QZ&x(k!Kx5x!c-1!o8q@?K<^q{9|Hgu)d+?O#zeOU`z7d4Na+9fX5_ z)oT??$-R9X;|-fPj~M(BOAs9f+tEME6-^)D1#J2`mOuV7zX-tK+@~oI?rYVe6KziA z9o)vkC(iMqe&M0uGm))wWA&a#zUenm5UN<=lfWlII@PLG0N(6O$Fvun{mOda`59Y< zQ=No5*4(jm6=ovo^yBNpU=;S2YKzGirj$OZ{D!v?goz}vQ|I2u9k;1r?W&xE6)slJ zCg^*H-ovF$&->@Ih|DLdd5lPAM)*^SbWYI;Q_^bk->#|R4)sCgkwLytrcxrT>kY#6 zYr>g9hbYzRBSqk2r`cv91f$dY4&)o+OZo?K9Af%h-o{b$tQUv&QAxUA)a+TwI@Q{> z_LH4$ZtZG~??>9CsjKO~gqdlDLfHgR6Vevzhi4z@f_l}|JU^#1twnDZn+Tzq4oM(H zFktR{KB@k;=AWOGS0H%+jtGzUPB$mmh@BFA&U+ ztZ>@DLN-}DLVkS>AHi?(6n<80%Oz{@89`r%_uE#;^v%8o{nUPgP5-##3raSa?EIxR zhe5b&)zXbZkiEM4%6-Y+CUqd@l#ra<4Xm)wn|yE8Ubp%&M?paW6?(DJr$csV5QZol z{9K%Mfj3W!5;#LY4o*}Lsi+nc197}k?ij>Hk`2cfaR#68^L`-QP5dDX6@to)l3V-y zg=I2H02kMK<}9)OzVqkE4Qx2D;&jon`M6NV*lzpvLG6Mp_P5A?`uDV`mIo-&E?VlI zl6ohBY9p6O(Hvx0=xgz7wAB`Z6r`UM5mcd}uN=%IxG9PX3z(LyESzOqOc>S2{;^bD z*3R%-M~(l5YshBuVwzn`>o0cWdxi7R`2TuN_P+iz)3-9sWqAzuxFHVq>mwO>Do|H;Ef(*l^*wU60#Aj5W~9vytW{F0B;(Ja$u!8i0;EjP`F-yaGHoB*B54}lhJsX9 zQA9P7tPUF@7n+W{_T) zdtUj~PUlxugTtifFRS3W$1D_M+eA}Q%=5)12V^q0se4iDD*le$VE**%fA~r4i zMTz*JwsoGE*#B&*iGp~5MAjA|5enkIgE~c+z}`@Z?KnSCmGO5BAqMTH(`Ya`;mo!B zaKLQEx*|Fe5|usiXyE>Qcoo?n2@{apZm{k*t2ok&JL3*`XC4WR8#MIb{oii*1#bm( z>+SGB&)_l?=&`;>H#jfmshCg9CnESpe8*RxGgnf}5*}oU_LQ9_yNpm(y8s-xPrdL= z_h4-FBlbX=2q_z-?`WdfYgD#fJ)2f+gX^gjLvCGC0Jc3B@K6T_Bq7Sbc>*>`JsY&u<-@@v9ED+yuhjGzK$mF=P^q zfL*|VN@p~g${Z0={F(G)LWOnB_}!8@VlTS(;^5e?z$LJq@SvAc7rm$LzOBj5u(07QWD=il)U6hpeN5dvR<@A9>g<-zI zX3ZCU4R= zW{3gr#HmkK7`#{7^9Ri_-`yc8BXIJ}MrBwEirxK8Q0uhS28jbgvOsAJ765gE539%| zbU&_M--?U7de7`U{ZSk=BzUsWXJ@94HWfJ40=5lO{F-8DJw_XMSIPqxNNyYiqS2Ft zL*%SWq0||{nYZcDC#0~WQw3d`>Q+YvjjxB6DWs2oeFM%$jHYwfqAfL&$oGOZEziXE zB6Ii6M$rP$&RX9h-Ui5~KOP8}eC_Bt?oy@|O)wF9?n5kR9gNp@pC?xisoF^f-8xm8KY(JfXS4&d%d1F3 zk`M-OjR~@jmtF`yg(h}`g@h>jfI)Su5C8lWBEjTyRs|r)gzu7?N{e*L9ISwF|CyWn zj_(MfGu1qkKHAshtiEf_g88>%-b%6Zt+=eb{W(H;!RImepzLXkMer1JKoU*BQfAJy zH(^9xxO$!Jc4IYqx+pD!PJ#00`)h}+?AWpEn;V^ibV@D~)@^IA+z{!r_4tCX8^6Wr z)8*$rRgS6oZGD0&*NLBh`Se$#{b}}B7VJiYgX_x)OP-XNCgz7=<8h(z6Bg9VZTS9d z_Tx_{qi?1BP3sWZsN?eyPpi|~jDCw;7dC>&8_CQZS!fp+m%DYy^MW!6imuXKofA!V zk8C(ri2G|%5-h_!b=LC6Z0_FypB2>;1i6D{G&3_{O@Dv7w|xt2|HtxF53`Du-Ti&X zV2F#k3urKQLi=%t9revGgwa@Zs4CUQb!SrM4g9uwHI&+=O~f8v6Rm zA!ks}+hjJ?U^eqG2tv-Iy{lcnauy=28g-cnpH=sG|Ks7ZoeRf)962>@)oXvgX8+Vg zMzNE$Rl?<*KF%75;dBr@bNoliCCqRobfWKF+|@ZTv{m3+oa0*o7N0FmGO|NS=$=i!`c%L zk~(DovK_Xc^Kq1?i$gPJ z7H{}Bq1SXsu;U&_+*)fi0{qcZTHCj*?48+E7@P?FYS487LL_AUR9xLt@B9<4q7U9R zM&rFy$*^XBKtGZfnT9AAW^w*soI#gx=r;>OMz%S?c9|(xJO7_Z19^< z^{()kk!fSnqkcdMRZ5?s898rxiYHXp~K@~QBu!# zDr`|@P#id8n?c>hp;FmSehu=)A60E~Kvmg7XAaDNyOBKgEl2w)8W72vJqP1@I^ng{ z=~=hDnV@{t*9q=0!8bBDcechHc*m>RYnc;a;CVZW#E8gyqgVtDk0pZZ&R3(8^}T@W z{c2q(NLHSZpyw}yYYnpdZ`@h!}7Sbk&-#O6|;GK*NV z(ark&Yae4vicWZOlFz?7;x{I1 z=&oI(x;@t!lj-eUidRCAyHQd$i?@IIy6t@Pz-g+c3p8eI}EpLh=R z!kC3Qf%reLM^%$@9ReT~Af`^fsP3cAA!yxgbG4DQcaRXY-I*`W`4y@g&HbkRvZs%( zL?~Zl#UT%4Z-qRbckdUYRfcC-xj8g+KBTR6?jZ*BPz-*Tu0!pK~tL3J`gvly#};!s*^(E5vT~ zpT;=LGt!uqekgZwi=@6A# zd6S!Fj(e_A5dCpqc$XYP+N@WF$HaWgcPG)&AqwN4bqIp{5?UvBgYGiI)bLm(+!ReK z_U(>)A!kBtqU!^GZLkkA&B4}H-bN&e)*1!nDXzMYsQR7k*GP#kq#M5gFSHyY2rlJ*w*)UYQ~1fASb*G%9Lpi)NsS zuH(twWnv2?d@xYWnx4i&!TSA|;vdMv%vsJ89il7JEoOTqHk3g_tQrl+R$J7NOi#Qh z7YaB}v+zablc{+u%`_1r5%c!HHQenN%_FW*Z#v=DI@O)XuPsl$CI!wN1^x>v<0B&~ zMNST5Zz(&jw#&N2o|YkA-r|$IwI(MW}>+>mRo9L`R7{H9pR$Rx1_oONTKt9V>8Jc!S(j zR-wda{7_Fd*~(IXyHqX{>?Z>uw#uI}A~5EPTh(cpzF@IlJz8U!7E)v+nAzBeIPNe@ zdM+p&uJA@OuhC^p+s7n4B-R&{o(Ulr%8pvkN-~w=C6E?s8YuIz>C&#apcR(@yv+1? zkiJ-R421FIC+9d}t$uLKZD{E$Dc5=1H~33DC%t8H^7m!rMK|o9jDMD3c3 zdNHJCtY!nE`}fxKUTpQ+=~i-CY;T{(C%!G`V+g+ElI|bcF*xtu31VQc;RV)Lyk`7= zlB(||@f*p1(W^HP{?wtCQnce!;<*9{Z+Lzq$*5y-zTtBp`avS67wT}5-s45J8v{Nc zAyg=P0qL%e*Sy`K8CSeFXJOY+HSD4H?ep_JDxnQzZ+WamaImmo>$l|cab2s0jltrT zaf~+y-~Rwq4?qaJh_y?S8_sXp;=c|1)rPaJj*A@OY5<0g!2Z-Vn*$XJE*FRob?~hrAXtmCX!fA@{e-`K- zw}hM3X>jcR;96eMqnMkUBSHk|W0H@})Pv?CuS~Zz<-#_)@mmte{Ex{gE9aGJ5^l5@ z8OQ&6HSVAEmd|oto{ylxt%P`tGt$S2n((&pFfIP*W}Q_2qA5l#t?_~FstqJ5vUdFa zlwQmBu@=Z`E{=mvVKK!fF{`P8`fJ+=Gn1_P`3BP8> z$e3AR5RST*yb|7%RWmK@Tj||h0|?y*X4zA|4)=0r{rXCJ*6Y6r(ME%hW$_AkvX<2S z{6@SQ0xN|Zi1?k>N8S7+yObUzA=R-2ZMt$FU-6s;jsVyG`jMmWkP6m1EdU3yrfjR- z2wTod-T11=&cOlAcG+zYo6@_))+AcpNu-G{MJP2_sCbvkpW}_MI?w3qP4Ue{qc@Km zeDCKHrIMmB9A7fxa=Xu_4!$4M`h9A3+6Zhr&7TNRIpkSGtM{h1hLi@TVh|9#*3Xp3 zm}d+9r#d1OpY@}a$u2DGc64Zjhg6;c7Bl zeC*DY6pPlxp88jhY4?n2yrE1nVPC@N)5)Yof&G}gU^Ot&f$fribA|jc5ne@R+5+Ob zs^WHuXn(664sgoz`m!oqr550eb*1w7AFw_KEJ+{ z6s-CRTN%<+-+7yU-@U)qT0h&Yy1IUQA;@t%ksYwPpL3aoqM*?5wV^>mW_D)g6D{pD z(1w19>V*S9D#I|vu&q%hdQ8$KB9$6G*5&qo8A7vYVOby5-IKhSpw z@IwF+To~B647p_aY^#L)zSHD@61*8!5Gfhj_!TmzkLh#fkFQw=_TIVHPuqQx+XyXM zC5mEAM7eGqsyrvN;~}-~ACwDEPStY?h03zhuxB}8(2*6^n#GP3Wn-o}D9Djl%U9B5 zEY;TIQlx%b-qg>CSxd$#m`L$PBi(LdW@WX0&%m%Ig$EXtQI^FQv?AIb8V?pwIsYp> zdK&=Uc23EwWf*&@X73%(_^{4~t_Oq8s{TSRsFj}%8wmC$5^YyL62$}4qL)PXk;|H6 zxz$G@U%=+g!{5aWwy^Q?I)7kb_zB+!L>s|4{?Vo=V)otRqZp9yhGu)@%QM*8Nf)K4 zi{31ZA&UdWOPH_c!arWflIltt_l9WgKy+q20lu%!uUH#8j!qM3^Z6Caw0@tyVNimj zv!cIzFG7Ngh_C@&2K2+_eU0QV@Me%lecOA6Ee}m3p6R8+y%Ub#fqa{xUFI37$;po& z7}}8G|M3)kzwTWB$Tb0Eq9OBD@Rygy12YA+iy+@+7DE}b-h{PNkRi?szgNk2)(V({&|GJqp(_d@L@Y2I4;cBT>P0egAS%Qn^oAs# z&XCbGQE2>c4QN-;XN=R_HiPhFugVFnTQkCLvj$w zf4XKU>Y5d&L1%jN%ejEo-s%A@)Wsm0(mE)HNzDGD#kF34bL8hwW28thmZDGqQZhAK zLF=ZetW1lhKCf4-i{o@l--Xz(_4PkO5#AV*re8-3k5A7>zs`Ay@H1@PA#_!{yCK)v zP?1GrZ|(U1_oCvBT)oDYh>jW(99*I(SaAu&i^bVG7h$r8k%KH`0!0yL+&0!wYJyij zYM(snABh^jNpSJ|D)kd1M}mUH6pFjD#S*5ab4{U|r3p&Ksr(c4Co@;Fy zTbR8Xw5wU4o!~zsipXToD$o7`{7!yb;}ABvCGySbLAwMqooT)DKPUqQPOnjbizH&l zL?oRircd+x=kp2LBmnYt6wjg^mbpJi$3GWz3IhNahd6YQsE{ z6K@rTPGzlY+%r6cx$ZGM&|a4+lSGj)Y@Bo_$|3Lryph2JlW(0@epK5YiIh4gX&_B9 zRzVMRWOr6)FJF1g3O$NJS?c&H=eEU#PidJhwtK(R4~i#w_W7Xv9h1~T7o3OZ2&#`Bs0VTv*Q zewTbtT`a&@m0qL7x>2TG>hMci#us%e0)0RyxMpew$cC!p5PmrgmjNk_tbEO@D*Xjr!~pnRS7_^vUwD|Db{mb5QSNnDF0oe^|jM$XqmW9 zrl(QF@cCJg8qGE3X5lIxmS_oXFI4ij&xzlOgP&aERjgh@eg%>Cdq z6#qb0hA4)K=c`xNMVEa+FTTI0Hm;fG(mFNsy4);-u7dwi^nm~re;96CIx^q8(eZ*? zHFhO&gfHC$!MDU~Ef7oTO$f{CM~`>L87@f_8H1#Z`108^C+8)9-#!nEK1=i2X|AOy zi*htHAvw20_|}7vc4C7!Hcc1kX{7oSyRE3yuo`9Hfo9gpL$MeAw+NFdUJQT7sa0dM ziZpU)Z`pWgHzx2D?yp~njHCW{*QT=OI0jP$UnI`Vn!nSFfPL-j{m|_~fV0vujf6K| zivA^72Y68W$l}vKVVV2}euIWJPO~!lrVkdTtDcPftQl*q`t>)Ueo*?f$E9aF)WLm4 z#KW#PUUrsy3UNI^mzGK`zmd0kI3s_hvPs}v(>?Jd8hE;;x*iq5bbKvpN&f9~o)#YX zGL3NjG{Hs$+qnf|M=HljModv~QsIc3p%8-?YR3v_D5=Z-aZzNAK2HXB_0{Pzw4p2+ znfz1In_G>0;9450&t4Nl#!U~kVn`KGUVg=|!?d=z17XHPsj2h*YE~0ocCgFZ`{U4S zs&6f|I1g15WvsaHk=e5HM9N9a-Hk3#hcHfyAn|P>x#@DTzp5fqMW{cWuQ>x3wj&a2DviUs`pa+%5o%_4V|P}{U{_a1pw#i zIpL_CO28M2z*fj9dVPB^=ZeWv$Xeyh`NsAWrEl8m$I2t8etcZ0j3<~3OV*oQ=Jfnn zg`KWV#P8I~$Bm-4#)!ol_5kL6k`oFt+LwjN?vQo4)t7Sep#P-=<;~f&I=e^Nl9CFe zP7iFk_u77daO&c!o-9EEGWQuGi#`8@X6}|X5x?YM+r^z#p5J|QLG*tJ`Zq+Y>|jw5 z+9WjBLY#r^g@W$_CB}^^y~qD-?E9ag6Hzi3EO|DEqD1OFxjpU$K|t4=WO+)zu6G_l z{~`PTZPiQu9m!Gkh2Ax7Lxx5#ceSv+LHJr|i*Hd_IE7lF;E?K1O6G3$B5x<7>V;VR zVr#@8)f`IUr_CW_-OUSeG0_Xar5x(;5JxEH^x{ct^7hfdB%rI_ZuR#b(8vItdR zviSOFNkRVE0H>?$f2;U%`dXy`{=bQU=)O+VZKdrC1E!0XH<=>k3g}@@x{uS4)hv@F;Mzj^qWgm(tF zJ{Af-*v@WLIx!O243lA6;SPUh+)mO)%=Jb7(l|-cMwUd}7xM{;@|lxSPLk^Lbjk48 z5WU$pK3dcnLSSCAkI8jjjuZ?VZ~fiXqdO;stD@Luq%z?XLe59&8F@d9qsv%BHOv_P zbS|07+lIS4sEj4zA8sQ&uuKmm38P!RFvzq(JuVitppRyH9Qd7i?z(0{Oh>EWIxmt@ z8;pdnp4z(kP@T`1=<4L)z_-1f=6&>wUI;hVr3z_;&wj)rN9W`@ZL@`AO|Y5}OSHY3 znt_32t2di!jXWY+TcLz?Ue$-Z!D_@X-^bD7aBdZTLxY&x_pSP`yM%l^e1Y@4^F0n3P zgBTxfVWFvb)wJuCv9wPKTWpC!ZIJN?u#eBKt~UubR7)!%oGOtDe=vp!V#?mvMeICC z*u4(@LG=uU`Gui}OF1WOKd<~!hn8T6mjD^vyQwsVQe1HU*}ps!^nhJuJ-TBXW+m1N z$@zCp^5pSf`^l{@vaji4O;ft?{h1`Am}$j8Mgml!z6;XUcE27~Rj*4W8V6Vlc#Lbi zByp0^?U{siTMHBwT7u#3i(}BNUHw~WbNcwY8B@@{lWC0&wKuZ$!7ATQjlkiPl=fzY z>j?WLfMEkRG6&5Y_GVD^(eyD72I$z!6DEIX0db_u>5JzeW{dH8$Kbtw8IqRneP5J0 z&t3X}oiA}tLQa5GTkRX4xr^>Uq|paxQ<)>w(&u7qRtiEtV$~YX+DHpn;tP=Rgp4l` zg-)^J1f02Qq=PBwD3!cQ4w4%_GpzNjjMHt~@<}(`=%l^)>X}`}Yu3NQeYLb1 zxAX6(u2QALDTl|~7(sBi32N+;IbL2Ex9jfOVJ1s8k)14azn}P7lY+K&M|efy zNi1#dn_pJ^4)yg$YmU&m@HU3`E^YSJS%RaOT582{5<}y@Vc;FT)s-%qcm;t_7W+C| zWJ-P;#3RQdSwXO(D)0KZTm>$RPY$Z(KENASA`LK2?G91M%u#jKPj0qSC z@y@(-`#VV^Bmw{exI5w$N^8rJw!~54$wi{CdlDvKnXJdvfBJShu5)HV`i3LL2D21- zFqSvLdnG!yN!as;OD`vB*f1+&y-6V!hMAiNKSkmlr;rH33XCi5TNLJZ%otf0?GD`J zm|!e(Tz+pB;yB?k?Hf5`qV&<#Tea*mStht^h@j;3Z#Sq&PkR}-)l;A`741tM z)O3Xs{}`Eq4vv00=q<2O#tkEiAq<_e^O!#@9?!FE6))ZqetoiY@-N>iob`(JG1*Wd)yJA^*m(5etR?sJ^-r z+NvUrt5!*qqa`brB0<2H4b-Q_l>IF_{s4&7+NU@BYvg2@|9gwMRk)b({i;4aTDTcs z!cVrdMIgVmifWAC_0$1@R$=~u7)G~uP~;%lZ{nLa-$*@KOT0vCGFFgfprDXIy;7%@Myv@hvmBYu44 z142h0=?fO6B=+V;ms?tP-sU5(_pD+y&JQpBrNRqWkuu+cj2&0}mMfBZ$!DTfW`HO7 z|ISZ9P|$V2FsUzdeDdq$*-})GuJjIRwEK0cuIB2%O1mhK^BXDX z$hne5ZlKk_bX3SfxcKgu9Xk`=?N$&LJBNlFg7JlJ5Z77mPL+S+!`e3M&mzF7hP z92SMFqT^WrzhHw(6^4SiVwkn7u`md!QsRxd@K1@)2q98Q-G$T0k>+*-=Dmc?%3{Sf z&zl+@)1Q?-ynn|C&PIJ6ZvTa)(q+V*yDN(px$rds<<=BN%&JH8Hvd;WGPZ6Ezd@xO zonS>rha1G9vdgG&8m$`Dt2F;9quiWaXh*2>oImTXB&-XIrkl1+qC| zKC@o@Qn4#x=jOI24cNq4OG^a*LP4DpTC#kXY!X`jaqpFim}N;5y$~Id2+JKohN|j> zA|IL~69Y$jZnX3%;h`viY%%k^M0$Gk(l->DAgo_e&8?xF2Lo(KimGPx~6`>O5|uBy5U=WlDvJnlLsO=thG#mBN;9JfXepd#5HK*7fxZ=7fINYEe@pYLJjq zgfc%^q3abG_N^AwRm!H?U`DuXtgrKfEU%?a|E?RRfOA<&J7_hKV9c{ivXNbOZa;z& zLikI^mZDVvjdUI@p@ieLv>K3_INCV7j<#H7;#lWj7Ck>CqAbn9qz+)B-)JeoL$*?u z2^Fr`Az5q^cG=x_#|#7?L+zCJczBLLJeE#h>LI)$8;68_7>331!=s*GV zXI)1~VQ4rkJG)c8g!c-+COQAi{PK9G#PumK{NpCI?AK_dPiChC)OU!PfrLGwITW3E zL=zLjZ(UC2AMXNUIFR-w{Q;ZeSvh z8(A%+TK72iO(2A`FCVX}??5@(En}S#&S6?q#r{ zgib6Ma6>*%Kli5@^$o0&l@shb@@yTv&HR+17+#qZJV~^@7QN^zW(Je8kSVJFoXbE3 z#|n&C6FE!wMs5a@!mQL5f)O4N!lGF|q@e=F^6vKrDxl?)P&+|4Rna#-GV#bS2 z!ucum@}R&FV6*zuWx(+>!CyuzZA|{MeeCxy{Ez;s7zkyc{Gtn&y1a?MpQ}8n>YSnk z*|y$U+N)37uBAbq-usYwAgZ<*fl-rf#F-J5vQggNf7;b8$@j|`xrEYC46QO2>X*pj z8*;PQi>*fs@w9dUT8_qihL(RyT_1wMa>Q#WsN`ALMsZ{D0ql3!~;Q zDRRq;8J~d%N8e=A5T60f?hG5tq!735=X$&ix*cEiM5ff-XQLy&-UQlgIY~bea)cC5 zMW}fvSanq7eY>1_)zFHqkV&p@Uf5KO9A#&m#!%h&)7$B%4W>dP6O=!k(y9Mq$qnP1G4bM%^9(_M7#pHv7Ry+)e2Kl)&`S#%meBhv zusdq${N?R~a*fWFUhQyMp5YxenzG8Sr-9Fr=v>y)@Z_-u))1k>Dxl!6k-Ua*G^nft zXOHIjbg0EWFUmbvEN-?US577!kFlI{!i@H4Eik89@SVPY= zj@LdQuJ`=ay!G`?()!okUr5Sn<@IZS`faX^XOrg4l(un{*7;Al2j5Bk*pZEvwZG0a zwnAsHgoHZvLXBeC(kZ!qTBo%>g{(WI;TXG0HfhxtQnSvrk0S#gL>slZj(5>wl|J5< z-)svz`I4{!zW( zJi~Irk;7w@n|ABT>~M@rfIqKH4Cj5OiK_@F>tP8dfd7us`>n<9ny3<~I4dnMPl0gP z^B%8gmcYM$Y0#4nY3N3-CYKZB-F>{$kkj%QqQv5XzoO<|LAuM0`>52^{l2oS*^%u- zt_|Em$;tJa75pl=uSVa`cCs>FIeH^EuVltULsSH=2bQzgydTzmltNpVn!S2R9Q>K5 zDGc;vEkyRVtNAhTqs^Nn)5H8ZpVNF9Z(rA)z}>n)6}5Ym4VxPfjmAB7rsY{I6wx#> zhBZ5kG?{B>y~&hbkcNi?~zVjhAh@wB~icK zmr70gp}?%SUf*uN$+iiJ_Kj3Q-5sxtzNlekRdyZ?Wt~Z9@_KF>cfCG(9O>Lz4xQOj z+_IYXxY%FvLd}}Jg<_WNnyd&|i^-GojN^6*CvbyllD4JkKeu=38CN0R6&&`a#oP5` zYjEM<;Z!jDPqxzzxFnKN{H?gt3)c53nxpU<0PocJ7bWe*S(-6!*AOPRBz=&_ic9~y z79yau-h1V8m%dG0YI2$7fE06PbqhW_D@`Vi?sn}P1!d)EM>{(@4WHQS0Jp#|>z!%O zs&>}E;;e-%ieW9O*6CCoJiPa)c4a~BNA5GkAGhn!5o6wS-1)`Jc=0%cVKl=Z@YYe+ zW3hRf{~MW0k55ON^DCjMU*EU@TUto=bN`SO92C zmxrUiB9En-Ef1MSv}$G#uKXD$t9WOXkF0a@2sO-nFwidkLe6rP*gtzM75rxqUnw@F z=9gv(;J9f2s+-1bO2`s5_f`&s+f;odkUxGrqHh>kw_cHDpt)_Cf{cvJ31_~U>o?A? z`@3(X9UYG<3?czjM&p2uBVwqH>aC~+V<$bBn!FI0U zoWBLf9y)jBRc3eo$77}MitSRKH{54r`mQ(Odqd7fv02V~`c2W;Rq_`-HTPo-BuC{g zuA9WnHBMKDNa(>Lpn{4U^+PZX@NV~739pn>dc#7405h7={6ah2sD5dhH{Poj4Fs`f z2POm?Q|G&!@7q+eY&kv{{(0^mIK>~^`P=1B-kPZ=1X0^GjQw!ugv`?1b)*gii^Pl+ zrNd{bI4xYZ%B#4g{K1jTD}eXUP6Xpn7jAi)Pyg9aEf(SD+hN(g~D^RIAa_7tx6)$ks;G4@5Tzk}dgI|7;x(n$d8mYC%RXe3XaXoee zcc|bjI=~W*vQ?u?kYM&zw$6@HiN*t(B%|zB>hzq2tcwR?D)AI3nd}o_87UO>SJ=^} zR4#joxUz)B96wQ2b>o9gh|B;xG>%reySC#^kHuDmIkSeq?Yexcdr2plevJvRH{r9M z4&K4ep6IO&tH5pJU;%XY$4Qa)XP#q%)V<#gvg`eIn%R71XJR$J-G3VaCLiTkOhMg& zxm**xdt676usWw6$~(Ej{ubD7gquud8QwkEx_Az>C~{O7S@nc4FvqoOw?Ts51j^>D z?apd13Vb}+ocK`$dmit0aT_ovd0AVG=-y+D6>)s9WG>LE>Gcd0{y9WVxb6H>%}?@Z zKm;cd)b+fB56VGmTne4tFMS5%z-F3Mv!^t5f3-9|#(%6;y=b$LW2H<@ZMUrK zB*G}S>j;`MLB{wL^<^^!+tbEqF9pN5yE5+ywp>*hE;0KUL1chattK@E~@)Qw!}4@ z10;k5(#AHR>ndNZy47;8E;4-m3Zr^vdrV4+SRhT^I~mE_qYh6js>IK`LnRPV14-Qe ztyLpQDsbCJg(RDz6^vYOWqqh)jskhiN%X6VU^R|@S71{u-u*9JiEH~wM~)3*iG+{t zI%cB7=k%+)3B6`KGEk}CzBaJ|XU6tNDg9X@uZLagC@FzP>!+B`(lvc=B1_Q?py~uV zg}*iSmlqomoFiTgFT2V5K7D50#J7`G#!xL*IOPaM6;dp(=E85!>C$X5N~Nni|46hO z%NdB)c#U$E{(wZ*?_S|V6ghfDpU_T}BSMiA#=?@A31VmcUV0*2O6!CNWj)@)*Pq<2 z9$RyEr^domXxWVu`n$KYW~L^7Yh&klR=lgw<%*J9^L?oK<7RNJLd%{NwEyN4>vs5N zoV4eJD+6|WXxzxlUaru?QKy)LCNWvi=nlE7)rIyP#r+IKVw?}i7u_#1`ZXNzCo=Ff zw4TL7`t+g(x1vHuW?FcIk+`hs0MYJm=Dd_$rh+5EJQ8!^6!b{hUBmfIVtA6C&ajaV zu^0?PI}Qxlm2g&r(UfX24F6754557MADHBKcs!_IG-<)s6?xfJN$g+P15JWd_)y@f zO1XHRL1KxgzqchgxK3-U@L<@_3LCR?RI@NoC(|`uhgu8Sa(eBmAdzN3<|}8}X6!=3 zeOAhATr?HRcfi~x2;bsr6<$lma>va^rt&H2E`o(~imK(BOI%tsOU>rMh z>g;O__d6$`5RyBZe>JN{dcR3HBL72=+1VE>K+I`E(E;jSIwZ==4tF&0BCPuo=lIC8 z1Nj3>GxiG|nZbd@y~w<+oFt7CZ+_uTtIcL>$w|?K-$G}; zDMvK<&6z6{DNX5k$iP{UX`CL4z!CdAo0KqET&Q&LK*!iz> z^Z~=NU9vD?L+_82UrZi(v4sUvc3mh_Y8P~Uu9`|!VDIs&f$>bExHsR)k2rD}89W*A zoS(%D9%Fu=e$MvYreIy$^g9Cp390Y!G9KKtsh^r>{m5tqCdWocp9v)7Qt@L9gFOk` zbSr2z((4V$FlID7J;e8Cvi#1QTYj9KD|A{)2!5*;>&8KSc`_H!*%`o5TYAo{h;QGe zyz1V;dBWUt2zbksA{m@ECQnc2gWD`8{%4N@Nq&%)k7p%8xrcXgyk_Un2f{M9MhS`B zc&CHAv1MKn=8=(=T%WZ!d)0F4L*a9N8~hQRe|wkS@%qQqxnA`4CffZxa4pwYT1uk! z$!x4u-t26aQ%!0oo!#lLZ5?(m&cSD*Sbn2<%TNX!xw|RRDf{ids8`mXGts^aQLLjD zP<1;Y&tl2W#4(2L>a35oE^xFHR1`nkABoqq=`bC{iA%PKYQ`N2mdV+e&}Q`T5*(xE zS3}YhiQ#BN{Q~wyn3i!uXq6B3l_Ug|_NWkeOw6HSmWZ4eecEccSW_ku0}x93{hCX_ z)Bg}*mBZ;l=5??DE$nS96-z}nHI!Td@7I#+RQ}W!SoUnulf&0Lxt&Bpuk#olCli0O zj`EuaFnw})F(sTTqMDXNpOK_ih!wN#e}j)*nQ0=t=wg)XYXO`-MhS+miwQ12ZOSmj znVXu0F{W&sXsR^8X#DY`BAOq;%-U+qp6b+;v-)m8*aT0>LaZQ@0&bg(uT~&NFUP)k zOiSW5(Ac75UicGNEq- zG?eA@+C2QUeNJ*&Lt*m{YxrStvY+t7;@-7CEsGaOa137F<6>#d$454eoAMP9s4K^B z;eU+*uF41@Ps#d3JgTl2c}}G$S3@2U%Sz=F9VdKq_=g&iVMfHWpLOObl z`W-Cgw{%Aq)Eo87^5}pr9~&cQB*^Xx^je{j0E*Y^YZN@yuPjTzt6!UC6&Uj++tDeu zc?b`Gcs!4xxtb>c6?bKyW$b0@&cgNh zt#L%}mBl!e=~_l#)4f$y_U{|ljUtE9;GV7?%6^U6*_= zF2f9&ZW*^IbO)tc`rrXD8}B_kt3q}Yzz@8r;@7=qi*a}nShPv66xp{=s=m#ALC_iM zuJ!T%7kU|n-rU9JA1~uV5-F= z+c@}Ye40)(Bt=s`TB9lf)JV%;IRz43Ct5v&v`7W4pI9pu8% zFWZ}%sIWYcE9{#hI>mv(Qqi|ApFi%O!P>75fxCOh58Ph&d={Z{CeQd;0q2l|*)Te0 zme|1apPtdJUo`^{6(p$3d2n{thA?pjy%G0qZIOE5P7HyK$YfxP!xf3t;q|8!v=Fu6 zvV1aR(~hsvulJi@$4YED|J7Gj8)VKzO6hf$pA-&1oV}fR`F6nnwJPJ8Q)LgaEvYQ- z!o4W)kKtKVF3&ySj6vTtBc!lqt#63&*@leT{VJDN-NQ-}MY0pSk#uvF?~!G-Gb1x9 z^oI20!pamX<>+j;jubt3dEv-%Hpg2^(GU8QGnMAD!gB42ov(@5i&^MyF}I*vh`iG|t7j<7?-F3Nc8^d% zFAckWap=lhHv0~?z5`iVos~E`o|B@%ICMmRu|TV1RHRlCbJ3@{l8wKw1iMRL&AQh> z+M+bHEAsiMw>nWFZQEL=%Tsp*(Xb_T2%5P+mZ6x3%Cw-ekk=4dSLqlXl5`{zD94W- z1%GXSRxF~_%p+*ZAb`m&ynMi!blN>nbBG~ewQXAkK!c{i2vuio4il+f*<+A&H{Dth z``#^Zy;kf`I3$oKrOy`@75(|V^zz_elqz|vpr}Y%GoP_hOPs7Iq>?6vZvdZ$vp6dO5 zj9YZB`+j3s<18HkBMYvD~jO*8{}r9B9=ZL!tsTqzCtW zr=jp-!5$s7&JX%=z)6RXtiPAKt!SK&S-w5%Veg;J5nFy*#ZS$fEDCoWka7NV;hRG_ z+BON*nfD82lei-!j>QgeAWRr~jw0@>MWzQ(*|Gk(E8vJ8mdlPjv;9Lb%ztX%smtA_0dKW%4)KNUAmqi$Saj%v$RN74il#SCS za1Ou%WttEU#`WedjW495u?p__E*R z0X-TlN^dGhMS_2mpF4wb`S{U!lz?&Z%@>TSZ@;G?`!k9ScS&Ubmpnk+ItMR{__K_J zj(Esif+@DTk(6_3zx5&DE+lZRq?gmQcOX#GTJ-z_AKqXKUy*nd(UyOW>xC$33)kZO zxoqCBFdZCD?OD^%Z*tck=!X{T6PM~|B_2pPMMkC z^6SHp1-qLj`fPaw+u>+FbaPUot6KPCtc_85*$J0pfi-|sS>+%ib9Yh`=@y=S(_9o= z6DY?go{L?IPJ6>1f@j61CVV7R7vZEmy!4cKs~khzbyTchEyv!Sj(i~UXIZ4SotR^R_(UF zCQJ)GcmxHuX`9G`K)XFHEyI(Q_$cTEk$IKdf0X5Ser-C3bFg7dry7~3lelhm2g*!x z-~Rcm8X$7h&4teWT(cV)nIt8*eme&CvpyU)4fsM_cM}j}k&jF|>gFc^49?qwBU5#? zTPLDJUhO_lA$iij`^u&Fd`0v6hWtGjA4=zCX}$l8Q6O`t4$LL?^830oMlD-2ed`iT zh5md+-f---Qj2IwPF#5N$rabPb^A@8gu$aBQ!;1U&9j4>JN3?$pIx{^gQ9u*Z(7v1=k2tRh>pL3 z-oelfBPZaW0XV~D(#iu0rc=2|iB{tCxC{r&@V4@XBHNsfs@r7J`If0w%eW456P&)z zxKYRWR0~~(&}1lW6hD>KX^Cp&Y&_ki1eCz#*8K4$46QR-(dHP>40H%&dA6;9c@3{f zdDmPBC%+^jF_5EOiqSn)5Rc^Ef=Exz+`MWD+d3c5&XfQ#{bbJ%M}ko(OV#g-%5n~l zdfh!?E_^wLWr9HY7XYR8M0jTIbZH9(CrXK^s_^U&G2`nsJ~<=q4w7UZIdrLIm<~E6 zL1taAMfnM`WVQmc;jkiB%2Q34bvlL?eF(>eP&$s3+n5c>olr#3Mzg)MRgu|Uf!P7f zOn+Q#)3^D=1MJOpDVx2be$Q7`qF-Ysq$1?<;?KhdO8K0K|Fat(0vzZyrcbDs^O}P@ zj-61zS4T8TX*ZptI(P+H;oo6BrJjAuojZMVE&5S4NBsN+2JFe1PkD6z6(8|{X$mKt zEgtl;mgyTpx>Wc)Dik)cR276iRo|l#jiX~eG|XL#?IFM(bF%Pk z8^WQSzc}W5?mCyVYIz&mRsu)7ntd1aJiP8HD{82Ycm>YODT@T-pr=f{(HjX#1fNws znNvq)%FTegLZ6wEC9ju6&{F$>98fmgyS=Q9VDokB3$4LRLi2~9sv=rw`G)Y2Y$#`j z|2-myCmFCPnLlP&!dJR>uiq>F@f732I7WVCI;KNawtm0W-u2h_%w*I^f2PeZB)-Jo zK>mu@&WnT=e{RaWtTGLwY+QZ~WV#;Qb8mZ*4KzAwme^9lahr8ZxwIaC1Q*~Uf=rBb z#5`a`PfuF5CP<-c%So2OwAg_ON7lxH=O1V5du9Wz#w}NqJ$L18>7$@6KgXSTtCapi z*KTmV*Ty!#l7B<2A>(wWy$~ysD(ZD@j=g{k@$5W*X6of2jg-CUoZ4rCG8MZ%Mlj@T z5e({L`^)pWi>|HtaPsUbL2t?aOT-&SoCyWd3GtwGGKmc*;w$(G7CFlb9i8mC*$-QO z(LUh2?BGsqXO*%OLO`1NoRJZ1BXU4-{9M#})#l4h_wYQ-xGw{Ut0EUAO?Gl4)93$e z$3<=KgaRUig;i!YGHb9zE@zP9_3eRL6=%^ z9Y~m=e_Q%LoQXuU)+CV4gVTeql#*R>Q_S|Iyb$CK=qX>#8{w(XsGh6dv-I%|mi68G z!r_IH_?-pHYE0xw#G`?C^$py`S|N~oQzbS1boD)z`WKrP!s zqB;2OxiH-MY16=>^TM6N`!m-~w@`9Tf5TU|Zhvw^tHAGI_Q za~p&IZkRNMJ(Ml9GIL`oFRPKh;K;mgo?2zgBSWy}NW^m4(av|z8j!0!$m-OYWrQ4G zP>@RA^`(%0`R(kRDzhXj@MxMAaULj`(6?|U{{L0)ps35o@j`!*m&Uwc3YCCxJ?YG9%S7-|3 zWK>w{^OP9(jL799jt?1R5jD6|>2Tx|6!1!`&TmW)u&rd5zt{^L_9Np2!dp>-j}PNk$P|#HE|2#ekpEO=T^;o^2KSqNoI_jB_I!GWLojKwM#E z!tr$$$8OafsUk7AG=1CIXzZa=wgAvOlJPUGg$KQ5el+D1dyYSAF}?n|(t(GKY)J_d ziT4Aau-n~xGlh!b0zoOk3JM`}*fqfHYHq7ktX`f=%6;Drt`4~@=gL8kc9_JlW!9vi zk+*XjlSNzD(Zuoh*F3T%SfR0f+XfbaH;5PuKjr49YaAGP_-;!^Oy+bRXYa@~`wpRb zUnr^sywmjGjaA;x&Zpy{u*UjDYMqNkUft$2t$FP|Te}>cGMSZDZJ!>UmDJ-aC&1Q5P9#_b0MyM71`Je4Yzj>q8isg_%;bE^+(y5WFtG zpUYA+;mrr=fB~j^xbSI>7YNO*YRTDh7DT!iHWU<~sb>C6VO3_!s8X zR^wqGs>^^r-FDGvU%Ef7S~$)2=*G|_Ecg|-VM@Ais2ZQSW-bzjQ}B`TUltwzCRe~J z!R%Q(iH-tV8wftKY%+O7rw)ER#}{vnL1dRI7b-y#A`}0qGy7@t%(|xMhKZ9~-q&Mz z^MylTlelLc*|sP1Tg;LOD+0WHE}b1W>YME+Eys-8ckVbOoeh zEtiU@!euuuMS(6ET!uCZ1#O~*38G_M5m$w85@f_gY8ui9)8ay!`0on@;j(Q;FQy?f zVfYNU9Rf4^(bCvZyv^3pzoi>9@61W&l?aIWt@OJOFOvv*W0*s=3dJ*`qM-5)w!=XUL$z7_; zH`_zv4z{jGu)HBqs!hC$gUPDRZ;zpsOJ68%=P+<8MMU)%d?j3eL$FO4?O3guYPmX` z9mU64?N6kdj4bq;`zrJB*v(BGisEWA79{zK6Q1LfOOuK3Eb%QXriOJOep}b$k21x> z>Z#JCsjH;tTm{By%pd#W zP%14moStNgooO*@3P38O@ZY5}TD#8QZMoLB$#%hOsMfM0;jf)y2cF6dq9p zgDY4(8WrkoW+h?mKA_8h+Jk7EfN}iy=oXOQPClr@nHvohT0f5I3C^D1#pt~6Pz}WF zdKE0UKhDRDuBGt0ctS_gm~~@T3wjWG=p>j9UtJGIwc(;H4 z^p$l-=XYQ;2EY8`)|2U=2z2p~*RB!x?B?c{B2D*^#<<)G<2{tBL6&VCuF=Swx@Y3L z<>H?ap%0MpX%o=M<`(_lwrLDjkIKiVs}f+4E?dFl&?x*Non)RgAev7QA+$WMrT)lrgxArX08NZ_8xt( zkfuT8n$yc?yln`zIB<5{u-!t8-<|douizYUBFYMVqv}KkD&^a2%H>n2?&8y%_CWH5 zG0Iw^+g*EUK{$9`?|Hu?q!KRwI~P1Hgj$L)pWva4P>%Xv5)T_+60~#7QTq&?ya@g2 zWeEM)-@{~*8wxLyl<{|MN|-kEMiAXI4ohUEYW9htCW@`s=PVkB%Z?bo9UlXUq?d)A zac{)zbHdnevY)+aKS!C5syXd%jFCo{L^03Z3QT^eth3ZCIW&Tueqdlzx5{U#!#rMTaDe}t$kWrmPI%)LC~)k6`vF1^&W+MD=;(L?H9zhty?SB|Cxq-EC_ zEv)wZnfJv#ITWzudq2Rd*Z7HT-8d;S{YVR2d-1CIDLNAr9cF*8ck$-_D6pG21&$Q! zWqlh1g-zL7DJ_;=N~Z`Y2!#rd&c>Bbg&Oe=S@5{lb7JJ<1Nzl6*oXJAE97B6Ds057 z^M3vo=q*0y0(Jjfl?B5~WIBUOnY z!>Z+QQO2Hyuyx<%(xeF9S;96Hl@$ zw7JVF%g10cA&ZM*ugR~y3m!lIR}O)!1{YCQ5F^1jwp$7fX!VV}GSI1J{qU*fT|o1% z6&8|lGR#&5PY|&>YXNwn32f}@e(kNL8XBRni|)o!Hz$6G#GUS7qO&*a0fG-zfvU^u z@N+y4ZWgQ;*hMPc;Q9B7XF7Vst)o=yolb4ux& zF|n^v{?zVoIUkzKw`{g&)xEx8{#_IceGpr%_JwV#D|ja0Q@#!@_G zt8&T1qO*31kHNH3D<5+meJ!OAP} QR`@rHAh2w;^vB@;11B;Z+yDRo literal 0 HcmV?d00001 diff --git a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProvider.tsx b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProvider.tsx index dfdcb566fc..d9059c70b9 100644 --- a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProvider.tsx +++ b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContextProvider.tsx @@ -4,6 +4,7 @@ import { ActionMenuContextProviderWorkflowObjects } from '@/action-menu/contexts import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; @@ -13,8 +14,10 @@ export const ActionMenuContextProvider = ({ isInRightDrawer, displayType, actionMenuType, + objectMetadataItemOverride, }: Omit & { children: React.ReactNode; + objectMetadataItemOverride?: ObjectMetadataItem; }) => { const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue( contextStoreCurrentObjectMetadataItemIdComponentState, @@ -22,10 +25,12 @@ export const ActionMenuContextProvider = ({ const objectMetadataItems = useRecoilValue(objectMetadataItemsState); - const objectMetadataItem = objectMetadataItems.find( - (objectMetadataItem) => - objectMetadataItem.id === contextStoreCurrentObjectMetadataItemId, - ); + const objectMetadataItem = + objectMetadataItemOverride ?? + objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.id === contextStoreCurrentObjectMetadataItemId, + ); if (!isDefined(objectMetadataItem)) { return null; diff --git a/packages/twenty-front/src/modules/app/components/AppRouter.tsx b/packages/twenty-front/src/modules/app/components/AppRouter.tsx index c5ebc2815b..c1845fa131 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouter.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouter.tsx @@ -1,7 +1,9 @@ import { useCreateAppRouter } from '@/app/hooks/useCreateAppRouter'; import { currentUserState } from '@/auth/states/currentUserState'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { RouterProvider } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; +import { FeatureFlagKey } from '~/generated/graphql'; export const AppRouter = () => { // We want to disable serverless function settings but keep the code for now @@ -13,9 +15,17 @@ export const AppRouter = () => { (currentUser?.canImpersonate || currentUser?.canAccessFullAdminPanel) ?? false; + const isPageLayoutFeatureFlagEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_PAGE_LAYOUT_ENABLED, + ); + return ( ); }; diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 7a49e0414e..0f207290d6 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -287,12 +287,20 @@ const SettingsObjectFieldEdit = lazy(() => ), ); -const PageLayoutEdition = lazy(() => - import('~/pages/settings/page-layout/PageLayoutEdition').then((module) => ({ - default: module.PageLayoutEdition, +const SettingsPageLayouts = lazy(() => + import('~/pages/settings/page-layout/SettingsPageLayouts').then((module) => ({ + default: module.SettingsPageLayouts, })), ); +const SettingsPageLayoutEdit = lazy(() => + import('~/pages/settings/page-layout/SettingsPageLayoutEdit').then( + (module) => ({ + default: module.SettingsPageLayoutEdit, + }), + ), +); + const SettingsSecurity = lazy(() => import('~/pages/settings/security/SettingsSecurity').then((module) => ({ default: module.SettingsSecurity, @@ -378,11 +386,13 @@ const SettingsRoleAddObjectLevel = lazy(() => type SettingsRoutesProps = { isFunctionSettingsEnabled?: boolean; isAdminPageEnabled?: boolean; + isPageLayoutFeatureFlagEnabled?: boolean; }; export const SettingsRoutes = ({ isFunctionSettingsEnabled, isAdminPageEnabled, + isPageLayoutFeatureFlagEnabled, }: SettingsRoutesProps) => ( }> @@ -604,10 +614,22 @@ export const SettingsRoutes = ({ /> )} - } - /> + {isPageLayoutFeatureFlagEnabled && ( + <> + } + /> + } + /> + } + /> + + )} createBrowserRouter( createRoutesFromElements( @@ -69,6 +70,7 @@ export const useCreateAppRouter = ( } /> diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx index 189b90b71e..71b5573cf7 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuRouter.tsx @@ -6,9 +6,13 @@ import { COMMAND_MENU_PAGES_CONFIG } from '@/command-menu/constants/CommandMenuP import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState'; import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState'; import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { SettingsPath } from '@/types/SettingsPath'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { motion } from 'framer-motion'; +import { useLocation } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; @@ -19,8 +23,9 @@ const StyledCommandMenuContent = styled.div` export const CommandMenuRouter = () => { const commandMenuPage = useRecoilValue(commandMenuPageState); - const commandMenuPageInfo = useRecoilValue(commandMenuPageInfoState); + const location = useLocation(); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); const commandMenuPageComponent = isDefined(commandMenuPage) ? ( COMMAND_MENU_PAGES_CONFIG.get(commandMenuPage) @@ -30,6 +35,13 @@ export const CommandMenuRouter = () => { const theme = useTheme(); + const isSettingsPage = location.pathname.includes(SettingsPath.PageLayout); + const objectMetadataItemOverride = isSettingsPage + ? objectMetadataItems.find( + (item) => item.nameSingular === CoreObjectNameSingular.Dashboard, + ) + : undefined; + return ( @@ -52,6 +64,7 @@ export const CommandMenuRouter = () => { isInRightDrawer={true} displayType="listItem" actionMenuType="command-menu" + objectMetadataItemOverride={objectMetadataItemOverride} > {commandMenuPageComponent} diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx index 833a7cbeed..54cbfae8b4 100644 --- a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx +++ b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx @@ -3,6 +3,8 @@ import { CommandMenuAIChatThreadsPage } from '@/command-menu/pages/AIChatThreads import { CommandMenuAskAIPage } from '@/command-menu/pages/ask-ai/components/CommandMenuAskAIPage'; import { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage'; import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage'; +import { CommandMenuPageLayoutGraphTypeSelect } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect'; +import { CommandMenuPageLayoutWidgetTypeSelect } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect'; import { CommandMenuMergeRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuMergeRecordPage'; import { CommandMenuRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuRecordPage'; import { CommandMenuEditRichTextPage } from '@/command-menu/pages/rich-text-page/components/CommandMenuEditRichTextPage'; @@ -38,4 +40,12 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map< [CommandMenuPages.SearchRecords, ], [CommandMenuPages.AskAI, ], [CommandMenuPages.ViewPreviousAIChats, ], + [ + CommandMenuPages.PageLayoutWidgetTypeSelect, + , + ], + [ + CommandMenuPages.PageLayoutGraphTypeSelect, + , + ], ]); diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect.tsx new file mode 100644 index 0000000000..ac9d099d61 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect.tsx @@ -0,0 +1,77 @@ +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { usePageLayoutWidgetCreate } from '@/settings/page-layout/hooks/usePageLayoutWidgetCreate'; +import { + GraphSubType, + WidgetType, +} from '@/settings/page-layout/mocks/mockWidgets'; +import styled from '@emotion/styled'; +import { + IconChartBar, + IconChartPie, + IconGauge, + IconNumber, +} from 'twenty-ui/display'; +import { MenuItemCommand } from 'twenty-ui/navigation'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)}; +`; + +const StyledSectionTitle = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + padding-top: ${({ theme }) => theme.spacing(2)}; + padding-bottom: ${({ theme }) => theme.spacing(1)}; + padding-left: ${({ theme }) => theme.spacing(1)}; +`; + +const graphTypeOptions = [ + { + type: GraphSubType.BAR, + icon: IconChartBar, + title: 'Bar Chart', + }, + { + type: GraphSubType.PIE, + icon: IconChartPie, + title: 'Pie Chart', + }, + { + type: GraphSubType.GAUGE, + icon: IconGauge, + title: 'Gauge', + }, + { + type: GraphSubType.NUMBER, + icon: IconNumber, + title: 'Number', + }, +]; + +export const CommandMenuPageLayoutGraphTypeSelect = () => { + const { closeCommandMenu } = useCommandMenu(); + const { handleCreateWidget } = usePageLayoutWidgetCreate(); + + const handleSelectGraphType = (graphType: GraphSubType) => { + handleCreateWidget(WidgetType.GRAPH, graphType); + closeCommandMenu(); + }; + + return ( + + Graph type + + {graphTypeOptions.map((option) => ( + handleSelectGraphType(option.type)} + /> + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx new file mode 100644 index 0000000000..da7a3c12be --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx @@ -0,0 +1,93 @@ +import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { WidgetType } from '@/settings/page-layout/mocks/mockWidgets'; +import { pageLayoutDraggedAreaState } from '@/settings/page-layout/states/pageLayoutDraggedAreaState'; +import styled from '@emotion/styled'; +import { useSetRecoilState } from 'recoil'; +import { IconChartPie, IconFrame, IconList } from 'twenty-ui/display'; +import { MenuItemCommand } from 'twenty-ui/navigation'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)}; +`; + +const StyledSectionTitle = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + padding-top: ${({ theme }) => theme.spacing(2)}; + padding-bottom: ${({ theme }) => theme.spacing(1)}; + padding-left: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledDisabledMenuItem = styled.div` + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +`; + +const widgetTypeOptions = [ + { + type: WidgetType.GRAPH, + icon: IconChartPie, + title: 'Add a graph', + disabled: false, + }, + { + type: WidgetType.VIEW, + icon: IconList, + title: 'Add a view', + disabled: true, + }, + { + type: WidgetType.IFRAME, + icon: IconFrame, + title: 'Add an iframe', + disabled: true, + }, +]; + +export const CommandMenuPageLayoutWidgetTypeSelect = () => { + const { navigateCommandMenu } = useNavigateCommandMenu(); + const setPageLayoutDraggedArea = useSetRecoilState( + pageLayoutDraggedAreaState, + ); + + const handleSelectWidget = (widgetType: WidgetType) => { + if (widgetType === WidgetType.GRAPH) { + navigateCommandMenu({ + page: CommandMenuPages.PageLayoutGraphTypeSelect, + pageTitle: 'Select Graph Type', + pageIcon: IconChartPie, + }); + } else { + setPageLayoutDraggedArea(null); + } + }; + + return ( + + Widget type + {widgetTypeOptions.map((option) => { + const MenuItem = ( + handleSelectWidget(option.type)} + /> + ); + + return option.disabled ? ( + + {MenuItem} + + ) : ( + MenuItem + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts index 52c0459b8f..6643b6543d 100644 --- a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts +++ b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts @@ -14,4 +14,6 @@ export enum CommandMenuPages { SearchRecords = 'search-records', AskAI = 'ask-ai', ViewPreviousAIChats = 'view-previous-ai-chats', + PageLayoutWidgetTypeSelect = 'page-layout-widget-type-select', + PageLayoutGraphTypeSelect = 'page-layout-graph-type-select', } diff --git a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts index 164c995322..1738d1c6c1 100644 --- a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts +++ b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts @@ -9,6 +9,7 @@ export enum CoreObjectNameSingular { Comment = 'comment', Company = 'company', ConnectedAccount = 'connectedAccount', + Dashboard = 'dashboard', TimelineActivity = 'timelineActivity', Favorite = 'favorite', FavoriteFolder = 'favoriteFolder', diff --git a/packages/twenty-front/src/modules/settings/components/SettingsPageFullWidthContainer.tsx b/packages/twenty-front/src/modules/settings/components/SettingsPageFullWidthContainer.tsx index b1e2a77f2e..23069dcad7 100644 --- a/packages/twenty-front/src/modules/settings/components/SettingsPageFullWidthContainer.tsx +++ b/packages/twenty-front/src/modules/settings/components/SettingsPageFullWidthContainer.tsx @@ -10,15 +10,19 @@ import { type ReactNode } from 'react'; type SettingsPageFullWidthContainerProps = { children: ReactNode; links: BreadcrumbProps['links']; + actionButton?: ReactNode; }; export const SettingsPageFullWidthContainer = ({ children, links, + actionButton, }: SettingsPageFullWidthContainerProps) => { return ( - } /> + }> + {actionButton} + {children} ); diff --git a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx index 90a6fa5c9f..abd89f7436 100644 --- a/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx +++ b/packages/twenty-front/src/modules/settings/hooks/useSettingsNavigationItems.tsx @@ -145,8 +145,8 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => { !isBillingEnabled || !permissionMap[PermissionFlagType.WORKSPACE], }, { - label: t`Layout`, - path: SettingsPath.PageLayoutEdition, + label: t`Page Layouts`, + path: SettingsPath.PageLayout, Icon: IconLayout, isHidden: !isPageLayoutEnabled || diff --git a/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutInitializationEffect.tsx b/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutInitializationEffect.tsx new file mode 100644 index 0000000000..e0ed4a173c --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutInitializationEffect.tsx @@ -0,0 +1,98 @@ +import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; +import { useEffect, useState } from 'react'; +import { useRecoilCallback, useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; +import { type Widget } from '../mocks/mockWidgets'; +import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState'; +import { pageLayoutDraftState } from '../states/pageLayoutDraftState'; +import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState'; +import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState'; +import { + PageLayoutType, + savedPageLayoutsState, + type SavedPageLayout, +} from '../states/savedPageLayoutsState'; + +type PageLayoutInitializationEffectProps = { + layoutId: string | undefined; + isEditMode: boolean; +}; + +export const PageLayoutInitializationEffect = ({ + layoutId, + isEditMode, +}: PageLayoutInitializationEffectProps) => { + const [isInitialized, setIsInitialized] = useState(false); + const savedPageLayouts = useRecoilValue(savedPageLayoutsState); + + const initializePageLayout = useRecoilCallback( + ({ set, snapshot }) => + (layout: SavedPageLayout | undefined) => { + const currentPersisted = getSnapshotValue( + snapshot, + pageLayoutPersistedState, + ); + + if (isDefined(layout)) { + if (!isDeeplyEqual(layout, currentPersisted)) { + set(pageLayoutPersistedState, layout); + set(pageLayoutDraftState, { + name: layout.name, + type: layout.type, + widgets: layout.widgets, + }); + + const widgets: Widget[] = layout.widgets.map((w) => ({ + id: w.id, + title: w.title, + type: w.type, + configuration: w.configuration, + data: w.data, + })); + set(pageLayoutWidgetsState, widgets); + + const layouts = layout.widgets.map((w) => ({ + i: w.id, + x: w.gridPosition.column, + y: w.gridPosition.row, + w: w.gridPosition.columnSpan, + h: w.gridPosition.rowSpan, + })); + set(pageLayoutCurrentLayoutsState, { + desktop: layouts, + mobile: layouts.map((l) => ({ ...l, w: 1, x: 0 })), + }); + } + } else { + set(pageLayoutDraftState, { + name: '', + type: PageLayoutType.DASHBOARD, + widgets: [], + }); + set(pageLayoutPersistedState, undefined); + set(pageLayoutWidgetsState, []); + set(pageLayoutCurrentLayoutsState, { desktop: [], mobile: [] }); + } + }, + [], + ); + + useEffect(() => { + if (!isInitialized) { + const existingLayout = isEditMode + ? savedPageLayouts.find((l) => l.id === layoutId) + : undefined; + initializePageLayout(existingLayout); + setIsInitialized(true); + } + }, [ + layoutId, + savedPageLayouts, + initializePageLayout, + isInitialized, + isEditMode, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutWidgetPlaceholder.tsx b/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutWidgetPlaceholder.tsx new file mode 100644 index 0000000000..3fd145d124 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/components/PageLayoutWidgetPlaceholder.tsx @@ -0,0 +1,131 @@ +import styled from '@emotion/styled'; +import { type ReactNode } from 'react'; +import { IconGripVertical, IconX } from 'twenty-ui/display'; +import { IconButton } from 'twenty-ui/input'; +import { + AnimatedPlaceholder, + AnimatedPlaceholderEmptyContainer, + AnimatedPlaceholderEmptySubTitle, + AnimatedPlaceholderEmptyTextContainer, + AnimatedPlaceholderEmptyTitle, + EMPTY_PLACEHOLDER_TRANSITION_PROPS, +} from 'twenty-ui/layout'; + +const StyledPlaceholderContainer = styled.div<{ isEmpty?: boolean }>` + background: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.md}; + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + position: relative; + width: 100%; + padding: ${({ theme }) => theme.spacing(4)}; + + &:hover { + cursor: ${({ isEmpty }) => (isEmpty ? 'pointer' : 'default')}; + border: 1px solid ${({ theme }) => theme.color.blue}; + background: ${({ theme }) => theme.background.primary}; + } +`; + +const StyledHeader = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledDragHandleButton = styled(IconButton)` + cursor: grab; + display: flex; + align-items: center; + user-select: none; + + &:active { + cursor: grabbing; + } +`; + +const StyledTitle = styled.span` + color: ${({ theme }) => theme.font.color.secondary}; + flex: 1; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + user-select: none; +`; + +const StyledContent = styled.div` + align-items: center; + display: flex; + height: 100%; + width: 100%; + justify-content: center; +`; + +type PageLayoutWidgetPlaceholderProps = { + title?: string; + onRemove?: () => void; + children?: ReactNode; + isEmpty?: boolean; +}; + +export const PageLayoutWidgetPlaceholder = ({ + title = 'Graph Title', + onRemove, + children, + isEmpty = false, +}: PageLayoutWidgetPlaceholderProps) => { + if (isEmpty) { + return ( + + + + Add Widget + + + + + + No widgets yet + + + Click to add your first widget + + + + + ); + } + + return ( + + + + {title} + {onRemove && ( + + )} + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/components/__stories__/PageLayoutWidgetPlaceholder.stories.tsx b/packages/twenty-front/src/modules/settings/page-layout/components/__stories__/PageLayoutWidgetPlaceholder.stories.tsx new file mode 100644 index 0000000000..d3219d7eb3 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/components/__stories__/PageLayoutWidgetPlaceholder.stories.tsx @@ -0,0 +1,313 @@ +import { GraphWidgetGaugeChart } from '@/dashboards/graphs/components/GraphWidgetGaugeChart'; +import { GraphWidgetNumberChart } from '@/dashboards/graphs/components/GraphWidgetNumberChart'; +import { GraphWidgetPieChart } from '@/dashboards/graphs/components/GraphWidgetPieChart'; +import { type Meta, type StoryObj } from '@storybook/react'; +import { ComponentDecorator } from 'twenty-ui/testing'; +import { PageLayoutWidgetPlaceholder } from '../PageLayoutWidgetPlaceholder'; + +const meta: Meta = { + title: 'Modules/Settings/PageLayout/PageLayoutWidgetPlaceholder', + component: PageLayoutWidgetPlaceholder, + decorators: [ComponentDecorator], + parameters: { + layout: 'centered', + }, + argTypes: { + title: { + control: 'text', + description: 'Widget title', + }, + isEmpty: { + control: 'boolean', + description: 'Show empty state', + }, + onRemove: { + action: 'onRemove', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const WithNumberChart: Story = { + args: { + title: 'Sales Pipeline', + children: , + }, + render: (args) => ( +
+ +
+ ), +}; + +export const WithGaugeChart: Story = { + args: { + title: 'Conversion Rate', + children: ( + + ), + }, + render: (args) => ( +
+ +
+ ), +}; + +export const EmptyState: Story = { + args: { + isEmpty: true, + }, + parameters: { + docs: { + description: { + story: 'Empty widget placeholder state with dashed border', + }, + }, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const WithPieChart: Story = { + args: { + title: 'Lead Distribution', + children: ( + + ), + }, + render: (args) => ( +
+ +
+ ), +}; + +export const SmallWidget: Story = { + args: { + title: 'Small Widget (2x2 grid)', + children: , + }, + parameters: { + docs: { + description: { + story: 'Simulates a 2x2 grid cell widget', + }, + }, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const MediumWidget: Story = { + args: { + title: 'Medium Widget (4x3 grid)', + children: ( + + ), + }, + parameters: { + docs: { + description: { + story: 'Simulates a 4x3 grid cell widget', + }, + }, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const LargeWidget: Story = { + args: { + title: 'Large Widget (6x4 grid)', + children: ( + + ), + }, + parameters: { + docs: { + description: { + story: 'Simulates a 6x4 grid cell widget', + }, + }, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const WideWidget: Story = { + args: { + title: 'Wide Widget (8x2 grid)', + children: ( + + ), + }, + parameters: { + docs: { + description: { + story: 'Simulates a wide 8x2 grid cell widget', + }, + }, + }, + render: (args) => ( +
+ +
+ ), +}; + +export const TallWidget: Story = { + args: { + title: 'Tall Widget (3x6 grid)', + children: ( + + ), + }, + parameters: { + docs: { + description: { + story: 'Simulates a tall 3x6 grid cell widget', + }, + }, + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/constants/EmptyLayout.ts b/packages/twenty-front/src/modules/settings/page-layout/constants/EmptyLayout.ts new file mode 100644 index 0000000000..4116515f53 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/constants/EmptyLayout.ts @@ -0,0 +1,6 @@ +import { type Layouts } from 'react-grid-layout'; + +export const EMPTY_LAYOUT: Layouts = { + desktop: [{ i: 'empty-placeholder', x: 0, y: 0, w: 4, h: 4, static: true }], + mobile: [{ i: 'empty-placeholder', x: 0, y: 0, w: 1, h: 4, static: true }], +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/constants/GridBufferRows.ts b/packages/twenty-front/src/modules/settings/page-layout/constants/GridBufferRows.ts new file mode 100644 index 0000000000..a25e1ddf73 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/constants/GridBufferRows.ts @@ -0,0 +1 @@ +export const GRID_BUFFER_ROWS = 10; diff --git a/packages/twenty-front/src/modules/settings/page-layout/constants/GridMinRows.ts b/packages/twenty-front/src/modules/settings/page-layout/constants/GridMinRows.ts new file mode 100644 index 0000000000..c0a04f1a42 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/constants/GridMinRows.ts @@ -0,0 +1 @@ +export const GRID_MIN_ROWS = 25; diff --git a/packages/twenty-front/src/modules/settings/page-layout/constants/PageLayoutBreakpoints.ts b/packages/twenty-front/src/modules/settings/page-layout/constants/PageLayoutBreakpoints.ts new file mode 100644 index 0000000000..115d947c86 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/constants/PageLayoutBreakpoints.ts @@ -0,0 +1,14 @@ +import { MOBILE_VIEWPORT } from 'twenty-ui/theme'; + +export const PAGE_LAYOUT_CONFIG = { + breakpoints: { + desktop: MOBILE_VIEWPORT, + mobile: 0, + }, + columns: { + desktop: 12, + mobile: 1, + }, +} as const; + +export type PageLayoutBreakpoint = 'desktop' | 'mobile'; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDraftState.test.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDraftState.test.ts new file mode 100644 index 0000000000..0cdf2116ae --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDraftState.test.ts @@ -0,0 +1,80 @@ +import { + GraphSubType, + WidgetType, +} from '@/settings/page-layout/mocks/mockWidgets'; +import { PageLayoutType } from '@/settings/page-layout/states/savedPageLayoutsState'; +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { usePageLayoutDraftState } from '../usePageLayoutDraftState'; + +describe('usePageLayoutDraftState', () => { + it('should detect dirty state when draft differs from persisted', () => { + const { result } = renderHook(() => usePageLayoutDraftState(), { + wrapper: RecoilRoot, + }); + + expect(result.current.isDirty).toBe(false); + expect(result.current.canSave).toBe(false); + }); + + it('should handle empty name as not saveable', () => { + const { result } = renderHook(() => usePageLayoutDraftState(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.setPageLayoutDraft({ + name: ' ', + type: PageLayoutType.DASHBOARD, + widgets: [], + }); + }); + + expect(result.current.isDirty).toBe(false); + expect(result.current.canSave).toBe(false); + }); + + it('should allow updating draft state', () => { + const { result } = renderHook(() => usePageLayoutDraftState(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.setPageLayoutDraft({ + name: 'Updated Name', + type: PageLayoutType.DASHBOARD, + widgets: [], + }); + }); + + expect(result.current.pageLayoutDraft.name).toBe('Updated Name'); + expect(result.current.canSave).toBe(true); + expect(result.current.isDirty).toBe(true); + }); + + it('should detect changes in widgets', () => { + const { result } = renderHook(() => usePageLayoutDraftState(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.setPageLayoutDraft({ + name: 'Test Layout', + type: PageLayoutType.DASHBOARD, + widgets: [ + { + id: 'widget-1', + title: 'New Widget', + type: WidgetType.GRAPH, + gridPosition: { row: 2, column: 2, rowSpan: 2, columnSpan: 2 }, + configuration: { graphType: GraphSubType.BAR }, + data: {}, + }, + ], + }); + }); + + expect(result.current.isDirty).toBe(true); + expect(result.current.canSave).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDragSelection.test.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDragSelection.test.ts new file mode 100644 index 0000000000..31de4c8084 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutDragSelection.test.ts @@ -0,0 +1,100 @@ +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { usePageLayoutDragSelection } from '../usePageLayoutDragSelection'; + +describe('usePageLayoutDragSelection', () => { + it('should initialize with empty selected cells', () => { + const { result } = renderHook(() => usePageLayoutDragSelection(), { + wrapper: RecoilRoot, + }); + + expect(result.current.pageLayoutSelectedCells).toEqual(new Set()); + }); + + it('should clear selected cells on drag start', () => { + const { result } = renderHook(() => usePageLayoutDragSelection(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleDragSelectionChange('cell-1', true); + result.current.handleDragSelectionChange('cell-2', true); + }); + + expect(result.current.pageLayoutSelectedCells.size).toBe(2); + + act(() => { + result.current.handleDragSelectionStart(); + }); + + expect(result.current.pageLayoutSelectedCells).toEqual(new Set()); + }); + + it('should add and remove cells during drag selection', () => { + const { result } = renderHook(() => usePageLayoutDragSelection(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleDragSelectionChange('cell-1', true); + }); + + expect(result.current.pageLayoutSelectedCells.has('cell-1')).toBe(true); + + act(() => { + result.current.handleDragSelectionChange('cell-2', true); + }); + + expect(result.current.pageLayoutSelectedCells.size).toBe(2); + + act(() => { + result.current.handleDragSelectionChange('cell-1', false); + }); + + expect(result.current.pageLayoutSelectedCells.has('cell-1')).toBe(false); + expect(result.current.pageLayoutSelectedCells.size).toBe(1); + }); + + it('should handle drag selection end with selected cells', () => { + const { result } = renderHook(() => usePageLayoutDragSelection(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleDragSelectionChange('0-0', true); + result.current.handleDragSelectionChange('1-0', true); + result.current.handleDragSelectionChange('0-1', true); + result.current.handleDragSelectionChange('1-1', true); + }); + + expect(result.current.pageLayoutSelectedCells.size).toBe(4); + + act(() => { + result.current.handleDragSelectionEnd(); + }); + + expect(result.current.pageLayoutSelectedCells).toEqual(new Set()); + }); + + it('should handle drag selection end with no selected cells', () => { + const { result } = renderHook(() => usePageLayoutDragSelection(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleDragSelectionEnd(); + }); + + expect(result.current.pageLayoutSelectedCells).toEqual(new Set()); + }); + + it('should provide all required handler functions', () => { + const { result } = renderHook(() => usePageLayoutDragSelection(), { + wrapper: RecoilRoot, + }); + + expect(typeof result.current.handleDragSelectionStart).toBe('function'); + expect(typeof result.current.handleDragSelectionChange).toBe('function'); + expect(typeof result.current.handleDragSelectionEnd).toBe('function'); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutHandleLayoutChange.test.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutHandleLayoutChange.test.ts new file mode 100644 index 0000000000..c98a1d6c90 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutHandleLayoutChange.test.ts @@ -0,0 +1,62 @@ +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { usePageLayoutHandleLayoutChange } from '../usePageLayoutHandleLayoutChange'; + +describe('usePageLayoutHandleLayoutChange', () => { + it('should update layouts and draft state when layout changes', () => { + const { result } = renderHook(() => usePageLayoutHandleLayoutChange(), { + wrapper: RecoilRoot, + }); + + const newLayouts = { + desktop: [ + { i: 'widget-1', x: 2, y: 3, w: 4, h: 5 }, + { i: 'widget-2', x: 6, y: 7, w: 8, h: 9 }, + ], + mobile: [ + { i: 'widget-1', x: 0, y: 0, w: 1, h: 5 }, + { i: 'widget-2', x: 0, y: 5, w: 1, h: 9 }, + ], + }; + + act(() => { + result.current.handleLayoutChange([], newLayouts); + }); + + expect(typeof result.current.handleLayoutChange).toBe('function'); + }); + + it('should handle empty layouts', () => { + const { result } = renderHook(() => usePageLayoutHandleLayoutChange(), { + wrapper: RecoilRoot, + }); + + const emptyLayouts = { + desktop: [], + mobile: [], + }; + + act(() => { + result.current.handleLayoutChange([], emptyLayouts); + }); + + expect(typeof result.current.handleLayoutChange).toBe('function'); + }); + + it('should maintain callback reference across renders', () => { + const { result, rerender } = renderHook( + () => usePageLayoutHandleLayoutChange(), + { + wrapper: RecoilRoot, + }, + ); + + const firstCallback = result.current.handleLayoutChange; + + rerender(); + + const secondCallback = result.current.handleLayoutChange; + + expect(firstCallback).toBe(secondCallback); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetCreate.test.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetCreate.test.ts new file mode 100644 index 0000000000..47220e3a9f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetCreate.test.ts @@ -0,0 +1,50 @@ +import { + GraphSubType, + WidgetType, +} from '@/settings/page-layout/mocks/mockWidgets'; +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { usePageLayoutWidgetCreate } from '../usePageLayoutWidgetCreate'; + +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'mock-uuid'), +})); + +describe('usePageLayoutWidgetCreate', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should create a new widget with default position', () => { + const { result } = renderHook(() => usePageLayoutWidgetCreate(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleCreateWidget(WidgetType.GRAPH, GraphSubType.BAR); + }); + + expect(typeof result.current.handleCreateWidget).toBe('function'); + }); + + it('should handle different graph types', () => { + const { result } = renderHook(() => usePageLayoutWidgetCreate(), { + wrapper: RecoilRoot, + }); + + const graphTypes = [ + GraphSubType.NUMBER, + GraphSubType.GAUGE, + GraphSubType.PIE, + GraphSubType.BAR, + ]; + + graphTypes.forEach((graphType) => { + act(() => { + result.current.handleCreateWidget(WidgetType.GRAPH, graphType); + }); + }); + + expect(typeof result.current.handleCreateWidget).toBe('function'); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetDelete.test.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetDelete.test.ts new file mode 100644 index 0000000000..53bcfc4d6d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/__tests__/usePageLayoutWidgetDelete.test.ts @@ -0,0 +1,41 @@ +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { usePageLayoutWidgetDelete } from '../usePageLayoutWidgetDelete'; + +describe('usePageLayoutWidgetDelete', () => { + it('should remove widget from all states', () => { + const { result } = renderHook(() => usePageLayoutWidgetDelete(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleRemoveWidget('widget-1'); + }); + + expect(typeof result.current.handleRemoveWidget).toBe('function'); + }); + + it('should handle removing non-existent widget', () => { + const { result } = renderHook(() => usePageLayoutWidgetDelete(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleRemoveWidget('non-existent-widget'); + }); + + expect(typeof result.current.handleRemoveWidget).toBe('function'); + }); + + it('should handle empty layouts', () => { + const { result } = renderHook(() => usePageLayoutWidgetDelete(), { + wrapper: RecoilRoot, + }); + + act(() => { + result.current.handleRemoveWidget('any-widget'); + }); + + expect(typeof result.current.handleRemoveWidget).toBe('function'); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDraftState.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDraftState.ts new file mode 100644 index 0000000000..d589e209fd --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDraftState.ts @@ -0,0 +1,28 @@ +import { useRecoilState, useRecoilValue } from 'recoil'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; +import { pageLayoutDraftState } from '../states/pageLayoutDraftState'; +import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState'; + +export const usePageLayoutDraftState = () => { + const [pageLayoutDraft, setPageLayoutDraft] = + useRecoilState(pageLayoutDraftState); + const pageLayoutPersisted = useRecoilValue(pageLayoutPersistedState); + + const isDirty = pageLayoutPersisted + ? !isDeeplyEqual(pageLayoutDraft, { + name: pageLayoutPersisted.name, + type: pageLayoutPersisted.type, + widgets: pageLayoutPersisted.widgets, + }) + : pageLayoutDraft.name.trim().length > 0 || + pageLayoutDraft.widgets.length > 0; + + const canSave = pageLayoutDraft.name?.trim().length > 0; + + return { + pageLayoutDraft, + setPageLayoutDraft, + isDirty, + canSave, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDragSelection.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDragSelection.ts new file mode 100644 index 0000000000..25706d0a0e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutDragSelection.ts @@ -0,0 +1,61 @@ +import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { useRecoilState, useSetRecoilState } from 'recoil'; +import { IconAppWindow } from 'twenty-ui/display'; +import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState'; +import { pageLayoutSelectedCellsState } from '../states/pageLayoutSelectedCellsState'; +import { calculateGridBoundsFromSelectedCells } from '../utils/calculateGridBoundsFromSelectedCells'; + +export const usePageLayoutDragSelection = () => { + const [pageLayoutSelectedCells, setPageLayoutSelectedCells] = useRecoilState( + pageLayoutSelectedCellsState, + ); + const setPageLayoutDraggedArea = useSetRecoilState( + pageLayoutDraggedAreaState, + ); + + const { navigateCommandMenu } = useNavigateCommandMenu(); + const handleDragSelectionStart = () => { + setPageLayoutSelectedCells(new Set()); + }; + + const handleDragSelectionChange = (cellId: string, selected: boolean) => { + setPageLayoutSelectedCells((prev) => { + const newSet = new Set(prev); + if (selected) { + newSet.add(cellId); + } else { + newSet.delete(cellId); + } + return newSet; + }); + }; + + const handleDragSelectionEnd = () => { + if (pageLayoutSelectedCells.size > 0) { + const draggedBounds = calculateGridBoundsFromSelectedCells( + Array.from(pageLayoutSelectedCells), + ); + + if (draggedBounds !== null) { + setPageLayoutDraggedArea(draggedBounds); + + navigateCommandMenu({ + page: CommandMenuPages.PageLayoutWidgetTypeSelect, + pageTitle: 'Add Widget', + pageIcon: IconAppWindow, + resetNavigationStack: true, + }); + + setPageLayoutSelectedCells(new Set()); + } + } + }; + + return { + pageLayoutSelectedCells, + handleDragSelectionStart, + handleDragSelectionChange, + handleDragSelectionEnd, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutHandleLayoutChange.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutHandleLayoutChange.ts new file mode 100644 index 0000000000..1cb791fe6b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutHandleLayoutChange.ts @@ -0,0 +1,32 @@ +import { type Layout, type Layouts } from 'react-grid-layout'; +import { useRecoilCallback } from 'recoil'; +import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState'; +import { pageLayoutDraftState } from '../states/pageLayoutDraftState'; +import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState'; +import { convertLayoutsToWidgets } from '../utils/convertLayoutsToWidgets'; + +export const usePageLayoutHandleLayoutChange = () => { + const handleLayoutChange = useRecoilCallback( + ({ snapshot, set }) => + (_: Layout[], allLayouts: Layouts) => { + set(pageLayoutCurrentLayoutsState, allLayouts); + + const pageLayoutWidgets = snapshot + .getLoadable(pageLayoutWidgetsState) + .getValue(); + + const updatedWidgets = convertLayoutsToWidgets( + pageLayoutWidgets, + allLayouts, + ); + + set(pageLayoutDraftState, (prev) => ({ + ...prev, + widgets: updatedWidgets, + })); + }, + [], + ); + + return { handleLayoutChange }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutSaveHandler.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutSaveHandler.ts new file mode 100644 index 0000000000..b0eb0ac9d7 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutSaveHandler.ts @@ -0,0 +1,63 @@ +import { useNavigate, useParams } from 'react-router-dom'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 as uuidv4 } from 'uuid'; +import { pageLayoutDraftState } from '../states/pageLayoutDraftState'; +import { pageLayoutPersistedState } from '../states/pageLayoutPersistedState'; +import { + savedPageLayoutsState, + type SavedPageLayout, +} from '../states/savedPageLayoutsState'; + +type WidgetWithGridPosition = SavedPageLayout['widgets'][0]; + +export const usePageLayoutSaveHandler = () => { + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const isEditMode = id && id !== 'new'; + + const savePageLayout = useRecoilCallback( + ({ snapshot, set }) => + async (widgetsWithPositions?: WidgetWithGridPosition[]) => { + const pageLayoutDraft = snapshot + .getLoadable(pageLayoutDraftState) + .getValue(); + const savedPageLayouts = snapshot + .getLoadable(savedPageLayoutsState) + .getValue(); + + const existingLayout = isEditMode + ? savedPageLayouts.find((layout) => layout.id === id) + : undefined; + + const widgets = widgetsWithPositions || pageLayoutDraft.widgets; + + const layoutToSave: SavedPageLayout = { + id: isEditMode ? id : uuidv4(), + name: pageLayoutDraft.name, + type: pageLayoutDraft.type, + createdAt: isEditMode + ? existingLayout?.createdAt || new Date().toISOString() + : new Date().toISOString(), + updatedAt: new Date().toISOString(), + widgets, + }; + + set(savedPageLayoutsState, (prev) => { + if (isDefined(isEditMode)) { + return prev.map((layout) => + layout.id === id ? layoutToSave : layout, + ); + } + return [...prev, layoutToSave]; + }); + + set(pageLayoutPersistedState, layoutToSave); + + navigate('/settings/page-layout'); + }, + [isEditMode, id, navigate], + ); + + return { savePageLayout }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetCreate.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetCreate.ts new file mode 100644 index 0000000000..1f5244b93a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetCreate.ts @@ -0,0 +1,98 @@ +import { useRecoilCallback } from 'recoil'; +import { v4 as uuidv4 } from 'uuid'; +import { + type GraphSubType, + type Widget, + type WidgetType, +} from '../mocks/mockWidgets'; +import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState'; +import { pageLayoutDraftState } from '../states/pageLayoutDraftState'; +import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState'; +import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState'; +import { + getDefaultWidgetData, + getWidgetSize, + getWidgetTitle, +} from '../utils/getDefaultWidgetData'; +import { getDefaultWidgetPosition } from '../utils/getDefaultWidgetPosition'; + +export const usePageLayoutWidgetCreate = () => { + const handleCreateWidget = useRecoilCallback( + ({ snapshot, set }) => + (widgetType: WidgetType, graphType: GraphSubType) => { + const widgetData = getDefaultWidgetData(graphType); + + const pageLayoutWidgets = snapshot + .getLoadable(pageLayoutWidgetsState) + .getValue(); + const pageLayoutCurrentLayouts = snapshot + .getLoadable(pageLayoutCurrentLayoutsState) + .getValue(); + const pageLayoutDraggedArea = snapshot + .getLoadable(pageLayoutDraggedAreaState) + .getValue(); + + const existingWidgetCount = pageLayoutWidgets.filter( + (w) => + w.type === widgetType && w.configuration?.graphType === graphType, + ).length; + const title = getWidgetTitle(graphType, existingWidgetCount); + + const newWidget: Widget = { + id: `widget-${uuidv4()}`, + type: widgetType, + title, + configuration: { + graphType, + }, + data: widgetData, + }; + + const defaultSize = getWidgetSize(graphType); + const position = getDefaultWidgetPosition( + pageLayoutDraggedArea, + defaultSize, + ); + + const newLayout = { + i: newWidget.id, + x: position.x, + y: position.y, + w: position.w, + h: position.h, + }; + + const updatedWidgets = [...pageLayoutWidgets, newWidget]; + set(pageLayoutWidgetsState, updatedWidgets); + + const updatedLayouts = { + desktop: [...(pageLayoutCurrentLayouts.desktop || []), newLayout], + mobile: [ + ...(pageLayoutCurrentLayouts.mobile || []), + { ...newLayout, w: 1, x: 0 }, + ], + }; + set(pageLayoutCurrentLayoutsState, updatedLayouts); + + const widgetWithPosition = { + ...newWidget, + gridPosition: { + row: position.y, + column: position.x, + rowSpan: position.h, + columnSpan: position.w, + }, + }; + + set(pageLayoutDraftState, (prev) => ({ + ...prev, + widgets: [...prev.widgets, widgetWithPosition], + })); + + set(pageLayoutDraggedAreaState, null); + }, + [], + ); + + return { handleCreateWidget }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetDelete.ts b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetDelete.ts new file mode 100644 index 0000000000..3e3bb4143b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/hooks/usePageLayoutWidgetDelete.ts @@ -0,0 +1,41 @@ +import { useRecoilCallback } from 'recoil'; +import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState'; +import { pageLayoutDraftState } from '../states/pageLayoutDraftState'; +import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState'; + +export const usePageLayoutWidgetDelete = () => { + const handleRemoveWidget = useRecoilCallback( + ({ snapshot, set }) => + (widgetId: string) => { + const pageLayoutWidgets = snapshot + .getLoadable(pageLayoutWidgetsState) + .getValue(); + const pageLayoutCurrentLayouts = snapshot + .getLoadable(pageLayoutCurrentLayoutsState) + .getValue(); + + const updatedWidgets = pageLayoutWidgets.filter( + (w) => w.id !== widgetId, + ); + set(pageLayoutWidgetsState, updatedWidgets); + + const updatedLayouts = { + desktop: (pageLayoutCurrentLayouts.desktop || []).filter( + (layout) => layout.i !== widgetId, + ), + mobile: (pageLayoutCurrentLayouts.mobile || []).filter( + (layout) => layout.i !== widgetId, + ), + }; + set(pageLayoutCurrentLayoutsState, updatedLayouts); + + set(pageLayoutDraftState, (prev) => ({ + ...prev, + widgets: prev.widgets.filter((w) => w.id !== widgetId), + })); + }, + [], + ); + + return { handleRemoveWidget }; +}; diff --git a/packages/twenty-front/src/pages/settings/page-layout/mocks/mockWidgets.ts b/packages/twenty-front/src/modules/settings/page-layout/mocks/mockWidgets.ts similarity index 51% rename from packages/twenty-front/src/pages/settings/page-layout/mocks/mockWidgets.ts rename to packages/twenty-front/src/modules/settings/page-layout/mocks/mockWidgets.ts index 17059ebbb7..9e61103d8d 100644 --- a/packages/twenty-front/src/pages/settings/page-layout/mocks/mockWidgets.ts +++ b/packages/twenty-front/src/modules/settings/page-layout/mocks/mockWidgets.ts @@ -1,19 +1,35 @@ import { type Layouts } from 'react-grid-layout'; -export type WidgetType = 'number' | 'gauge' | 'pie' | 'view' | 'iframe'; +export enum WidgetType { + VIEW = 'VIEW', + IFRAME = 'IFRAME', + FIELDS = 'FIELDS', + GRAPH = 'GRAPH', +} + +export enum GraphSubType { + NUMBER = 'NUMBER', + GAUGE = 'GAUGE', + PIE = 'PIE', + BAR = 'BAR', +} export type Widget = { id: string; type: WidgetType; title: string; + configuration?: Record; data?: any; }; export const mockWidgets: Widget[] = [ { id: 'widget-1', - type: 'number', + type: WidgetType.GRAPH, title: 'Sales Pipeline', + configuration: { + graphType: GraphSubType.NUMBER, + }, data: { value: '1,234', trendPercentage: 12.5, @@ -21,8 +37,11 @@ export const mockWidgets: Widget[] = [ }, { id: 'widget-2', - type: 'gauge', + type: WidgetType.GRAPH, title: 'Conversion Rate', + configuration: { + graphType: GraphSubType.GAUGE, + }, data: { value: 0.5, min: 0, @@ -32,8 +51,11 @@ export const mockWidgets: Widget[] = [ }, { id: 'widget-3', - type: 'pie', + type: WidgetType.GRAPH, title: 'Lead Distribution', + configuration: { + graphType: GraphSubType.PIE, + }, data: { items: [ { @@ -69,10 +91,64 @@ export const mockWidgets: Widget[] = [ ], }, }, + { + id: 'widget-4', + type: WidgetType.GRAPH, + title: 'Monthly Performance', + configuration: { + graphType: GraphSubType.BAR, + }, + data: { + items: [ + { + month: 'Jan', + sales: 120, + leads: 45, + conversions: 12, + to: '/metrics/january', + }, + { + month: 'Feb', + sales: 150, + leads: 52, + conversions: 15, + to: '/metrics/february', + }, + { + month: 'Mar', + sales: 180, + leads: 48, + conversions: 18, + to: '/metrics/march', + }, + { + month: 'Apr', + sales: 140, + leads: 60, + conversions: 14, + to: '/metrics/april', + }, + { + month: 'May', + sales: 200, + leads: 55, + conversions: 20, + to: '/metrics/may', + }, + ], + indexBy: 'month', + keys: ['sales', 'leads', 'conversions'], + showLegend: true, + showGrid: true, + displayType: 'number', + xAxisLabel: 'Month', + yAxisLabel: 'Count', + }, + }, ]; export const mockLayouts: Layouts = { - lg: [ + desktop: [ { i: 'widget-1', x: 0, @@ -94,31 +170,15 @@ export const mockLayouts: Layouts = { w: 6, h: 5, }, - ], - md: [ { - i: 'widget-1', - x: 0, + i: 'widget-4', + x: 9, y: 0, - w: 3, - h: 2, - }, - { - i: 'widget-2', - x: 3, - y: 0, - w: 3, - h: 5, - }, - { - i: 'widget-3', - x: 6, - y: 0, - w: 6, - h: 5, + w: 4, + h: 8, }, ], - sm: [ + mobile: [ { i: 'widget-1', x: 0, @@ -140,5 +200,12 @@ export const mockLayouts: Layouts = { w: 1, h: 5, }, + { + i: 'widget-4', + x: 0, + y: 12, + w: 1, + h: 5, + }, ], }; diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentBreakpointState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentBreakpointState.ts new file mode 100644 index 0000000000..665d60995a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentBreakpointState.ts @@ -0,0 +1,8 @@ +import { createState } from 'twenty-ui/utilities'; +import { type PageLayoutBreakpoint } from '../constants/PageLayoutBreakpoints'; + +export const pageLayoutCurrentBreakpointState = + createState({ + key: 'pageLayoutCurrentBreakpointState', + defaultValue: 'desktop', + }); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentLayoutsState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentLayoutsState.ts new file mode 100644 index 0000000000..1edc017f62 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutCurrentLayoutsState.ts @@ -0,0 +1,7 @@ +import { type Layouts } from 'react-grid-layout'; +import { createState } from 'twenty-ui/utilities'; + +export const pageLayoutCurrentLayoutsState = createState({ + key: 'pageLayoutCurrentLayoutsState', + defaultValue: { desktop: [], mobile: [] }, +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraftState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraftState.ts new file mode 100644 index 0000000000..bb69b28ae6 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraftState.ts @@ -0,0 +1,16 @@ +import { createState } from 'twenty-ui/utilities'; +import { PageLayoutType, type SavedPageLayout } from './savedPageLayoutsState'; + +export type DraftPageLayout = Omit< + SavedPageLayout, + 'id' | 'createdAt' | 'updatedAt' +>; + +export const pageLayoutDraftState = createState({ + key: 'pageLayoutDraftState', + defaultValue: { + name: '', + type: PageLayoutType.DASHBOARD, + widgets: [], + }, +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraggedAreaState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraggedAreaState.ts new file mode 100644 index 0000000000..d0c1a37b66 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutDraggedAreaState.ts @@ -0,0 +1,13 @@ +import { createState } from 'twenty-ui/utilities'; + +type DraggedArea = { + x: number; + y: number; + w: number; + h: number; +} | null; + +export const pageLayoutDraggedAreaState = createState({ + key: 'pageLayoutDraggedAreaState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutPersistedState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutPersistedState.ts new file mode 100644 index 0000000000..aa1dc4a6fc --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutPersistedState.ts @@ -0,0 +1,9 @@ +import { createState } from 'twenty-ui/utilities'; +import { type SavedPageLayout } from './savedPageLayoutsState'; + +export const pageLayoutPersistedState = createState< + SavedPageLayout | undefined +>({ + key: 'pageLayoutPersistedState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutSelectedCellsState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutSelectedCellsState.ts new file mode 100644 index 0000000000..26f4200c0b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutSelectedCellsState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui/utilities'; + +export const pageLayoutSelectedCellsState = createState>({ + key: 'pageLayoutSelectedCellsState', + defaultValue: new Set(), +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutWidgetsState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutWidgetsState.ts new file mode 100644 index 0000000000..ae751042e8 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/pageLayoutWidgetsState.ts @@ -0,0 +1,7 @@ +import { createState } from 'twenty-ui/utilities'; +import { type Widget } from '../mocks/mockWidgets'; + +export const pageLayoutWidgetsState = createState({ + key: 'pageLayoutWidgetsState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/states/savedPageLayoutsState.ts b/packages/twenty-front/src/modules/settings/page-layout/states/savedPageLayoutsState.ts new file mode 100644 index 0000000000..2656d813ae --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/states/savedPageLayoutsState.ts @@ -0,0 +1,34 @@ +import { type WidgetType } from '@/settings/page-layout/mocks/mockWidgets'; +import { createState } from 'twenty-ui/utilities'; + +export enum PageLayoutType { + DASHBOARD = 'DASHBOARD', + RECORD_INDEX = 'RECORD_INDEX', + RECORD_PAGE = 'RECORD_PAGE', +} + +export type SavedPageLayout = { + id: string; + name: string; + type: PageLayoutType; + createdAt: string; + updatedAt: string; + widgets: Array<{ + id: string; + title: string; + type: WidgetType; + gridPosition: { + row: number; + column: number; + rowSpan: number; + columnSpan: number; + }; + configuration?: Record; + data?: any; // TODO: Remove when backend connected - data will be fetched dynamically + }>; +}; + +export const savedPageLayoutsState = createState({ + key: 'savedPageLayoutsState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/calculateGridBoundsFromSelectedCells.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/calculateGridBoundsFromSelectedCells.test.ts new file mode 100644 index 0000000000..9bf87c94e5 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/calculateGridBoundsFromSelectedCells.test.ts @@ -0,0 +1,138 @@ +import { calculateGridBoundsFromSelectedCells } from '../calculateGridBoundsFromSelectedCells'; + +describe('calculateGridBoundsFromSelectedCells', () => { + it('should return null for empty array', () => { + expect(calculateGridBoundsFromSelectedCells([])).toBeNull(); + }); + + it('should calculate bounds for single cell', () => { + expect(calculateGridBoundsFromSelectedCells(['cell-2-3'])).toEqual({ + x: 2, + y: 3, + w: 1, + h: 1, + }); + }); + + it('should calculate bounds for rectangular selection', () => { + expect( + calculateGridBoundsFromSelectedCells([ + 'cell-1-1', + 'cell-2-1', + 'cell-1-2', + 'cell-2-2', + ]), + ).toEqual({ + x: 1, + y: 1, + w: 2, + h: 2, + }); + }); + + it('should handle non-contiguous selection', () => { + expect( + calculateGridBoundsFromSelectedCells([ + 'cell-0-0', + 'cell-5-3', + 'cell-2-1', + ]), + ).toEqual({ + x: 0, + y: 0, + w: 6, + h: 4, + }); + }); + + it('should handle large grid selections', () => { + expect( + calculateGridBoundsFromSelectedCells(['cell-0-0', 'cell-11-24']), + ).toEqual({ + x: 0, + y: 0, + w: 12, + h: 25, + }); + }); + + it('should handle single row selection', () => { + expect( + calculateGridBoundsFromSelectedCells([ + 'cell-0-5', + 'cell-1-5', + 'cell-2-5', + 'cell-3-5', + ]), + ).toEqual({ + x: 0, + y: 5, + w: 4, + h: 1, + }); + }); + + it('should handle single column selection', () => { + expect( + calculateGridBoundsFromSelectedCells([ + 'cell-3-0', + 'cell-3-1', + 'cell-3-2', + 'cell-3-3', + ]), + ).toEqual({ + x: 3, + y: 0, + w: 1, + h: 4, + }); + }); + + it('should handle duplicate cell IDs in selection', () => { + expect( + calculateGridBoundsFromSelectedCells([ + 'cell-1-1', + 'cell-1-1', + 'cell-2-2', + 'cell-2-2', + ]), + ).toEqual({ + x: 1, + y: 1, + w: 2, + h: 2, + }); + }); + + it('should handle L-shaped selection', () => { + expect( + calculateGridBoundsFromSelectedCells([ + 'cell-0-0', + 'cell-1-0', + 'cell-0-1', + 'cell-0-2', + ]), + ).toEqual({ + x: 0, + y: 0, + w: 2, + h: 3, + }); + }); + + it('should handle sparse diagonal selection', () => { + expect( + calculateGridBoundsFromSelectedCells([ + 'cell-0-0', + 'cell-1-1', + 'cell-2-2', + 'cell-3-3', + ]), + ).toEqual({ + x: 0, + y: 0, + w: 4, + h: 4, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/calculateTotalGridRows.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/calculateTotalGridRows.test.ts new file mode 100644 index 0000000000..83c5f6ac6d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/calculateTotalGridRows.test.ts @@ -0,0 +1,40 @@ +import { GRID_MIN_ROWS } from '../../constants/GridMinRows'; +import { calculateTotalGridRows } from '../calculateTotalGridRows'; + +describe('calculateTotalGridRows', () => { + it('should return minimum rows for empty layouts', () => { + expect(calculateTotalGridRows({})).toBe(GRID_MIN_ROWS); + }); + + it('should calculate rows based on content when exceeding minimum', () => { + const layouts = { + desktop: [ + { i: '1', x: 0, y: 0, w: 2, h: 2 }, + { i: '2', x: 2, y: 20, w: 2, h: 3 }, + ], + }; + expect(calculateTotalGridRows(layouts)).toBe(33); + }); + + it('should respect minimum rows even with content', () => { + const layouts = { + desktop: [{ i: '1', x: 0, y: 0, w: 1, h: 1 }], + }; + expect(calculateTotalGridRows(layouts)).toBe(GRID_MIN_ROWS); + }); + + it('should handle custom min and buffer values', () => { + const layouts = { + desktop: [{ i: '1', x: 0, y: 10, w: 1, h: 5 }], + }; + expect(calculateTotalGridRows(layouts, 10, 5)).toBe(20); + }); + + it('should consider both desktop and mobile layouts', () => { + const layouts = { + desktop: [{ i: '1', x: 0, y: 5, w: 2, h: 2 }], + mobile: [{ i: '1', x: 0, y: 25, w: 1, h: 3 }], + }; + expect(calculateTotalGridRows(layouts)).toBe(38); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/convertLayoutsToWidgets.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/convertLayoutsToWidgets.test.ts new file mode 100644 index 0000000000..964498b3e0 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/convertLayoutsToWidgets.test.ts @@ -0,0 +1,79 @@ +import { GraphSubType, WidgetType, type Widget } from '../../mocks/mockWidgets'; +import { convertLayoutsToWidgets } from '../convertLayoutsToWidgets'; + +describe('convertLayoutsToWidgets', () => { + const mockWidgets: Widget[] = [ + { + id: 'widget-1', + title: 'Widget 1', + type: WidgetType.GRAPH, + configuration: { + graphType: GraphSubType.NUMBER, + }, + data: { value: 100 }, + }, + { + id: 'widget-2', + title: 'Widget 2', + type: WidgetType.GRAPH, + configuration: { + graphType: GraphSubType.PIE, + }, + data: { items: [] }, + }, + ]; + + it('should map layout positions to widgets', () => { + const layouts = { + desktop: [ + { i: 'widget-1', x: 2, y: 3, w: 4, h: 5 }, + { i: 'widget-2', x: 6, y: 7, w: 8, h: 9 }, + ], + }; + + const result = convertLayoutsToWidgets(mockWidgets, layouts); + + expect(result[0].gridPosition).toEqual({ + column: 2, + row: 3, + columnSpan: 4, + rowSpan: 5, + }); + expect(result[1].gridPosition).toEqual({ + column: 6, + row: 7, + columnSpan: 8, + rowSpan: 9, + }); + }); + + it('should use defaults when layout not found', () => { + const layouts = { + desktop: [{ i: 'widget-1', x: 1, y: 1, w: 1, h: 1 }], + }; + + const result = convertLayoutsToWidgets(mockWidgets, layouts); + + expect(result[1].gridPosition).toEqual({ + column: 0, + row: 0, + columnSpan: 2, + rowSpan: 2, + }); + }); + + it('should handle mobile layout', () => { + const layouts = { + mobile: [{ i: 'widget-1', x: 0, y: 4, w: 1, h: 6 }], + }; + + const result = convertLayoutsToWidgets(mockWidgets, layouts); + + expect(result[0].gridPosition).toEqual({ + column: 0, + row: 4, + columnSpan: 1, + rowSpan: 6, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/generateCellId.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/generateCellId.test.ts new file mode 100644 index 0000000000..71b5abb831 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/generateCellId.test.ts @@ -0,0 +1,15 @@ +import { generateCellId } from '../generateCellId'; + +describe('generateCellId', () => { + it('should generate cell ID with correct format', () => { + expect(generateCellId(3, 5)).toBe('cell-3-5'); + }); + + it('should handle zero values', () => { + expect(generateCellId(0, 0)).toBe('cell-0-0'); + }); + + it('should handle large numbers', () => { + expect(generateCellId(100, 200)).toBe('cell-100-200'); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/getDefaultWidgetPosition.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/getDefaultWidgetPosition.test.ts new file mode 100644 index 0000000000..e74301769d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/getDefaultWidgetPosition.test.ts @@ -0,0 +1,23 @@ +import { getDefaultWidgetPosition } from '../getDefaultWidgetPosition'; + +describe('getDefaultWidgetPosition', () => { + it('should return dragged area when provided', () => { + const draggedArea = { x: 2, y: 3, w: 4, h: 5 }; + const defaultSize = { w: 2, h: 2 }; + + expect(getDefaultWidgetPosition(draggedArea, defaultSize)).toEqual( + draggedArea, + ); + }); + + it('should return default position with size when no dragged area', () => { + const defaultSize = { w: 3, h: 4 }; + + expect(getDefaultWidgetPosition(null, defaultSize)).toEqual({ + x: 0, + y: 0, + w: 3, + h: 4, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/parseCellIdToCoordinates.test.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/parseCellIdToCoordinates.test.ts new file mode 100644 index 0000000000..2dddbc1d63 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/__tests__/parseCellIdToCoordinates.test.ts @@ -0,0 +1,18 @@ +import { parseCellIdToCoordinates } from '../parseCellIdToCoordinates'; + +describe('parseCellIdToCoordinates', () => { + it('should parse cell ID correctly', () => { + expect(parseCellIdToCoordinates('cell-3-5')).toEqual({ col: 3, row: 5 }); + }); + + it('should handle zero coordinates', () => { + expect(parseCellIdToCoordinates('cell-0-0')).toEqual({ col: 0, row: 0 }); + }); + + it('should handle double-digit coordinates', () => { + expect(parseCellIdToCoordinates('cell-12-25')).toEqual({ + col: 12, + row: 25, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/calculateGridBoundsFromSelectedCells.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/calculateGridBoundsFromSelectedCells.ts new file mode 100644 index 0000000000..e65112f8f4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/calculateGridBoundsFromSelectedCells.ts @@ -0,0 +1,30 @@ +import { parseCellIdToCoordinates } from './parseCellIdToCoordinates'; + +export type GridBounds = { + x: number; + y: number; + w: number; + h: number; +}; + +export const calculateGridBoundsFromSelectedCells = ( + selectedCellIds: string[], +): GridBounds | null => { + if (selectedCellIds.length === 0) { + return null; + } + + const cellCoords = selectedCellIds.map(parseCellIdToCoordinates); + + const minCol = Math.min(...cellCoords.map((c) => c.col)); + const maxCol = Math.max(...cellCoords.map((c) => c.col)); + const minRow = Math.min(...cellCoords.map((c) => c.row)); + const maxRow = Math.max(...cellCoords.map((c) => c.row)); + + return { + x: minCol, + y: minRow, + w: maxCol - minCol + 1, + h: maxRow - minRow + 1, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/calculateTotalGridRows.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/calculateTotalGridRows.ts new file mode 100644 index 0000000000..f382ccbabb --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/calculateTotalGridRows.ts @@ -0,0 +1,18 @@ +import { type Layouts } from 'react-grid-layout'; +import { GRID_BUFFER_ROWS } from '../constants/GridBufferRows'; +import { GRID_MIN_ROWS } from '../constants/GridMinRows'; + +export const calculateTotalGridRows = ( + layouts: Layouts, + minRows = GRID_MIN_ROWS, + bufferRows = GRID_BUFFER_ROWS, +): number => { + const allLayouts = [...(layouts.desktop || []), ...(layouts.mobile || [])]; + + const contentRows = + allLayouts.length === 0 + ? 0 + : Math.max(...allLayouts.map((item) => item.y + item.h)); + + return Math.max(minRows, contentRows + bufferRows); +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/convertLayoutsToWidgets.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/convertLayoutsToWidgets.ts new file mode 100644 index 0000000000..e62ccc4056 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/convertLayoutsToWidgets.ts @@ -0,0 +1,31 @@ +import { type Layouts } from 'react-grid-layout'; +import { type Widget } from '../mocks/mockWidgets'; + +export type WidgetWithGridPosition = Widget & { + gridPosition: { + row: number; + column: number; + rowSpan: number; + columnSpan: number; + }; +}; + +export const convertLayoutsToWidgets = ( + widgets: Widget[], + layouts: Layouts, +): WidgetWithGridPosition[] => { + const activeLayouts = layouts.desktop || layouts.mobile || []; + + return widgets.map((widget) => { + const layout = activeLayouts.find((l) => l.i === widget.id); + return { + ...widget, + gridPosition: { + row: layout?.y ?? 0, + column: layout?.x ?? 0, + rowSpan: layout?.h ?? 2, + columnSpan: layout?.w ?? 2, + }, + }; + }); +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/generateCellId.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/generateCellId.ts new file mode 100644 index 0000000000..e932de6edd --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/generateCellId.ts @@ -0,0 +1,3 @@ +export const generateCellId = (col: number, row: number): string => { + return `cell-${col}-${row}`; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetData.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetData.ts new file mode 100644 index 0000000000..6370e87eed --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetData.ts @@ -0,0 +1,76 @@ +import { GraphSubType } from '../mocks/mockWidgets'; + +export const getDefaultWidgetData = (graphType: GraphSubType) => { + switch (graphType) { + case GraphSubType.NUMBER: + return { + value: '1,234', + trendPercentage: 15.2, + }; + + case GraphSubType.GAUGE: + return { + value: 0.7, + min: 0, + max: 1, + label: 'Progress', + }; + + case GraphSubType.PIE: + return { + items: [ + { id: 'segment1', value: 35, label: 'Segment A' }, + { id: 'segment2', value: 28, label: 'Segment B' }, + { id: 'segment3', value: 20, label: 'Segment C' }, + { id: 'segment4', value: 17, label: 'Segment D' }, + ], + }; + + case GraphSubType.BAR: + return { + items: [ + { category: 'Jan', value: 45 }, + { category: 'Feb', value: 52 }, + { category: 'Mar', value: 48 }, + { category: 'Apr', value: 61 }, + { category: 'May', value: 55 }, + ], + indexBy: 'category', + keys: ['value'], + seriesLabels: { value: 'Value' }, + layout: 'vertical' as const, + }; + + default: + return {}; + } +}; + +export const getWidgetTitle = ( + graphType: GraphSubType, + index: number, +): string => { + const baseNames: Record = { + [GraphSubType.NUMBER]: 'Number', + [GraphSubType.GAUGE]: 'Gauge', + [GraphSubType.PIE]: 'Pie Chart', + [GraphSubType.BAR]: 'Bar Chart', + }; + + return `${baseNames[graphType] || 'Widget'} ${index + 1}`; +}; + +export const getWidgetSize = (graphType: GraphSubType) => { + switch (graphType) { + case GraphSubType.NUMBER: + return { w: 3, h: 2 }; + case GraphSubType.GAUGE: + return { w: 3, h: 3 }; + case GraphSubType.PIE: + return { w: 4, h: 4 }; + case GraphSubType.BAR: + return { w: 6, h: 4 }; + default: + return { w: 4, h: 4 }; + } +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetPosition.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetPosition.ts new file mode 100644 index 0000000000..3fe5aff634 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/getDefaultWidgetPosition.ts @@ -0,0 +1,17 @@ +import { type GridBounds } from './calculateGridBoundsFromSelectedCells'; + +export const getDefaultWidgetPosition = ( + draggedArea: GridBounds | null, + defaultSize: { w: number; h: number }, +): GridBounds => { + if (draggedArea !== null) { + return draggedArea; + } + + return { + x: 0, + y: 0, + w: defaultSize.w, + h: defaultSize.h, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/parseCellIdToCoordinates.ts b/packages/twenty-front/src/modules/settings/page-layout/utils/parseCellIdToCoordinates.ts new file mode 100644 index 0000000000..4e67a7cbe7 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/parseCellIdToCoordinates.ts @@ -0,0 +1,9 @@ +export type CellCoordinate = { + col: number; + row: number; +}; + +export const parseCellIdToCoordinates = (cellId: string): CellCoordinate => { + const [col, row] = cellId.split('-').slice(1).map(Number); + return { col, row }; +}; diff --git a/packages/twenty-front/src/modules/settings/page-layout/utils/widgetRegistry.tsx b/packages/twenty-front/src/modules/settings/page-layout/utils/widgetRegistry.tsx new file mode 100644 index 0000000000..f6c9fc293d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/page-layout/utils/widgetRegistry.tsx @@ -0,0 +1,69 @@ +import { GraphWidgetBarChart } from '@/dashboards/graphs/components/GraphWidgetBarChart'; +import { GraphWidgetGaugeChart } from '@/dashboards/graphs/components/GraphWidgetGaugeChart'; +import { GraphWidgetNumberChart } from '@/dashboards/graphs/components/GraphWidgetNumberChart'; +import { GraphWidgetPieChart } from '@/dashboards/graphs/components/GraphWidgetPieChart'; +import { type ReactNode } from 'react'; +import { GraphSubType, WidgetType, type Widget } from '../mocks/mockWidgets'; + +type WidgetRenderer = (widget: Widget) => ReactNode; + +const widgetRenderers: Record = { + [GraphSubType.NUMBER]: (widget) => ( + + ), + [GraphSubType.GAUGE]: (widget) => ( + + ), + [GraphSubType.PIE]: (widget) => ( + + ), + [GraphSubType.BAR]: (widget) => ( + + ), +}; + +export const renderWidget = (widget: Widget): ReactNode => { + if (widget.type !== WidgetType.GRAPH) { + return null; + } + + const graphType = widget.configuration?.graphType as GraphSubType | undefined; + if (!graphType) { + return null; + } + + const renderer = widgetRenderers[graphType]; + if (!renderer) { + return null; + } + + return renderer(widget); +}; diff --git a/packages/twenty-front/src/modules/types/SettingsPath.ts b/packages/twenty-front/src/modules/types/SettingsPath.ts index 9b3d974a94..98c1d678fa 100644 --- a/packages/twenty-front/src/modules/types/SettingsPath.ts +++ b/packages/twenty-front/src/modules/types/SettingsPath.ts @@ -54,5 +54,7 @@ export enum SettingsPath { RoleDetail = 'roles/:roleId', RoleObjectLevel = 'roles/:roleId/object/:objectMetadataId', RoleAddObjectLevel = 'roles/:roleId/add-object-permission', - PageLayoutEdition = 'page-layout/edition', + PageLayout = 'page-layout', + PageLayoutNew = 'page-layout/new', + PageLayoutEdit = 'page-layout/:id', } diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx index 5b92580519..39e0c41846 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/DefaultLayout.tsx @@ -60,9 +60,9 @@ export const DefaultLayout = () => { const isMobile = useIsMobile(); const isSettingsPage = useIsSettingsPage(); const location = useLocation(); - const isPageLayoutEditor = location.pathname.includes( - '/settings/page-layout/edition', - ); + const isPageLayoutEditor = + location.pathname.includes('/settings/page-layout/new') || + location.pathname.match(/\/settings\/page-layout\/[^/]+$/); const theme = useTheme(); const windowsWidth = useScreenSize().width; const showAuthModal = useShowAuthModal(); diff --git a/packages/twenty-front/src/pages/settings/page-layout/PageLayoutEdition.tsx b/packages/twenty-front/src/pages/settings/page-layout/PageLayoutEdition.tsx deleted file mode 100644 index 9e42cd95b2..0000000000 --- a/packages/twenty-front/src/pages/settings/page-layout/PageLayoutEdition.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { GraphWidgetGaugeChart } from '@/dashboards/graphs/components/GraphWidgetGaugeChart'; -import { GraphWidgetNumberChart } from '@/dashboards/graphs/components/GraphWidgetNumberChart'; -import { GraphWidgetPieChart } from '@/dashboards/graphs/components/GraphWidgetPieChart'; -import { SettingsPageFullWidthContainer } from '@/settings/components/SettingsPageFullWidthContainer'; -import { SettingsPath } from '@/types/SettingsPath'; -import styled from '@emotion/styled'; -import { useLingui } from '@lingui/react/macro'; -import { - Responsive, - WidthProvider, - type Layout, - type Layouts, - type ResponsiveProps, -} from 'react-grid-layout'; -import 'react-grid-layout/css/styles.css'; -import 'react-resizable/css/styles.css'; -import { useRecoilState } from 'recoil'; -import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; -import { PageLayoutWidgetPlaceholder } from './components/PageLayoutWidgetPlaceholder'; -import { mockLayouts, mockWidgets } from './mocks/mockWidgets'; -import { - pageLayoutLayoutsState, - pageLayoutWidgetsState, -} from './states/pageLayoutState'; - -const StyledGridContainer = styled.div` - background: ${({ theme }) => theme.background.secondary}; - border: 1px solid ${({ theme }) => theme.border.color.light}; - border-radius: ${({ theme }) => theme.border.radius.md}; - box-sizing: border-box; - flex: 1; - overflow-y: auto; - padding: ${({ theme }) => theme.spacing(2)}; - position: relative; - width: 100%; - - .react-grid-placeholder { - background: ${({ theme }) => theme.adaptiveColors.blue3} !important; - - border-radius: ${({ theme }) => theme.border.radius.sm}; - } -`; - -const StyledGridOverlay = styled.div` - position: absolute; - top: ${({ theme }) => theme.spacing(2)}; - left: ${({ theme }) => theme.spacing(2)}; - right: ${({ theme }) => theme.spacing(2)}; - bottom: ${({ theme }) => theme.spacing(2)}; - display: grid; - grid-template-columns: repeat(12, 1fr); - grid-auto-rows: 50px; - gap: ${({ theme }) => theme.spacing(2)}; - pointer-events: none; - z-index: 0; - - @media (max-width: 480px) { - grid-template-columns: 1fr; - } - - @media (max-width: 768px) and (min-width: 481px) { - grid-template-columns: repeat(12, 1fr); - } -`; - -const StyledGridCell = styled.div` - background: 'transparent'; - border: 1px solid ${({ theme }) => theme.border.color.light}; - border-radius: ${({ theme }) => theme.border.radius.sm}; -`; - -type ExtendedResponsiveProps = ResponsiveProps & { - maxCols?: number; - preventCollision?: boolean; -}; - -const ResponsiveGridLayout = WidthProvider( - Responsive, -) as React.ComponentType; - -export const PageLayoutEdition = () => { - const { t } = useLingui(); - const [pageLayoutWidgets, setPageLayoutWidgets] = useRecoilState( - pageLayoutWidgetsState, - ); - const [pageLayoutLayouts, setPageLayoutLayouts] = useRecoilState( - pageLayoutLayoutsState, - ); - - const handleLayoutChange = (_: Layout[], allLayouts: Layouts) => { - setPageLayoutLayouts(allLayouts); - }; - - const handleAddWidget = () => { - // temp: Populate with mock data when empty placeholder is clicked - setPageLayoutWidgets(mockWidgets); - setPageLayoutLayouts(mockLayouts); - }; - - const isEmptyState = pageLayoutWidgets.length === 0; - - const emptyLayout: Layouts = { - lg: [{ i: 'empty-placeholder', x: 0, y: 0, w: 4, h: 4, static: true }], - md: [{ i: 'empty-placeholder', x: 0, y: 0, w: 4, h: 4, static: true }], - sm: [{ i: 'empty-placeholder', x: 0, y: 0, w: 1, h: 4, static: true }], - }; - - return ( - - - - {Array.from({ length: 12 * 20 }).map((_, i) => ( - - ))} - - - {isEmptyState ? ( -
- -
- ) : ( - pageLayoutWidgets.map((widget) => ( -
- - {widget.type === 'number' && ( - - )} - {widget.type === 'gauge' && ( - - )} - {widget.type === 'pie' && ( - - )} - -
- )) - )} -
-
-
- ); -}; diff --git a/packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx b/packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx new file mode 100644 index 0000000000..8f09ae5c5f --- /dev/null +++ b/packages/twenty-front/src/pages/settings/page-layout/SettingsPageLayoutEdit.tsx @@ -0,0 +1,311 @@ +import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; +import { SettingsPageFullWidthContainer } from '@/settings/components/SettingsPageFullWidthContainer'; +import { PageLayoutInitializationEffect } from '@/settings/page-layout/components/PageLayoutInitializationEffect'; +import { PageLayoutWidgetPlaceholder } from '@/settings/page-layout/components/PageLayoutWidgetPlaceholder'; +import { EMPTY_LAYOUT } from '@/settings/page-layout/constants/EmptyLayout'; +import { + PAGE_LAYOUT_CONFIG, + type PageLayoutBreakpoint, +} from '@/settings/page-layout/constants/PageLayoutBreakpoints'; +import { usePageLayoutDraftState } from '@/settings/page-layout/hooks/usePageLayoutDraftState'; +import { usePageLayoutDragSelection } from '@/settings/page-layout/hooks/usePageLayoutDragSelection'; +import { usePageLayoutHandleLayoutChange } from '@/settings/page-layout/hooks/usePageLayoutHandleLayoutChange'; +import { usePageLayoutSaveHandler } from '@/settings/page-layout/hooks/usePageLayoutSaveHandler'; +import { usePageLayoutWidgetDelete } from '@/settings/page-layout/hooks/usePageLayoutWidgetDelete'; +import { pageLayoutCurrentBreakpointState } from '@/settings/page-layout/states/pageLayoutCurrentBreakpointState'; +import { pageLayoutCurrentLayoutsState } from '@/settings/page-layout/states/pageLayoutCurrentLayoutsState'; +import { pageLayoutSelectedCellsState } from '@/settings/page-layout/states/pageLayoutSelectedCellsState'; +import { pageLayoutWidgetsState } from '@/settings/page-layout/states/pageLayoutWidgetsState'; +import { calculateTotalGridRows } from '@/settings/page-layout/utils/calculateTotalGridRows'; +import { convertLayoutsToWidgets } from '@/settings/page-layout/utils/convertLayoutsToWidgets'; +import { generateCellId } from '@/settings/page-layout/utils/generateCellId'; +import { renderWidget } from '@/settings/page-layout/utils/widgetRegistry'; +import { SettingsPath } from '@/types/SettingsPath'; +import { TitleInput } from '@/ui/input/components/TitleInput'; +import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; +import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { + Responsive, + WidthProvider, + type ResponsiveProps, +} from 'react-grid-layout'; +import 'react-grid-layout/css/styles.css'; +import 'react-resizable/css/styles.css'; +import { useParams } from 'react-router-dom'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { IconAppWindow, IconPlus } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +import { getSettingsPath } from '~/utils/navigation/getSettingsPath'; + +const StyledGridContainer = styled.div` + background: ${({ theme }) => theme.background.secondary}; + box-sizing: border-box; + flex: 1; + min-height: 100%; + overflow-y: auto; + overflow-x: hidden; + position: relative; + padding: ${({ theme }) => theme.spacing(2)}; + width: 100%; + user-select: none; + + .react-grid-placeholder { + background: ${({ theme }) => theme.adaptiveColors.blue3} !important; + + border-radius: ${({ theme }) => theme.border.radius.sm}; + } + + .react-grid-item:not(.react-draggable-dragging) { + user-select: auto; + } +`; + +const StyledGridOverlay = styled.div<{ + isDragSelecting?: boolean; + breakpoint: PageLayoutBreakpoint; +}>` + position: absolute; + top: ${({ theme }) => theme.spacing(2)}; + left: ${({ theme }) => theme.spacing(2)}; + right: ${({ theme }) => theme.spacing(2)}; + bottom: ${({ theme }) => theme.spacing(2)}; + display: grid; + grid-template-columns: ${({ breakpoint }) => + breakpoint === 'mobile' ? '1fr' : 'repeat(12, 1fr)'}; + grid-auto-rows: 55px; + gap: ${({ theme }) => theme.spacing(2)}; + pointer-events: ${({ isDragSelecting }) => + isDragSelecting ? 'auto' : 'none'}; + z-index: 0; +`; + +const StyledActionButtonContainer = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledGridCell = styled.div<{ isSelected?: boolean }>` + background: ${({ isSelected, theme }) => + isSelected ? theme.adaptiveColors.blue1 : 'transparent'}; + border: 1px solid + ${({ theme, isSelected }) => + isSelected ? theme.adaptiveColors.blue3 : theme.border.color.light}; + border-radius: ${({ theme }) => theme.border.radius.md}; + transition: background-color 0.3s ease; + + &:hover { + background: ${({ theme }) => theme.background.transparent.lighter}; + border-color: ${({ theme }) => theme.border.color.medium}; + } +`; + +type ExtendedResponsiveProps = ResponsiveProps & { + maxCols?: number; + preventCollision?: boolean; +}; + +const ResponsiveGridLayout = WidthProvider( + Responsive, +) as React.ComponentType; + +export const SettingsPageLayoutEdit = () => { + const { t } = useLingui(); + const { id } = useParams<{ id: string }>(); + const isEditMode = id && id !== 'new'; + + const { pageLayoutDraft, setPageLayoutDraft, isDirty } = + usePageLayoutDraftState(); + + const { savePageLayout } = usePageLayoutSaveHandler(); + const navigateSettings = useNavigateSettings(); + const [isSaving, setIsSaving] = useState(false); + + const [pageLayoutCurrentBreakpoint, setPageLayoutCurrentBreakpoint] = + useRecoilState(pageLayoutCurrentBreakpointState); + const pageLayoutSelectedCells = useRecoilValue(pageLayoutSelectedCellsState); + const pageLayoutCurrentLayouts = useRecoilValue( + pageLayoutCurrentLayoutsState, + ); + const pageLayoutWidgets = useRecoilValue(pageLayoutWidgetsState); + const { navigateCommandMenu } = useNavigateCommandMenu(); + + const { + handleDragSelectionStart, + handleDragSelectionChange, + handleDragSelectionEnd, + } = usePageLayoutDragSelection(); + + const handleOpenAddWidget = useCallback(() => { + navigateCommandMenu({ + page: CommandMenuPages.PageLayoutWidgetTypeSelect, + pageTitle: 'Add Widget', + pageIcon: IconAppWindow, + resetNavigationStack: true, + }); + }, [navigateCommandMenu]); + + const { handleRemoveWidget } = usePageLayoutWidgetDelete(); + const { handleLayoutChange } = usePageLayoutHandleLayoutChange(); + + const gridContainerRef = useRef(null); + + const isEmptyState = pageLayoutWidgets.length === 0; + + const gridRows = useMemo( + () => calculateTotalGridRows(pageLayoutCurrentLayouts), + [pageLayoutCurrentLayouts], + ); + + const handleCancel = () => { + navigateSettings(SettingsPath.PageLayout); + }; + + const handleSaveClick = async () => { + setIsSaving(true); + try { + const widgetsWithPositions = convertLayoutsToWidgets( + pageLayoutWidgets, + pageLayoutCurrentLayouts, + ); + + setPageLayoutDraft((prev) => ({ + ...prev, + widgets: widgetsWithPositions, + })); + + await savePageLayout(widgetsWithPositions); + } finally { + setIsSaving(false); + } + }; + + return ( + <> + + + setPageLayoutDraft((prev) => ({ ...prev, name: value })) + } + sizeVariant="md" + /> + ), + }, + ]} + actionButton={ + + + {!isEmptyState && ( +