From 9286d099ff363366c30d21630c5ec43ddd3376e3 Mon Sep 17 00:00:00 2001 From: raman Date: Tue, 16 Sep 2025 11:17:28 +0530 Subject: [PATCH] {CODE} : new tabs --- addons_extensions/tabbar/__init__.py | 0 addons_extensions/tabbar/__manifest__.py | 26 + .../tabbar/static/description/1.png | Bin 0 -> 804858 bytes .../tabbar/static/description/icon.png | Bin 0 -> 4737 bytes .../tabbar/static/description/index.html | 10 + .../tabbar/static/description/main_banner.png | Bin 0 -> 12938 bytes .../tabbar/static/src/action_service.js | 1942 +++++++++++++++++ .../tabbar/static/src/akl_action_container.js | 128 ++ .../static/src/akl_action_container.scss | 29 + .../src/components/multi_tab/akl_multi_tab.js | 31 + .../components/multi_tab/akl_multi_tab.scss | 295 +++ .../components/multi_tab/akl_multi_tab.xml | 99 + .../tabbar/static/src/scss/tabbar.scss | 18 + 13 files changed, 2578 insertions(+) create mode 100644 addons_extensions/tabbar/__init__.py create mode 100644 addons_extensions/tabbar/__manifest__.py create mode 100644 addons_extensions/tabbar/static/description/1.png create mode 100644 addons_extensions/tabbar/static/description/icon.png create mode 100644 addons_extensions/tabbar/static/description/index.html create mode 100644 addons_extensions/tabbar/static/description/main_banner.png create mode 100644 addons_extensions/tabbar/static/src/action_service.js create mode 100644 addons_extensions/tabbar/static/src/akl_action_container.js create mode 100644 addons_extensions/tabbar/static/src/akl_action_container.scss create mode 100644 addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.js create mode 100644 addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.scss create mode 100644 addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.xml create mode 100644 addons_extensions/tabbar/static/src/scss/tabbar.scss diff --git a/addons_extensions/tabbar/__init__.py b/addons_extensions/tabbar/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/addons_extensions/tabbar/__manifest__.py b/addons_extensions/tabbar/__manifest__.py new file mode 100644 index 000000000..95111363d --- /dev/null +++ b/addons_extensions/tabbar/__manifest__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +{ + 'name': "Multi Tabs", + + 'summary': "Multi Tabs for Odoo 18", + + 'description': """ + Multi Tabs + """, + + "author": "1311793927@qq.com", + 'support': '1311793927qq.com', + 'images': ['static/description/main_banner.png'], + 'category': 'General', + 'version': '0.1', + "license": "LGPL-3", + 'depends': ['base','web'], + "installable": True, + "auto_install": False, + "assets": { + "web.assets_backend": [ + "tabbar/static/src/**/*", + ], + }, + +} diff --git a/addons_extensions/tabbar/static/description/1.png b/addons_extensions/tabbar/static/description/1.png new file mode 100644 index 0000000000000000000000000000000000000000..04cc792e02d770b9846b98bae57e98de91f30dc4 GIT binary patch literal 804858 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
  • RRoWMbwy>?Le`tJ_=?g@9)W`01XI!x|bQ2$Ur>0)x&{Y}8Tor=~*)KmMS3Y&l_u%ilr>AZy!A_}TnR7$>{@}Z( zeS_s15+DH*Ac3$EXs9>S3@$k)eGSXIJL~F0zjR~MV`h3R_qdtXX?$?|lJ&kajhj{8 zEA*(-=&v`sBQZD*BtQZr&=3Nl_GW2wM~a`?XL%}=Uqq?Iz|M)E%8>C~ax!k0$o=cS zIyB(6#JKmsH{0uCYIk2ljS zC#GGKcfrJZ{0B!%U6A~>`X5{ObIyjIw%wam%1$|FFC$(0Zp=BpSL!q#M_x&xtL~!d z3*>A;rE8g~)0ips8jnlAAojjP%(|QM4r>l1KmsJt5CWn0W+o1nA$@z?E6>OJZTVjN zQ%s#3kK!Z0VB@+I9_u$s3@vHG9M|$Jt?PJK>M#0PhtcH4Oug}{kXyfb-}I9G*D$wR zcj;GSmus2!uc6+|?$>mF=S1~JVmzw{^;-kvePb2k6Rv>sydIeVM|b36KB@kU#_x zXpA?DpwnYkNq_`MARq+j%_IgVaW@?sG{`f&Mh%VKuTcl%P9#7ABtQZr;4A|4X3n}E z%nb>UfHMiuoB8R@oOv0UD-s|95+DH*a5MpWGe=(@W{w0%z##B1L9{1W14c zNT3-Lpf_vA7ni3-0wfSH0`z9|W&slu6Cwc;AOR8}0UreD&3u62ViF($60i}VH={SR z$$_&ZKmsH{0wfR=0`z7QBS;s}X5aJqHdXKmsH{0wh2J z!686z796fjl>|tjc@YSNH~V?^!ie`~dJY^&fCNZ@1W14cfLOy=~{rBJ`&QwW& z1W2Hn5D0`fyHZeyNRFLp-t`{%Xo3)6`%#$Pm5+DH*AOR8}0TLhq5^y*HdNYS#AZCvQNPq-LfCNZ@1W14c zNT5X!pf_s~mzgI?0wh2JBtQZrKmsH{0wmyY0`z7Mzd+0$36KB@kN^pg011!)36MaG zAV6={A}%vek_1SA1W14cNPq-LfCNau;RMv1@ttqA-}y!!)BrU= z4NwE2)&S#Xp62=X?c1IUTlf-X!qJmK3ztRMPoKZluwMdZfCNZ@1W14cNFaa&=*>J~ zvb^V=3B4J;nWr+syM({h@bc%;Nq_`MfCNZ@1OiKd-prFH%X{9L(3{blc`75kOZZz2 zFMl4L1W14cNPq-LAg~1J%{+Osyyu+>y&1ikr!vC3gum7B^5@Y>fCNZ@1W14c0!x72 z%#$a}d)}GQo6(zjDkHp0_*)Gxe;%C#NPq-LfCNY&umtGMJbALb=bZ_?8NHdOGQzur zzt!;a=g~=k1W14cNPq+aOMu?YlPAl2-kH#w(VKZHBfLxaTMaLN9-Rb8fCNZ@1V|vT z1nA8?d9u9coe8}ey_u&n!n=gO)$sD?(Mf;=NPq-LfCK_dfZoiLC(C=@nb4cjn|UfD zyi5374KIHlodigL1W14cNFcBT=*>KNvb^V=3B4J;nWr+syM({h@bc%;Nq_`MfCNZ@ z1OiKd-prFH%X{9L(3{blc`75kOZZz2FMl4L1W14cNPq-LAg~1J%{+Osyyu+>y&1ik zr!vC3gum7B^5@Y>fCNZ@1W14c0!x72%#$a}d)}GQo6(zjDkHp0_*)Gxe;%C#NPq-L zfCNY&umt?}W)B*9{KoFr$ip>dH-F1}_wV>|v5qJF zdpr2s+8Nb1?a?TrWA7l3M38W@8D`7q&x=l=Y(eEJIZ1 zb{Ny69dLeKt?laia_7z+=;&*xYn`us{3-8UmqZ54-h}~@@Ab3C;(FdLugM-RzK2m$ zS0XvC6L2rf>o$Q}7%V3Kc%YBg=40#pemInx>KdaaCgS$Nxmcff8;8Ca?A3ooeUWNy zf6Clu z{|Y(BO&NuV9z7q)qooXcBLf#Dw6J7j24eb+#oUj(BY*D?zym2O%V00xnsRP^QyiOb z4p;9q7)w$BrFqls~KYLbz=ZYMo4!=O076wZp}2(`x0!mp9Yj>X>Dz znA$@$d~qFC<%m}M_d;n!8shagEWdp-_)5d9_cjfNF1;Ox%SeC(NPq-LfCNY&5()U@ z&F;$yM$JwI(!6^Fjeo_*ivPM+Q+_i{=I*~|#mLNeo%-}<8})g9!w2}9^D_%cnaF=g&3jVV;ewX`KpRQxX7-(M4} z&~oe&DI<%q)kF9k0_Dy00D)#wpI@@Cug{yAI*q?d`?1-*7vuJ|ok90znm@ZgYpO4c z1fAG*SDIJzT%aMoYL)y{$8?t8@ZO7bWXX&z4bm#dPI;fY7 zG1eH>C&p^0Wxne)s)y8{YGmBVl=&J{kI{bpnp)=8wA?+Gy^L%>mixT>e(vl2{T@gr zcJEc+yn3@Cz1v~Pz<#bVsz=XSbs9B|(=yH;_-)9%nI6;lv&PnAw4b{_*F)oGb`P)n zx?k@=>%>=jy4+iPGyD0(ZH{Lb+f#&Q!N7riizEmG-cl27s!uiwB6VT5;Lo6Hx|q~jV8|~UgloE z(X97p>d$ovhfaLyu0CINUrtQxFIPzYUg(EKs`i=NfQZ5+DH*AOR8} z0TOUBfyQ{Vt>fBB-KG-6OqMoQIaU38sHZS>_%w#LK2G~@tEod}ycvpX{7RtfG@5vs zc_y!A@Wu>UHuq;RW$wfov9i&dZJowIGHIx1*(rX@-Uo<%ey8*e zvOe#O)lcJjsn0lMOfqg9{=%xysObmWn;BoGQyrV?&z!E4#(J}SIkAa@q%LFWCLESZ zy74`vey+454TLu{&kJ-K>ffqA*NfYd011!)36KB@kU*#jG{&248r=??E|!S@()x^r zkgnViuk{$K`*6#b??bg*msj0RMZT+l87gtJ^lkH9{q7I_ax0Q2w{v~hKKY|2uk<@^ zW-sf>VXUDkWj=qsnZ_8>f0g$E)bmb(d|9vUbm|%c6*mh8lZJY;)5X$9zVw}+kdlfY zr;fnZ)PEs%@LU{9nTgY~e0^TA<;@nRmSf*s?Q5~-J!HZ3W_Dj@j@eXyR@b$3Ul&T9 zq@I_ypwf@3c_vUQ&jhqSWnF!NYP>qa(!2C zrv2zA&L8E9EyawRh4{GIeeOraP4df|c`NIggBpLPQ-213y_u%1eWRPYn!cvEqrHUG zJp{s=1&fLCGYy?i*<6g%tE=8Yrt`T!Xx&Ar!&v=(GwlZ?;k}xEKy+QOyqPy&X3oR- zGo6}c4V~6~H9gmcN+NZqvn|HMYH3uypB3nM+831W14cNPq-LATkLw$(#9c%IeLu zy;(5oG}h(4bX{HRy`6%V%l#d+9p1z~>N97#yr;cRV?#Y>AiSC8y{>a^ER%-%72WQ< zj313k#_c>AWqxa^)N_1K-fvcZ*yYW1dB5eyQL_1CmnSmb%zF3Pt=jKCvmd{)edycM zu+OV63xziehfb^~0u{BN3)I)%O!Ikoc01gEa1Ym|9ca#Zec!yi6GGkx(O7R5EM5Bh zTjlm7KmsH{0wh2JB;Zs6jqzr6)r+#P8zax%jTbZCESPu?a)eo7B44hc^ez!&Ag@4)Mfr&kx=VI9obNMFIoD+?lO1J zmEC&1nN(#oeg5dS>xYe%eRG>!r_q&x0L|$2<3Iu=KmsH{0wh2J&4qx!-pt#1=SzLQ zm^f)$mQ~d+h_`Ljo9X+Rjn}AaTDo1^xrA?MOnFbs+@}7Qw{!FMZh#7@YU!)~qx*8W z#XUkz$6pU~wX_^pORr-6nX6dXZG_D4w7%cID$Ar!V^rtCuC`AfNxiw-xmEEsJD*i6ftH+D(D;{r@wpFX@-_hUv1!{YLuR|zxVtm=wiQ>!T-4^ztK5u6F z=~;iaLf(lX?xHu8tY|NAGwOt|Cs4D_T??j+;XY^_{~3Y`&O0bQh(=bydmqn zr4hm(;fJlTUPFJ;fGwh`SKug^egdp5I;#&k7SD_w#}A3xr=$-Dt%rl@E0 zOy8sv?5SRB4kSPVBtQZrKmsJtTnGfhn>7~}<~&&5GbC!Jq4YbEbgUXeoC^m5{#L_5 zoyQ~r5+DH*AOR8x76E!QPqHlUd1n$yo+(s$^(4h5p(McHYA9**SR_CKBtQZrKmx%g zKyT(rnB_h1OhV;%BI!7uq_`xM1o&GGC2byy1W14cNPq-LAlL-x%{&RSyyu-sBzdM# z<<*lEmxPi4f2*OS&0~=O36KB@kN^n;n*hC;Ct;TNyfX=v--)E-c#`6hP!iy8HI%e@ zED|6A5+DH*Ac0^Lpf~d*%<`UhCXwWsLX}rfQd|;B0{pFpk~WV;0wh2JBtQZr5NrbU zW}bvu-t*2RRDLItj^jy+OF~J2ztvFE=CMeC1W14cNPq-_O@Q9alQ7GB-kC&_X9`tb zJxOs%C<*Yl8cNzc7736536KB@kU+2r(3^P@W_iy$lTi7cNIH%uDJ}^m0sdA)Nt?$a z0TLhq5+DH*2sQzFGf%=S?|EkuNuDWGdG#d4C7~q1-)bmn^H?N60wh2JBtQbeCO~iI zNtoq5?@U7FcOvOHo}{=Wlmz%&4JBI{v%Ke>NhEouQ03K= z6qkgO0Dr5Yq|IZI011!)36KB@1e*Z8nI~bE_q;O+mEVb^<9L$dl28)hZ#9&(c`Onj z0TLhq5+H$K6Hsq9xl=EWA!Lx$elhgn;Kn+j>)BrU=4NwCu zQ3Ld5)O}0TJ)BrU=4NwErfYUTUZ$?czO_R(IHAxLn1JnRD zKn+j>)BrWm5;Z_?M%}kW-SeEOduo6hpa!S`YJeJ`2B-n2X`nfHv$g$4;z;r;q|E&u z4dt66`|Mt++s|z9J`GKq+wpf!4NwEr05w1jPy^HeH9!qe1O93tP~PmMc%91fTp4e; z#{8liIN5)V$MO={=0N}5s4U8GZL9m~@+4XA?>S^IkYkrt9lPv`95XE!YiF(Tn$Ce) zGf|Qz$1W?yo`I`rB&)*(3Kn+j>)BrU=4NwEr05w1jG(`i=!JC=$-!f$+ z^0%gl=gY+{@p(tm3dDDP4ULiItXqX!1v!=%ED_ImU~2}F#x1aX*#Y&HZa&fOG3%R8 z+L{{_f46y@XnRUk9?d!Wny%Gtn@J3C$K2U+-iv%P4aK=`6A!<3*4G|+&=_sfxcXzQ zp~#~zEpR#i6Q}`dfEu6%r~zt#8i-5{G#78SS<1}WKTE7^$tGy|IX@){l~;;z@=O^@ zwtkPYD`m)*cv_0AKe2iN4*aa~vMS%B+wWMg06P{Xd5k%-tg^fSJNC%vWxXz|%SSZj zcX4^oDmiu;E*F;J$evSjoK+rPLF0gBnW?x{R)&o!IUehqa{Q+5Tb{NCxxzntd#Y=! zER^#QFPpz|q+FX)ulcP!Be7E-es{NAtIEQoa&5~jpPimrCTY~~mHauEjdF}zr*`93 zsj%1B>Q{%XeCqj>6_lY|SnuAZtY>1MJP){a=8&bcr2e7>*;mKHl)189DRPb%VDqv~ z*0yGyPW5>~?#5NvtuK_TP$|wnS(yZ*e1Mnn_IGt zo_9mXef#zKM`Ygnm~UP;YJeJ`2B-mQfEsX`2AYdEGqEpwd9-lNOo^XK+1<^vXt`DE zTP8_;%lfW|sjF#Ex4wM>xn9fgC%<2W@-rDy*1v%rKj&b}l$ln1@K#~2wM@(Z>N)ke zCcmcK-H_?YGs+_HzfI+QSvZ$u=eowq(xO`4Ok-s3@u|AJnLVE+Tg8Jn@H*P}Yv{ac zs$Al!Nh_=Tlb+9kH1S*C%(m*#<{vMzra9t2lcs3f#Ttf3B#wEj^c0FR3s6~7WL>B9 zgX&}E;^eZKvMdK>r4rlSryj4y)7jH#%Dm?FmelXHd#rNtUUJ(wXUrDzPn}l?(a7tf3-d@D8-@u;!$n;nf(JbRrdDh2_`=-Z&vL$^>}(M z@{9C0QuVAtz1c1CW+mB0E>CUu*S89C>CLLIcc88p(}~RgPhK}(H)?UPZ>Du%Z)I-5WvSnIS?VpC{$#f4@-wMbvCRDv)0EhliMc74 zlk-KRS8`EUcmr#{+EPoCc5Z=~SN3(r&1~Lm$E=YOyR4}%OK-;inLuceX;4Sh05w1j zPy^I}{~8FCH}h7PE>!(A>UH<#QA``O&8sD@B-@xVlD(O`$MSaG<~;3F_xFh35tGRCH}3{s-GI@wsIyP%6{!O zJ$6-FJUu6^%jhj`X7({t{ViXXxY>?HhrG72yu2r=*7E|hAEz}MAPcgf2B-mQfEu6% zsDZ|4pt*Uo>=Q+vLV4OI>HD)3mAR?d zyuGRqjNB!%ZJE?h99P@_>$&HCFLh8$3#|H!g~_vPo#V-`W=cOP5?7m^Qp?xX#mt{| zs+-G&rPwU(6SJkxqSZFh-rmd}H*@P3Q#D^E-Yk3jDX+h!vJ>K4gVaXRpL684{LiBX zr~zt#8lVQKf##-x=Hku#?PD%CPrX3x{u{hqTPJuk?K8sO-p4+FS^C+~ceT~$9YY;A zQx9p+r+q$0@|?diMauW3IG}xINIYkAb$m&$LAbq{(SeCIW$((tiG$LIMw--P6y0b& z#_a7GmT$?GJ}C9wdPy6#PY*fgeJQohyX=g0lIVJs+CdoS^q>iO6Znh)klq)7ynJaA-WxYAB(Y!t0X`k0~p*Q1yq0tMc)B-g? z4NwEr05uRe4Kx>T7A#Gea;m-T8mwb^OIQ2*x5xZ^&Fi}ZyzOt6h1#1Pk$95k_42_~ z6c&^scl#lUAxSJo`YJpk%kx*Rvb0h$io9&Z$nR`F^rk<&< z{Hu4q0>Nf@@7RpNerd|?`FCz+a$?* zo*JUFaCr*Og_r?ufmZNRqe5K-n7j!bsDXH zK(5sE1ETvm*_&y2jbJY5fzGAoa^$%)44(CI6Ungs_rUs}1YJeJ` z1|m}ff%0Zfxc=%t%A`$=dl}f2k#$-kah!5}=b5d&jpkkZ-b)(i>2JdA&CKyja!YH~ zl{0k&wLkNQw4DowH#2nt4QuUpzDV8&o;3ZJT77|3N}q?jtv=geaOXWQU1qD`p%6$2r z^p$73IKx|i2Zz*mW*@4hHPx6mZ)R`z=!D-t^HBBsAqR4x2B-mQfEu6%B3A=}@@B?E zxR+;7O8*X((yxGhd5QK(&|ltbCGB6_m-~9(Z9_DsWpz!vt~4}WQ!ek9GJe6N>#aO) z&zrA#_x|}!dV!iM*K*#?*S zk?if8tooehnSigj*$H`8kTk2R|C6TV)7QS5|9p98xmBl8Jf*Mg?A}cEX8LGMm%5l5 zYcp-r%8yI@?YP7ZWqDyrk~|a8G=rCaU-{+o$o;?Jwd1v;2B-mQfEu6%>efJW^Jd=s zhux2;M=4kT<|S^nW5I$t>kgVag!XB$+6x)o8qe$gt}1g*dOq%Pv(4gbb}ah3LFckX z%Gvg|Ugo$>ooXD?@XSw6vi!@AMe?p}IbOSmsIp{=(0U_V#9~H}l?U)6a~F*;W=+#osh$Tb|pncx_|y z&wc8?XOj>4Py^HeH9!qe1Id&*CjJ?3-@WT;}wDYz1Zu|**&3sz9H?+0+}a& z(<=J;YaBVMea?H<8TFRdq3I)$lPPVu#;vJUf6#u;W5*pmuC=3vRu<@h~7+E!DR34ytu6S{txlm^4d}Z z)BrU=4NwDB8fZ@5jMtREYifWRpa!S`YJeJ`2B-mQfEtKY4bYoWyOFA0=9}832B-mQ zfEu6%r~zt#8fZ=$2$VN#PE2?%{Ebrs)BrU=4NwEr05w1jPy^IJs5L-uM$Ls)BrUQY7NkvQFEcz z9FIrMQ3KQfH9!qe1JnRDKn+j>o*H=i>8Gu}v&qJj4VjP$H9!qe1JnRDKn+j>)BrU= z4TMSq^k&prsI)PQdds5e{EZzyAD^4?wQgbb(wYJeJ` z2B-mQfEu6%r~zsqTpCbsHvOF#`Vx9GYJeJ`2B-mQfEu6%r~zt#8lVQksDWpmc?JXD z?80jh#%mBRU(Vz5nGiKV4NwEr05w1jPy^HeHQ*c#JonslX#3*JNTfHRKcxn!0cwC6 zpa!S`YJeJ`2B-mQAZ!|V{`u$e?6c3JU)zph%LBc-bG$mQ1+N7)Kn+j>)BrU=4NwEr z05uRE4X8KM*jeirUqXD3{`6;jPCyM%1JnRDKn+j>)BrU=4NwErKo~U8s#Pl(fA*Zj m&tfEg#`=xn@yUEXLGIK5H9!qe1JnRDKn+j>)IiJC!2b_hGl!}G literal 0 HcmV?d00001 diff --git a/addons_extensions/tabbar/static/description/icon.png b/addons_extensions/tabbar/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..fd8242404652b22bc3787c08336755b5faf4e8d2 GIT binary patch literal 4737 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 literal 0 HcmV?d00001 diff --git a/addons_extensions/tabbar/static/description/index.html b/addons_extensions/tabbar/static/description/index.html new file mode 100644 index 000000000..fc996a05c --- /dev/null +++ b/addons_extensions/tabbar/static/description/index.html @@ -0,0 +1,10 @@ +
    + +The basic functions have been implemented, and further optimization will be carried out later + + +
    +
    + + +
    diff --git a/addons_extensions/tabbar/static/description/main_banner.png b/addons_extensions/tabbar/static/description/main_banner.png new file mode 100644 index 0000000000000000000000000000000000000000..e85a4140d421d3220db748ac4c260d9316b6675e GIT binary patch literal 12938 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 + + + + `; + static components = { ControlPanel }; + + setup() { + useChildSubEnv({ config: { breadcrumbs: [], noBreadcrumbs: true } }); + onMounted(() => this.props.onMounted()); + } +} + +const actionHandlersRegistry = registry.category('action_handlers'); +const actionRegistry = registry.category('actions'); + + + + +function parseActiveIds(ids) { + const activeIds = []; + if (typeof ids === 'string') { + activeIds.push(...ids.split(',').map(Number)); + } else if (typeof ids === 'number') { + activeIds.push(ids); + } + return activeIds; +} + +const DIALOG_SIZES = { + 'extra-large': 'xl', + large: 'lg', + medium: 'md', + small: 'sm', +}; + + + +const CTX_KEY_REGEX = + /^(?:(?:default_|search_default_|show_).+|.+_view_ref|group_by|active_id|active_ids|orderedBy)$/; +// keys added to the context for the embedded actions feature +const EMBEDDED_ACTIONS_CTX_KEYS = [ + 'current_embedded_action_id', + 'parent_action_embedded_actions', + 'parent_action_id', + 'from_embedded_action', +]; + +// only register this template once for all dynamic classes ControllerComponent +const ControllerComponentTemplate = xml``; + +export function makeActionManager(env, router = _router) { + const breadcrumbCache = {}; + // ! my edit + const controllerStacks = {}; + let count = 0 + const keepLast = new KeepLast(); + let id = 0; + let controllerStack = []; + let dialogCloseProm; + let actionCache = {}; + let dialog = null; + let nextDialog = null; + + router.hideKeyFromUrl('globalState'); + + env.bus.addEventListener('CLEAR-CACHES', () => { + actionCache = {}; + }); + rpcBus.addEventListener('RPC:RESPONSE', (ev) => { + const { model, method } = ev.detail.data.params; + if ( + model === 'ir.actions.act_window' && + UPDATE_METHODS.includes(method) + ) { + actionCache = {}; + } + }); + + // --------------------------------------------------------------------------- + // misc + // --------------------------------------------------------------------------- + + /** + * Create an array of virtual controllers based on the current state of the + * router. + * + * @returns {Promise} an array of virtual controllers + */ + async function _controllersFromState() { + const state = router.current; + if (!state?.actionStack?.length) { + return []; + } + // The last controller will be created by doAction and won't be virtual + const controllers = state.actionStack + .slice(0, -1) + .map((actionState, index) => { + const controller = _makeController({ + displayName: actionState.displayName, + virtual: true, + action: {}, + props: {}, + state: { + ...actionState, + actionStack: state.actionStack.slice(0, index + 1), + }, + currentState: {}, + }); + if (actionState.action) { + controller.action.id = actionState.action; + + const [actionRequestKey, clientAction] = + actionRegistry.contains(actionState.action) + ? [ + actionState.action, + actionRegistry.get(actionState.action), + ] + : actionRegistry + .getEntries() + .find( + (a) => a[1].path === actionState.action + ) ?? []; + if (actionRequestKey && clientAction) { + if ( + state.actionStack[index + 1]?.action === + actionState.action + ) { + // client actions don't have multi-record views, so we can't go further to the next controller + return; + } + controller.action.tag = actionRequestKey; + controller.action.type = 'ir.actions.client'; + controller.displayName = + clientAction.displayName?.toString(); + } + if (actionState.active_id) { + controller.action.context = { + active_id: actionState.active_id, + }; + controller.currentState.active_id = + actionState.active_id; + } + } + if (actionState.model) { + controller.action.type = 'ir.actions.act_window'; + controller.props.resModel = actionState.model; + } + if (actionState.resId) { + controller.action.type ||= 'ir.actions.act_window'; + controller.props.resId = actionState.resId; + controller.currentState.resId = actionState.resId; + controller.props.type = 'form'; + } + return controller; + }) + .filter(Boolean); + + if ( + state.action && + state.resId && + controllers.at(-1)?.action?.id === state.action + ) { + // When loading the state on a form view, we will need to load the action for it, + // and this will give us the display name of the corresponding multi-record view in + // the breadcrumb. + // By marking the last controller as a lazyController, we can in some cases avoid + // _loadBreadcrumbs from doing any network request as the breadcrumbs may only contain + // the form view and the multi-record view. + const bcControllers = await _loadBreadcrumbs( + controllers.slice(0, -1) + ); + controllers.at(-1).lazy = true; + return [...bcControllers, controllers.at(-1)]; + } + return _loadBreadcrumbs(controllers); + } + + /** + * Load breadcrumbs for an array of controllers. This function adds display + * names to controllers that the current user has access to and for which + * the view (and record) exist. Controllers that correspond to a deleted + * record or a record/view that the user can't access are removed. + * + * @param {object[]} controllers an array of controllers whose breadcrumbs + * should be loaded + * @returns {Promise} a new array of the displayable controllers + * to which a display name was added + */ + async function _loadBreadcrumbs(controllers) { + const toFetch = []; + const keys = []; + for (const { action, state, displayName } of controllers) { + if ( + action.id === 'menu' || + (action.type === 'ir.actions.client' && !displayName) + ) { + continue; + } + const actionInfo = pick(state, 'action', 'model', 'resId'); + const key = JSON.stringify(actionInfo); + keys.push(key); + if (displayName) { + breadcrumbCache[key] = { display_name: displayName }; + } + if (key in breadcrumbCache) { + continue; + } + toFetch.push(actionInfo); + } + if (toFetch.length) { + const req = rpc('/web/action/load_breadcrumbs', { + actions: toFetch, + }); + for (const [i, info] of toFetch.entries()) { + const key = JSON.stringify(info); + breadcrumbCache[key] = req.then((res) => { + breadcrumbCache[key] = res[i]; + return res[i]; + }); + } + } + const results = await Promise.all(keys.map((k) => breadcrumbCache[k])); + const controllersToRemove = []; + for (const [controller, res] of zip(controllers, results)) { + if ('display_name' in res) { + controller.displayName = res.display_name; + } else { + controllersToRemove.push(controller); + if ('error' in res) { + console.warn( + 'The following element was removed from the breadcrumb and from the url.\n', + controller.state, + "\nThis could be because the action wasn't found or because the user doesn't have the right to access to the record, the original error is :\n", + res.error + ); + } + } + } + return controllers.filter((c) => !controllersToRemove.includes(c)); + } + + /** + * Removes the current dialog from the action service's state. + * It returns the dialog's onClose callback to be able to propagate it to the next dialog. + * + * @return {Function|undefined} When there was a dialog, returns its onClose callback for propagation to next dialog. + */ + function _removeDialog() { + if (dialog) { + const { onClose, remove } = dialog; + dialog = null; + // Remove the dialog from the dialog_service. + // The code is well enough designed to avoid falling in a function call loop. + remove(); + return onClose; + } + } + + /** + * Returns the last controller of the current controller stack. + * + * @returns {Controller|null} + */ + function _getCurrentController() { + const stack = controllerStack; + return stack.length ? stack[stack.length - 1] : null; + } + + /** + * Given an id, xmlid, tag (key of the client action registry) or directly an + * object describing an action. + * + * @private + * @param {ActionRequest} actionRequest + * @param {Context} [context={}] + * @returns {Promise} + */ + async function _loadAction(actionRequest, context = {}) { + if ( + typeof actionRequest === 'string' && + actionRegistry.contains(actionRequest) + ) { + // actionRequest is a key in the actionRegistry + return { + target: 'current', + tag: actionRequest, + type: 'ir.actions.client', + }; + } + + if ( + typeof actionRequest === 'string' || + typeof actionRequest === 'number' + ) { + // actionRequest is an id or an xmlid + const ctx = makeContext([user.context, context]); + delete ctx.params; + const key = `${JSON.stringify(actionRequest)},${JSON.stringify( + ctx + )}`; + let action = await actionCache[key]; + if (!action) { + actionCache[key] = rpc('/web/action/load', { + action_id: actionRequest, + context: ctx, + }); + action = await actionCache[key]; + if (action.help) { + action.help = markup(action.help); + } + } + return Object.assign({}, action); + } + + // actionRequest is an object describing the action + return actionRequest; + } + + /** + * Makes a controller from the given params. + * + * @param {Object} params + * @returns {Controller} + */ + function _makeController(params) { + return { + ...params, + jsId: `controller_${++id}`, + isMounted: false, + }; + } + + /** + * this function returns an action description + * with a unique jsId. + */ + function _preprocessAction(action, context = {}) { + try { + action._originalAction = JSON.stringify(action); + } catch { + // do nothing, the action might simply not be serializable + } + action.context = makeContext([context, action.context], user.context); + const domain = action.domain || []; + action.domain = + typeof domain === 'string' + ? evaluateExpr( + domain, + Object.assign({}, user.context, action.context) + ) + : domain; + if (action.help) { + if (isHtmlEmpty(action.help)) { + delete action.help; + } + } + action = { ...action }; // manipulate a copy to keep cached action unmodified + action.jsId = `action_${++id}`; + if ( + action.type === 'ir.actions.act_window' || + action.type === 'ir.actions.client' + ) { + action.target = action.target || 'current'; + } + if (action.type === 'ir.actions.act_window') { + action.views = [...action.views.map((v) => [v[0], v[1]])]; // manipulate a copy to keep cached action unmodified + action.controllers = {}; + const target = action.target; + if ( + target !== 'inline' && + !(target === 'new' && action.views[0][1] === 'form') + ) { + // FIXME: search view arch is already sent with load_action, so either remove it + // from there or load all fieldviews alongside the action for the sake of consistency + const searchViewId = action.search_view_id + ? action.search_view_id[0] + : false; + action.views.push([searchViewId, 'search']); + } + if ('no_breadcrumbs' in action.context) { + action._noBreadcrumbs = action.context.no_breadcrumbs; + delete action.context.no_breadcrumbs; + } + } + return action; + } + + /** + * @private + * @param {string} viewType + * @throws {Error} if the current controller is not a view + * @returns {View | null} + */ + function _getView(viewType) { + const currentController = controllerStack[controllerStack.length - 1]; + if (currentController.action.type !== 'ir.actions.act_window') { + throw new Error( + `switchView called but the current controller isn't a view` + ); + } + const view = currentController.views.find( + (view) => view.type === viewType + ); + return view || null; + } + + /** + * Given a controller stack, returns the list of breadcrumb items. + * + * @private + * @param {ControllerStack} stack + * @returns {Breadcrumbs} + */ + function _getBreadcrumbs(stack) { + return stack + .filter((controller) => controller.action.tag !== 'menu') + .map((controller) => { + return { + jsId: controller.jsId, + get name() { + return controller.displayName; + }, + get isFormView() { + return controller.props?.type === 'form'; + }, + get url() { + return stateToUrl(controller.state); + }, + onSelected() { + restore(controller.jsId); + }, + }; + }); + } + + /** + * @private + * @param {object} [state] the state from which to get the action params + * @returns {{ actionRequest: object, options: object} | null} + */ + function _getActionParams(state = router.current) { + const options = {}; + let actionRequest = null; + if (state.action) { + const context = {}; + if (state.active_id) { + context.active_id = state.active_id; + } + if (state.active_ids) { + context.active_ids = parseActiveIds(state.active_ids); + } else if (state.active_id) { + context.active_ids = [state.active_id]; + } + // ClientAction + const [actionRequestKey, clientAction] = actionRegistry.contains( + state.action + ) + ? [state.action, actionRegistry.get(state.action)] + : actionRegistry + .getEntries() + .find((a) => a[1].path === state.action) ?? []; + if (actionRequestKey && clientAction) { + actionRequest = { + context, + params: state, + tag: actionRequestKey, + type: 'ir.actions.client', + }; + if (clientAction.path) { + actionRequest.path = clientAction.path; + } + } else { + // The action to load isn't the current one => executes it + actionRequest = state.action; + context.params = state; + Object.assign(options, { + additionalContext: context, + viewType: state.resId ? 'form' : state.view_type, + }); + } + if ((state.resId && state.resId !== 'new') || state.globalState) { + options.props = {}; + if (state.resId && state.resId !== 'new') { + options.props.resId = state.resId; + } + if (state.globalState) { + options.props.globalState = state.globalState; + } + } + } else if (state.model) { + if (state.resId || state.view_type === 'form') { + actionRequest = { + res_model: state.model, + res_id: state.resId === 'new' ? undefined : state.resId, + type: 'ir.actions.act_window', + views: [[state.view_id ? state.view_id : false, 'form']], + }; + } else { + // This is a window action on a multi-record view => restores it from + // the session storage + const storedAction = + browser.sessionStorage.getItem('current_action'); + const lastAction = JSON.parse(storedAction || '{}'); + if (lastAction.help) { + lastAction.help = markup(lastAction.help); + } + if (lastAction.res_model === state.model) { + if (lastAction.context) { + // If this method is called because of a company switch, the + // stored allowed_company_ids is incorrect. + delete lastAction.context.allowed_company_ids; + } + actionRequest = lastAction; + options.viewType = state.view_type; + } + } + } + if (!actionRequest) { + // If the last action isn't valid (eg a model with no resId and no view_type) which can + // happen if the user edits the url and removes the id from the end of the url, we don't want + // to send him back to the home menu: we unwind the actionStack until we find a valid action + const { actionStack } = state; + if (actionStack?.length > 1) { + const nextState = { actionStack: actionStack.slice(0, -1) }; + Object.assign(nextState, nextState.actionStack.at(-1)); + const params = _getActionParams(nextState); + // Place the controller at the found position in the action stack to remove all the + // invalid virtual controllers. + if (params.options && params.options.index === undefined) { + params.options.index = nextState.actionStack.length - 1; + } + return params; + } + // Fall back to the home action if no valid action was found + actionRequest = user.homeActionId; + } + return actionRequest ? { actionRequest, options } : null; + } + + /** + * @param {ClientAction} action + * @param {Object} props + * @returns {{ props: ActionProps, config: Config }} + */ + function _getActionInfo(action, props) { + const actionProps = Object.assign({}, props, { + action, + actionId: action.id, + }); + const currentState = { + resId: actionProps.resId || false, + active_id: action.context.active_id || false, + }; + actionProps.updateActionState = (controller, patchState) => { + const oldState = { ...currentState }; + Object.assign(currentState, patchState); + const changed = !shallowEqual(currentState, oldState); + if (changed && action.target !== 'new' && controller.isMounted) { + pushState(); + } + }; + return { + props: actionProps, + currentState, + config: { + actionId: action.id, + actionType: 'ir.actions.client', + actionFlags: action.flags, + }, + displayName: action.display_name || action.name || '', + }; + } + + /** + * @param {Action} action + * @returns {ActionMode} + */ + function _getActionMode(action) { + if (action.target === 'new') { + // No possible override for target="new" + return 'new'; + } + if (action.type === 'ir.actions.client') { + const clientAction = actionRegistry.get(action.tag); + if (clientAction.target) { + // Target is forced by the definition of the client action + return clientAction.target; + } + } + if (action.target === 'fullscreen') { + return 'fullscreen'; + } + // Default: current + return 'current'; + } + + /** + * @param {BaseView} view + * @param {ActWindowAction} action + * @param {BaseView[]} views + * @param {Object} props + */ + function _getViewInfo(view, action, views, props = {}) { + const target = action.target; + const viewSwitcherEntries = views + .filter((v) => v.multiRecord === view.multiRecord) + .map((v) => { + const viewSwitcherEntry = { + icon: v.icon, + name: v.display_name, + type: v.type, + multiRecord: v.multiRecord, + }; + if (view.type === v.type) { + viewSwitcherEntry.active = true; + } + return viewSwitcherEntry; + }); + const context = action.context || {}; + let groupBy = context.group_by || []; + if (typeof groupBy === 'string') { + groupBy = [groupBy]; + } + const openFormView = (resId, { activeIds, mode, force } = {}) => { + if (target !== 'new') { + if (_getView('form')) { + return switchView('form', { + mode, + resId, + resIds: activeIds, + }); + } else if (force || !resId) { + return doAction( + { + type: 'ir.actions.act_window', + res_model: action.res_model, + views: [[false, 'form']], + }, + { props: { mode, resId, resIds: activeIds } } + ); + } + } + }; + const viewProps = Object.assign({}, props, { + context, + display: { mode: target === 'new' ? 'inDialog' : target }, + domain: action.domain || [], + groupBy, + loadActionMenus: target !== 'new' && target !== 'inline', + loadIrFilters: action.views.some((v) => v[1] === 'search'), + resModel: action.res_model, + type: view.type, + selectRecord: openFormView, + createRecord: () => openFormView(false), + }); + if (view.type === 'form') { + if (target === 'new') { + viewProps.mode = 'edit'; + if (!viewProps.onSave) { + viewProps.onSave = (record, params) => { + if (params && params.closable) { + doAction({ type: 'ir.actions.act_window_close' }); + } + }; + } + } + if (action.flags && 'mode' in action.flags) { + viewProps.mode = action.flags.mode; + } + } + + if (target === 'inline') { + viewProps.searchMenuTypes = []; + } + + const specialKeys = ['help', 'useSampleModel', 'limit', 'count']; + for (const key of specialKeys) { + if (key in action) { + if (key === 'help') { + viewProps.noContentHelp = action.help; + } else { + viewProps[key] = action[key]; + } + } + } + + if (context.search_disable_custom_filters) { + viewProps.activateFavorite = false; + } + + // view specific + if (!viewProps.resId) { + viewProps.resId = action.res_id || false; + } + + const currentState = { + resId: viewProps.resId, + active_id: action.context.active_id || false, + }; + viewProps.updateActionState = (controller, patchState) => { + const oldState = { ...currentState }; + Object.assign(currentState, patchState); + const changed = !shallowEqual(currentState, oldState); + if (changed && target !== 'new' && controller.isMounted) { + pushState(); + } + }; + + viewProps.noBreadcrumbs = + '_noBreadcrumbs' in action + ? action._noBreadcrumbs + : target === 'new'; + + const embeddedActions = + view.type === 'form' + ? [] + : context.parent_action_embedded_actions || + action.embedded_action_ids; + const parentActionId = + (view.type !== 'form' && context.parent_action_id) || false; + const currentEmbeddedActionId = + context.current_embedded_action_id || false; + return { + props: viewProps, + currentState, + config: { + actionId: action.id, + actionName: action.name, + actionType: 'ir.actions.act_window', + embeddedActions, + parentActionId, + currentEmbeddedActionId, + actionFlags: action.flags, + views: action.views, + viewSwitcherEntries, + }, + displayName: action.display_name || action.name || '', + }; + } + + /** + * Computes the position of the controller in the nextStack according to options + * @param {ActionOptions} options + */ + function _computeStackIndex(options) { + if (options.clearBreadcrumbs) { + return 0; + } else if (options.stackPosition === 'replaceCurrentAction') { + const currentController = + controllerStack[controllerStack.length - 1]; + if (currentController) { + return controllerStack.findIndex( + (ct) => ct.action.jsId === currentController.action.jsId + ); + } + } else if (options.stackPosition === 'replacePreviousAction') { + let last; + for (let i = controllerStack.length - 1; i >= 0; i--) { + const action = controllerStack[i].action.jsId; + if (!last) { + last = action; + } + if (action !== last) { + last = action; + break; + } + } + if (last) { + return controllerStack.findIndex( + (ct) => ct.action.jsId === last + ); + } + // TODO: throw if there is no previous action? + } else if (options.index !== undefined) { + return options.index; + } + return controllerStack.length; + } + + /** + * Triggers a re-rendering with respect to the given controller. + * + * @private + * @param {Controller} controller + * @param {UpdateStackOptions} options + * @param {boolean} [options.clearBreadcrumbs=false] + * @param {number} [options.index] + * @returns {Promise} + */ + async function _updateUI(controller, options = {}) { + let resolve; + let reject; + let dialogCloseResolve; + let removeDialogFn; + const currentActionProm = new Promise((_res, _rej) => { + resolve = _res; + reject = _rej; + }); + const action = controller.action; + if (action.target !== 'new' && 'newStack' in options) { + controllerStack = options.newStack; + } + + const index = _computeStackIndex(options); + + const nextStack = [...controllerStack.slice(0, index), controller,]; + if (controller.action.target != 'new') { + // ! my edit + count = count + 1 + controller.count = count; + controllerStacks[nextStack[0].displayName] = nextStack; + debugger + } + + // Compute breadcrumbs + controller.config.breadcrumbs = reactive( + action.target === 'new' ? [] : _getBreadcrumbs(nextStack) + ); + controller.config.getDisplayName = () => controller.displayName; + controller.config.setDisplayName = (displayName) => { + controller.displayName = displayName; + if (controller === _getCurrentController()) { + // if not mounted yet, will be done in "mounted" + env.services.title.setParts({ action: controller.displayName }); + } + if (action.target !== 'new') { + // This is a hack to force the reactivity when a new displayName is set + controller.config.breadcrumbs.push(undefined); + controller.config.breadcrumbs.pop(); + } + }; + controller.config.setCurrentEmbeddedAction = (embeddedActionId) => { + controller.currentEmbeddedActionId = embeddedActionId; + }; + controller.config.setEmbeddedActions = (embeddedActions) => { + controller.embeddedActions = embeddedActions; + }; + controller.config.historyBack = () => { + if (dialog) { + _executeCloseAction(); + } else { + const previousController = + controllerStack[controllerStack.length - 2]; + if (previousController) { + restore(previousController.jsId); + } else { + env.bus.trigger('WEBCLIENT:LOAD_DEFAULT_APP'); + } + } + }; + + class ControllerComponent extends Component { + static template = ControllerComponentTemplate; + static Component = controller.Component; + static props = { + '*': true, + }; + setup() { + this.Component = controller.Component; + this.titleService = useService('title'); + useDebugCategory('action', { action }); + useChildSubEnv({ + config: controller.config, + pushStateBeforeReload: () => { + if (controller.isMounted) { + return; + } + pushState(nextStack); + }, + }); + if (action.target !== 'new') { + this.__beforeLeave__ = new CallbackRecorder(); + this.__getGlobalState__ = new CallbackRecorder(); + this.__getLocalState__ = new CallbackRecorder(); + useBus(env.bus, 'CLEAR-UNCOMMITTED-CHANGES', (ev) => { + const callbacks = ev.detail; + const beforeLeaveFns = this.__beforeLeave__.callbacks; + callbacks.push(...beforeLeaveFns); + }); + if (this.constructor.Component !== View) { + useChildSubEnv({ + __beforeLeave__: this.__beforeLeave__, + __getGlobalState__: this.__getGlobalState__, + __getLocalState__: this.__getLocalState__, + }); + } + } + + onMounted(this.onMounted); + onWillUnmount(this.onWillUnmount); + onError(this.onError); + } + onError(error) { + if (controller.isMounted) { + // the error occurred on the controller which is + // already in the DOM, so simply show the error + Promise.reject(error); + return; + } + if (!controller.isMounted && status(this) === 'mounted') { + // The error occured during an onMounted hook of one of the components. + env.bus.trigger('ACTION_MANAGER:UPDATE', { + id: ++id, + Component: BlankComponent, + componentProps: { + onMounted: () => { }, + withControlPanel: + action.type === 'ir.actions.act_window', + }, + }); + Promise.reject(error); + return; + } + // forward the error to the _updateUI caller then restore the action container + // to an unbroken state + reject(error); + if (action.target === 'new') { + removeDialogFn?.(); + return; + } + const index = controllerStack.findIndex( + (ct) => ct.jsId === controller.jsId + ); + if (index > 0) { + // The error occurred while rendering an existing controller, + // so go back to the previous controller, of the current faulty one. + // This occurs when clicking on a breadcrumbs. + return restore(controllerStack[index - 1].jsId); + } + if (index === 0) { + // No previous controller to restore, so do nothing but display the error + return; + } + const lastController = controllerStack.at(-1); + if (lastController) { + if (lastController.jsId !== controller.jsId) { + // the error occurred while rendering a new controller, + // so go back to the last non faulty controller + // (the error will be shown anyway as the promise + // has been rejected) + // ! my edit + delete controllerStacks[controller.displayName]; + + return restore(lastController.jsId); + } + } else { + env.bus.trigger('ACTION_MANAGER:UPDATE', {}); + } + } + onMounted() { + if (action.target === 'new') { + dialogCloseProm = new Promise((_r) => { + dialogCloseResolve = _r; + }).then(() => { + dialogCloseProm = undefined; + }); + dialog = nextDialog; + } else { + controller.getGlobalState = () => { + const exportFns = this.__getGlobalState__.callbacks; + if (exportFns.length) { + return Object.assign( + {}, + ...exportFns.map((fn) => fn()) + ); + } + }; + controller.getLocalState = () => { + const exportFns = this.__getLocalState__.callbacks; + if (exportFns.length) { + return Object.assign( + {}, + ...exportFns.map((fn) => fn()) + ); + } + }; + + controllerStack = nextStack; // the controller is mounted, commit the new stack + // todo del + window.router = router + window.controllerStack = controllerStack; + window.controllerStacks = controllerStacks; + pushState(); + this.titleService.setParts({ + action: controller.displayName, + }); + browser.sessionStorage.setItem( + 'current_action', + action._originalAction || '{}' + ); + } + resolve(); + env.bus.trigger( + 'ACTION_MANAGER:UI-UPDATED', + _getActionMode(action) + ); + controller.isMounted = true; + } + onWillUnmount() { + controller.isMounted = false; + if (action.target === 'new' && dialogCloseResolve) { + dialogCloseResolve(); + } + } + get componentProps() { + const componentProps = { ...this.props }; + const updateActionState = componentProps.updateActionState; + componentProps.updateActionState = (newState) => + updateActionState(controller, newState); + if (this.constructor.Component === View) { + componentProps.__beforeLeave__ = this.__beforeLeave__; + componentProps.__getGlobalState__ = this.__getGlobalState__; + componentProps.__getLocalState__ = this.__getLocalState__; + } + return componentProps; + } + } + if (action.target === 'new') { + const actionDialogProps = { + ActionComponent: ControllerComponent, + actionProps: controller.props, + actionType: action.type, + }; + if (action.name) { + actionDialogProps.title = action.name; + } + const size = DIALOG_SIZES[action.context.dialog_size]; + if (size) { + actionDialogProps.size = size; + } + actionDialogProps.footer = + action.context.footer ?? actionDialogProps.footer; + const onClose = _removeDialog(); + removeDialogFn = env.services.dialog.add( + ActionDialog, + actionDialogProps, + { + onClose: () => { + const onClose = _removeDialog(); + if (onClose) { + onClose(); + } + }, + } + ); + if (nextDialog) { + nextDialog.remove(); + } + nextDialog = { + remove: removeDialogFn, + onClose: onClose || options.onClose, + }; + return currentActionProm; + } + + const currentController = _getCurrentController(); + if (currentController && currentController.getLocalState) { + currentController.exportedState = currentController.getLocalState(); + } + if (controller.exportedState) { + controller.props.state = controller.exportedState; + } + + // TODO DAM Remarks: + // this thing seems useless for client actions. + // restore and switchView (at least) use this --> cannot be done in switchView only + // if prop globalState has been passed in doAction, since the action is new the prop won't be overridden in l655. + // if globalState is not useful for client actions --> maybe use that thing in useSetupView instead of useSetupAction? + // a good thing: the Object.assign seems to reflect the use of "externalState" in legacy Model class --> things should be fine. + if (currentController && currentController.getGlobalState) { + const globalState = Object.assign( + {}, + currentController.action.globalState, + currentController.getGlobalState() // what if this = {}? + ); + + currentController.action.globalState = globalState; + // Avoid pushing the globalState, if the state on the router was changed. + // For instance, if a link was clicked, the state of the router will be the one of the link and not the one of the currentController. + // Or when using the back or forward buttons on the browser. + if ( + currentController.state.action === router.current.action && + currentController.state.active_id === + router.current.active_id && + currentController.state.resId === router.current.resId + ) { + router.pushState({ globalState }, { sync: true }); + } + } + if (controller.action.globalState) { + controller.props.globalState = controller.action.globalState; + } + + const closingProm = _executeCloseAction({ + onCloseInfo: { noReload: true }, + }); + + // if (options.clearBreadcrumbs && !options.noEmptyTransition) { + // const def = new Deferred(); + // env.bus.trigger('ACTION_MANAGER:UPDATE', { + // id: ++id, + // Component: BlankComponent, + // componentProps: { + // onMounted: () => def.resolve(), + // withControlPanel: action.type === 'ir.actions.act_window', + // }, + // }); + // await def; + // } + if (options.onActionReady) { + options.onActionReady(action); + } + controller.__info__ = { + id: ++id, + Component: ControllerComponent, + componentProps: controller.props, + controllerStacks, + count + }; + env.services.dialog.closeAll(); + env.bus.trigger('ACTION_MANAGER:UPDATE', controller.__info__); + return Promise.all([currentActionProm, closingProm]).then((r) => r[0]); + } + + // --------------------------------------------------------------------------- + // ir.actions.act_url + // --------------------------------------------------------------------------- + + /** + * Executes actions of type 'ir.actions.act_url', i.e. redirects to the + * given url. + * + * @private + * @param {ActURLAction} action + * @param {ActionOptions} options + */ + function _executeActURLAction(action, options) { + let url = action.url; + if (url && !(url.startsWith('http') || url.startsWith('/'))) { + url = '/' + url; + } + if (action.target === 'download' || action.target === 'self') { + browser.location.assign(url); + } else { + const w = browser.open(url, '_blank'); + if (!w || w.closed || typeof w.closed === 'undefined') { + const msg = _t( + 'A popup window has been blocked. You may need to change your ' + + 'browser settings to allow popup windows for this page.' + ); + env.services.notification.add(msg, { + sticky: true, + type: 'warning', + }); + } + if (action.close) { + return doAction( + { type: 'ir.actions.act_window_close' }, + { onClose: options.onClose } + ); + } else if (options.onClose) { + options.onClose(); + } + } + } + + // --------------------------------------------------------------------------- + // ir.actions.act_window + // --------------------------------------------------------------------------- + + /** + * Executes an action of type 'ir.actions.act_window'. + * + * @private + * @param {ActWindowAction} action + * @param {ActionOptions} options + */ + async function _executeActWindowAction(action, options) { + const views = []; + const unknown = []; + for (const [, type] of action.views) { + if (type === 'search') { + continue; + } + if (session.view_info[type]) { + const { + icon, + display_name, + multi_record: multiRecord, + } = session.view_info[type]; + views.push({ icon, display_name, multiRecord, type }); + } else { + unknown.push(type); + } + } + if (unknown.length) { + throw new Error( + `View types not defined ${unknown.join( + ', ' + )} found in act_window action ${action.id}` + ); + } + if (!views.length) { + throw new Error(`No view found for act_window action ${action.id}`); + } + + let view = + (options.viewType && + views.find((v) => v.type === options.viewType)) || + views[0]; + if (env.isSmall) { + view = + _findView(views, view.multiRecord, action.mobile_view_mode) || + view; + } + + const controller = _makeController({ + Component: View, + action, + view, + views, + ..._getViewInfo(view, action, views, options.props), + }); + action.controllers[view.type] = controller; + + const newStackLastController = options.newStack?.at(-1); + if (newStackLastController?.lazy) { + const multiView = action.views.find( + (view) => view[1] !== 'form' && view[1] !== 'search' + ); + if (multiView) { + // If the current action has a multi-record view, we add the last + // controller to the breadcrumb controllers. + delete newStackLastController.lazy; + newStackLastController.displayName = + action.display_name || action.name || ''; + newStackLastController.action = action; + newStackLastController.props.type = multiView[1]; + } else { + // If the current action doesn't have a multi-record view, + // we don't need to add the last controller to the breadcrumb controllers + options.newStack.splice(-1); + } + } + + return _updateUI(controller, options); + } + + /** + * @private + * @param {Array} views an array of views + * @param {boolean} multiRecord true if we search for a multiRecord view + * @param {string} viewType type of the view to search + * @returns {Object|undefined} the requested view if it could be found + */ + function _findView(views, multiRecord, viewType) { + return views.find( + (v) => v.type === viewType && v.multiRecord == multiRecord + ); + } + + // --------------------------------------------------------------------------- + // ir.actions.client + // --------------------------------------------------------------------------- + + /** + * Executes an action of type 'ir.actions.client'. + * + * @private + * @param {ClientAction} action + * @param {ActionOptions} options + */ + async function _executeClientAction(action, options) { + const clientAction = actionRegistry.get(action.tag); + action.path ||= clientAction.path; + if (clientAction.prototype instanceof Component) { + if (action.target !== 'new') { + const canProceed = await clearUncommittedChanges(env); + if (!canProceed) { + return; + } + if (clientAction.target) { + action.target = clientAction.target; + } + } + const controller = _makeController({ + Component: clientAction, + action, + ..._getActionInfo(action, options.props), + }); + controller.displayName ||= + clientAction.displayName?.toString() || ''; + return _updateUI(controller, options); + } else { + const next = await clientAction(env, action); + if (next) { + return doAction(next, options); + } + } + } + + // --------------------------------------------------------------------------- + // ir.actions.report + // --------------------------------------------------------------------------- + + function _executeReportClientAction(action, options) { + const props = Object.assign({}, options.props, { + data: action.data, + display_name: action.display_name, + name: action.name, + report_file: action.report_file, + report_name: action.report_name, + report_url: getReportUrl(action, 'html', user.context), + context: Object.assign({}, action.context), + }); + + const controller = _makeController({ + Component: ReportAction, + action, + ..._getActionInfo(action, props), + }); + + return _updateUI(controller, options); + } + + /** + * Executes actions of type 'ir.actions.report'. + * + * @private + * @param {ReportAction} action + * @param {ActionOptions} options + */ + async function _executeReportAction(action, options) { + const handlers = registry + .category('ir.actions.report handlers') + .getAll(); + for (const handler of handlers) { + const result = await handler(action, options, env); + if (result) { + return result; + } + } + if (action.report_type === 'qweb-html') { + return _executeReportClientAction(action, options); + } else if ( + action.report_type === 'qweb-pdf' || + action.report_type === 'qweb-text' + ) { + const type = action.report_type.slice(5); + let success, message; + env.services.ui.block(); + try { + const downloadContext = { ...user.context }; + if (action.context) { + Object.assign(downloadContext, action.context); + } + ({ success, message } = await downloadReport( + rpc, + action, + type, + downloadContext + )); + } finally { + env.services.ui.unblock(); + } + if (message) { + env.services.notification.add(message, { + sticky: true, + title: _t('Report'), + }); + } + if (!success) { + return _executeReportClientAction(action, options); + } + const { onClose } = options; + if (action.close_on_report_download) { + return doAction( + { type: 'ir.actions.act_window_close' }, + { onClose } + ); + } else if (onClose) { + onClose(); + } + } else { + console.error( + `The ActionManager can't handle reports of type ${action.report_type}`, + action + ); + } + } + + // --------------------------------------------------------------------------- + // ir.actions.server + // --------------------------------------------------------------------------- + + /** + * Executes an action of type 'ir.actions.server'. + * + * @private + * @param {ServerAction} action + * @param {ActionOptions} options + * @returns {Promise} + */ + async function _executeServerAction(action, options) { + const runProm = rpc('/web/action/run', { + action_id: action.id, + context: makeContext([user.context, action.context]), + }); + let nextAction = await keepLast.add(runProm); + if (nextAction.help) { + nextAction.help = markup(nextAction.help); + } + nextAction = nextAction || { type: 'ir.actions.act_window_close' }; + if (typeof nextAction === 'object') { + nextAction.path ||= action.path; + } + return doAction(nextAction, options); + } + + async function _executeCloseAction(params = {}) { + let onClose; + if (dialog) { + onClose = _removeDialog(); + } else { + onClose = params.onClose; + } + if (onClose) { + await onClose(params.onCloseInfo); + } + + return dialogCloseProm; + } + + // --------------------------------------------------------------------------- + // public API + // --------------------------------------------------------------------------- + + /** + * Main entry point of a 'doAction' request. Loads the action and executes it. + * + * @param {ActionRequest} actionRequest + * @param {ActionOptions} options + * @returns {Promise} + */ + async function doAction(actionRequest, options = {}) { + const actionProm = _loadAction( + actionRequest, + options.additionalContext + ); + let action = await keepLast.add(actionProm); + action = _preprocessAction(action, options.additionalContext); + options.clearBreadcrumbs = + action.target === 'main' || options.clearBreadcrumbs; + switch (action.type) { + case 'ir.actions.act_url': + return _executeActURLAction(action, options); + case 'ir.actions.act_window': + if (action.target !== 'new') { + const canProceed = await clearUncommittedChanges(env); + if (!canProceed) { + return new Promise(() => { }); + } + } + return _executeActWindowAction(action, options); + case 'ir.actions.act_window_close': + return _executeCloseAction({ + onClose: options.onClose, + onCloseInfo: action.infos, + }); + case 'ir.actions.client': + return _executeClientAction(action, options); + case 'ir.actions.server': + return _executeServerAction(action, options); + case 'ir.actions.report': + return _executeReportAction(action, options); + default: { + const handler = actionHandlersRegistry.get(action.type, null); + if (handler !== null) { + return handler({ env, action, options }); + } + throw new Error( + `The ActionManager service can't handle actions of type ${action.type}` + ); + } + } + } + + /** + * Executes an action on top of the current one (typically, when a button in a + * view is clicked). The button may be of type 'object' (call a given method + * of a given model) or 'action' (execute a given action). Alternatively, the + * button may have the attribute 'special', and in this case an + * 'ir.actions.act_window_close' is executed. + * + * @param {DoActionButtonParams} params + * @params {Object} [options={}] + * @params {boolean} [options.isEmbeddedAction] set to true if the action request is an + * embedded action. This allows to do the necessary context cleanup and avoid infinite + * recursion. + * @returns {Promise} + */ + async function doActionButton(params, { isEmbeddedAction } = {}) { + // determine the action to execute according to the params + let action; + if (!isEmbeddedAction) { + for (const key of EMBEDDED_ACTIONS_CTX_KEYS) { + delete params.context?.[key]; + } + } + const context = makeContext([params.context, params.buttonContext]); + const blockUi = exprToBoolean(params['block-ui']); + if (blockUi) { + env.services.ui.block(); + } + if (params.special) { + action = { + type: 'ir.actions.act_window_close', + infos: { special: true }, + }; + } else if (params.type === 'object') { + // call a Python Object method, which may return an action to execute + let args = params.resId ? [[params.resId]] : [params.resIds]; + if (params.args) { + let additionalArgs; + try { + // warning: quotes and double quotes problem due to json and xml clash + // maybe we should force escaping in xml or do a better parse of the args array + additionalArgs = JSON.parse(params.args.replace(/'/g, '"')); + } catch { + browser.console.error( + 'Could not JSON.parse arguments', + params.args + ); + } + args = args.concat(additionalArgs); + } + const callProm = rpc( + `/web/dataset/call_button/${params.resModel}/${params.name}`, + { + args, + kwargs: { context }, + method: params.name, + model: params.resModel, + } + ); + action = await keepLast.add(callProm); + action = + action && typeof action === 'object' + ? action + : { type: 'ir.actions.act_window_close' }; + if (action.help) { + action.help = markup(action.help); + } + } else if (params.type === 'action') { + // execute a given action, so load it first + context.active_id = params.resId || null; + context.active_ids = params.resIds; + context.active_model = params.resModel; + action = await keepLast.add(_loadAction(params.name, context)); + } else { + if (blockUi) { + env.services.ui.unblock(); + } + throw new InvalidButtonParamsError( + 'Missing type for doActionButton request' + ); + } + if (!isEmbeddedAction && action.embedded_action_ids?.length) { + const embeddedActionsOrder = JSON.parse( + browser.localStorage.getItem( + `orderEmbedded${action.id}+${params.resId || ''}+${user.userId + }` + ) + ); + const embeddedActionId = embeddedActionsOrder?.[0]; + const embeddedAction = action.embedded_action_ids?.find( + (embeddedAction) => embeddedAction.id === embeddedActionId + ); + if (embeddedAction) { + const embeddedActions = [ + ...action.embedded_action_ids, + { + id: false, + name: action.name, + parent_action_id: action.id, + parent_res_model: action.res_model, + action_id: action.id, + user_id: false, + context: {}, + }, + ]; + const context = { + ...action.context, + ...(embeddedAction.context + ? makeContext([embeddedAction.context]) + : {}), + active_id: params.resId, + active_model: params.resModel, + current_embedded_action_id: embeddedActionId, + parent_action_embedded_actions: embeddedActions, + parent_action_id: action.id, + }; + await this.doActionButton( + { + name: + embeddedAction.python_method || + embeddedAction.action_id[0] || + embeddedAction.action_id, + resId: params.resId, + context, + type: embeddedAction.python_method + ? 'object' + : 'action', + resModel: embeddedAction.parent_res_model, + viewType: embeddedAction.default_view_mode, + }, + { isEmbeddedAction: true } + ); + return; + } + } + // filter out context keys that are specific to the current action, because: + // - wrong default_* and search_default_* values won't give the expected result + // - wrong group_by values will fail and forbid rendering of the destination view + const currentCtx = {}; + for (const key in params.context) { + if (key.match(CTX_KEY_REGEX) === null) { + currentCtx[key] = params.context[key]; + } + } + const activeCtx = { active_model: params.resModel }; + if (params.resId) { + activeCtx.active_id = params.resId; + activeCtx.active_ids = [params.resId]; + } + action.context = makeContext([ + currentCtx, + params.buttonContext, + activeCtx, + action.context, + ]); + // in case an effect is returned from python and there is already an effect + // attribute on the button, the priority is given to the button attribute + const effect = params.effect + ? evaluateExpr(params.effect) + : action.effect; + const { onClose, stackPosition, viewType } = params; + const options = { onClose, stackPosition, viewType }; + await doAction(action, options); + if (params.close) { + await _executeCloseAction(); + } + if (blockUi) { + env.services.ui.unblock(); + } + if (effect) { + env.services.effect.add(effect); + } + } + + /** + * Switches to the given view type in action of the last controller of the + * stack. This action must be of type 'ir.actions.act_window'. + * + * @param {ViewType} viewType + * @param {Object} [props={}] + * @throws {ViewNotFoundError} if the viewType is not found on the current action + * @returns {Promise} + */ + async function switchView(viewType, props = {}) { + await keepLast.add(Promise.resolve()); + if (dialog) { + // we don't want to switch view when there's a dialog open, as we would + // not switch in the correct action (action in background != dialog action) + return; + } + const controller = controllerStack[controllerStack.length - 1]; + const view = _getView(viewType); + if (!view) { + throw new ViewNotFoundError( + _t( + "No view of type '%s' could be found in the current action.", + viewType + ) + ); + } + const newController = + controller.action.controllers[viewType] || + _makeController({ + Component: View, + action: controller.action, + views: controller.views, + view, + }); + + const canProceed = await clearUncommittedChanges(env); + if (!canProceed) { + return; + } + + Object.assign( + newController, + _getViewInfo(view, controller.action, controller.views, props) + ); + controller.action.controllers[viewType] = newController; + let index; + if (view.multiRecord) { + index = controllerStack.findIndex( + (ct) => ct.action.jsId === controller.action.jsId + ); + index = index > -1 ? index : controllerStack.length - 1; + } else { + // This case would mostly happen when loadState detects a change in the URL. + // Also, I guess we may need it when we have other monoRecord views + index = controllerStack.findIndex( + (ct) => + ct.action.jsId === controller.action.jsId && + !ct.view.multiRecord + ); + index = index > -1 ? index : controllerStack.length; + } + return _updateUI(newController, { index }); + } + + /** + * Restores a controller from the controller stack given its id. Typically, + * this function is called when clicking on the breadcrumbs. If no id is given + * restores the previous controller from the stack (penultimate). + * + * @param {string} jsId + */ + async function restore(jsId) { + await keepLast.add(Promise.resolve()); + let index; + if (!jsId) { + index = controllerStack.length - 2; + } else { + index = controllerStack.findIndex( + (controller) => controller.jsId === jsId + ); + } + if (index < 0) { + const msg = jsId + ? 'Invalid controller to restore' + : 'No controller to restore'; + throw new ControllerNotFoundError(msg); + } + const canProceed = await clearUncommittedChanges(env); + if (!canProceed) { + return; + } + const controller = controllerStack[index]; + if (controller.virtual) { + const actionParams = _getActionParams(controller.state); + if (!actionParams) { + throw new Error( + 'Attempted to restore a virtual controller whose state is invalid' + ); + } + const { actionRequest, options } = actionParams; + controllerStack = controllerStack.slice(0, index); + return doAction(actionRequest, options); + } + if (controller.action.type === 'ir.actions.act_window') { + if (controller.isMounted) { + controller.exportedState = controller.getLocalState(); + } + const { action, exportedState, view, views } = controller; + const props = { ...controller.props }; + if (exportedState && 'resId' in exportedState) { + // When restoring, we want to use the last exported ID of the controller + props.resId = exportedState.resId; + } + Object.assign(controller, _getViewInfo(view, action, views, props)); + } + return _updateUI(controller, { index }); + } + + /** + * Restores a stack of virtual controllers from the current contents of the + * URL and performs a "doAction" on the last one. + * + * @returns {Promise} true if doAction was performed + */ + async function loadState() { + const newStack = await _controllersFromState(); + const actionParams = _getActionParams(); + if (actionParams) { + // Params valid => performs a "doAction" + const { actionRequest, options } = actionParams; + if (options.index) { + options.newStack = newStack.slice(0, options.index); + delete options.index; + } else { + options.newStack = newStack; + } + await doAction(actionRequest, options); + return true; + } + } + + function pushState(cStack = controllerStack) { + if (!cStack.length) { + return; + } + const actions = cStack.map((controller) => { + const { action, props, displayName } = controller; + const actionState = { displayName }; + if (action.path || action.id) { + actionState.action = action.path || action.id; + } else if (action.type === 'ir.actions.client') { + actionState.action = action.tag; + } else if (action.type === 'ir.actions.act_window') { + actionState.model = props.resModel; + } + if (action.type === 'ir.actions.act_window') { + actionState.view_type = props.type; + if ( + props.type === 'form' && + action.res_model !== 'res.config.settings' + ) { + actionState.resId = controller.currentState.resId || 'new'; + } + } + if ( + action.type === 'ir.actions.client' && + controller.currentState?.resId + ) { + actionState.resId = controller.currentState.resId; + } + + if (controller.currentState?.active_id) { + const activeId = controller.currentState.active_id; + if (activeId) { + actionState.active_id = activeId; + } + } + Object.assign( + actionState, + omit(controller.currentState || {}, ...PATH_KEYS) + ); + return actionState; + }); + const newState = { + actionStack: actions, + }; + const stateKeys = [...PATH_KEYS]; + const { action, props, currentState } = cStack.at(-1); + if (props.type !== 'form' && props.type !== action.views?.[0][1]) { + // add view_type only when it's not already known implicitly + stateKeys.push('view_type'); + } + if (currentState) { + stateKeys.push(...Object.keys(omit(currentState, ...PATH_KEYS))); + } + Object.assign( + newState, + pick(newState.actionStack.at(-1), ...stateKeys) + ); + + cStack.at(-1).state = newState; + router.pushState(newState, { replace: true }); + } + return { + doAction, + doActionButton, + switchView, + restore, + loadState, + async loadAction(actionRequest, context) { + const action = await _loadAction(actionRequest, context); + return _preprocessAction(action, context); + }, + get currentController() { + return _getCurrentController(); + }, + }; +} + +export const actionService = { + dependencies: [ + 'dialog', + 'effect', + 'localization', + 'notification', + 'title', + 'ui', + ], + start(env) { + return makeActionManager(env); + }, +}; +registry.category('services').remove('action'); +registry.category('services').add('action', actionService); diff --git a/addons_extensions/tabbar/static/src/akl_action_container.js b/addons_extensions/tabbar/static/src/akl_action_container.js new file mode 100644 index 000000000..bfc603c05 --- /dev/null +++ b/addons_extensions/tabbar/static/src/akl_action_container.js @@ -0,0 +1,128 @@ +import { ActionContainer } from '@web/webclient/actions/action_container'; +import { patch } from '@web/core/utils/patch'; +import { AklMultiTab } from './components/multi_tab/akl_multi_tab'; + +import { xml, useState } from '@odoo/owl'; +import { browser } from '@web/core/browser/browser'; +import { useService } from '@web/core/utils/hooks'; +import { + router as _router, +} from '@web/core/browser/router'; +patch(ActionContainer.prototype, { + setup() { + + super.setup(); + this.action_infos = []; + this.controllerStacks = {}; + // this.action_service = useService('action'); + + this.env.bus.addEventListener( + 'ACTION_MANAGER:UPDATE', + ({ detail: info }) => { + debugger + this.action_infos = this.get_controllers(info); + this.controllerStacks = info.controllerStacks; + this.render(); + } + ); + }, + get_controllers(info) { + const action_infos = []; + const entries = Object.entries(info.controllerStacks); + + entries.forEach(([key, stack]) => { + const lastController = stack[stack.length - 1]; + + const action_info = { + key: key, + __info__: lastController, + Component: lastController.__info__.Component, + active: false, + componentProps: lastController.__info__.componentProps || {}, + } + + if (lastController.count == info.count) { + action_info.active = true; + } + action_infos.push(action_info); + }) + + + return action_infos; + }, + + _on_close_action(action_info) { + this.action_infos = this.action_infos.filter((info) => { + return info.key !== action_info.key; + }); + if (this.action_infos.length > 0) { + + delete this.controllerStacks[action_info.key]; + this.action_infos[this.action_infos.length - 1].active = true; // Set last + this.render(); + + } + + }, + _on_active_action(action_info) { + debugger + this.action_infos.forEach((info) => { + info.active = info.key === action_info.key; + }); + const url = _router.stateToUrl(action_info.__info__.state) + browser.history.pushState({}, "", url); + this.render(); + }, + _close_other_action() { + this.action_infos = this.action_infos.filter((info) => { + if (info.active == false) { + delete this.controllerStacks[info.key]; + } + return info.active == true + }); + + this.render(); + }, + _close_current_action() { + debugger + this.action_infos = this.action_infos.filter((info) => { + if (info.active == true) { + delete this.controllerStacks[info.key]; + } + return info.active == false + }); + this.action_infos[this.action_infos.length - 1].active = true; + this.render(); + }, + _on_close_all_action() { + debugger + this.action_infos.forEach((info) => { + delete this.controllerStacks[info.key]; + }); + this.action_infos = {} + window.location.href = "/"; + + } +}); +ActionContainer.components = { + ...ActionContainer.components, + AklMultiTab, +}; +ActionContainer.template = xml` + + +
    + +
    + +
    +
    +
    +`; diff --git a/addons_extensions/tabbar/static/src/akl_action_container.scss b/addons_extensions/tabbar/static/src/akl_action_container.scss new file mode 100644 index 000000000..f9c4f4b8b --- /dev/null +++ b/addons_extensions/tabbar/static/src/akl_action_container.scss @@ -0,0 +1,29 @@ +.akl_controller_container { + overflow-y: hidden; + flex: 1 1 auto; + + .o_view_controller { + display: flex; + height: 100%; + overflow: hidden; + flex-direction: column; + + .o_content { + flex: 1 1 auto; + overflow-y: auto; + } + } + + .o_action { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + background-color: white; + .o_content { + flex: 1 1 auto; + overflow-y: auto; + background-color: white; + } + } +} diff --git a/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.js b/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.js new file mode 100644 index 000000000..103e19688 --- /dev/null +++ b/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.js @@ -0,0 +1,31 @@ +import { Component, useRef } from '@odoo/owl'; +import { Dropdown } from '@web/core/dropdown/dropdown'; +import { DropdownItem } from '@web/core/dropdown/dropdown_item'; +import { DropdownGroup } from '@web/core/dropdown/dropdown_group'; +export class AklMultiTab extends Component { + static template = 'akl_multi_tab.tab'; + static components = { Dropdown, DropdownItem, DropdownGroup }; + static props = ['*']; + setup() { + super.setup(); + this.tabContainerRef = []; + } + rollPage() { } + _close_all_action() { this.props.close_all_action(); } + _close_current_action() { + this.props.close_current_action(); + } + _close_other_action() { + this.props.close_other_action(); + } + _on_click_tab_close(info) { + this.props.close_action(info); + } + _on_click_tab_item(info) { + this.props.active_action(info); + } + _on_multi_tab_next() { } + _on_multi_tab_prev() { } + get action_infos() { } + get current_action_info() { } +} diff --git a/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.scss b/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.scss new file mode 100644 index 000000000..ccdb69bf0 --- /dev/null +++ b/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.scss @@ -0,0 +1,295 @@ +.akl_multi_tab_container { + line-height: 40px; + background-color: #fff; + border-bottom: 1px solid #dee2e6; + box-sizing: border-box; + z-index: 3; + + .akl_multi_tab { + position: relative; + padding: 0 80px 0 40px; + height: 40px; + display: flex; + flex-direction: row; + box-sizing: border-box; + + .akl_tab_scroll { + width: 100%; + height: 100%; + overflow: hidden; + + .akl_page_items { + position: relative; + left: 0px; + height: 100%; + flex: 1 1 auto; + background: rgb(255, 255, 255); + overflow: visible; + white-space: nowrap; + padding: 0px; + + li { + display: inline-block; + *display: inline; + *zoom: 1; + + vertical-align: middle; + font-size: 14px; + transition: all 0.2s; + -webkit-transition: all 0.2s; + min-width: 65px; + padding: 0 15px; + text-align: center; + cursor: pointer; + + line-height: 40px; + max-width: 160px; + text-overflow: ellipsis; + padding-right: 40px; + overflow: hidden; + border-right: 1px solid #f6f6f6; + vertical-align: top; + position: relative; + white-space: nowrap; + padding: 0px 30px 0px 10px; + user-select: none; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 0; + height: 2px; + border-radius: 0; + background-color: #292b34; + transition: all 0.3s; + -webkit-transition: all 0.3s; + } + + &.akl_multi_tab_active { + &::after { + width: 100%; + } + } + + &:hover { + background-color: #f6f6f6; + + &::after { + width: 100%; + } + } + + .akl_tab_close { + position: absolute; + right: 8px; + top: 50%; + margin: -8px 0 0 0; + width: 16px; + height: 16px; + line-height: 16px; + border-radius: 50%; + font-size: 12px; + + &:first { + display: none; + } + + &:hover { + background-color: #ff5722; + color: #fff; + } + + svg { + margin-top: -2px; + } + } + + &.active { + background-color: $o-brand-primary; + color:white; + } + } + } + } + + .akl_tab_control { + width: 40px; + height: 100%; + text-align: center; + cursor: pointer; + transition: all 0.3s; + -webkit-transition: all 0.3s; + box-sizing: border-box; + border-left: 1px solid #f6f6f6; + + &:hover { + background-color: #f6f6f6; + } + } + + .akl_icon_prev { + border-right: 1px solid #f6f6f6; + color: #666; + line-height: 40px; + margin: 0; + padding: 0; + + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + -webkit-font-smoothing: antialiased; + -webkit-transition: all 0.3s; + + font-size: 16px; + font-style: normal; + position: absolute; + + top: 0; + left: 0; + + width: 40px; + height: 100%; + text-align: center; + cursor: pointer; + box-sizing: border-box; + border-left: none; + border-right: 1px solid #f6f6f6; + } + + .akl_icon_next { + border-right: 1px solid #f6f6f6; + color: #666; + line-height: 40px; + margin: 0; + padding: 0; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + font-family: layui-icon !important; + font-size: 16px; + font-style: normal; + -webkit-font-smoothing: antialiased; + position: absolute; + top: 0; + width: 40px; + height: 100%; + text-align: center; + cursor: pointer; + -webkit-transition: all 0.3s; + box-sizing: border-box; + border-left: 1px solid #f6f6f6; + right: 40px; + } + + .akl_icon_down { + //right: 1px; + color: #666; + line-height: 40px; + margin: 0; + padding: 0; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + -webkit-transition: all 0.3s; + font-family: layui-icon !important; + font-size: 16px; + font-style: normal; + -webkit-font-smoothing: antialiased; + position: absolute; + top: 0; + width: 40px; + height: 100%; + text-align: center; + cursor: pointer; + box-sizing: border-box; + border-left: 1px solid #f6f6f6; + right: 0; + + .dropdown-toggle { + width: 100%; + height: 100%; + background: none; + border: none; + } + + .dropdown-menu { + .dropdown-item { + line-height: 30px; + } + } + } + + .akl_multi_tab_active { + background-color: #f6f6f6; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 2px; + border-radius: 0; + background-color: #292b34; + transition: all 0.3s; + -webkit-transition: all 0.3s; + } + } + } + + .dropdown-menu { + z-index: 1000; + } +} + +.o_action_manager { + display: flex; + flex-direction: column; + + .akl_tab_page_container { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1 1 auto; + + // > div { + // flex: 1 1 auto; + // overflow: auto; + // display: flex; + // flex-direction: column; + // } + + .o_view_controller { + display: flex; + flex: 1 1 auto; + flex-direction: column; + overflow: hidden; + height: 100%; + + .o_content { + overflow: auto; + } + } + } +} + +.o_home_menu_background { + .akl_multi_tab_container { + display: none !important; + } +} + +[name='product_template_id'] { + div, + span { + width: 100% !important; + display: block !important; + } + span { + white-space: pre-wrap !important; + word-break: break-all !important; + text-overflow: initial !important; + } +} + +td[name='product_id'] { + white-space: pre-wrap !important; + word-break: break-all !important; + text-overflow: initial !important; +} diff --git a/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.xml b/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.xml new file mode 100644 index 000000000..e4accb557 --- /dev/null +++ b/addons_extensions/tabbar/static/src/components/multi_tab/akl_multi_tab.xml @@ -0,0 +1,99 @@ + + + + +
    +
    +
    + + + + +
    + +
    + + + + +
    +
    + + + + + + + + + + Close Current Tab + + + Close Other Tabs + + + Close All Tabs + + + + + +
    + +
    +
      +
    • + + + + + + + + + +
    • +
    +
    + +
    +
    +
    +
    diff --git a/addons_extensions/tabbar/static/src/scss/tabbar.scss b/addons_extensions/tabbar/static/src/scss/tabbar.scss new file mode 100644 index 000000000..aaf5b5c4e --- /dev/null +++ b/addons_extensions/tabbar/static/src/scss/tabbar.scss @@ -0,0 +1,18 @@ +.tabbar { + height: 30px; + margin-left: 0px; + width: 100%; + display: flex; + .tabbar_left { + display: flex; + overflow: hidden; + flex: 1 1 0%; + height: 100%; + } + .tabbar_right { + display: flex; + height: 100%; + align-items: center; + justify-content: center; + } +}